fix: 미인증 계정 재가입 허용 및 로그인 화면 인증 메일 재발송
인증 미완료 상태에서 재가입/로그인이 모두 막혀 데드락되던 문제 해결. - signup: 인증 완료 계정만 409로 거부하고, 미인증 로컬 계정은 입력값으로 갱신 후 인증 메일 재발송(중복 계정 생성 안 함) - 로그인 화면: 미인증(403) 응답 시 '인증 메일 다시 보내기' 인라인 액션 노출 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,11 +59,13 @@ export class AuthService {
|
||||
private readonly refreshRepo: Repository<RefreshSession>,
|
||||
) {}
|
||||
|
||||
// 회원가입 — 이메일 중복 검사 후 미인증 계정 생성 + 인증 메일 발송(자동 로그인 X)
|
||||
// 회원가입 — 인증 완료 계정만 중복 거부. 미인증(가입 대기) 계정은 재가입을 허용한다.
|
||||
// (인증 메일을 받기 전 사이트를 닫아 가입을 못 끝낸 경우, 같은 이메일로 다시 가입해
|
||||
// 새 인증 메일을 받아 가입을 완료할 수 있게 한다. 자동 로그인은 하지 않음)
|
||||
async signup(dto: SignupDto): Promise<SignupResult> {
|
||||
const exists = await this.userService.findByEmail(dto.email);
|
||||
if (exists) {
|
||||
// 409 + 비즈니스 코드/문구 명시 → 필터가 그대로 노출
|
||||
const existing = await this.userService.findByEmail(dto.email);
|
||||
// 이미 인증을 마친 계정(로컬·소셜)은 재가입 불가
|
||||
if (existing && (existing.emailVerified || existing.provider !== 'local')) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'이미 가입된 이메일입니다.',
|
||||
@@ -71,6 +73,19 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS);
|
||||
|
||||
// 미인증 로컬 계정이 있으면 입력값으로 갱신 후 인증 메일만 재발송(중복 계정 생성 X)
|
||||
if (existing) {
|
||||
await this.userService.updateRegistration(existing.id, {
|
||||
passwordHash,
|
||||
name: dto.name,
|
||||
role: dto.role ?? null,
|
||||
});
|
||||
existing.name = dto.name; // 메일 인사말에 최신 이름 반영
|
||||
await this.sendVerification(existing);
|
||||
return { email: existing.email, verificationPending: true };
|
||||
}
|
||||
|
||||
const user = await this.userService.create({
|
||||
email: dto.email,
|
||||
passwordHash,
|
||||
|
||||
@@ -113,6 +113,19 @@ export class UserService implements OnApplicationBootstrap {
|
||||
return this.userRepository.save(user);
|
||||
}
|
||||
|
||||
// 미인증(가입 대기) 로컬 계정 재가입 — 비밀번호/이름/역할을 새 입력으로 갱신.
|
||||
// (인증 완료 전 계정에 한해 호출; 직후 인증 메일을 재발송한다)
|
||||
async updateRegistration(
|
||||
userId: string,
|
||||
payload: { passwordHash: string; name: string; role?: string | null },
|
||||
): Promise<void> {
|
||||
await this.userRepository.update(userId, {
|
||||
passwordHash: payload.passwordHash,
|
||||
name: payload.name,
|
||||
role: payload.role ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// 소셜(OAuth) 사용자 생성 — 비밀번호 없음, 제공자가 이메일을 검증하므로 인증 완료 상태.
|
||||
createOAuthUser(payload: {
|
||||
email: string;
|
||||
|
||||
@@ -16,6 +16,12 @@ const showPassword = ref(false)
|
||||
const keepLogin = ref(true)
|
||||
const submitting = ref(false)
|
||||
|
||||
// 미인증 계정 로그인 시도 시(서버 403) 인라인 재발송 안내 노출
|
||||
const needsVerify = ref(false)
|
||||
const pendingEmail = ref('')
|
||||
const resending = ref(false)
|
||||
const resendDone = ref(false)
|
||||
|
||||
// 상단 안내 배너 — 이메일 인증(/login?verified=1|0) 또는 비밀번호 재설정 완료(/login?reset=1)
|
||||
const verifyBanner = computed<{ ok: boolean; text: string } | null>(() => {
|
||||
if (route.query.reset === '1')
|
||||
@@ -34,6 +40,7 @@ const verifyBanner = computed<{ ok: boolean; text: string } | null>(() => {
|
||||
async function onLogin() {
|
||||
if (submitting.value) return
|
||||
submitting.value = true
|
||||
needsVerify.value = false
|
||||
try {
|
||||
await authStore.login({
|
||||
email: email.value.trim(),
|
||||
@@ -41,10 +48,30 @@ async function onLogin() {
|
||||
keepLogin: keepLogin.value,
|
||||
})
|
||||
await router.push('/projects')
|
||||
} catch (err) {
|
||||
// 미인증 계정은 서버가 403 으로 응답 → 인라인 재발송 안내 노출(메시지 토스트는 인터셉터가 처리)
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
if (status === 403) {
|
||||
pendingEmail.value = email.value.trim()
|
||||
resendDone.value = false
|
||||
needsVerify.value = true
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 인증 메일 재발송 — 미인증 로그인 안내에서 호출(존재 여부는 서버가 노출하지 않음)
|
||||
async function resendVerify() {
|
||||
if (resending.value || !pendingEmail.value) return
|
||||
resending.value = true
|
||||
try {
|
||||
await authStore.resendVerification(pendingEmail.value)
|
||||
resendDone.value = true
|
||||
} catch {
|
||||
// 에러 메시지는 API 인터셉터에서 전역 처리됨
|
||||
} finally {
|
||||
submitting.value = false
|
||||
resending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +183,32 @@ const points = [
|
||||
{{ verifyBanner.text }}
|
||||
</div>
|
||||
|
||||
<!-- 미인증 계정 로그인 시도 — 인증 메일 재발송 안내 -->
|
||||
<div
|
||||
v-if="needsVerify"
|
||||
class="verify-banner err resend-banner"
|
||||
role="status"
|
||||
>
|
||||
<div class="rb-msg">
|
||||
이메일 인증이 완료되지 않았습니다. 받은 인증 메일의 링크로 가입을 완료해 주세요.
|
||||
</div>
|
||||
<button
|
||||
v-if="!resendDone"
|
||||
class="rb-btn"
|
||||
type="button"
|
||||
:disabled="resending"
|
||||
@click="resendVerify"
|
||||
>
|
||||
{{ resending ? '보내는 중…' : '인증 메일 다시 보내기' }}
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
class="rb-done"
|
||||
>
|
||||
인증 메일을 다시 보냈습니다. 메일함을 확인해 주세요.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 소셜 로그인 -->
|
||||
<div class="socials">
|
||||
<button
|
||||
@@ -518,6 +571,34 @@ const points = [
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* 미인증 재발송 안내 — 메시지 + 액션 버튼(세로 배치) */
|
||||
.resend-banner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.resend-banner .rb-btn {
|
||||
border: 1px solid var(--red-border);
|
||||
background: #fff;
|
||||
color: var(--red);
|
||||
font-family: inherit;
|
||||
font-size: 0.781rem;
|
||||
font-weight: 700;
|
||||
padding: 0.4375rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.resend-banner .rb-btn:hover:not(:disabled) {
|
||||
background: #fff5f5;
|
||||
}
|
||||
.resend-banner .rb-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.resend-banner .rb-done {
|
||||
font-weight: 600;
|
||||
color: #1b7a3d;
|
||||
}
|
||||
.socials {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user