Add account registration flow
This commit is contained in:
@@ -34,3 +34,6 @@
|
||||
- 거래 목록 API에 검색어, 유형, 자산, 카테고리 필터 query를 추가했다.
|
||||
- 월 선택 바텀 시트를 공통 컴포넌트로 분리하고 내역, 통계, 자산 화면에 적용했다.
|
||||
- 거래 추가 화면을 거래 수정 화면과 같은 행 기반 입력 구조로 정리했다.
|
||||
- 회원가입 API와 로그인 화면의 로그인/회원가입 전환 UI를 추가했다.
|
||||
- 가입 시 기본 자산과 기본 수입/지출 카테고리가 생성되도록 했다.
|
||||
- 읽기 전용 공유 링크 기능 계획을 TODO에 추가했다.
|
||||
|
||||
@@ -3,29 +3,64 @@
|
||||
<div class="mb-6">
|
||||
<p class="text-sm font-semibold text-brand-700">Money</p>
|
||||
<h1 class="mt-2 text-2xl font-bold tracking-normal text-ink">빠르게 기록하는 개인 가계부</h1>
|
||||
<p class="mt-2 text-sm leading-6 text-muted">홈 화면에 설치해서 앱처럼 쓰고, 로그인 상태를 유지할 수 있게 구성했습니다.</p>
|
||||
<p class="mt-2 text-sm leading-6 text-muted">계정을 만들면 내 자산, 카테고리, 거래 내역을 따로 관리할 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="segmented-button border border-line py-2.5"
|
||||
:class="{ 'border-red-500 text-red-600': mode === 'login' }"
|
||||
@click="mode = 'login'"
|
||||
>
|
||||
로그인
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="segmented-button border border-line py-2.5"
|
||||
:class="{ 'border-red-500 text-red-600': mode === 'register' }"
|
||||
@click="mode = 'register'"
|
||||
>
|
||||
회원가입
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="space-y-3" @submit.prevent="submit">
|
||||
<div v-if="mode === 'register'">
|
||||
<label class="label" for="name">이름</label>
|
||||
<input id="name" v-model="name" class="field" autocomplete="name" placeholder="표시 이름">
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="email">이메일</label>
|
||||
<input id="email" v-model="email" class="field" type="email" autocomplete="email" required>
|
||||
<input id="email" v-model="email" class="field" type="email" autocomplete="email" placeholder="you@example.com" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="password">비밀번호</label>
|
||||
<input id="password" v-model="password" class="field" type="password" autocomplete="current-password" required>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
class="field"
|
||||
type="password"
|
||||
:autocomplete="mode === 'login' ? 'current-password' : 'new-password'"
|
||||
placeholder="8자 이상"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<p v-if="error" class="text-sm font-medium text-red-600">{{ error }}</p>
|
||||
<button class="btn-primary w-full" type="submit" :disabled="pending">
|
||||
{{ pending ? '확인 중...' : '로그인' }}
|
||||
{{ pending ? '확인 중...' : mode === 'login' ? '로그인' : '가입하고 시작하기' }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const email = ref('user@example.com')
|
||||
const password = ref('money1234')
|
||||
type AuthMode = 'login' | 'register'
|
||||
|
||||
const mode = ref<AuthMode>('login')
|
||||
const name = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const pending = ref(false)
|
||||
const session = useState('session')
|
||||
@@ -34,15 +69,27 @@ async function submit() {
|
||||
pending.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
session.value = await $fetch('/api/auth/login', {
|
||||
const endpoint = mode.value === 'login' ? '/api/auth/login' : '/api/auth/register'
|
||||
session.value = await $fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: { email: email.value, password: password.value }
|
||||
body: { name: name.value, email: email.value, password: password.value }
|
||||
})
|
||||
await navigateTo('/')
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
const status = err?.statusCode || err?.response?.status
|
||||
if (mode.value === 'register' && status === 409) {
|
||||
error.value = '이미 가입된 이메일입니다.'
|
||||
} else if (mode.value === 'register') {
|
||||
error.value = '이메일과 8자 이상 비밀번호를 확인해 주세요.'
|
||||
} else {
|
||||
error.value = '이메일 또는 비밀번호를 확인해 주세요.'
|
||||
}
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(mode, () => {
|
||||
error.value = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { hash } from 'bcryptjs'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { createDefaultMoneyData } from '../server/utils/defaults'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
@@ -14,57 +15,17 @@ async function main() {
|
||||
}
|
||||
})
|
||||
|
||||
const cash = await prisma.asset.upsert({
|
||||
where: { id: 'seed-cash' },
|
||||
update: {},
|
||||
create: {
|
||||
id: 'seed-cash',
|
||||
userId: user.id,
|
||||
name: '현금',
|
||||
balanceType: 'cumulative',
|
||||
initialBalance: 0,
|
||||
sortOrder: 0
|
||||
const assetCount = await prisma.asset.count({ where: { userId: user.id } })
|
||||
const categoryCount = await prisma.category.count({ where: { userId: user.id } })
|
||||
if (assetCount === 0 && categoryCount === 0) {
|
||||
await createDefaultMoneyData(prisma, user.id)
|
||||
}
|
||||
})
|
||||
|
||||
await prisma.asset.upsert({
|
||||
where: { id: 'seed-living' },
|
||||
update: {},
|
||||
create: {
|
||||
id: 'seed-living',
|
||||
userId: user.id,
|
||||
name: '생활비',
|
||||
balanceType: 'monthly_reset',
|
||||
sortOrder: 1
|
||||
}
|
||||
})
|
||||
|
||||
const incomeParent = await prisma.category.upsert({
|
||||
where: { id: 'seed-income-work' },
|
||||
update: {},
|
||||
create: { id: 'seed-income-work', userId: user.id, type: 'income', name: '근로', sortOrder: 0 }
|
||||
})
|
||||
|
||||
const salary = await prisma.category.upsert({
|
||||
where: { id: 'seed-income-salary' },
|
||||
update: {},
|
||||
create: { id: 'seed-income-salary', userId: user.id, type: 'income', name: '월급', parentId: incomeParent.id, sortOrder: 0 }
|
||||
})
|
||||
|
||||
const foodParent = await prisma.category.upsert({
|
||||
where: { id: 'seed-expense-food' },
|
||||
update: {},
|
||||
create: { id: 'seed-expense-food', userId: user.id, type: 'expense', name: '식비', sortOrder: 0 }
|
||||
})
|
||||
|
||||
const meal = await prisma.category.upsert({
|
||||
where: { id: 'seed-expense-meal' },
|
||||
update: {},
|
||||
create: { id: 'seed-expense-meal', userId: user.id, type: 'expense', name: '외식', parentId: foodParent.id, sortOrder: 0 }
|
||||
})
|
||||
|
||||
const transactionCount = await prisma.transaction.count({ where: { userId: user.id } })
|
||||
if (transactionCount === 0) {
|
||||
const cash = await prisma.asset.findFirstOrThrow({ where: { userId: user.id }, orderBy: { sortOrder: 'asc' } })
|
||||
const salary = await prisma.category.findFirstOrThrow({ where: { userId: user.id, type: 'income', name: '월급' } })
|
||||
const meal = await prisma.category.findFirstOrThrow({ where: { userId: user.id, type: 'expense', name: '외식' } })
|
||||
await prisma.transaction.createMany({
|
||||
data: [
|
||||
{
|
||||
|
||||
34
server/api/auth/register.post.ts
Normal file
34
server/api/auth/register.post.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { hash } from 'bcryptjs'
|
||||
import { readBody, createError } from 'h3'
|
||||
import { prisma } from '../../utils/db'
|
||||
import { setSession } from '../../utils/auth'
|
||||
import { createDefaultMoneyData } from '../../utils/defaults'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<{ email: string; password: string; name?: string }>(event)
|
||||
const email = body.email?.trim().toLowerCase()
|
||||
const password = body.password || ''
|
||||
const name = body.name?.trim() || email?.split('@')[0] || '사용자'
|
||||
|
||||
if (!email || !email.includes('@') || password.length < 8) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid registration' })
|
||||
}
|
||||
|
||||
const exists = await prisma.user.findUnique({ where: { email } })
|
||||
if (exists) {
|
||||
throw createError({ statusCode: 409, statusMessage: 'Email already exists' })
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
name,
|
||||
passwordHash: await hash(password, 10)
|
||||
}
|
||||
})
|
||||
|
||||
await createDefaultMoneyData(prisma, user.id)
|
||||
setSession(event, user.id)
|
||||
|
||||
return { id: user.id, email: user.email, name: user.name }
|
||||
})
|
||||
36
server/utils/defaults.ts
Normal file
36
server/utils/defaults.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
|
||||
export async function createDefaultMoneyData(prisma: PrismaClient, userId: string) {
|
||||
const cash = await prisma.asset.create({
|
||||
data: {
|
||||
userId,
|
||||
name: '기본자산',
|
||||
balanceType: 'cumulative',
|
||||
initialBalance: 0,
|
||||
sortOrder: 0
|
||||
}
|
||||
})
|
||||
|
||||
const incomeParent = await prisma.category.create({
|
||||
data: { userId, type: 'income', name: '근로', sortOrder: 0 }
|
||||
})
|
||||
await prisma.category.create({
|
||||
data: { userId, type: 'income', name: '월급', parentId: incomeParent.id, sortOrder: 0 }
|
||||
})
|
||||
|
||||
const foodParent = await prisma.category.create({
|
||||
data: { userId, type: 'expense', name: '식비', sortOrder: 0 }
|
||||
})
|
||||
await prisma.category.create({
|
||||
data: { userId, type: 'expense', name: '외식', parentId: foodParent.id, sortOrder: 0 }
|
||||
})
|
||||
|
||||
const livingParent = await prisma.category.create({
|
||||
data: { userId, type: 'expense', name: '생활', sortOrder: 1 }
|
||||
})
|
||||
await prisma.category.create({
|
||||
data: { userId, type: 'expense', name: '마트', parentId: livingParent.id, sortOrder: 0 }
|
||||
})
|
||||
|
||||
return { cash }
|
||||
}
|
||||
17
todo.md
17
todo.md
@@ -63,17 +63,30 @@
|
||||
- 검색과 필터용 API query를 추가한다.
|
||||
- 카테고리 아이콘/색상 필드를 Prisma schema에 추가한다.
|
||||
- 사용자 설정 테이블을 추가한다.
|
||||
- 읽기 전용 공유 링크 테이블과 만료 기간 필드를 추가한다.
|
||||
- 거래 목록 조회 성능을 고려해 필요한 index를 점검한다.
|
||||
- CSV 내보내기 컬럼을 실제 사용 흐름에 맞게 정리한다.
|
||||
|
||||
## 9. 인증과 사용자 경험
|
||||
|
||||
- 로그인 실패, 세션 만료, 네트워크 오류 상태를 화면에서 명확히 보여준다.
|
||||
- 계정 정보 수정 화면을 구현한다.
|
||||
- 비밀번호 변경 기능을 구현한다.
|
||||
- 회원 탈퇴 또는 데이터 삭제 정책을 정한다.
|
||||
- 로그인 실패, 세션 만료, 네트워크 오류 상태를 화면에서 더 명확히 보여준다.
|
||||
- 로그아웃 동작과 위치를 정리한다.
|
||||
- 첫 실행 시 기본 자산/카테고리 생성 흐름을 점검한다.
|
||||
- 빈 데이터 상태 화면을 페이지별로 다듬는다.
|
||||
|
||||
## 10. 품질
|
||||
## 10. 공유
|
||||
|
||||
- 사용자가 공유 링크를 생성하는 화면을 구현한다.
|
||||
- 공유 링크의 조회 가능 기간을 설정할 수 있게 한다.
|
||||
- 공유 링크 접근자는 읽기 전용으로만 내역, 통계, 자산을 볼 수 있게 한다.
|
||||
- 공유 링크 만료, 삭제, 재생성 기능을 구현한다.
|
||||
- 공유 화면에서 편집, 삭제, 내보내기 같은 쓰기 기능을 숨긴다.
|
||||
- 공유 링크 접근 로그 또는 마지막 조회 시각을 기록할지 결정한다.
|
||||
|
||||
## 11. 품질
|
||||
|
||||
- 핵심 유틸 함수 테스트를 추가한다.
|
||||
- 거래 생성, 수정, 삭제 API 테스트를 추가한다.
|
||||
|
||||
Reference in New Issue
Block a user