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`를 읽는다.
|
- Docker 배포용 루트 `.env`와 개발용 `.env.dev`를 분리했다. `docker-compose.yml`은 운영 `.env`, `docker-compose.dev.yml`은 기존 dev Postgres 볼륨과 맞는 `.env.dev`를 읽는다.
|
||||||
- STATS의 `RANGE FLOW`는 항목 수에 따라 막대 폭과 간격을 조정한다. 1주일 내외는 넓은 막대로 카드 폭을 채우고, 1달 내외는 좁은 막대와 요약 라벨로 한 화면에서 흐름을 보며, 세부 날짜와 집중 시간은 막대 hover 팝업으로 확인한다.
|
- STATS의 `RANGE FLOW`는 항목 수에 따라 막대 폭과 간격을 조정한다. 1주일 내외는 넓은 막대로 카드 폭을 채우고, 1달 내외는 좁은 막대와 요약 라벨로 한 화면에서 흐름을 보며, 세부 날짜와 집중 시간은 막대 hover 팝업으로 확인한다.
|
||||||
- 왼쪽 사이드바의 인쇄 영역은 `PRINT` 버튼 하나로 줄이고, 클릭 시 모달에서 시작일/종료일과 `1페이지씩` 또는 `2페이지씩` 출력 방식을 선택한다. 인쇄 전용 렌더링은 선택 기간의 날짜를 순서대로 여러 장 생성하며, 2페이지씩 출력에서 홀수 날짜가 남으면 오른쪽은 빈 페이지 프레임으로 둔다.
|
- 왼쪽 사이드바의 인쇄 영역은 `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`을 사용한다.
|
- `5173` 포트가 직접 `npm run dev` 없이 열려 있으면 대부분 `ten-minute-frontend-dev` 컨테이너가 떠 있는 상태다. 개발용 Docker를 끄려면 `docker compose -f docker-compose.dev.yml down`을 사용한다.
|
||||||
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
|
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
|
||||||
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
|
- 이미지 저장 기능은 추후 `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) {
|
export async function registerAuthRoutes(app) {
|
||||||
app.post('/api/auth/signup', async (request, reply) => {
|
app.post('/api/auth/signup', async (request, reply) => {
|
||||||
const payload = signupSchema.safeParse(request.body)
|
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 now = new Date()
|
||||||
const passwordHash = await hashPassword(password)
|
const passwordHash = await hashPassword(password)
|
||||||
const [user] = await db
|
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
|
const [updatedUser] = await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
74
src/App.vue
74
src/App.vue
@@ -12,9 +12,11 @@ import { fetchAdminOverview } from './lib/adminApi'
|
|||||||
import {
|
import {
|
||||||
clearAuthState,
|
clearAuthState,
|
||||||
fetchCurrentUser,
|
fetchCurrentUser,
|
||||||
|
confirmPasswordReset,
|
||||||
login,
|
login,
|
||||||
persistAuthState,
|
persistAuthState,
|
||||||
readAuthState,
|
readAuthState,
|
||||||
|
requestPasswordReset,
|
||||||
signup,
|
signup,
|
||||||
updatePassword,
|
updatePassword,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
@@ -67,6 +69,8 @@ const authForm = reactive({
|
|||||||
nickname: '',
|
nickname: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
resetToken: '',
|
||||||
|
newPassword: '',
|
||||||
rememberSession: false,
|
rememberSession: false,
|
||||||
})
|
})
|
||||||
const goalForm = reactive({
|
const goalForm = reactive({
|
||||||
@@ -1415,6 +1419,8 @@ function resetAuthForm() {
|
|||||||
authForm.nickname = ''
|
authForm.nickname = ''
|
||||||
authForm.email = ''
|
authForm.email = ''
|
||||||
authForm.password = ''
|
authForm.password = ''
|
||||||
|
authForm.resetToken = ''
|
||||||
|
authForm.newPassword = ''
|
||||||
authForm.rememberSession = false
|
authForm.rememberSession = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1443,6 +1449,29 @@ function openAuthDialog(mode = 'login') {
|
|||||||
authDialogOpen.value = true
|
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() {
|
function closeAuthDialog() {
|
||||||
authDialogOpen.value = false
|
authDialogOpen.value = false
|
||||||
authMessage.value = ''
|
authMessage.value = ''
|
||||||
@@ -1474,17 +1503,39 @@ async function submitAuthForm() {
|
|||||||
authMessage.value = ''
|
authMessage.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result =
|
if (authMode.value === 'reset-request') {
|
||||||
authMode.value === 'login'
|
const result = await requestPasswordReset({
|
||||||
? await login({
|
email: authForm.email,
|
||||||
email: authForm.email,
|
})
|
||||||
password: authForm.password,
|
authMessage.value = result.resetPreviewUrl
|
||||||
})
|
? `${result.message} 개발용 링크: ${result.resetPreviewUrl}`
|
||||||
: await signup({
|
: result.message
|
||||||
nickname: authForm.nickname,
|
return
|
||||||
email: authForm.email,
|
}
|
||||||
password: authForm.password,
|
|
||||||
})
|
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)
|
await applyAuthSuccess(result, authMode.value === 'login' && authForm.rememberSession)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1983,6 +2034,7 @@ onMounted(() => {
|
|||||||
updateWindowWidth()
|
updateWindowWidth()
|
||||||
window.addEventListener('resize', updateWindowWidth)
|
window.addEventListener('resize', updateWindowWidth)
|
||||||
window.addEventListener('keydown', handleGlobalKeydown)
|
window.addEventListener('keydown', handleGlobalKeydown)
|
||||||
|
openPasswordResetFromUrl()
|
||||||
restoreAuthSession()
|
restoreAuthSession()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,58 @@ function updateField(field, event) {
|
|||||||
value: event.target.type === 'checkbox' ? event.target.checked : event.target.value,
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -47,10 +99,10 @@ function updateField(field, event) {
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-[10px] font-bold uppercase tracking-[0.24em] text-stone-500">10 Minute Planner</p>
|
<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">
|
<h2 class="mt-2 text-2xl font-semibold tracking-[-0.04em] text-stone-900">
|
||||||
{{ mode === 'login' ? '로그인' : '회원가입' }}
|
{{ getTitle(mode) }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-2 text-sm leading-6 text-stone-600">
|
<p class="mt-2 text-sm leading-6 text-stone-600">
|
||||||
{{ mode === 'login' ? '내 플래너를 이어서 열어요.' : '기록을 저장할 계정을 만들어요.' }}
|
{{ getDescription(mode) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -74,7 +126,7 @@ function updateField(field, event) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 class="text-[11px] font-bold tracking-[0.16em] text-stone-600">
|
||||||
{{ mode === 'login' ? '이메일 또는 아이디' : '이메일' }}
|
{{ mode === 'login' ? '이메일 또는 아이디' : '이메일' }}
|
||||||
</label>
|
</label>
|
||||||
@@ -87,7 +139,18 @@ function updateField(field, event) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">비밀번호</label>
|
||||||
<input
|
<input
|
||||||
:value="form.password"
|
:value="form.password"
|
||||||
@@ -98,6 +161,17 @@ function updateField(field, event) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<label
|
||||||
v-if="mode === 'login'"
|
v-if="mode === 'login'"
|
||||||
class="-mt-1 flex items-center gap-2 px-1 text-left"
|
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"
|
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"
|
:disabled="busy"
|
||||||
>
|
>
|
||||||
{{ busy ? '처리 중...' : mode === 'login' ? '로그인하기' : '가입하기' }}
|
{{ getSubmitLabel(mode, busy) }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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">
|
<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">
|
<p class="text-sm font-semibold text-stone-600">
|
||||||
{{ mode === 'login' ? '아직 계정이 없나요?' : '이미 계정이 있나요?' }}
|
{{ mode === 'signup' ? '이미 계정이 있나요?' : '계정 화면으로 돌아갈까요?' }}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -107,3 +107,17 @@ export async function updatePassword(token, { currentPassword, newPassword }) {
|
|||||||
body: { 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