Add account management settings
This commit is contained in:
@@ -46,3 +46,5 @@
|
||||
- 거래 목록의 카테고리 아이콘 자리를 카테고리별 아이콘/색상 설정과 연결했다.
|
||||
- 거래 입력/수정의 카테고리 선택을 실제 선택 가능한 최종 카테고리 중심으로 정리했다.
|
||||
- README에 개발, 빌드, Prisma 관련 주요 명령과 현재 검증 기준을 정리했다.
|
||||
- TODO에서 요구사항과 맞지 않던 이체 관련 작업 항목을 제거했다.
|
||||
- 설정의 계정 관리 화면에서 이름 수정과 비밀번호 변경 기능을 구현했다.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- PC: 사이드바 내비게이션과 넓은 콘텐츠 영역을 사용한다.
|
||||
- 입력 화면은 한 화면에서 빠르게 끝나는 흐름을 유지한다.
|
||||
- 거래 입력에서는 시간 입력을 요구하지 않고 날짜만 선택한다.
|
||||
- 거래 타입은 수입과 지출만 사용하고 이체 타입이나 이체 탭은 만들지 않는다.
|
||||
- 날짜 선택은 모바일 친화적인 바텀 시트 캘린더를 사용한다.
|
||||
- 금액 입력은 기본값 `0`을 채우지 않고 placeholder로만 안내한다.
|
||||
- 셀렉트 박스보다 모바일 친화적인 바텀 시트를 우선한다.
|
||||
|
||||
@@ -58,11 +58,43 @@
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'account'" class="px-4 py-4">
|
||||
<section class="border-y border-line bg-white px-4 py-4">
|
||||
<p class="text-sm font-semibold text-muted">로그인 계정</p>
|
||||
<p class="mt-2 text-base font-bold">{{ session?.name }}</p>
|
||||
<p class="mt-1 text-sm text-muted">{{ session?.email }}</p>
|
||||
</section>
|
||||
<form class="space-y-4 border-y border-line bg-white px-4 py-4" @submit.prevent="saveAccount">
|
||||
<div>
|
||||
<label class="label" for="accountName">이름</label>
|
||||
<input id="accountName" v-model="accountForm.name" class="field" maxlength="40" placeholder="이름" required>
|
||||
</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">
|
||||
로그아웃
|
||||
@@ -341,6 +373,20 @@ const shareForm = reactive({
|
||||
name: '',
|
||||
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({
|
||||
name: '',
|
||||
icon: 'circle',
|
||||
@@ -396,6 +442,11 @@ function open(nextView: SettingsView) {
|
||||
view.value = nextView
|
||||
selectedParentId.value = ''
|
||||
selectedCategoryId.value = ''
|
||||
if (nextView === 'account') {
|
||||
accountForm.name = session.value?.name || ''
|
||||
accountMessage.value = ''
|
||||
passwordMessage.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
@@ -501,6 +552,50 @@ async function copyShareUrl(token: string) {
|
||||
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) {
|
||||
if (!import.meta.client) return `/share/${token}`
|
||||
return `${window.location.origin}/share/${token}`
|
||||
|
||||
19
server/api/auth/me.patch.ts
Normal file
19
server/api/auth/me.patch.ts
Normal 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 }
|
||||
})
|
||||
})
|
||||
27
server/api/auth/password.post.ts
Normal file
27
server/api/auth/password.post.ts
Normal 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 }
|
||||
})
|
||||
6
todo.md
6
todo.md
@@ -16,7 +16,7 @@
|
||||
|
||||
## 3. 거래 입력/수정
|
||||
|
||||
- 수입, 지출, 이체 탭의 동작과 저장 로직을 완성한다.
|
||||
- 수입과 지출 탭의 동작과 저장 로직을 더 안정화한다.
|
||||
- 날짜 선택 바텀 시트의 오늘/이번 달 빠른 이동 기능을 추가한다.
|
||||
- 자산 선택 후 카테고리 선택으로 자연스럽게 이어지는 입력 흐름을 만든다.
|
||||
- 금액 입력 키패드 또는 숫자 입력 UX를 개선한다.
|
||||
@@ -36,7 +36,6 @@
|
||||
- 자산 상세에서 일별/월별/연별 보기 전환을 구현한다.
|
||||
- 자산 추가/수정 화면을 별도 상세 화면 또는 바텀 시트로 정리한다.
|
||||
- 자산 삭제 또는 숨김 처리 규칙을 정한다.
|
||||
- 이체 거래가 자산 잔액에 정확히 반영되도록 구현한다.
|
||||
|
||||
## 6. 통계
|
||||
|
||||
@@ -55,15 +54,12 @@
|
||||
|
||||
## 8. 데이터와 API
|
||||
|
||||
- 거래 API에서 이체 타입을 지원한다.
|
||||
- 사용자 설정 테이블을 추가한다.
|
||||
- 거래 목록 조회 성능을 고려해 필요한 index를 점검한다.
|
||||
- CSV 내보내기 컬럼을 실제 사용 흐름에 맞게 정리한다.
|
||||
|
||||
## 9. 인증과 사용자 경험
|
||||
|
||||
- 계정 정보 수정 화면을 구현한다.
|
||||
- 비밀번호 변경 기능을 구현한다.
|
||||
- 회원 탈퇴 또는 데이터 삭제 정책을 정한다.
|
||||
- 로그인 실패, 세션 만료, 네트워크 오류 상태를 화면에서 더 명확히 보여준다.
|
||||
- 빈 데이터 상태 화면을 페이지별로 다듬는다.
|
||||
|
||||
Reference in New Issue
Block a user