diff --git a/HANDOFF.md b/HANDOFF.md index 8a982d6..1378d9e 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 6f1835f..6b50fe3 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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({ diff --git a/src/App.vue b/src/App.vue index 4e51df9..ec8a58e 100644 --- a/src/App.vue +++ b/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() }) diff --git a/src/components/AuthDialog.vue b/src/components/AuthDialog.vue index e681da0..7456af0 100644 --- a/src/components/AuthDialog.vue +++ b/src/components/AuthDialog.vue @@ -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 '로그인하기' +}