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:
@@ -19,6 +19,8 @@ import { AuthService, type SignupResult } from './auth.service';
|
||||
import { SignupDto } from './dto/signup.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { ResendVerificationDto } from './dto/resend-verification.dto';
|
||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { SkipTransform } from '../../common/decorators/skip-transform.decorator';
|
||||
@@ -114,6 +116,29 @@ export class AuthController {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Post('forgot-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
// 메일 폭탄/계정 탐색 방지 — IP당 분당 3회
|
||||
@Throttle({ default: { limit: 3, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: '비밀번호 재설정 요청 (재설정 메일 발송)' })
|
||||
@ApiResponse({ status: 200, description: '요청 접수(존재 여부 비노출)' })
|
||||
async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<null> {
|
||||
await this.authService.requestPasswordReset(dto.email);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Post('reset-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
// 토큰 추측 방지 — IP당 분당 5회
|
||||
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: '비밀번호 재설정 (토큰 + 새 비밀번호)' })
|
||||
@ApiResponse({ status: 200, description: '비밀번호 변경 성공' })
|
||||
@ApiResponse({ status: 400, description: '토큰 만료/무효 (BIZ_001)' })
|
||||
async resetPassword(@Body() dto: ResetPasswordDto): Promise<null> {
|
||||
await this.authService.resetPassword(dto.token, dto.password);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 이메일 인증 링크 — 메일의 버튼에서 직접 GET 진입. 검증 후 프론트로 리다이렉트.
|
||||
@Get('verify-email')
|
||||
@SkipTransform()
|
||||
@@ -167,7 +192,7 @@ export class AuthController {
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
await this.loginWith(res, req.user as PublicUser);
|
||||
res.redirect(`${this.webUrl()}/repos`);
|
||||
res.redirect(`${this.webUrl()}/projects`);
|
||||
}
|
||||
|
||||
// ── 카카오 OAuth ──
|
||||
@@ -188,7 +213,7 @@ export class AuthController {
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
await this.loginWith(res, req.user as PublicUser);
|
||||
res.redirect(`${this.webUrl()}/repos`);
|
||||
res.redirect(`${this.webUrl()}/projects`);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
|
||||
@@ -18,6 +18,8 @@ import type { JwtPayload } from './types/jwt-payload';
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
// 이메일 인증 토큰 유효 시간 (24시간)
|
||||
const VERIFY_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
// 비밀번호 재설정 토큰 유효 시간 (1시간 — 보안상 짧게)
|
||||
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000;
|
||||
// refresh 세션 만료(=refresh TTL, 정리용). JWT_REFRESH_TTL(기본 14d)과 맞춘다.
|
||||
const REFRESH_SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
// 동시 갱신(재시도) 유예 — 직전 jti 가 이 시간 내 재제출이면 재사용으로 보지 않음
|
||||
@@ -112,6 +114,42 @@ export class AuthService {
|
||||
await this.userService.markEmailVerified(user.id);
|
||||
}
|
||||
|
||||
// 비밀번호 재설정 요청 — 존재/상태를 노출하지 않기 위해 항상 동일하게 응답.
|
||||
// 로컬(이메일) 계정만 실제 재설정 메일 발송(소셜 전용은 비밀번호가 없음).
|
||||
async requestPasswordReset(email: string): Promise<void> {
|
||||
const user = await this.userService.findByEmail(email);
|
||||
if (!user || user.provider !== 'local') {
|
||||
return;
|
||||
}
|
||||
const rawToken = randomBytes(32).toString('hex');
|
||||
const tokenHash = this.hashToken(rawToken);
|
||||
const expiresAt = new Date(Date.now() + PASSWORD_RESET_TTL_MS);
|
||||
await this.userService.setPasswordResetToken(user.id, tokenHash, expiresAt);
|
||||
|
||||
const resetUrl = `${this.webUrl()}/reset-password?token=${rawToken}`;
|
||||
this.mailService.sendPasswordResetEmail(user.email, user.name, resetUrl);
|
||||
}
|
||||
|
||||
// 비밀번호 재설정 — 토큰(원문) 해시로 조회 + 만료 검사 후 새 비밀번호 저장.
|
||||
// 보안상 비밀번호 변경 시 해당 사용자의 모든 세션을 폐기(전 기기 강제 로그아웃).
|
||||
async resetPassword(token: string, newPassword: string): Promise<void> {
|
||||
if (!token) {
|
||||
throw this.invalidResetLink();
|
||||
}
|
||||
const tokenHash = this.hashToken(token);
|
||||
const user = await this.userService.findByPasswordResetTokenHash(tokenHash);
|
||||
if (
|
||||
!user ||
|
||||
!user.passwordResetExpiresAt ||
|
||||
user.passwordResetExpiresAt.getTime() < Date.now()
|
||||
) {
|
||||
throw this.invalidResetLink();
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
||||
await this.userService.updatePassword(user.id, passwordHash);
|
||||
await this.revokeAllSessions(user.id);
|
||||
}
|
||||
|
||||
// 로그인 검증 — 자격 확인 + 소셜전용/미인증 분기
|
||||
async validateUser(dto: LoginDto): Promise<PublicUser> {
|
||||
const user = await this.userService.findByEmailWithPassword(dto.email);
|
||||
@@ -325,6 +363,24 @@ export class AuthService {
|
||||
return createHash('sha256').update(raw).digest('hex');
|
||||
}
|
||||
|
||||
// 프론트엔드(웹) 기준 URL — 비밀번호 재설정 폼 링크 대상(컨트롤러 webUrl 과 동일 정책)
|
||||
private webUrl(): string {
|
||||
const url =
|
||||
this.config.get<string>('FRONTEND_URL') ||
|
||||
this.config.get<string>('CORS_ORIGIN') ||
|
||||
'http://localhost:5273';
|
||||
return url.split(',')[0].trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
// 재설정 링크 무효/만료 공통 예외
|
||||
private invalidResetLink(): BusinessException {
|
||||
return new BusinessException(
|
||||
'BIZ_001',
|
||||
'비밀번호 재설정 링크가 만료되었거나 유효하지 않습니다. 다시 요청해 주세요.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
// 자격 불일치 공통 예외(이메일/비밀번호 구분 없이 동일 문구 — 계정 존재 노출 방지)
|
||||
private invalidCredentials(): BusinessException {
|
||||
return new BusinessException(
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsEmail, IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 비밀번호 재설정 요청 DTO — 대상 이메일
|
||||
export class ForgotPasswordDto {
|
||||
@ApiProperty({
|
||||
description: '비밀번호 재설정 메일을 받을 이메일',
|
||||
example: 'sykim@acme.co',
|
||||
})
|
||||
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
|
||||
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
|
||||
email!: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 비밀번호 재설정 DTO — 메일 링크의 토큰 + 새 비밀번호(가입과 동일 규칙)
|
||||
export class ResetPasswordDto {
|
||||
@ApiProperty({ description: '재설정 토큰(메일 링크의 token 값)' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '재설정 토큰이 필요합니다.' })
|
||||
token!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '새 비밀번호 (8~72자, 영문·숫자 포함)',
|
||||
example: 'newpass123',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
|
||||
// bcrypt 는 72바이트 초과분을 잘라내므로 길이를 제한해 혼동을 방지
|
||||
@MaxLength(72, { message: '비밀번호는 최대 72자까지 가능합니다.' })
|
||||
@Matches(/(?=.*[A-Za-z])(?=.*\d)/, {
|
||||
message: '비밀번호는 영문과 숫자를 모두 포함해야 합니다.',
|
||||
})
|
||||
password!: string;
|
||||
}
|
||||
@@ -33,4 +33,29 @@ export class MailService {
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
// 비밀번호 재설정 메일 — 개발 환경에서만 재설정 링크(원문 토큰 포함)를 로그로 출력한다.
|
||||
// 운영 환경에서는 토큰이 로그에 남지 않도록 수신자만 기록한다(실 메일러로 교체 전제).
|
||||
sendPasswordResetEmail(to: string, name: string, resetUrl: string): void {
|
||||
const isProd = this.config.get<string>('NODE_ENV') === 'production';
|
||||
if (isProd) {
|
||||
this.logger.log(
|
||||
`[메일 발송 - 비밀번호 재설정] 수신자=${to} (재설정 링크는 보안상 로그에 남기지 않음 — 실 메일러 필요)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.logger.log(
|
||||
[
|
||||
'',
|
||||
'──────────────── [메일 발송 - 비밀번호 재설정] ────────────────',
|
||||
`받는 사람 : ${name} <${to}>`,
|
||||
'제목 : [Relay] 비밀번호 재설정 안내',
|
||||
'본문 : 아래 링크를 클릭해 새 비밀번호를 설정해 주세요. (1시간 유효)',
|
||||
`재설정 링크 : ${resetUrl}`,
|
||||
'안내 : 본인이 요청하지 않았다면 이 메일을 무시하세요.',
|
||||
'────────────────────────────────────────────────────────────',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,24 @@ export class User {
|
||||
})
|
||||
emailVerifyExpiresAt!: Date | null;
|
||||
|
||||
// 비밀번호 재설정 토큰 해시(SHA-256) — 원문은 메일 링크에만, DB엔 해시만. 조회 기본 제외.
|
||||
@Column({
|
||||
name: 'password_reset_token_hash',
|
||||
type: 'varchar',
|
||||
nullable: true,
|
||||
select: false,
|
||||
})
|
||||
passwordResetTokenHash!: string | null;
|
||||
|
||||
// 비밀번호 재설정 토큰 만료 시각 — 보안상 짧게(1시간). 조회 기본 제외.
|
||||
@Column({
|
||||
name: 'password_reset_expires_at',
|
||||
type: 'timestamptz',
|
||||
nullable: true,
|
||||
select: false,
|
||||
})
|
||||
passwordResetExpiresAt!: Date | null;
|
||||
|
||||
// 표시 이름
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
@@ -152,6 +152,37 @@ export class UserService implements OnApplicationBootstrap {
|
||||
});
|
||||
}
|
||||
|
||||
// 비밀번호 재설정 토큰(해시)·만료 시각 저장
|
||||
async setPasswordResetToken(
|
||||
userId: string,
|
||||
tokenHash: string,
|
||||
expiresAt: Date,
|
||||
): Promise<void> {
|
||||
await this.userRepository.update(userId, {
|
||||
passwordResetTokenHash: tokenHash,
|
||||
passwordResetExpiresAt: expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
// 비밀번호 재설정 토큰 해시로 조회 — 토큰 컬럼은 select:false 라 명시적으로 포함.
|
||||
findByPasswordResetTokenHash(tokenHash: string): Promise<User | null> {
|
||||
return this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.addSelect('user.passwordResetTokenHash')
|
||||
.addSelect('user.passwordResetExpiresAt')
|
||||
.where('user.passwordResetTokenHash = :tokenHash', { tokenHash })
|
||||
.getOne();
|
||||
}
|
||||
|
||||
// 비밀번호 변경 — 새 해시 저장 + 재설정 토큰 폐기
|
||||
async updatePassword(userId: string, passwordHash: string): Promise<void> {
|
||||
await this.userRepository.update(userId, {
|
||||
passwordHash,
|
||||
passwordResetTokenHash: null,
|
||||
passwordResetExpiresAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
// 엔티티 → 외부 노출용 형태로 변환 (민감정보 제거)
|
||||
static toPublic(user: User): PublicUser {
|
||||
return {
|
||||
|
||||
@@ -20,6 +20,16 @@ export function useAuth() {
|
||||
await api.post('/auth/resend-verification', { email })
|
||||
}
|
||||
|
||||
// 비밀번호 재설정 요청 — 재설정 메일 발송(존재 여부 비노출)
|
||||
async function forgotPassword(email: string): Promise<void> {
|
||||
await api.post('/auth/forgot-password', { email })
|
||||
}
|
||||
|
||||
// 비밀번호 재설정 — 메일 링크의 토큰 + 새 비밀번호
|
||||
async function resetPassword(token: string, password: string): Promise<void> {
|
||||
await api.post('/auth/reset-password', { token, password })
|
||||
}
|
||||
|
||||
// 로그인
|
||||
async function login(payload: LoginPayload): Promise<AuthUser> {
|
||||
return (await api.post('/auth/login', payload)) as unknown as AuthUser
|
||||
@@ -36,5 +46,13 @@ export function useAuth() {
|
||||
return (await api.get('/auth/me', { silent: true })) as unknown as AuthUser
|
||||
}
|
||||
|
||||
return { signup, resendVerification, login, logout, fetchMe }
|
||||
return {
|
||||
signup,
|
||||
resendVerification,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
login,
|
||||
logout,
|
||||
fetchMe,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -19,6 +19,18 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/relay/SignupPage.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
component: () => import('@/pages/relay/ForgotPasswordPage.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
name: 'reset-password',
|
||||
component: () => import('@/pages/relay/ResetPasswordPage.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/projects',
|
||||
name: 'project-list',
|
||||
|
||||
Reference in New Issue
Block a user