Add read only sharing links

This commit is contained in:
2026-08-13 17:47:01 +09:00
parent 255a6e8529
commit 4cd091e2e3
12 changed files with 406 additions and 14 deletions

View File

@@ -1,6 +1,7 @@
<template>
<NuxtLink
:to="`/transactions/${transaction.id}`"
<component
:is="rowComponent"
v-bind="rowAttrs"
class="grid grid-cols-[2.5rem_minmax(0,1fr)_auto] items-start gap-3 px-4 py-3 active:bg-paper md:px-5"
>
<div
@@ -29,7 +30,7 @@
>
{{ formatWon(transaction.amount) }}
</p>
</NuxtLink>
</component>
</template>
<script setup lang="ts">
@@ -37,8 +38,11 @@ import type { Transaction } from '~/composables/useMoney'
const props = defineProps<{
transaction: Transaction
readonly?: boolean
}>()
const rowComponent = computed(() => props.readonly ? 'div' : resolveComponent('NuxtLink'))
const rowAttrs = computed(() => props.readonly ? {} : { to: `/transactions/${props.transaction.id}` })
const categoryLabel = computed(() => {
const category = props.transaction.category
return category.name

View File

@@ -37,3 +37,7 @@
- 회원가입 API와 로그인 화면의 로그인/회원가입 전환 UI를 추가했다.
- 가입 시 기본 자산과 기본 수입/지출 카테고리가 생성되도록 했다.
- 읽기 전용 공유 링크 기능 계획을 TODO에 추가했다.
- 읽기 전용 공유 링크 DB 모델과 migration을 추가했다.
- 공유 링크 생성, 목록 조회, 삭제 API를 추가했다.
- 만료 기간이 있는 공개 읽기 전용 공유 화면을 추가했다.
- 설정 화면에 계정 관리, 로그아웃, 공유 링크 관리 화면을 추가했다.

View File

@@ -1,5 +1,5 @@
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path === '/login') return
if (to.path === '/login' || to.path.startsWith('/share/')) return
const session = useState<{ id: string; email: string; name: string } | null>('session')

View File

@@ -57,6 +57,65 @@
</div>
</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>
<button type="button" class="btn-secondary mt-4 w-full border-red-200 text-red-600" @click="logout">
로그아웃
</button>
</div>
<div v-else-if="view === 'sharing'" class="px-4 py-4">
<form class="space-y-3 border-b border-line pb-5" @submit.prevent="createShareLink">
<input v-model="shareForm.name" class="field" placeholder="공유 이름">
<div>
<span class="label">조회 가능 기간</span>
<div class="grid grid-cols-3 gap-2">
<button
v-for="days in shareDayOptions"
:key="days"
type="button"
class="rounded-md border px-3 py-2 text-sm font-bold"
:class="shareForm.days === days ? 'border-red-500 bg-red-50 text-red-600' : 'border-line text-muted'"
@click="shareForm.days = days"
>
{{ days }}일
</button>
</div>
</div>
<button class="btn-primary w-full" type="submit">공유 링크 만들기</button>
</form>
<div class="mt-4 divide-y divide-line border-y border-line bg-white">
<div v-for="link in shareLinks" :key="link.id" class="px-4 py-4">
<div class="flex items-start justify-between gap-3">
<span class="min-w-0">
<span class="block truncate text-base font-semibold text-ink">{{ link.name }}</span>
<span class="mt-1 block text-xs text-muted">
{{ link.active ? '공유 중' : '비활성' }} · {{ dateLabel(link.expiresAt) }}까지
</span>
<span class="mt-1 block truncate text-xs text-muted">{{ shareUrl(link.token) }}</span>
</span>
<span class="shrink-0 rounded-full px-2 py-1 text-xs font-bold" :class="link.active ? 'bg-brand-50 text-brand-700' : 'bg-paper text-muted'">
{{ link.active ? 'ON' : 'OFF' }}
</span>
</div>
<div class="mt-3 grid grid-cols-2 gap-2">
<button type="button" class="btn-secondary" :disabled="!link.active" @click="copyShareUrl(link.token)">복사</button>
<button type="button" class="btn-secondary border-red-200 text-red-600" :disabled="!link.active" @click="revokeShareLink(link.id)">삭제</button>
</div>
</div>
<p v-if="!shareLinks.length" class="px-4 py-12 text-center text-muted">
만든 공유 링크가 없습니다.
</p>
</div>
</div>
<div v-else-if="view === 'assets'" class="px-4 py-4">
<form class="space-y-3 border-b border-line pb-5" @submit.prevent="createAsset">
<input v-model="assetForm.name" class="field" placeholder="자산 이름" required>
@@ -166,18 +225,24 @@
</template>
<script setup lang="ts">
type SettingsView = 'home' | 'assets' | 'categories'
import { format } from 'date-fns'
type SettingsView = 'home' | 'account' | 'sharing' | 'assets' | 'categories'
type CategoryType = 'income' | 'expense'
const view = ref<SettingsView>('home')
const session = useState<{ id: string; email: string; name: string } | null>('session')
const categoryType = ref<CategoryType>('expense')
const selectedParentId = ref('')
const addSheetOpen = ref(false)
const categoryName = ref('')
const { data: assetData, refresh: refreshAssets } = await useFetch('/api/assets', { query: { includeInactive: true } })
const { data: categoryData, refresh: refreshCategories } = await useFetch('/api/categories', { query: { includeInactive: true } })
const { data: shareLinkData, refresh: refreshShareLinks } = await useFetch('/api/share-links')
const menuItems = [
{ id: 'account', label: '계정 관리', description: '로그인 계정 확인과 로그아웃' },
{ id: 'sharing', label: '공유 링크 관리', description: '읽기 전용 공유 링크 생성과 삭제' },
{ id: 'assets', label: '자산 관리', description: '자산 추가, 잔액 계산 방식, 활성 상태' },
{ id: 'categories', label: '카테고리 관리', description: '수입/지출 상위 분류와 하위 분류' }
] as const
@@ -187,8 +252,15 @@ const assetForm = reactive({
balanceType: 'cumulative',
initialBalance: 0
})
const shareDayOptions = [1, 7, 30]
const shareForm = reactive({
name: '',
days: 7
})
const title = computed(() => {
if (view.value === 'account') return '계정 관리'
if (view.value === 'sharing') return '공유 링크 관리'
if (view.value === 'assets') return '자산 관리'
if (selectedParentCategory.value) return selectedParentCategory.value.name
if (view.value === 'categories') return '카테고리 관리'
@@ -196,6 +268,7 @@ const title = computed(() => {
})
const assets = computed(() => assetData.value || [])
const categories = computed(() => categoryData.value || [])
const shareLinks = computed(() => shareLinkData.value || [])
const selectedParentCategory = computed(() => categories.value.find((category: any) => category.id === selectedParentId.value))
const categoryTypeLabel = computed(() => categoryType.value === 'income' ? '수입' : '지출')
const visibleCategories = computed(() => {
@@ -269,4 +342,38 @@ async function createCategory() {
addSheetOpen.value = false
await refreshCategories()
}
async function createShareLink() {
await $fetch('/api/share-links', {
method: 'POST',
body: shareForm
})
Object.assign(shareForm, { name: '', days: 7 })
await refreshShareLinks()
}
async function revokeShareLink(id: string) {
await $fetch(`/api/share-links/${id}`, { method: 'DELETE' })
await refreshShareLinks()
}
async function copyShareUrl(token: string) {
if (!import.meta.client) return
await navigator.clipboard?.writeText(shareUrl(token))
}
function shareUrl(token: string) {
if (!import.meta.client) return `/share/${token}`
return `${window.location.origin}/share/${token}`
}
function dateLabel(value: string) {
return format(new Date(value), 'yyyy.MM.dd')
}
async function logout() {
await $fetch('/api/auth/logout', { method: 'POST' })
session.value = null
await navigateTo('/login')
}
</script>

106
pages/share/[token].vue Normal file
View File

@@ -0,0 +1,106 @@
<template>
<section class="min-h-dvh bg-paper">
<header class="sticky top-0 z-10 border-b border-line bg-white px-4 py-4">
<p class="text-xs font-bold text-red-500">읽기 전용 공유</p>
<h1 class="mt-1 text-lg font-extrabold">{{ data?.name || '공유 가계부' }}</h1>
<p v-if="data" class="mt-1 text-xs text-muted">
{{ data.owner }}님의 {{ displayMonth }} 내역 · {{ expiresLabel }}까지
</p>
</header>
<div v-if="error" class="px-4 py-20 text-center">
<h2 class="text-lg font-bold">공유 링크를 볼 수 없습니다.</h2>
<p class="mt-2 text-sm text-muted">링크가 만료되었거나 삭제되었을 수 있습니다.</p>
</div>
<template v-else>
<section class="border-b border-line bg-white px-4 py-4">
<div class="flex items-center justify-between">
<button type="button" class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper" aria-label="이전 달" @click="moveMonth(-1)">
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_left.svg" alt="">
</button>
<button type="button" class="text-lg font-bold" @click="monthPickerOpen = true">{{ displayMonth }}</button>
<button type="button" class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper" aria-label="다음 달" @click="moveMonth(1)">
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
</button>
</div>
<div class="mt-4 grid grid-cols-3 gap-2 text-center text-sm">
<div>
<p class="text-muted">수입</p>
<p class="mt-1 font-bold text-brand-700">{{ formatWon(data?.summary.income || 0) }}</p>
</div>
<div>
<p class="text-muted">지출</p>
<p class="mt-1 font-bold text-red-600">{{ formatWon(data?.summary.expense || 0) }}</p>
</div>
<div>
<p class="text-muted">차액</p>
<p class="mt-1 font-bold">{{ formatWon(data?.summary.diff || 0) }}</p>
</div>
</div>
</section>
<div class="divide-y divide-line bg-white">
<section v-for="group in groupedTransactions" :key="group.date" class="py-2">
<h2 class="px-4 py-3 text-sm font-semibold text-muted">
{{ group.label }}
</h2>
<TransactionListRow
v-for="tx in group.items"
:key="tx.id"
:transaction="tx"
readonly
/>
</section>
</div>
<p v-if="!data?.transactions.length" class="px-4 py-16 text-center text-muted">
공유된 {{ displayMonth }} 거래가 없습니다.
</p>
</template>
<BottomSheetMonthPicker v-model="selectedMonth" v-model:open="monthPickerOpen" />
</section>
</template>
<script setup lang="ts">
import { addMonths, format, parse } from 'date-fns'
import type { Transaction } from '~/composables/useMoney'
definePageMeta({ layout: false })
const route = useRoute()
const token = computed(() => String(route.params.token))
const selectedMonth = ref(monthKey())
const monthPickerOpen = ref(false)
const { data, error } = await useFetch<any>(() => `/api/shared/${token.value}`, {
query: { month: selectedMonth }
})
const displayMonth = computed(() => {
const date = parse(selectedMonth.value, 'yyyy-MM', new Date())
return format(date, 'yyyy년 M월')
})
const expiresLabel = computed(() => {
if (!data.value?.expiresAt) return ''
return format(new Date(data.value.expiresAt), 'yyyy.MM.dd')
})
const groupedTransactions = computed(() => {
const groups = new Map<string, { date: string; label: string; items: Transaction[] }>()
for (const tx of data.value?.transactions || []) {
const date = new Date(tx.occurredAt)
const key = format(date, 'yyyy-MM-dd')
const weekday = new Intl.DateTimeFormat('ko-KR', { weekday: 'short' }).format(date)
const group = groups.get(key) || { date: key, label: `${format(date, 'M. d')}(${weekday})`, items: [] }
group.items.push(tx)
groups.set(key, group)
}
return Array.from(groups.values()).sort((a, b) => b.date.localeCompare(a.date))
})
function moveMonth(amount: number) {
const date = parse(selectedMonth.value, 'yyyy-MM', new Date())
selectedMonth.value = format(addMonths(date, amount), 'yyyy-MM')
}
</script>

View File

@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "ShareLink" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"name" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
"lastViewedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ShareLink_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ShareLink_token_key" ON "ShareLink"("token");
-- CreateIndex
CREATE INDEX "ShareLink_userId_createdAt_idx" ON "ShareLink"("userId", "createdAt");
-- CreateIndex
CREATE INDEX "ShareLink_token_expiresAt_revokedAt_idx" ON "ShareLink"("token", "expiresAt", "revokedAt");
-- AddForeignKey
ALTER TABLE "ShareLink" ADD CONSTRAINT "ShareLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -37,6 +37,7 @@ model User {
categories Category[]
transactions Transaction[]
recurringTransactions RecurringTransaction[]
shareLinks ShareLink[]
}
model Asset {
@@ -137,3 +138,20 @@ model RecurringTransaction {
@@index([userId, active, nextRunAt])
}
model ShareLink {
id String @id @default(cuid())
userId String
token String @unique
name String
expiresAt DateTime
revokedAt DateTime?
lastViewedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@index([token, expiresAt, revokedAt])
}

View File

@@ -0,0 +1,20 @@
import { getRouterParam, createError } from 'h3'
import { prisma } from '../../utils/db'
import { requireUser } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const id = getRouterParam(event, 'id')!
const link = await prisma.shareLink.findFirst({ where: { id, userId: user.id } })
if (!link) {
throw createError({ statusCode: 404, statusMessage: 'Share link not found' })
}
await prisma.shareLink.update({
where: { id },
data: { revokedAt: new Date() }
})
return { ok: true }
})

View File

@@ -0,0 +1,21 @@
import { prisma } from '../../utils/db'
import { requireUser } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const links = await prisma.shareLink.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'desc' }
})
return links.map(link => ({
id: link.id,
name: link.name,
token: link.token,
expiresAt: link.expiresAt,
revokedAt: link.revokedAt,
lastViewedAt: link.lastViewedAt,
createdAt: link.createdAt,
active: !link.revokedAt && link.expiresAt > new Date()
}))
})

