v0.1.60 - 닉네임 중복 검증과 비밀번호 재설정 연결
This commit is contained in:
@@ -220,6 +220,7 @@
|
||||
- Docker 배포용 루트 `.env`와 개발용 `.env.dev`를 분리했다. `docker-compose.yml`은 운영 `.env`, `docker-compose.dev.yml`은 기존 dev Postgres 볼륨과 맞는 `.env.dev`를 읽는다.
|
||||
- STATS의 `RANGE FLOW`는 항목 수에 따라 막대 폭과 간격을 조정한다. 1주일 내외는 넓은 막대로 카드 폭을 채우고, 1달 내외는 좁은 막대와 요약 라벨로 한 화면에서 흐름을 보며, 세부 날짜와 집중 시간은 막대 hover 팝업으로 확인한다.
|
||||
- 왼쪽 사이드바의 인쇄 영역은 `PRINT` 버튼 하나로 줄이고, 클릭 시 모달에서 시작일/종료일과 `1페이지씩` 또는 `2페이지씩` 출력 방식을 선택한다. 인쇄 전용 렌더링은 선택 기간의 날짜를 순서대로 여러 장 생성하며, 2페이지씩 출력에서 홀수 날짜가 남으면 오른쪽은 빈 페이지 프레임으로 둔다.
|
||||
- 회원가입과 프로필 수정 시 이메일뿐 아니라 닉네임 중복도 서버에서 409로 막는다. 비밀번호 재설정 API는 로그인 모달의 `비밀번호 찾기` 흐름과 연결되어 있고, `/reset-password?token=...` URL로 들어오면 새 비밀번호 설정 모드가 열린다. 실제 메일 발송 전까지는 백엔드 응답의 `resetPreviewUrl`을 개발용 링크로 표시한다.
|
||||
- `5173` 포트가 직접 `npm run dev` 없이 열려 있으면 대부분 `ten-minute-frontend-dev` 컨테이너가 떠 있는 상태다. 개발용 Docker를 끄려면 `docker compose -f docker-compose.dev.yml down`을 사용한다.
|
||||
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
|
||||
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
|
||||
|
||||
@@ -108,6 +108,16 @@ function sanitizeUser(user) {
|
||||
}
|
||||
}
|
||||
|
||||
async function findUserByNickname(nickname) {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.nickname, nickname))
|
||||
.limit(1)
|
||||
|
||||
return user ?? null
|
||||
}
|
||||
|
||||
export async function registerAuthRoutes(app) {
|
||||
app.post('/api/auth/signup', async (request, reply) => {
|
||||
const payload = signupSchema.safeParse(request.body)
|
||||
@@ -134,6 +144,14 @@ export async function registerAuthRoutes(app) {
|
||||
})
|
||||
}
|
||||
|
||||
const existingNicknameUser = await findUserByNickname(nickname)
|
||||
|
||||
if (existingNicknameUser) {
|
||||
return reply.code(409).send({
|
||||
message: '이미 사용 중인 닉네임입니다.',
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const passwordHash = await hashPassword(password)
|
||||
const [user] = await db
|
||||
@@ -266,6 +284,14 @@ export async function registerAuthRoutes(app) {
|
||||
})
|
||||
}
|
||||
|
||||
const existingNicknameUser = await findUserByNickname(payload.data.nickname)
|
||||
|
||||
if (existingNicknameUser && existingNicknameUser.id !== user.id) {
|
||||
return reply.code(409).send({
|
||||
message: '이미 사용 중인 닉네임입니다.',
|
||||
})
|
||||
}
|
||||
|
||||
const [updatedUser] = await db
|
||||
.update(users)
|
||||
.set({
|
||||
|
||||
74
src/App.vue
74
src/App.vue
@@ -12,9 +12,11 @@ import { fetchAdminOverview } from './lib/adminApi'
|
||||
import {
|
||||
clearAuthState,
|
||||
fetchCurrentUser,
|
||||
confirmPasswordReset,
|
||||
login,
|
||||
persistAuthState,
|
||||
readAuthState,
|
||||
requestPasswordReset,
|
||||
signup,
|
||||
updatePassword,
|
||||
updateProfile,
|
||||
@@ -67,6 +69,8 @@ const authForm = reactive({
|
||||
nickname: '',
|
||||
email: '',
|
||||
password: '',
|
||||
resetToken: '',
|
||||
newPassword: '',
|
||||
rememberSession: false,
|
||||
})
|
||||
const goalForm = reactive({
|
||||
@@ -1415,6 +1419,8 @@ function resetAuthForm() {
|
||||
authForm.nickname = ''
|
||||
authForm.email = ''
|
||||
authForm.password = ''
|
||||
authForm.resetToken = ''
|
||||
authForm.newPassword = ''
|
||||
authForm.rememberSession = false
|
||||
}
|
||||
|
||||
@@ -1443,6 +1449,29 @@ function openAuthDialog(mode = 'login') {
|
||||
authDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openPasswordResetFromUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href)
|
||||
|
||||
if (!url.pathname.includes('reset-password')) {
|
||||
return
|
||||
}
|
||||
|
||||
const token = url.searchParams.get('token') ?? ''
|
||||
|
||||
if (!token) {
|
||||
return
|
||||
}
|
||||
|
||||
authForm.resetToken = token
|
||||
authMode.value = 'reset-confirm'
|
||||
authMessage.value = ''
|
||||
authDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeAuthDialog() {
|
||||
authDialogOpen.value = false
|
||||
authMessage.value = ''
|
||||
@@ -1474,17 +1503,39 @@ async function submitAuthForm() {
|
||||
authMessage.value = ''
|
||||
|
||||
try {
|
||||
const result =
|
||||
authMode.value === 'login'
|
||||
? await login({
|
||||
email: authForm.email,
|
||||
password: authForm.password,
|
||||
})
|
||||
: await signup({
|
||||
nickname: authForm.nickname,
|
||||
email: authForm.email,
|
||||
password: authForm.password,
|
||||
})
|
||||
if (authMode.value === 'reset-request') {
|
||||
const result = await requestPasswordReset({
|
||||
email: authForm.email,
|
||||
})
|
||||
authMessage.value = result.resetPreviewUrl
|
||||
? `${result.message} 개발용 링크: ${result.resetPreviewUrl}`
|
||||
: result.message
|
||||
return
|
||||
}
|
||||
|
||||
if (authMode.value === 'reset-confirm') {
|
||||
const result = await confirmPasswordReset({
|
||||
token: authForm.resetToken,
|
||||
newPassword: authForm.newPassword,
|
||||
})
|
||||
authMode.value = 'login'
|
||||
authForm.password = ''
|
||||
authForm.newPassword = ''
|
||||
authForm.resetToken = ''
|
||||
authMessage.value = result.message
|
||||
return
|
||||
}
|
||||
|
||||
const result = authMode.value === 'login'
|
||||
? await login({
|
||||
email: authForm.email,
|
||||
password: authForm.password,
|
||||
})
|
||||
: await signup({
|
||||
nickname: authForm.nickname,
|
||||
email: authForm.email,
|
||||
password: authForm.password,
|
||||
})
|
||||
|
||||
await applyAuthSuccess(result, authMode.value === 'login' && authForm.rememberSession)
|
||||
} catch (error) {
|
||||
@@ -1983,6 +2034,7 @@ onMounted(() => {
|
||||
updateWindowWidth()
|
||||
window.addEventListener('resize', updateWindowWidth)
|
||||
window.addEventListener('keydown', handleGlobalKeydown)
|
||||
openPasswordResetFromUrl()
|
||||
restoreAuthSession()
|
||||
})
|
||||
|
||||
|
||||
@@ -35,6 +35,58 @@ function updateField(field, event) {
|
||||
value: event.target.type === 'checkbox' ? event.target.checked : event.target.value,
|
||||
})
|
||||
}
|
||||
|
||||
function getTitle(mode) {
|
||||
if (mode === 'signup') {
|
||||
return '회원가입'
|
||||
}
|
||||
|
||||
if (mode === 'reset-request') {
|
||||
return '비밀번호 찾기'
|
||||
}
|
||||
|
||||
if (mode === 'reset-confirm') {
|
||||
return '새 비밀번호 설정'
|
||||
}
|
||||
|
||||
return '로그인'
|
||||
}
|
||||
|
||||
function getDescription(mode) {
|
||||
if (mode === 'signup') {
|
||||
return '기록을 저장할 계정을 만들어요.'
|
||||
}
|
||||
|
||||
if (mode === 'reset-request') {
|
||||
return '가입한 이메일로 재설정 링크를 받을 수 있습니다.'
|
||||
}
|
||||
|
||||
if (mode === 'reset-confirm') {
|
||||
return '메일로 받은 링크의 토큰과 새 비밀번호를 확인합니다.'
|
||||
}
|
||||
|
||||
return '내 플래너를 이어서 열어요.'
|
||||
}
|
||||
|
||||
function getSubmitLabel(mode, busy) {
|
||||
if (busy) {
|
||||
return '처리 중...'
|
||||
}
|
||||
|
||||
if (mode === 'signup') {
|
||||
return '가입하기'
|
||||
}
|
||||
|
||||
if (mode === 'reset-request') {
|
||||
return '재설정 링크 받기'
|
||||
}
|
||||
|
||||
if (mode === 'reset-confirm') {
|
||||
return '비밀번호 재설정'
|
||||
}
|
||||
|
||||
return '로그인하기'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -47,10 +99,10 @@ function updateField(field, event) {
|
||||
<div>
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.24em] text-stone-500">10 Minute Planner</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold tracking-[-0.04em] text-stone-900">
|
||||
{{ mode === 'login' ? '로그인' : '회원가입' }}
|
||||
{{ getTitle(mode) }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-stone-600">
|
||||
{{ mode === 'login' ? '내 플래너를 이어서 열어요.' : '기록을 저장할 계정을 만들어요.' }}
|
||||
{{ getDescription(mode) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -74,7 +126,7 @@ function updateField(field, event) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-if="mode !== 'reset-confirm'" class="space-y-2">
|
||||
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">
|
||||
{{ mode === 'login' ? '이메일 또는 아이디' : '이메일' }}
|
||||
</label>
|
||||
@@ -87,7 +139,18 @@ function updateField(field, event) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-if="mode === 'reset-confirm'" class="space-y-2">
|
||||
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">재설정 토큰</label>
|
||||
<input
|
||||
:value="form.resetToken"
|
||||
type="text"
|
||||
class="w-full rounded-2xl border border-stone-300 bg-white/90 px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
|
||||
placeholder="메일 링크의 토큰"
|
||||
@input="updateField('resetToken', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'login' || mode === 'signup'" class="space-y-2">
|
||||
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">비밀번호</label>
|
||||
<input
|
||||
:value="form.password"
|
||||
@@ -98,6 +161,17 @@ function updateField(field, event) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'reset-confirm'" class="space-y-2">
|
||||
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">새 비밀번호</label>
|
||||
<input
|
||||
:value="form.newPassword"
|
||||
type="password"
|
||||
class="w-full rounded-2xl border border-stone-300 bg-white/90 px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
|
||||
placeholder="8자 이상 입력해 주세요."
|
||||
@input="updateField('newPassword', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label
|
||||
v-if="mode === 'login'"
|
||||
class="-mt-1 flex items-center gap-2 px-1 text-left"
|
||||
@@ -123,13 +197,22 @@ function updateField(field, event) {
|
||||
class="w-full rounded-full bg-stone-900 px-5 py-3 text-xs font-bold tracking-[0.18em] text-white transition hover:bg-stone-700 disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||
:disabled="busy"
|
||||
>
|
||||
{{ busy ? '처리 중...' : mode === 'login' ? '로그인하기' : '가입하기' }}
|
||||
{{ getSubmitLabel(mode, busy) }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button
|
||||
v-if="mode === 'login'"
|
||||
type="button"
|
||||
class="mt-4 w-full text-center text-xs font-bold tracking-[0.14em] text-stone-500 underline underline-offset-4 transition hover:text-stone-900"
|
||||
@click="emit('switch-mode', 'reset-request')"
|
||||
>
|
||||
비밀번호를 잊으셨나요?
|
||||
</button>
|
||||
|
||||
<div class="mt-5 flex items-center justify-center gap-2 border-t border-stone-300/70 pt-4">
|
||||
<p class="text-sm font-semibold text-stone-600">
|
||||
{{ mode === 'login' ? '아직 계정이 없나요?' : '이미 계정이 있나요?' }}
|
||||
{{ mode === 'signup' ? '이미 계정이 있나요?' : '계정 화면으로 돌아갈까요?' }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -107,3 +107,17 @@ export async function updatePassword(token, { currentPassword, newPassword }) {
|
||||
body: { currentPassword, newPassword },
|
||||
})
|
||||
}
|
||||
|
||||
export async function requestPasswordReset({ email }) {
|
||||
return request('/api/auth/password-reset/request', {
|
||||
method: 'POST',
|
||||
body: { email },
|
||||
})
|
||||
}
|
||||
|
||||
export async function confirmPasswordReset({ token, newPassword }) {
|
||||
return request('/api/auth/password-reset/confirm', {
|
||||
method: 'POST',
|
||||
body: { token, newPassword },
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user