feat: 비밀번호 찾기 화면을 로그인 2단 레이아웃으로 변경
- 시안(비밀번호 찾기.html) 적용 — 좌측 브랜드 패널 + 우측 폼(로그인 화면과 동일 구조) - 1단계 이메일 입력(뒤로가기 링크·재설정 링크 보내기) → 2단계 전송 완료 (메일 아이콘·수신 이메일 칩·스팸함 안내 박스·다시 보내기·로그인 복귀) - 다시 보내기 시 동일 이메일 재요청 + '전송됨 ✓' 일시 피드백 - 시안의 '30분 유효'는 실제 백엔드 토큰 TTL(1시간)에 맞춰 표기 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 비밀번호 재설정 요청 — 이메일 입력 시 재설정 메일 발송(존재 여부 비노출)
|
||||
import { ref } from 'vue'
|
||||
// 비밀번호 찾기 — 로그인 화면과 동일한 2단 레이아웃(브랜드 패널 + 폼).
|
||||
// 이메일 입력(1단계) → 전송 완료 안내(2단계). 존재 여부는 노출하지 않는다.
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
|
||||
@@ -9,13 +10,26 @@ const { forgotPassword } = useAuth()
|
||||
const email = ref('')
|
||||
const submitting = ref(false)
|
||||
const sent = ref(false)
|
||||
const sentEmail = ref('')
|
||||
|
||||
// 요청 전송 — 성공/존재여부와 무관하게 동일 안내(계정 탐색 방지)
|
||||
// 재발송 버튼 라벨(피드백용)
|
||||
const resendLabel = ref('다시 보내기')
|
||||
let resendTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 브랜드 패널 강조 포인트
|
||||
const points = [
|
||||
'가입 이메일로 안전하게 본인 확인',
|
||||
'재설정 링크는 1시간 동안 유효합니다',
|
||||
'소셜 로그인 계정은 해당 서비스에서 변경',
|
||||
]
|
||||
|
||||
// 재설정 링크 전송 — 성공/존재여부와 무관하게 완료 화면으로 전환(계정 탐색 방지)
|
||||
async function onSubmit() {
|
||||
if (submitting.value || !email.value.trim()) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await forgotPassword(email.value.trim())
|
||||
sentEmail.value = email.value.trim()
|
||||
sent.value = true
|
||||
} catch {
|
||||
// 에러는 API 인터셉터에서 전역 처리
|
||||
@@ -23,136 +37,372 @@ async function onSubmit() {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 다시 보내기 — 같은 이메일로 재요청 + 일시 피드백
|
||||
async function onResend() {
|
||||
if (!sentEmail.value) return
|
||||
try {
|
||||
await forgotPassword(sentEmail.value)
|
||||
} catch {
|
||||
// 인터셉터 전역 처리
|
||||
}
|
||||
resendLabel.value = '전송됨 ✓'
|
||||
if (resendTimer) clearTimeout(resendTimer)
|
||||
resendTimer = setTimeout(() => {
|
||||
resendLabel.value = '다시 보내기'
|
||||
}, 2400)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resendTimer) clearTimeout(resendTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-simple">
|
||||
<div class="card">
|
||||
<div class="auth">
|
||||
<!-- 브랜드 패널 -->
|
||||
<aside class="brand-panel">
|
||||
<div class="blob b1" />
|
||||
<div class="blob b2" />
|
||||
<div class="ring" />
|
||||
<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 class="bp-body">
|
||||
<div class="bp-head">
|
||||
비밀번호를 잊으셨나요?<br>금방 다시 들어오실 수 있어요
|
||||
</div>
|
||||
<div class="bp-sub">
|
||||
가입하신 이메일로 재설정 링크를 보내드립니다. 링크를 눌러 새 비밀번호를 설정하세요.
|
||||
</div>
|
||||
<div class="bp-points">
|
||||
<div
|
||||
v-for="point in points"
|
||||
:key="point"
|
||||
class="bp-point"
|
||||
>
|
||||
<span class="ic">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</span>
|
||||
{{ point }}
|
||||
</div>
|
||||
</div>
|
||||
<h1>메일을 확인해 주세요</h1>
|
||||
<p class="lede">
|
||||
입력하신 주소가 가입된 계정이라면 비밀번호 재설정 링크를 보내드렸습니다.
|
||||
링크는 1시간 동안 유효합니다.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="back">
|
||||
<RouterLink to="/login">
|
||||
로그인으로 돌아가기
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-foot">
|
||||
© 2026 Relay · 사내 업무 지시 플랫폼
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 폼 패널 -->
|
||||
<main class="form-panel">
|
||||
<div class="auth-card">
|
||||
<div class="ac-logo">
|
||||
<span class="logo">R</span> Relay
|
||||
</div>
|
||||
|
||||
<!-- 1단계: 이메일 입력 -->
|
||||
<div v-if="!sent">
|
||||
<RouterLink
|
||||
class="back-link"
|
||||
to="/login"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
로그인으로 돌아가기
|
||||
</RouterLink>
|
||||
<h1 class="ac-title">
|
||||
비밀번호 찾기
|
||||
</h1>
|
||||
<p class="ac-sub">
|
||||
가입하신 이메일 주소를 입력하시면<br>비밀번호 재설정 링크를 보내드립니다.
|
||||
</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>
|
||||
|
||||
<div class="signup">
|
||||
비밀번호가 기억나셨나요? <RouterLink to="/login">
|
||||
로그인
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2단계: 전송 완료 -->
|
||||
<div
|
||||
v-else
|
||||
class="sent"
|
||||
>
|
||||
<div class="sent-ic">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect
|
||||
x="2"
|
||||
y="4"
|
||||
width="20"
|
||||
height="16"
|
||||
rx="2"
|
||||
/>
|
||||
<path d="m22 7-10 6L2 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="ac-title">
|
||||
메일을 확인해 주세요
|
||||
</h1>
|
||||
<p class="ac-sub">
|
||||
<span class="email-chip">{{ sentEmail }}</span> 주소로<br>재설정 링크를 보냈습니다.
|
||||
</p>
|
||||
|
||||
<div class="sent-box">
|
||||
메일이 보이지 않나요? <b>스팸함</b>을 확인하거나, 입력하신 주소가 맞는지 다시 살펴보세요.
|
||||
재설정 링크는 <b>1시간</b> 동안 유효합니다.
|
||||
</div>
|
||||
|
||||
<div class="resend">
|
||||
메일을 받지 못하셨나요? <a @click="onResend">{{ resendLabel }}</a>
|
||||
</div>
|
||||
<div class="signup">
|
||||
<RouterLink to="/login">
|
||||
로그인으로 돌아가기
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-simple {
|
||||
.auth {
|
||||
--radius: 8px;
|
||||
display: flex;
|
||||
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;
|
||||
|
||||
/* ---- 브랜드 패널 ---- */
|
||||
.brand-panel {
|
||||
flex: 0 0 46%;
|
||||
max-width: 38.75rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(155deg, #4f46e5 0%, #4338ca 52%, #2e2a8f 100%);
|
||||
color: #fff;
|
||||
padding: 2.75rem 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.brand-panel .blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.brand-panel .b1 {
|
||||
width: 22.5rem;
|
||||
height: 22.5rem;
|
||||
right: -7.5rem;
|
||||
top: -5.625rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.brand-panel .b2 {
|
||||
width: 16.25rem;
|
||||
height: 16.25rem;
|
||||
left: -5.625rem;
|
||||
bottom: -4.375rem;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.brand-panel .ring {
|
||||
position: absolute;
|
||||
right: 3.75rem;
|
||||
bottom: 5rem;
|
||||
width: 11.25rem;
|
||||
height: 11.25rem;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.brand-panel .ring::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2.125rem;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.bp-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.0625rem;
|
||||
gap: 0.625rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.125rem;
|
||||
letter-spacing: -0.0125rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.bp-logo .logo {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5625rem;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.9375rem;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
.bp-body {
|
||||
margin-top: auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.bp-head {
|
||||
font-size: 2.125rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025rem;
|
||||
margin-bottom: 0.4375rem;
|
||||
line-height: 1.32;
|
||||
letter-spacing: -0.05rem;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.lede {
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-2);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.375rem;
|
||||
.bp-sub {
|
||||
margin-top: 1.125rem;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
max-width: 25rem;
|
||||
}
|
||||
.field {
|
||||
margin-bottom: 1rem;
|
||||
.bp-points {
|
||||
margin-top: 1.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.flabel {
|
||||
display: block;
|
||||
.bp-point {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6875rem;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.bp-point .ic {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bp-point .ic svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
.bp-foot {
|
||||
margin-top: 2.5rem;
|
||||
font-size: 0.781rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ---- 폼 패널 ---- */
|
||||
.form-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2.5rem 1.5rem;
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
}
|
||||
.ac-logo {
|
||||
display: none;
|
||||
}
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
margin-bottom: 1.375rem;
|
||||
}
|
||||
.back-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.back-link svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
.ac-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03125rem;
|
||||
}
|
||||
.ac-sub {
|
||||
color: var(--text-2);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.4375rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 0.875rem;
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
.flabel {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 0.4375rem;
|
||||
display: block;
|
||||
}
|
||||
.tinput {
|
||||
width: 100%;
|
||||
height: 2.625rem;
|
||||
height: 2.75rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.8125rem;
|
||||
@@ -166,17 +416,23 @@ h1 {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.tinput::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
height: 2.625rem;
|
||||
border: none;
|
||||
height: 2.875rem;
|
||||
border-radius: var(--radius);
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
|
||||
margin-top: 1.375rem;
|
||||
}
|
||||
.submit:hover {
|
||||
background: var(--accent-hover);
|
||||
@@ -185,31 +441,104 @@ h1 {
|
||||
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 {
|
||||
|
||||
.signup {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.back a {
|
||||
.signup a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.back a:hover {
|
||||
.signup a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---- 전송 완료 상태 ---- */
|
||||
.sent {
|
||||
text-align: center;
|
||||
}
|
||||
.sent-ic {
|
||||
width: 3.75rem;
|
||||
height: 3.75rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-weak);
|
||||
border: 1px solid var(--accent-border);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0 auto 1.375rem;
|
||||
}
|
||||
.sent-ic svg {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
.sent .ac-title,
|
||||
.sent .ac-sub {
|
||||
text-align: center;
|
||||
}
|
||||
.email-chip {
|
||||
display: inline-block;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.sent-box {
|
||||
margin-top: 1.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background: #fbfbfd;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.125rem;
|
||||
text-align: left;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-2);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.sent-box b {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.resend {
|
||||
margin-top: 1.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.resend a {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.resend a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 좁은 화면 — 브랜드 패널 숨기고 카드 로고 노출 */
|
||||
@media (max-width: 55rem) {
|
||||
.brand-panel {
|
||||
display: none;
|
||||
}
|
||||
.ac-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.0625rem;
|
||||
margin-bottom: 1.625rem;
|
||||
}
|
||||
.ac-logo .logo {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(135deg, #5b54f0, #4338ca);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user