From 5e742e5c8b454edaf902a78727f9fe806bf92f53 Mon Sep 17 00:00:00 2001 From: ttipo Date: Mon, 22 Jun 2026 13:42:34 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=AF=B8=EC=9D=B8=EC=A6=9D=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EC=9E=AC=EA=B0=80=EC=9E=85=20=ED=97=88=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EB=A9=94=EC=9D=BC=20=EC=9E=AC=EB=B0=9C?= =?UTF-8?q?=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인증 미완료 상태에서 재가입/로그인이 모두 막혀 데드락되던 문제 해결. - signup: 인증 완료 계정만 409로 거부하고, 미인증 로컬 계정은 입력값으로 갱신 후 인증 메일 재발송(중복 계정 생성 안 함) - 로그인 화면: 미인증(403) 응답 시 '인증 메일 다시 보내기' 인라인 액션 노출 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/modules/auth/auth.service.ts | 23 +++++-- backend/src/modules/user/user.service.ts | 13 ++++ frontend/src/pages/relay/LoginPage.vue | 83 +++++++++++++++++++++++- 3 files changed, 114 insertions(+), 5 deletions(-) diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index a42a12d..1e066a5 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -59,11 +59,13 @@ export class AuthService { private readonly refreshRepo: Repository, ) {} - // 회원가입 — 이메일 중복 검사 후 미인증 계정 생성 + 인증 메일 발송(자동 로그인 X) + // 회원가입 — 인증 완료 계정만 중복 거부. 미인증(가입 대기) 계정은 재가입을 허용한다. + // (인증 메일을 받기 전 사이트를 닫아 가입을 못 끝낸 경우, 같은 이메일로 다시 가입해 + // 새 인증 메일을 받아 가입을 완료할 수 있게 한다. 자동 로그인은 하지 않음) async signup(dto: SignupDto): Promise { - 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, diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index 26d7fd7..b8a8b92 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -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 { + await this.userRepository.update(userId, { + passwordHash: payload.passwordHash, + name: payload.name, + role: payload.role ?? null, + }); + } + // 소셜(OAuth) 사용자 생성 — 비밀번호 없음, 제공자가 이메일을 검증하므로 인증 완료 상태. createOAuthUser(payload: { email: string; diff --git a/frontend/src/pages/relay/LoginPage.vue b/frontend/src/pages/relay/LoginPage.vue index 78160b0..8e8ded6 100644 --- a/frontend/src/pages/relay/LoginPage.vue +++ b/frontend/src/pages/relay/LoginPage.vue @@ -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 }} + +
+
+ 이메일 인증이 완료되지 않았습니다. 받은 인증 메일의 링크로 가입을 완료해 주세요. +
+ +
+ 인증 메일을 다시 보냈습니다. 메일함을 확인해 주세요. +
+
+