Add account registration flow

This commit is contained in:
2026-08-13 17:43:13 +09:00
parent 0117a0c097
commit 255a6e8529
6 changed files with 154 additions and 60 deletions

View File

@@ -34,3 +34,6 @@
- 거래 목록 API에 검색어, 유형, 자산, 카테고리 필터 query를 추가했다.
- 월 선택 바텀 시트를 공통 컴포넌트로 분리하고 내역, 통계, 자산 화면에 적용했다.
- 거래 추가 화면을 거래 수정 화면과 같은 행 기반 입력 구조로 정리했다.
- 회원가입 API와 로그인 화면의 로그인/회원가입 전환 UI를 추가했다.
- 가입 시 기본 자산과 기본 수입/지출 카테고리가 생성되도록 했다.
- 읽기 전용 공유 링크 기능 계획을 TODO에 추가했다.

View File

@@ -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 {
error.value = '이메일 또는 비밀번호를 확인해 주세요.'
} 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>

View File

@@ -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
}
})
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 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)
}
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: [
{

View 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
View 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
View File

@@ -63,17 +63,30 @@
- 검색과 필터용 API query를 추가한다.
- 카테고리 아이콘/색상 필드를 Prisma schema에 추가한다.
- 사용자 설정 테이블을 추가한다.
- 읽기 전용 공유 링크 테이블과 만료 기간 필드를 추가한다.
- 거래 목록 조회 성능을 고려해 필요한 index를 점검한다.
- CSV 내보내기 컬럼을 실제 사용 흐름에 맞게 정리한다.
## 9. 인증과 사용자 경험
- 로그인 실패, 세션 만료, 네트워크 오류 상태를 화면에서 명확히 보여준다.
- 계정 정보 수정 화면을 구현한다.
- 비밀번호 변경 기능을 구현한다.
- 회원 탈퇴 또는 데이터 삭제 정책을 정한다.
- 로그인 실패, 세션 만료, 네트워크 오류 상태를 화면에서 더 명확히 보여준다.
- 로그아웃 동작과 위치를 정리한다.
- 첫 실행 시 기본 자산/카테고리 생성 흐름을 점검한다.
- 빈 데이터 상태 화면을 페이지별로 다듬는다.
## 10. 품질
## 10. 공유
- 사용자가 공유 링크를 생성하는 화면을 구현한다.
- 공유 링크의 조회 가능 기간을 설정할 수 있게 한다.
- 공유 링크 접근자는 읽기 전용으로만 내역, 통계, 자산을 볼 수 있게 한다.
- 공유 링크 만료, 삭제, 재생성 기능을 구현한다.
- 공유 화면에서 편집, 삭제, 내보내기 같은 쓰기 기능을 숨긴다.
- 공유 링크 접근 로그 또는 마지막 조회 시각을 기록할지 결정한다.
## 11. 품질
- 핵심 유틸 함수 테스트를 추가한다.
- 거래 생성, 수정, 삭제 API 테스트를 추가한다.