Add account management settings

This commit is contained in:
2026-08-13 18:20:36 +09:00
parent 04666d607d
commit 6f54d09dde
6 changed files with 150 additions and 10 deletions

View File

@@ -46,3 +46,5 @@
- 거래 목록의 카테고리 아이콘 자리를 카테고리별 아이콘/색상 설정과 연결했다. - 거래 목록의 카테고리 아이콘 자리를 카테고리별 아이콘/색상 설정과 연결했다.
- 거래 입력/수정의 카테고리 선택을 실제 선택 가능한 최종 카테고리 중심으로 정리했다. - 거래 입력/수정의 카테고리 선택을 실제 선택 가능한 최종 카테고리 중심으로 정리했다.
- README에 개발, 빌드, Prisma 관련 주요 명령과 현재 검증 기준을 정리했다. - README에 개발, 빌드, Prisma 관련 주요 명령과 현재 검증 기준을 정리했다.
- TODO에서 요구사항과 맞지 않던 이체 관련 작업 항목을 제거했다.
- 설정의 계정 관리 화면에서 이름 수정과 비밀번호 변경 기능을 구현했다.

View File

@@ -14,6 +14,7 @@
- PC: 사이드바 내비게이션과 넓은 콘텐츠 영역을 사용한다. - PC: 사이드바 내비게이션과 넓은 콘텐츠 영역을 사용한다.
- 입력 화면은 한 화면에서 빠르게 끝나는 흐름을 유지한다. - 입력 화면은 한 화면에서 빠르게 끝나는 흐름을 유지한다.
- 거래 입력에서는 시간 입력을 요구하지 않고 날짜만 선택한다. - 거래 입력에서는 시간 입력을 요구하지 않고 날짜만 선택한다.
- 거래 타입은 수입과 지출만 사용하고 이체 타입이나 이체 탭은 만들지 않는다.
- 날짜 선택은 모바일 친화적인 바텀 시트 캘린더를 사용한다. - 날짜 선택은 모바일 친화적인 바텀 시트 캘린더를 사용한다.
- 금액 입력은 기본값 `0`을 채우지 않고 placeholder로만 안내한다. - 금액 입력은 기본값 `0`을 채우지 않고 placeholder로만 안내한다.
- 셀렉트 박스보다 모바일 친화적인 바텀 시트를 우선한다. - 셀렉트 박스보다 모바일 친화적인 바텀 시트를 우선한다.

View File

