From de531bfe114898b5f1bc2b6a92c5e2121ae0d36b Mon Sep 17 00:00:00 2001 From: Johngreen Date: Sun, 8 Mar 2026 23:15:21 +0900 Subject: [PATCH] Protect admin pages, add store/subsidy actions Enforce authentication/authorization for admin pages and add several management actions and UI improvements. Key changes: - Added auth checks and redirects on admin pages (contracts, stores, subsidies, vendors) to restrict access to SUPER_ADMIN/OPS_MANAGER. - Hooked server actions to authenticated user IDs (release escrow, review/publish stores, review subsidies/vendors, open disputes, create subsidy cases, create match requests, submit/delete store drafts). - Implemented store publish flow including policy version resolution and StoreActionButtons update to show approve/reject/publish based on reviewStatus. - Added filtering UIs and query handling: admin lists (stores/subsidies/vendors) now support status filters; public stores list uses a new client StoreFilters component to build search params. - New client-side improvements: invite form triggers router.refresh() after success; register page accepts/validates optional phone and persists it; store creation now uses createStoreDraftService and shows error banner on failure. - Matching and subsidies pages now support contextual forms to create match requests and subsidy cases when a storeId is provided. - Various UX tweaks: disabled inspection button, dispute form, list link styling, and revalidation calls after server actions. - Added docs/BUG-REPORT-2026-03-08.md. These changes centralize auth, connect actions to real user IDs, and improve admin and store workflows and filtering. --- apps/web/src/app/admin/contracts/page.tsx | 11 +- .../app/admin/settings/invite/invite-form.tsx | 3 + .../app/admin/stores/StoreActionButtons.tsx | 45 ++-- apps/web/src/app/admin/stores/page.tsx | 96 +++++++-- apps/web/src/app/admin/subsidies/page.tsx | 47 +++- apps/web/src/app/admin/vendors/page.tsx | 58 +++-- apps/web/src/app/auth/register/actions.ts | 9 +- apps/web/src/app/auth/register/page.tsx | 16 ++ apps/web/src/app/contracts/page.tsx | 29 ++- apps/web/src/app/matching/page.tsx | 135 ++++++++++-- apps/web/src/app/stores/[id]/page.tsx | 99 +++++++-- apps/web/src/app/stores/new/page.tsx | 110 +++++----- apps/web/src/app/stores/page.tsx | 63 ++---- apps/web/src/app/stores/store-filters.tsx | 94 ++++++++ apps/web/src/app/subsidies/page.tsx | 104 ++++++++- apps/web/src/app/vendors/actions.ts | 8 +- docs/BUG-REPORT-2026-03-08.md | 203 ++++++++++++++++++ 17 files changed, 931 insertions(+), 199 deletions(-) create mode 100644 apps/web/src/app/stores/store-filters.tsx create mode 100644 docs/BUG-REPORT-2026-03-08.md diff --git a/apps/web/src/app/admin/contracts/page.tsx b/apps/web/src/app/admin/contracts/page.tsx index 82e708a..b0d65ce 100644 --- a/apps/web/src/app/admin/contracts/page.tsx +++ b/apps/web/src/app/admin/contracts/page.tsx @@ -1,5 +1,7 @@ import { revalidatePath } from 'next/cache'; +import { redirect } from 'next/navigation'; import { createPrismaClient } from '@startover/database'; +import { auth } from '@/lib/auth'; import { releaseEscrowService } from '@/services/contract-service'; import ContractActionButtons from './ContractActionButtons'; @@ -21,14 +23,19 @@ const ESCROW_MAP: Record = { async function handleRelease(formData: FormData) { 'use server'; + const session = await auth(); + if (!session?.user?.dbId) throw new Error('Unauthorized'); const contractPublicId = formData.get('contractPublicId') as string; const prisma = createPrismaClient(); - // TODO: 인증 연동 후 실제 actorUserId 사용 - await releaseEscrowService(prisma, contractPublicId, '1'); + await releaseEscrowService(prisma, contractPublicId, session.user.dbId); revalidatePath('/admin/contracts'); } export default async function AdminContractsPage() { + const session = await auth(); + if (!session?.user || !['SUPER_ADMIN', 'OPS_MANAGER'].includes(session.user.primaryRole)) { + redirect('/403'); + } const prisma = createPrismaClient(); const [contracts, activeCount, releaseReviewCount, disputedCount, escrowTotal] = diff --git a/apps/web/src/app/admin/settings/invite/invite-form.tsx b/apps/web/src/app/admin/settings/invite/invite-form.tsx index 31c76f9..c7a6615 100644 --- a/apps/web/src/app/admin/settings/invite/invite-form.tsx +++ b/apps/web/src/app/admin/settings/invite/invite-form.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import { useRouter } from 'next/navigation'; const OPERATOR_ROLES = [ { value: 'OPS_MANAGER', label: '운영 매니저' }, @@ -10,6 +11,7 @@ const OPERATOR_ROLES = [ ] as const; export function InviteForm() { + const router = useRouter(); const [isPending, setIsPending] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); @@ -34,6 +36,7 @@ export function InviteForm() { if (res.ok) { setMessage({ type: 'success', text: `${formData.get('email')}로 초대가 발송되었습니다` }); (e.target as HTMLFormElement).reset(); + router.refresh(); } else { setMessage({ type: 'error', text: data.error || '초대 발송에 실패했습니다' }); } diff --git a/apps/web/src/app/admin/stores/StoreActionButtons.tsx b/apps/web/src/app/admin/stores/StoreActionButtons.tsx index cb52a41..239ee37 100644 --- a/apps/web/src/app/admin/stores/StoreActionButtons.tsx +++ b/apps/web/src/app/admin/stores/StoreActionButtons.tsx @@ -2,29 +2,48 @@ interface StoreActionButtonsProps { storePublicId: string; + reviewStatus: string; approveAction: (formData: FormData) => Promise; rejectAction: (formData: FormData) => Promise; + publishAction: (formData: FormData) => Promise; } export default function StoreActionButtons({ storePublicId, + reviewStatus, approveAction, rejectAction, + publishAction, }: StoreActionButtonsProps) { - return ( -
-
+ if (reviewStatus === 'SUBMITTED') { + return ( +
+ + + + +
+ + +
+
+ ); + } + + if (reviewStatus === 'APPROVED') { + return ( +
-
-
- - -
-
- ); + ); + } + + return null; } diff --git a/apps/web/src/app/admin/stores/page.tsx b/apps/web/src/app/admin/stores/page.tsx index dabe101..dc41297 100644 --- a/apps/web/src/app/admin/stores/page.tsx +++ b/apps/web/src/app/admin/stores/page.tsx @@ -1,6 +1,10 @@ +import Link from 'next/link'; import { revalidatePath } from 'next/cache'; +import { redirect } from 'next/navigation'; +import { type StoreReviewStatus } from '@prisma/client'; import { createPrismaClient } from '@startover/database'; -import { reviewStoreService } from '@/services/store-service'; +import { auth } from '@/lib/auth'; +import { reviewStoreService, publishStoreService } from '@/services/store-service'; import StoreActionButtons from './StoreActionButtons'; export const dynamic = 'force-dynamic'; @@ -12,29 +16,76 @@ const STATUS_MAP: Record = { PUBLISHED: { label: '공개', color: 'bg-blue-100 text-blue-700' }, }; +const FILTER_LABELS: { label: string; status?: string }[] = [ + { label: '전체' }, + { label: '검토 대기', status: 'SUBMITTED' }, + { label: '승인', status: 'APPROVED' }, + { label: '반려', status: 'REJECTED' }, +]; + async function handleApprove(formData: FormData) { 'use server'; + const session = await auth(); + if (!session?.user?.dbId) throw new Error('Unauthorized'); const storePublicId = formData.get('storePublicId') as string; const prisma = createPrismaClient(); - // TODO: 인증 연동 후 실제 actorUserId 사용 - const actorUserId = '1'; - await reviewStoreService(prisma, storePublicId, 'APPROVED', actorUserId); + await reviewStoreService(prisma, storePublicId, 'APPROVED', session.user.dbId); revalidatePath('/admin/stores'); } async function handleReject(formData: FormData) { 'use server'; + const session = await auth(); + if (!session?.user?.dbId) throw new Error('Unauthorized'); const storePublicId = formData.get('storePublicId') as string; const prisma = createPrismaClient(); - // TODO: 인증 연동 후 실제 actorUserId 사용 - const actorUserId = '1'; - await reviewStoreService(prisma, storePublicId, 'REJECTED', actorUserId); + await reviewStoreService(prisma, storePublicId, 'REJECTED', session.user.dbId); revalidatePath('/admin/stores'); } -export default async function AdminStoresPage() { +async function handlePublish(formData: FormData) { + 'use server'; + const session = await auth(); + if (!session?.user?.dbId) throw new Error('Unauthorized'); + const storePublicId = formData.get('storePublicId') as string; + const prisma = createPrismaClient(); + + let policyVersion = await prisma.policyVersion.findFirst({ + where: { isActive: true }, + orderBy: { createdAt: 'desc' }, + }); + if (!policyVersion) { + policyVersion = await prisma.policyVersion.create({ + data: { + policyType: 'TERMS_OF_SERVICE', + versionCode: 'v1.0.0', + contentHash: 'initial', + effectiveFrom: new Date(), + isActive: true, + }, + }); + } + + await publishStoreService(prisma, storePublicId, policyVersion.id.toString(), session.user.dbId); + revalidatePath('/admin/stores'); +} + +export default async function AdminStoresPage({ + searchParams, +}: { + searchParams: Promise<{ status?: string }>; +}) { + const session = await auth(); + if (!session?.user || !['SUPER_ADMIN', 'OPS_MANAGER'].includes(session.user.primaryRole)) { + redirect('/403'); + } + + const params = await searchParams; + const activeStatus = params.status; + const prisma = createPrismaClient(); const stores = await prisma.store.findMany({ + where: activeStatus ? { reviewStatus: activeStatus as StoreReviewStatus } : undefined, include: { ownerUser: { select: { phone: true } }, regionCluster: { select: { nameKo: true } }, @@ -50,14 +101,23 @@ export default async function AdminStoresPage() { {/* 필터 */}
- {['전체', '검토 대기', '승인', '반려'].map((f) => ( - - ))} + {FILTER_LABELS.map((f) => { + const href = f.status ? `/admin/stores?status=${f.status}` : '/admin/stores'; + const isActive = f.status ? activeStatus === f.status : !activeStatus; + return ( + + {f.label} + + ); + })}
{/* 매장 목록 */} @@ -97,11 +157,13 @@ export default async function AdminStoresPage() { {store.createdAt.toLocaleDateString('ko-KR')} - {store.reviewStatus === 'SUBMITTED' && ( + {(store.reviewStatus === 'SUBMITTED' || store.reviewStatus === 'APPROVED') && ( )} diff --git a/apps/web/src/app/admin/subsidies/page.tsx b/apps/web/src/app/admin/subsidies/page.tsx index b026dfe..d287421 100644 --- a/apps/web/src/app/admin/subsidies/page.tsx +++ b/apps/web/src/app/admin/subsidies/page.tsx @@ -1,7 +1,11 @@ import { revalidatePath } from 'next/cache'; +import { redirect } from 'next/navigation'; +import Link from 'next/link'; import { createPrismaClient } from '@startover/database'; +import { auth } from '@/lib/auth'; import { reviewSubsidyCaseService } from '@/services/subsidy-case-service'; import SubsidyActionButtons from './SubsidyActionButtons'; +import type { SubsidyCaseStatus } from '@prisma/client'; export const dynamic = 'force-dynamic'; @@ -15,25 +19,45 @@ const STATUS_MAP: Record = { async function handleApprove(formData: FormData) { 'use server'; + const session = await auth(); + if (!session?.user?.dbId) throw new Error('Unauthorized'); const subsidyCasePublicId = formData.get('subsidyCasePublicId') as string; const prisma = createPrismaClient(); - // TODO: 인증 연동 후 실제 actorUserId 사용 - await reviewSubsidyCaseService(prisma, subsidyCasePublicId, 'APPROVED', '1'); + await reviewSubsidyCaseService(prisma, subsidyCasePublicId, 'APPROVED', session.user.dbId); revalidatePath('/admin/subsidies'); } async function handleReject(formData: FormData) { 'use server'; + const session = await auth(); + if (!session?.user?.dbId) throw new Error('Unauthorized'); const subsidyCasePublicId = formData.get('subsidyCasePublicId') as string; const prisma = createPrismaClient(); - // TODO: 인증 연동 후 실제 actorUserId 사용 - await reviewSubsidyCaseService(prisma, subsidyCasePublicId, 'REJECTED', '1'); + await reviewSubsidyCaseService(prisma, subsidyCasePublicId, 'REJECTED', session.user.dbId); revalidatePath('/admin/subsidies'); } -export default async function AdminSubsidiesPage() { +const FILTER_BUTTONS = [ + { label: '전체', href: '/admin/subsidies', value: undefined }, + { label: '검토 대기', href: '/admin/subsidies?status=SUBMITTED', value: 'SUBMITTED' }, + { label: '검토 중', href: '/admin/subsidies?status=REVIEWING', value: 'REVIEWING' }, + { label: '승인', href: '/admin/subsidies?status=APPROVED', value: 'APPROVED' }, + { label: '반려', href: '/admin/subsidies?status=REJECTED', value: 'REJECTED' }, +] as const; + +export default async function AdminSubsidiesPage({ + searchParams, +}: { + searchParams: Promise<{ status?: string }>; +}) { + const params = await searchParams; + const session = await auth(); + if (!session?.user || !['SUPER_ADMIN', 'OPS_MANAGER'].includes(session.user.primaryRole)) { + redirect('/403'); + } const prisma = createPrismaClient(); const cases = await prisma.subsidyCase.findMany({ + where: params.status ? { status: params.status as SubsidyCaseStatus } : undefined, include: { store: { select: { listingTitle: true } }, applicantUser: { select: { phone: true } }, @@ -48,13 +72,14 @@ export default async function AdminSubsidiesPage() {

지원금 케이스를 검토하고 승인 또는 반려합니다

- {['전체', '검토 대기', '검토 중', '승인', '반려'].map((f) => ( - + {f.label} + ))}
diff --git a/apps/web/src/app/admin/vendors/page.tsx b/apps/web/src/app/admin/vendors/page.tsx index 8aa5dc2..d36040c 100644 --- a/apps/web/src/app/admin/vendors/page.tsx +++ b/apps/web/src/app/admin/vendors/page.tsx @@ -1,7 +1,11 @@ import { revalidatePath } from 'next/cache'; +import { redirect } from 'next/navigation'; +import Link from 'next/link'; import { createPrismaClient } from '@startover/database'; +import { auth } from '@/lib/auth'; import { reviewVendorCertificationService } from '@/services/vendor-certification-service'; import VendorActionButtons from './VendorActionButtons'; +import type { VendorCertificationStatus } from '@prisma/client'; export const dynamic = 'force-dynamic'; @@ -20,43 +24,66 @@ const STATUS_MAP: Record = { async function handleApprove(formData: FormData) { 'use server'; + const session = await auth(); + if (!session?.user?.dbId) throw new Error('Unauthorized'); const vendorPublicId = formData.get('vendorPublicId') as string; const prisma = createPrismaClient(); - // TODO: 인증 연동 후 실제 actorUserId 사용 - await reviewVendorCertificationService(prisma, vendorPublicId, 'APPROVED', '1'); + await reviewVendorCertificationService(prisma, vendorPublicId, 'APPROVED', session.user.dbId); revalidatePath('/admin/vendors'); } async function handleReject(formData: FormData) { 'use server'; + const session = await auth(); + if (!session?.user?.dbId) throw new Error('Unauthorized'); const vendorPublicId = formData.get('vendorPublicId') as string; const prisma = createPrismaClient(); - // TODO: 인증 연동 후 실제 actorUserId 사용 - await reviewVendorCertificationService(prisma, vendorPublicId, 'REJECTED', '1'); + await reviewVendorCertificationService(prisma, vendorPublicId, 'REJECTED', session.user.dbId); revalidatePath('/admin/vendors'); } async function handleSuspend(formData: FormData) { 'use server'; + const session = await auth(); + if (!session?.user?.dbId) throw new Error('Unauthorized'); const vendorPublicId = formData.get('vendorPublicId') as string; const prisma = createPrismaClient(); - // TODO: 인증 연동 후 실제 actorUserId 사용 - await reviewVendorCertificationService(prisma, vendorPublicId, 'SUSPENDED', '1'); + await reviewVendorCertificationService(prisma, vendorPublicId, 'SUSPENDED', session.user.dbId); revalidatePath('/admin/vendors'); } async function handleRestore(formData: FormData) { 'use server'; + const session = await auth(); + if (!session?.user?.dbId) throw new Error('Unauthorized'); const vendorPublicId = formData.get('vendorPublicId') as string; const prisma = createPrismaClient(); - // TODO: 인증 연동 후 실제 actorUserId 사용 - await reviewVendorCertificationService(prisma, vendorPublicId, 'APPROVED', '1'); + await reviewVendorCertificationService(prisma, vendorPublicId, 'APPROVED', session.user.dbId); revalidatePath('/admin/vendors'); } -export default async function AdminVendorsPage() { +const FILTER_BUTTONS = [ + { label: '전체', href: '/admin/vendors', value: undefined }, + { label: '심사 대기', href: '/admin/vendors?status=APPLIED', value: 'APPLIED' }, + { label: '인증됨', href: '/admin/vendors?status=APPROVED', value: 'APPROVED' }, + { label: '중지', href: '/admin/vendors?status=SUSPENDED', value: 'SUSPENDED' }, +] as const; + +export default async function AdminVendorsPage({ + searchParams, +}: { + searchParams: Promise<{ status?: string }>; +}) { + const params = await searchParams; + const session = await auth(); + if (!session?.user || !['SUPER_ADMIN', 'OPS_MANAGER'].includes(session.user.primaryRole)) { + redirect('/403'); + } const prisma = createPrismaClient(); const vendors = await prisma.vendor.findMany({ + where: params.status + ? { certificationStatus: params.status as VendorCertificationStatus } + : undefined, include: { coverageRegions: { include: { region: { select: { nameKo: true } } } }, }, @@ -69,13 +96,14 @@ export default async function AdminVendorsPage() {

인증 신청을 검토하고 승인·반려·중지를 관리합니다

- {['전체', '심사 대기', '인증됨', '중지'].map((f) => ( - + {f.label} + ))}
diff --git a/apps/web/src/app/auth/register/actions.ts b/apps/web/src/app/auth/register/actions.ts index c91dc9b..6641d49 100644 --- a/apps/web/src/app/auth/register/actions.ts +++ b/apps/web/src/app/auth/register/actions.ts @@ -17,6 +17,11 @@ const registerSchema = z.object({ .regex(/[a-zA-Z]/, '영문을 포함해야 합니다') .regex(/[0-9]/, '숫자를 포함해야 합니다'), name: z.string().min(1, '이름을 입력하세요'), + phone: z + .string() + .regex(/^01[0-9]-?\d{3,4}-?\d{4}$/, '올바른 휴대폰 번호를 입력하세요') + .optional() + .or(z.literal('')), role: z.enum(['CLOSING_OWNER', 'FOUNDER', 'VENDOR_MANAGER']), termsOfService: z.literal(true, { errorMap: () => ({ message: '이용약관에 동의해야 합니다' }), @@ -48,6 +53,7 @@ export async function registerAction( email: formData.get('email'), password: formData.get('password'), name: formData.get('name'), + phone: formData.get('phone') || '', role: formData.get('role'), termsOfService: formData.get('termsOfService') === 'on', privacyPolicy: formData.get('privacyPolicy') === 'on', @@ -63,7 +69,7 @@ export async function registerAction( }; } - const { email, password, name, role, marketingConsent, kakaoNotification } = parsed.data; + const { email, password, name, phone, role, marketingConsent, kakaoNotification } = parsed.data; const emailNormalized = email.toLowerCase().trim(); const existing = await prisma.user.findFirst({ where: { emailNormalized } }); @@ -99,6 +105,7 @@ export async function registerAction( email, emailNormalized, name, + phone: phone || null, passwordHash, primaryRole: role as UserRole, status: 'PENDING_VERIFICATION', diff --git a/apps/web/src/app/auth/register/page.tsx b/apps/web/src/app/auth/register/page.tsx index ac2dfe1..d7f0cbd 100644 --- a/apps/web/src/app/auth/register/page.tsx +++ b/apps/web/src/app/auth/register/page.tsx @@ -80,6 +80,22 @@ export default function RegisterPage() { )} +
+ + + {state.fieldErrors?.phone && ( +

{state.fieldErrors.phone[0]}

+ )} +
+
diff --git a/apps/web/src/app/matching/page.tsx b/apps/web/src/app/matching/page.tsx index f7bf7e7..26dfbee 100644 --- a/apps/web/src/app/matching/page.tsx +++ b/apps/web/src/app/matching/page.tsx @@ -1,5 +1,7 @@ import Link from 'next/link'; +import { revalidatePath } from 'next/cache'; import { createPrismaClient } from '@startover/database'; +import { auth } from '@/lib/auth'; export const dynamic = 'force-dynamic'; @@ -16,8 +18,65 @@ const STATUS_LABELS: Record = { REJECTED: { label: '거절됨', color: 'bg-red-100 text-red-700' }, }; -export default async function MatchingPage() { +async function handleCreateMatchRequest(formData: FormData) { + 'use server'; + const storePublicId = formData.get('storePublicId') as string; + const matchType = formData.get('matchType') as string; + const message = (formData.get('message') as string)?.trim() || null; + const session = await auth(); + if (!session?.user?.dbId) return; + const prisma = createPrismaClient(); + const store = await prisma.store.findUnique({ where: { publicId: storePublicId } }); + if (!store) return; + + await prisma.$transaction(async (tx) => { + const matchRequest = await tx.matchRequest.create({ + data: { + storeId: store.id, + requesterUserId: BigInt(session.user.dbId!), + matchType: matchType as 'ACQUISITION' | 'DEMOLITION' | 'INTERIOR', + sourceType: 'USER_REQUEST', + status: 'OPEN', + message, + }, + }); + + await tx.auditLog.create({ + data: { + actorUserId: BigInt(session.user.dbId!), + resourceType: 'MatchRequest', + resourceId: matchRequest.publicId, + actionType: 'MATCH_REQUEST_CREATED', + afterJson: { storePublicId, matchType }, + }, + }); + }); + + revalidatePath('/matching'); +} + +export default async function MatchingPage({ + searchParams, +}: { + searchParams: Promise<{ storeId?: string }>; +}) { + const params = await searchParams; + const prisma = createPrismaClient(); + + // Load store info if storeId is provided + let targetStore: { publicId: string; listingTitle: string } | null = null; + if (params.storeId) { + try { + targetStore = await prisma.store.findUnique({ + where: { publicId: params.storeId }, + select: { publicId: true, listingTitle: true }, + }); + } catch (err) { + console.error('매장 조회 실패:', err); + } + } + const query = { include: { store: { select: { publicId: true, listingTitle: true } }, @@ -37,7 +96,69 @@ export default async function MatchingPage() {

매칭 요청

매장과의 매칭 요청 현황을 관리합니다

-
+ {targetStore ? ( +
+

매칭 요청 보내기

+

+ {targetStore.listingTitle} 매장에 매칭 요청을 + 보냅니다. +

+
+ +
+ + +
+
+ +