feat: 비밀번호 재설정 플로우 (이메일 링크 기반)

백엔드:
- User 엔티티에 passwordResetTokenHash/passwordResetExpiresAt(select:false) 추가
- UserService: setPasswordResetToken/findByPasswordResetTokenHash/updatePassword
- MailService: sendPasswordResetEmail (dev=로그 출력, prod=수신자만)
- AuthService: requestPasswordReset(존재 비노출·로컬계정만·1시간 토큰),
  resetPassword(토큰 해시 검증→새 비번 저장→전 세션 폐기)
- POST /auth/forgot-password(3/분)·/auth/reset-password(5/분), 가입과 동일 비번 규칙
- OAuth 콜백의 잔여 /repos 리다이렉트를 /projects 로 교정(리팩터 누락분)

프론트:
- useAuth: forgotPassword/resetPassword
- ForgotPasswordPage(/forgot-password), ResetPasswordPage(/reset-password, 토큰=쿼리)
- LoginPage '비밀번호 찾기' 링크 연결 + 재설정 완료(?reset=1) 배너

런타임 검증: 재설정→새 비번 로그인 200·구 비번 거부·토큰 재사용 거부·약한 비번 VAL_001·
없는 이메일 동일응답(비노출). 백엔드 build/lint 0·14 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 20:50:34 +09:00
parent cb6aa07ebc
commit 52b70363d9
12 changed files with 745 additions and 7 deletions
@@ -0,0 +1,215 @@
<script setup lang="ts">
// 비밀번호 재설정 요청 — 이메일 입력 시 재설정 메일 발송(존재 여부 비노출)
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
const { forgotPassword } = useAuth()
const email = ref('')
const submitting = ref(false)
const sent = ref(false)
// 요청 전송 — 성공/존재여부와 무관하게 동일 안내(계정 탐색 방지)
async function onSubmit() {
if (submitting.value || !email.value.trim()) return
submitting.value = true
try {
await forgotPassword(email.value.trim())
sent.value = true
} catch {
// 에러는 API 인터셉터에서 전역 처리
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="auth-simple">
<div class="card">
<div class="bp-logo">
<span class="logo">R</span> Relay
</div>
<template v-if="!sent">
<h1>비밀번호 재설정</h1>
<p class="lede">
가입하신 이메일을 입력하시면 비밀번호를 재설정할 있는 링크를 보내드립니다.
</p>
<form @submit.prevent="onSubmit">
<div class="field">
<label
class="flabel"
for="fp-email"
>이메일</label>
<input
id="fp-email"
v-model="email"
class="tinput"
type="email"
placeholder="name@acme.co"
autocomplete="email"
>
</div>
<button
class="submit"
type="submit"
:disabled="submitting"
>
{{ submitting ? '전송 중…' : '재설정 링크 보내기' }}
</button>
</form>
</template>
<template v-else>
<div class="sent-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z" />
<path d="m22 6-10 7L2 6" />
</svg>
</div>
<h1>메일을 확인해 주세요</h1>
<p class="lede">
입력하신 주소가 가입된 계정이라면 비밀번호 재설정 링크를 보내드렸습니다.
링크는 1시간 동안 유효합니다.
</p>
</template>
<div class="back">
<RouterLink to="/login">
로그인으로 돌아가기
</RouterLink>
</div>
</div>
</div>
</template>
<style scoped>
.auth-simple {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--bg, #f6f7f9);
padding: 1.5rem;
}
.card {
width: 100%;
max-width: 25rem;
background: #fff;
border: 1px solid var(--border);
border-radius: 0.875rem;
box-shadow: var(--shadow-sm);
padding: 2rem 1.875rem;
}
.bp-logo {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.0625rem;
font-weight: 700;
color: var(--text);
margin-bottom: 1.5rem;
}
.bp-logo .logo {
width: 1.75rem;
height: 1.75rem;
border-radius: 0.5rem;
background: var(--accent);
color: #fff;
display: grid;
place-items: center;
font-size: 0.9375rem;
}
h1 {
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.025rem;
margin-bottom: 0.4375rem;
}
.lede {
font-size: 0.844rem;
color: var(--text-2);
line-height: 1.6;
margin-bottom: 1.375rem;
}
.field {
margin-bottom: 1rem;
}
.flabel {
display: block;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.4375rem;
}
.tinput {
width: 100%;
height: 2.625rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0 0.8125rem;
font-family: inherit;
font-size: 0.875rem;
color: var(--text);
background: #fff;
}
.tinput:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.submit {
width: 100%;
height: 2.625rem;
border: none;
border-radius: var(--radius);
background: var(--accent);
color: #fff;
font-family: inherit;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
}
.submit:hover {
background: var(--accent-hover);
}
.submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.sent-ico {
width: 3rem;
height: 3rem;
border-radius: 50%;
background: var(--accent-weak);
color: var(--accent);
display: grid;
place-items: center;
margin-bottom: 1rem;
}
.sent-ico svg {
width: 1.5rem;
height: 1.5rem;
}
.back {
margin-top: 1.5rem;
text-align: center;
font-size: 0.844rem;
}
.back a {
color: var(--accent);
text-decoration: none;
font-weight: 600;
}
.back a:hover {
text-decoration: underline;
}
</style>
+8 -4
View File
@@ -15,8 +15,10 @@ const showPassword = ref(false)
const keepLogin = ref(true)
const submitting = ref(false)
// 이메일 인증 결과 배너 — 백엔드 인증 링크가 /login?verified=1|0 으로 리다이렉트한다
// 상단 안내 배너 — 이메일 인증(/login?verified=1|0) 또는 비밀번호 재설정 완료(/login?reset=1)
const verifyBanner = computed<{ ok: boolean; text: string } | null>(() => {
if (route.query.reset === '1')
return { ok: true, text: '비밀번호가 변경되었습니다. 새 비밀번호로 로그인해 주세요.' }
const v = route.query.verified
if (v === '1') return { ok: true, text: '이메일 인증이 완료되었습니다. 로그인해 주세요.' }
if (v === '0')
@@ -288,10 +290,12 @@ const points = [
</span>
로그인 상태 유지
</button>
<a
<RouterLink
class="link"
href="#"
>비밀번호 찾기</a>
to="/forgot-password"
>
비밀번호 찾기
</RouterLink>
</div>
<button
@@ -0,0 +1,292 @@
<script setup lang="ts">
// 비밀번호 재설정 — 메일 링크의 token(쿼리)으로 새 비밀번호를 설정
import { computed, ref } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
const route = useRoute()
const router = useRouter()
const { resetPassword } = useAuth()
// 메일 링크의 토큰 — 없으면 잘못된 진입
const token = computed(() => {
const t = route.query.token
return typeof t === 'string' ? t : ''
})
const password = ref('')
const confirm = ref('')
const showPassword = ref(false)
const submitting = ref(false)
// 클라이언트 검증(서버 규칙과 동일: 8자 이상, 영문·숫자 포함)
const ruleOk = computed(
() => password.value.length >= 8 && /[A-Za-z]/.test(password.value) && /\d/.test(password.value),
)
const matchOk = computed(() => confirm.value.length > 0 && password.value === confirm.value)
const canSubmit = computed(() => ruleOk.value && matchOk.value && !submitting.value)
// 재설정 제출 — 성공 시 로그인 화면으로(배너 안내)
async function onSubmit() {
if (!canSubmit.value) return
submitting.value = true
try {
await resetPassword(token.value, password.value)
await router.push('/login?reset=1')
} catch {
// 토큰 만료/무효 등은 API 인터셉터에서 전역 처리
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="auth-simple">
<div class="card">
<div class="bp-logo">
<span class="logo">R</span> Relay
</div>
<!-- 토큰 없음 -->
<template v-if="!token">
<h1>잘못된 접근입니다</h1>
<p class="lede">
비밀번호 재설정 링크가 올바르지 않습니다. 재설정을 다시 요청해 주세요.
</p>
<div class="back">
<RouterLink to="/forgot-password">
재설정 다시 요청하기
</RouterLink>
</div>
</template>
<!-- 비밀번호 입력 -->
<template v-else>
<h1> 비밀번호 설정</h1>
<p class="lede">
새로 사용할 비밀번호를 입력해 주세요. 변경 후에는 모든 기기에서 다시 로그인해야 합니다.
</p>
<form @submit.prevent="onSubmit">
<div class="field">
<label
class="flabel"
for="rp-pw"
> 비밀번호</label>
<div class="pw-wrap">
<input
id="rp-pw"
v-model="password"
class="tinput"
:type="showPassword ? 'text' : 'password'"
placeholder="8자 이상, 영문·숫자 포함"
autocomplete="new-password"
>
<button
class="pw-toggle"
type="button"
:aria-label="showPassword ? '비밀번호 숨기기' : '비밀번호 표시'"
@click="showPassword = !showPassword"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle
cx="12"
cy="12"
r="3"
/>
</svg>
</button>
</div>
<div
v-if="password && !ruleOk"
class="fstatus bad"
>
8 이상이며 영문과 숫자를 모두 포함해야 합니다.
</div>
</div>
<div class="field">
<label
class="flabel"
for="rp-confirm"
>비밀번호 확인</label>
<input
id="rp-confirm"
v-model="confirm"
class="tinput"
:type="showPassword ? 'text' : 'password'"
placeholder="비밀번호 다시 입력"
autocomplete="new-password"
>
<div
v-if="confirm && !matchOk"
class="fstatus bad"
>
비밀번호가 일치하지 않습니다.
</div>
</div>
<button
class="submit"
type="submit"
:disabled="!canSubmit"
>
{{ submitting ? '변경 중…' : '비밀번호 변경' }}
</button>
</form>
<div class="back">
<RouterLink to="/login">
로그인으로 돌아가기
</RouterLink>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.auth-simple {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--bg, #f6f7f9);
padding: 1.5rem;
}
.card {
width: 100%;
max-width: 25rem;
background: #fff;
border: 1px solid var(--border);
border-radius: 0.875rem;
box-shadow: var(--shadow-sm);
padding: 2rem 1.875rem;
}
.bp-logo {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.0625rem;
font-weight: 700;
color: var(--text);
margin-bottom: 1.5rem;
}
.bp-logo .logo {
width: 1.75rem;
height: 1.75rem;
border-radius: 0.5rem;
background: var(--accent);
color: #fff;
display: grid;
place-items: center;
font-size: 0.9375rem;
}
h1 {
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.025rem;
margin-bottom: 0.4375rem;
}
.lede {
font-size: 0.844rem;
color: var(--text-2);
line-height: 1.6;
margin-bottom: 1.375rem;
}
.field {
margin-bottom: 1rem;
}
.flabel {
display: block;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.4375rem;
}
.pw-wrap {
position: relative;
}
.tinput {
width: 100%;
height: 2.625rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0 0.8125rem;
font-family: inherit;
font-size: 0.875rem;
color: var(--text);
background: #fff;
}
.tinput:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.pw-wrap .tinput {
padding-right: 2.75rem;
}
.pw-toggle {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
width: 2rem;
height: 2rem;
border: none;
background: transparent;
color: var(--text-3);
cursor: pointer;
display: grid;
place-items: center;
}
.pw-toggle svg {
width: 1.125rem;
height: 1.125rem;
}
.fstatus {
font-size: 0.781rem;
margin-top: 0.4375rem;
}
.fstatus.bad {
color: var(--red);
}
.submit {
width: 100%;
height: 2.625rem;
border: none;
border-radius: var(--radius);
background: var(--accent);
color: #fff;
font-family: inherit;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
}
.submit:hover {
background: var(--accent-hover);
}
.submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.back {
margin-top: 1.5rem;
text-align: center;
font-size: 0.844rem;
}
.back a {
color: var(--accent);
text-decoration: none;
font-weight: 600;
}
.back a:hover {
text-decoration: underline;
}
</style>