@@ -58,11 +58,43 @@
</div> </div>
<div v-else-if="view === 'account'" class="px-4 py-4"> <div v-else-if="view === 'account'" class="px-4 py-4">
<section class="border-y border-line bg-white px-4 py-4"> <form class="space-y-4 border-y border-line bg-white px-4 py-4" @submit.prevent="saveAccount">
<p class="text-sm font-semibold text-muted">로그인 계정</p> <div>
<p class="mt-2 text-base font-bold">{{ session?.name }}</p> <label class="label" for="accountName">이름</label>
<p class="mt-1 text-sm text-muted">{{ session?.email }}</p> <input id="accountName" v-model="accountForm.name" class="field" maxlength="40" placeholder="이름" required>
</section> </div>
<div>
<span class="label">이메일</span>
<p class="rounded-md bg-paper px-4 py-2.5 text-sm font-semibold text-muted">{{ session?.email }}</p>
</div>
<p v-if="accountMessage" class="text-sm font-semibold" :class="accountMessageType === 'error' ? 'text-red-600' : 'text-brand-700'">
{{ accountMessage }}
</p>
<button class="btn-primary w-full" type="submit" :disabled="accountPending">
{{ accountPending ? '저장 중...' : '계정 정보 저장' }}
</button>
</form>
<form class="mt-4 space-y-4 border-y border-line bg-white px-4 py-4" @submit.prevent="changePassword">
<div>
<label class="label" for="currentPassword">현재 비밀번호</label>
<input id="currentPassword" v-model="passwordForm.currentPassword" class="field" autocomplete="current-password" type="password" required>
</div>
<div>
<label class="label" for="nextPassword">새 비밀번호</label>
<input id="nextPassword" v-model="passwordForm.nextPassword" class="field" autocomplete="new-password" minlength="8" type="password" required>
</div>
<div>
<label class="label" for="nextPasswordConfirm">새 비밀번호 확인</label>
<input id="nextPasswordConfirm" v-model="passwordForm.confirmPassword" class="field" autocomplete="new-password" minlength="8" type="password" required>
</div>
<p v-if="passwordMessage" class="text-sm font-semibold" :class="passwordMessageType === 'error' ? 'text-red-600' : 'text-brand-700'">
{{ passwordMessage }}
</p>
<button class="btn-secondary w-full" type="submit" :disabled="passwordPending">
{{ passwordPending ? '변경 중...' : '비밀번호 변경' }}
</button>
</form>
<button type="button" class="btn-secondary mt-4 w-full border-red-200 text-red-600" @click="logout"> <button type="button" class="btn-secondary mt-4 w-full border-red-200 text-red-600" @click="logout">
로그아웃 로그아웃
@@ -341,6 +373,20 @@ const shareForm = reactive({
name: '', name: '',
days: 7 days: 7
}) })
const accountPending = ref(false)
const passwordPending = ref(false)
const accountMessage = ref('')
const passwordMessage = ref('')
const accountMessageType = ref<'ok' | 'error'>('ok')
const passwordMessageType = ref<'ok' | 'error'>('ok')
const accountForm = reactive({
name: session.value?.name || ''
})
const passwordForm = reactive({
currentPassword: '',
nextPassword: '',
confirmPassword: ''
})
const categoryEditForm = reactive({ const categoryEditForm = reactive({
name: '', name: '',
icon: 'circle', icon: 'circle',
@@ -396,6 +442,11 @@ function open(nextView: SettingsView) {
view.value = nextView view.value = nextView
selectedParentId.value = '' selectedParentId.value = ''
selectedCategoryId.value = '' selectedCategoryId.value = ''
if (nextView === 'account') {
accountForm.name = session.value?.name || ''
accountMessage.value = ''
passwordMessage.value = ''
}
} }
function goBack() { function goBack() {
@@ -501,6 +552,50 @@ async function copyShareUrl(token: string) {
await navigator.clipboard?.writeText(shareUrl(token)) await navigator.clipboard?.writeText(shareUrl(token))
} }
async function saveAccount() {
accountPending.value = true
accountMessage.value = ''
try {
const updated = await $fetch<{ id: string; email: string; name: string }>('/api/auth/me', {
method: 'PATCH',
body: accountForm
})
session.value = updated
accountMessageType.value = 'ok'
accountMessage.value = '계정 정보를 저장했습니다.'
} catch {
accountMessageType.value = 'error'
accountMessage.value = '계정 정보를 저장하지 못했습니다.'
} finally {
accountPending.value = false
}
}
async function changePassword() {
passwordPending.value = true
passwordMessage.value = ''
try {
if (passwordForm.nextPassword !== passwordForm.confirmPassword) {
throw new Error('password mismatch')
}
await $fetch('/api/auth/password', {
method: 'POST',
body: {
currentPassword: passwordForm.currentPassword,
nextPassword: passwordForm.nextPassword
}
})
Object.assign(passwordForm, { currentPassword: '', nextPassword: '', confirmPassword: '' })
passwordMessageType.value = 'ok'
passwordMessage.value = '비밀번호를 변경했습니다.'
} catch {
passwordMessageType.value = 'error'
passwordMessage.value = '비밀번호를 확인해 주세요.'
} finally {
passwordPending.value = false
}
}
function shareUrl(token: string) { function shareUrl(token: string) {
if (!import.meta.client) return `/share/${token}` if (!import.meta.client) return `/share/${token}`
return `${window.location.origin}/share/${token}` return `${window.location.origin}/share/${token}`

View File

@@ -0,0 +1,19 @@
import { readBody, createError } from 'h3'
import { prisma } from '../../utils/db'
import { requireUser } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const body = await readBody<{ name?: string }>(event)
const name = body.name?.trim()
if (!name || name.length > 40) {
throw createError({ statusCode: 400, statusMessage: 'Invalid name' })
}
return prisma.user.update({
where: { id: user.id },
data: { name },
select: { id: true, email: true, name: true }
})
})

View File

@@ -0,0 +1,27 @@
import { compare, hash } from 'bcryptjs'
import { readBody, createError } from 'h3'
import { prisma } from '../../utils/db'
import { requireUser } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const sessionUser = await requireUser(event)
const body = await readBody<{ currentPassword?: string; nextPassword?: string }>(event)
const currentPassword = body.currentPassword || ''
const nextPassword = body.nextPassword || ''
if (nextPassword.length < 8) {
throw createError({ statusCode: 400, statusMessage: 'Password must be at least 8 characters' })
}
const user = await prisma.user.findUnique({ where: { id: sessionUser.id } })
if (!user || !(await compare(currentPassword, user.passwordHash))) {
throw createError({ statusCode: 401, statusMessage: 'Current password is invalid' })
}
await prisma.user.update({
where: { id: user.id },
data: { passwordHash: await hash(nextPassword, 10) }
})
return { ok: true }
})

View File

@@ -16,7 +16,7 @@
## 3. 거래 입력/수정 ## 3. 거래 입력/수정
- 수입, 지출, 이체 탭의 동작과 저장 로직을 완성한다. - 수입과 지출 탭의 동작과 저장 로직을 더 안정화한다.
- 날짜 선택 바텀 시트의 오늘/이번 달 빠른 이동 기능을 추가한다. - 날짜 선택 바텀 시트의 오늘/이번 달 빠른 이동 기능을 추가한다.
- 자산 선택 후 카테고리 선택으로 자연스럽게 이어지는 입력 흐름을 만든다. - 자산 선택 후 카테고리 선택으로 자연스럽게 이어지는 입력 흐름을 만든다.
- 금액 입력 키패드 또는 숫자 입력 UX를 개선한다. - 금액 입력 키패드 또는 숫자 입력 UX를 개선한다.
@@ -36,7 +36,6 @@
- 자산 상세에서 일별/월별/연별 보기 전환을 구현한다. - 자산 상세에서 일별/월별/연별 보기 전환을 구현한다.
- 자산 추가/수정 화면을 별도 상세 화면 또는 바텀 시트로 정리한다. - 자산 추가/수정 화면을 별도 상세 화면 또는 바텀 시트로 정리한다.
- 자산 삭제 또는 숨김 처리 규칙을 정한다. - 자산 삭제 또는 숨김 처리 규칙을 정한다.
- 이체 거래가 자산 잔액에 정확히 반영되도록 구현한다.
## 6. 통계 ## 6. 통계
@@ -55,15 +54,12 @@
## 8. 데이터와 API ## 8. 데이터와 API
- 거래 API에서 이체 타입을 지원한다.
- 사용자 설정 테이블을 추가한다. - 사용자 설정 테이블을 추가한다.
- 거래 목록 조회 성능을 고려해 필요한 index를 점검한다. - 거래 목록 조회 성능을 고려해 필요한 index를 점검한다.
- CSV 내보내기 컬럼을 실제 사용 흐름에 맞게 정리한다. - CSV 내보내기 컬럼을 실제 사용 흐름에 맞게 정리한다.
## 9. 인증과 사용자 경험 ## 9. 인증과 사용자 경험
- 계정 정보 수정 화면을 구현한다.
- 비밀번호 변경 기능을 구현한다.
- 회원 탈퇴 또는 데이터 삭제 정책을 정한다. - 회원 탈퇴 또는 데이터 삭제 정책을 정한다.
- 로그인 실패, 세션 만료, 네트워크 오류 상태를 화면에서 더 명확히 보여준다. - 로그인 실패, 세션 만료, 네트워크 오류 상태를 화면에서 더 명확히 보여준다.
- 빈 데이터 상태 화면을 페이지별로 다듬는다. - 빈 데이터 상태 화면을 페이지별로 다듬는다.