View File

@@ -0,0 +1,35 @@
import { randomBytes } from 'node:crypto'
import { addDays } from 'date-fns'
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; days?: number }>(event)
const days = Number(body.days || 7)
if (!Number.isFinite(days) || days < 1 || days > 365) {
throw createError({ statusCode: 400, statusMessage: 'Invalid expiration days' })
}
const link = await prisma.shareLink.create({
data: {
userId: user.id,
token: randomBytes(24).toString('base64url'),
name: body.name?.trim().slice(0, 40) || '공유 링크',
expiresAt: addDays(new Date(), days)
}
})
return {
id: link.id,
name: link.name,
token: link.token,
expiresAt: link.expiresAt,
revokedAt: link.revokedAt,
lastViewedAt: link.lastViewedAt,
createdAt: link.createdAt,
active: true
}
})

View File

@@ -0,0 +1,58 @@
import { getQuery, getRouterParam, createError } from 'h3'
import { startOfMonth, endOfMonth } from 'date-fns'
import { prisma } from '../../utils/db'
export default defineEventHandler(async (event) => {
const token = getRouterParam(event, 'token')!
const query = getQuery(event)
const month = typeof query.month === 'string' ? query.month : new Date().toISOString().slice(0, 7)
const monthStart = startOfMonth(new Date(`${month}-01T00:00:00`))
const monthEnd = endOfMonth(monthStart)
const link = await prisma.shareLink.findUnique({
where: { token },
include: { user: { select: { id: true, name: true } } }
})
if (!link || link.revokedAt || link.expiresAt <= new Date()) {
throw createError({ statusCode: 404, statusMessage: 'Share link not found' })
}
const [transactions, assets] = await Promise.all([
prisma.transaction.findMany({
where: {
userId: link.userId,
occurredAt: { gte: monthStart, lte: monthEnd }
},
include: {
asset: true,
category: { include: { parent: true } }
},
orderBy: { occurredAt: 'desc' }
}),
prisma.asset.findMany({
where: { userId: link.userId, active: true },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }]
})
])
await prisma.shareLink.update({
where: { id: link.id },
data: { lastViewedAt: new Date() }
})
const summary = transactions.reduce((acc, tx) => {
acc[tx.type] += tx.amount
acc.diff = acc.income - acc.expense
return acc
}, { income: 0, expense: 0, diff: 0 })
return {
owner: link.user.name,
name: link.name,
expiresAt: link.expiresAt,
summary,
assets,
transactions
}
})

11
todo.md
View File

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