From 5dea44046d3eac2342e96b6540f87b7721b81b38 Mon Sep 17 00:00:00 2001 From: Johngreen Date: Sun, 8 Mar 2026 12:59:21 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Auth.js=20v5=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84=20-=20?= =?UTF-8?q?=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EC=86=8C=EC=85=9C=20+=20?= =?UTF-8?q?=EC=9D=B4=EB=A9=94=EC=9D=BC/=EB=B9=84=EB=B2=88=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auth.js v5 (next-auth beta.30) + 커스텀 Prisma 어댑터 (BigInt PK 호환) - 카카오 OAuth2 소셜 로그인 (PKCE 비활성화, placeholder 이메일 처리) - 이메일/비밀번호 자격증명 로그인 (argon2 해시) - JWT 세션 전략 + 커스텀 클레임 (publicId, primaryRole, userStatus) - 역할 기반 미들웨어 (/admin 운영자 전용, /auth 비인증 전용) - Prisma 스키마: Account, VerificationToken, InviteToken 모델 추가 - ConsentType enum 7개로 업데이트 - 로그인/회원가입/프로필완성/403 페이지 구현 - 운영자 초대 시스템 (admin/settings/invite) --- apps/web/package.json | 7 +- apps/web/src/app/403/page.tsx | 17 + .../app/admin/settings/invite/invite-form.tsx | 100 ++++++ .../src/app/admin/settings/invite/page.tsx | 67 ++++ .../src/app/api/auth/[...nextauth]/route.ts | 3 + .../app/api/auth/complete-profile/route.ts | 91 ++++++ .../src/app/api/auth/invite/accept/route.ts | 79 +++++ .../src/app/api/auth/invite/create/route.ts | 69 +++++ apps/web/src/app/auth-buttons.tsx | 62 ++++ .../src/app/auth/complete-profile/page.tsx | 113 +++++++ apps/web/src/app/auth/invite/[token]/page.tsx | 110 +++++++ apps/web/src/app/auth/login/page.tsx | 122 ++++++++ apps/web/src/app/auth/register/actions.ts | 167 ++++++++++ apps/web/src/app/auth/register/page.tsx | 164 ++++++++++ apps/web/src/app/auth/verify-pending/page.tsx | 23 ++ apps/web/src/app/auth/verify/page.tsx | 108 +++++++ apps/web/src/app/layout.tsx | 30 +- apps/web/src/lib/auth.config.ts | 28 ++ apps/web/src/lib/auth.ts | 291 ++++++++++++++++++ apps/web/src/middleware.ts | 65 ++++ packages/database/prisma/schema.prisma | 61 +++- pnpm-lock.yaml | 154 ++++++++- 22 files changed, 1912 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/app/403/page.tsx create mode 100644 apps/web/src/app/admin/settings/invite/invite-form.tsx create mode 100644 apps/web/src/app/admin/settings/invite/page.tsx create mode 100644 apps/web/src/app/api/auth/[...nextauth]/route.ts create mode 100644 apps/web/src/app/api/auth/complete-profile/route.ts create mode 100644 apps/web/src/app/api/auth/invite/accept/route.ts create mode 100644 apps/web/src/app/api/auth/invite/create/route.ts create mode 100644 apps/web/src/app/auth-buttons.tsx create mode 100644 apps/web/src/app/auth/complete-profile/page.tsx create mode 100644 apps/web/src/app/auth/invite/[token]/page.tsx create mode 100644 apps/web/src/app/auth/login/page.tsx create mode 100644 apps/web/src/app/auth/register/actions.ts create mode 100644 apps/web/src/app/auth/register/page.tsx create mode 100644 apps/web/src/app/auth/verify-pending/page.tsx create mode 100644 apps/web/src/app/auth/verify/page.tsx create mode 100644 apps/web/src/lib/auth.config.ts create mode 100644 apps/web/src/lib/auth.ts create mode 100644 apps/web/src/middleware.ts diff --git a/apps/web/package.json b/apps/web/package.json index 06ffa63..e4c957f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,24 +13,27 @@ "clean": "rm -rf .next" }, "dependencies": { + "@auth/prisma-adapter": "^2.11.1", "@prisma/client": "^6.1.0", "@relink/database": "workspace:*", "@relink/domain": "workspace:*", "@relink/infrastructure": "workspace:*", "@relink/shared": "workspace:*", "@relink/ui": "workspace:*", + "argon2": "^0.44.0", "next": "^15.1.0", + "next-auth": "5.0.0-beta.30", "react": "^19.0.0", "react-dom": "^19.0.0", "zod": "^3.24.0" }, "devDependencies": { "@tailwindcss/postcss": "^4.0.0", - "eslint": "^8.57.1", - "eslint-config-next": "^15.1.0", "@types/node": "^22.10.2", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", + "eslint": "^8.57.1", + "eslint-config-next": "^15.1.0", "postcss": "^8.4.49", "tailwindcss": "^4.0.0", "typescript": "^5.7.2", diff --git a/apps/web/src/app/403/page.tsx b/apps/web/src/app/403/page.tsx new file mode 100644 index 0000000..6819761 --- /dev/null +++ b/apps/web/src/app/403/page.tsx @@ -0,0 +1,17 @@ +import Link from 'next/link'; + +export default function ForbiddenPage() { + return ( +
+

403

+

접근 권한이 없습니다

+

이 페이지에 접근할 수 있는 권한이 없습니다.

+ + 홈으로 + +
+ ); +} diff --git a/apps/web/src/app/admin/settings/invite/invite-form.tsx b/apps/web/src/app/admin/settings/invite/invite-form.tsx new file mode 100644 index 0000000..31c76f9 --- /dev/null +++ b/apps/web/src/app/admin/settings/invite/invite-form.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useState } from 'react'; + +const OPERATOR_ROLES = [ + { value: 'OPS_MANAGER', label: '운영 매니저' }, + { value: 'SUBSIDY_OPERATOR', label: '지원금 담당자' }, + { value: 'TRUST_OPERATOR', label: '신뢰 담당자' }, + { value: 'FINANCE_OPERATOR', label: '재무 담당자' }, +] as const; + +export function InviteForm() { + const [isPending, setIsPending] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsPending(true); + setMessage(null); + + const formData = new FormData(e.currentTarget); + + try { + const res = await fetch('/api/auth/invite/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: formData.get('email'), + role: formData.get('role'), + }), + }); + + const data = await res.json(); + if (res.ok) { + setMessage({ type: 'success', text: `${formData.get('email')}로 초대가 발송되었습니다` }); + (e.target as HTMLFormElement).reset(); + } else { + setMessage({ type: 'error', text: data.error || '초대 발송에 실패했습니다' }); + } + } catch { + setMessage({ type: 'error', text: '네트워크 오류가 발생했습니다. 다시 시도해주세요.' }); + } + setIsPending(false); + } + + return ( +
+

새 운영자 초대

+ + {message && ( +
+ {message.text} +
+ )} + +
+
+ + +
+
+ + +
+ +
+
+ ); +} diff --git a/apps/web/src/app/admin/settings/invite/page.tsx b/apps/web/src/app/admin/settings/invite/page.tsx new file mode 100644 index 0000000..815ff15 --- /dev/null +++ b/apps/web/src/app/admin/settings/invite/page.tsx @@ -0,0 +1,67 @@ +import { auth } from '@/lib/auth'; +import { redirect } from 'next/navigation'; +import { createPrismaClient } from '@relink/database'; +import { InviteForm } from './invite-form'; + +const prisma = createPrismaClient(); + +export default async function AdminInvitePage() { + const session = await auth(); + if (!session?.user || session.user.primaryRole !== 'SUPER_ADMIN') { + redirect('/403'); + } + + const invites = await prisma.inviteToken.findMany({ + orderBy: { createdAt: 'desc' }, + take: 20, + include: { creator: { select: { name: true, email: true } } }, + }); + + return ( +
+

운영자 초대

+ + + +
+

초대 내역

+ {invites.length === 0 ? ( +

초대 내역이 없습니다.

+ ) : ( +
+ + + + + + + + + + + {invites.map((invite) => ( + + + + + + + ))} + +
이메일역할상태생성일
{invite.email}{invite.role} + {invite.usedAt ? ( + 수락됨 + ) : invite.expires < new Date() ? ( + 만료 + ) : ( + 대기중 + )} + + {invite.createdAt.toLocaleDateString('ko-KR')} +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/api/auth/[...nextauth]/route.ts b/apps/web/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..5ef28c1 --- /dev/null +++ b/apps/web/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from '@/lib/auth'; + +export const { GET, POST } = handlers; diff --git a/apps/web/src/app/api/auth/complete-profile/route.ts b/apps/web/src/app/api/auth/complete-profile/route.ts new file mode 100644 index 0000000..823c2a2 --- /dev/null +++ b/apps/web/src/app/api/auth/complete-profile/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { createPrismaClient } from '@relink/database'; + +import type { ConsentType, ProfileType, UserRole } from '@prisma/client'; + +const prisma = createPrismaClient(); + +const ROLE_TO_PROFILE_TYPE: Record = { + CLOSING_OWNER: 'CLOSING_OWNER', + FOUNDER: 'FOUNDER', + VENDOR_MANAGER: 'VENDOR_MANAGER', +}; + +const VALID_ROLES = ['CLOSING_OWNER', 'FOUNDER', 'VENDOR_MANAGER']; + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.dbId) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const { role, termsOfService, privacyPolicy, marketingConsent, kakaoNotification } = body; + + if (!role || !VALID_ROLES.includes(role)) { + return NextResponse.json({ error: '올바른 역할을 선택해주세요' }, { status: 400 }); + } + + if (!termsOfService || !privacyPolicy) { + return NextResponse.json({ error: '필수 약관에 동의해주세요' }, { status: 400 }); + } + + const userId = BigInt(session.user.dbId); + + const existingProfile = await prisma.userProfile.findFirst({ + where: { userId }, + }); + if (existingProfile) { + return NextResponse.json({ success: true }); + } + + 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 prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: userId }, + data: { primaryRole: role as UserRole }, + }); + + await tx.userProfile.create({ + data: { + userId, + profileType: ROLE_TO_PROFILE_TYPE[role]!, + }, + }); + + const consentItems: { type: ConsentType; granted: boolean }[] = [ + { type: 'TERMS_OF_SERVICE', granted: true }, + { type: 'PRIVACY_POLICY_REQUIRED', granted: true }, + { type: 'PRIVACY_POLICY_MARKETING', granted: marketingConsent ?? false }, + { type: 'NOTIFICATION_KAKAO', granted: kakaoNotification ?? false }, + ]; + + await tx.userConsent.createMany({ + data: consentItems.map((item) => ({ + userId, + consentType: item.type, + policyVersionId: policyVersion!.id, + isGranted: item.granted, + grantedAt: new Date(), + })), + }); + }); + + return NextResponse.json({ success: true }); +} diff --git a/apps/web/src/app/api/auth/invite/accept/route.ts b/apps/web/src/app/api/auth/invite/accept/route.ts new file mode 100644 index 0000000..b65c247 --- /dev/null +++ b/apps/web/src/app/api/auth/invite/accept/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from 'next/server'; +import argon2 from 'argon2'; +import { createPrismaClient } from '@relink/database'; + +const prisma = createPrismaClient(); + +export async function POST(request: Request) { + const { token, password, name } = await request.json(); + + if (!token || !password || !name) { + return NextResponse.json({ error: '필수 정보가 누락되었습니다' }, { status: 400 }); + } + + if (password.length < 8 || !/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) { + return NextResponse.json( + { error: '비밀번호는 8자 이상, 영문+숫자를 포함해야 합니다' }, + { status: 400 }, + ); + } + + const inviteToken = await prisma.inviteToken.findUnique({ where: { token } }); + if (!inviteToken) { + return NextResponse.json({ error: '유효하지 않은 초대입니다' }, { status: 400 }); + } + + if (inviteToken.usedAt) { + return NextResponse.json({ error: '이미 사용된 초대입니다' }, { status: 400 }); + } + + if (inviteToken.expires < new Date()) { + return NextResponse.json({ error: '초대가 만료되었습니다' }, { status: 400 }); + } + + const emailNormalized = inviteToken.email.toLowerCase().trim(); + const existing = await prisma.user.findFirst({ where: { emailNormalized } }); + if (existing) { + return NextResponse.json({ error: '이미 가입된 이메일입니다' }, { status: 400 }); + } + + const passwordHash = await argon2.hash(password, { type: argon2.argon2id }); + + await prisma.$transaction(async (tx) => { + const user = await tx.user.create({ + data: { + email: inviteToken.email, + emailNormalized, + name, + passwordHash, + primaryRole: inviteToken.role, + status: 'ACTIVE', + emailVerifiedAt: new Date(), + }, + }); + + await tx.userProfile.create({ + data: { + userId: user.id, + profileType: 'OPERATOR', + }, + }); + + await tx.inviteToken.update({ + where: { id: inviteToken.id }, + data: { usedAt: new Date() }, + }); + + await tx.auditLog.create({ + data: { + actorUserId: user.id, + resourceType: 'User', + resourceId: user.id.toString(), + actionType: 'OPERATOR_INVITED_ACCEPTED', + afterJson: { role: inviteToken.role, invitedBy: inviteToken.createdBy.toString() }, + }, + }); + }); + + return NextResponse.json({ success: true }); +} diff --git a/apps/web/src/app/api/auth/invite/create/route.ts b/apps/web/src/app/api/auth/invite/create/route.ts new file mode 100644 index 0000000..a279732 --- /dev/null +++ b/apps/web/src/app/api/auth/invite/create/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { createPrismaClient } from '@relink/database'; + +import type { UserRole } from '@prisma/client'; + +const prisma = createPrismaClient(); + +const VALID_OPERATOR_ROLES: UserRole[] = [ + 'OPS_MANAGER', + 'SUBSIDY_OPERATOR', + 'TRUST_OPERATOR', + 'FINANCE_OPERATOR', +]; + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user || session.user.primaryRole !== 'SUPER_ADMIN') { + return NextResponse.json({ error: '권한이 없습니다' }, { status: 403 }); + } + + const { email, role } = await request.json(); + + if (!email || !role) { + return NextResponse.json({ error: '이메일과 역할을 입력해주세요' }, { status: 400 }); + } + + if (!VALID_OPERATOR_ROLES.includes(role as UserRole)) { + return NextResponse.json({ error: '유효하지 않은 역할입니다' }, { status: 400 }); + } + + const emailNormalized = (email as string).toLowerCase().trim(); + const existing = await prisma.user.findFirst({ where: { emailNormalized } }); + if (existing) { + return NextResponse.json({ error: '이미 가입된 이메일입니다' }, { status: 400 }); + } + + const existingInvite = await prisma.inviteToken.findFirst({ + where: { email: emailNormalized, usedAt: null, expires: { gt: new Date() } }, + }); + if (existingInvite) { + return NextResponse.json({ error: '이미 발송된 초대가 있습니다' }, { status: 400 }); + } + + const inviteToken = await prisma.inviteToken.create({ + data: { + email: emailNormalized, + role: role as UserRole, + expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7일 + createdBy: BigInt(session.user.dbId), + }, + }); + + // MVP: 콘솔에 초대 링크 출력 + const inviteUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/auth/invite/${inviteToken.token}`; + console.log(`[운영자 초대] ${email} (${role}) → ${inviteUrl}`); + + await prisma.auditLog.create({ + data: { + actorUserId: BigInt(session.user.dbId), + resourceType: 'InviteToken', + resourceId: inviteToken.id.toString(), + actionType: 'OPERATOR_INVITED', + afterJson: { email: emailNormalized, role }, + }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/apps/web/src/app/auth-buttons.tsx b/apps/web/src/app/auth-buttons.tsx new file mode 100644 index 0000000..4b1bc55 --- /dev/null +++ b/apps/web/src/app/auth-buttons.tsx @@ -0,0 +1,62 @@ +'use client'; + +import Link from 'next/link'; +import { signOut } from 'next-auth/react'; + +interface AuthButtonsProps { + session: { + user: { + name?: string | null; + primaryRole: string; + }; + } | null; +} + +const ROLE_LABELS: Record = { + CLOSING_OWNER: '폐업자', + FOUNDER: '창업자', + VENDOR_MANAGER: '업체', + OPS_MANAGER: '운영', + SUBSIDY_OPERATOR: '지원금', + TRUST_OPERATOR: '신뢰', + FINANCE_OPERATOR: '재무', + SUPER_ADMIN: '관리자', +}; + +export function AuthButtons({ session }: AuthButtonsProps) { + if (!session?.user) { + return ( +
+ + 로그인 + + + 회원가입 + +
+ ); + } + + return ( +
+ + {session.user.name} + + ({ROLE_LABELS[session.user.primaryRole] || session.user.primaryRole}) + + + +
+ ); +} diff --git a/apps/web/src/app/auth/complete-profile/page.tsx b/apps/web/src/app/auth/complete-profile/page.tsx new file mode 100644 index 0000000..2236d0b --- /dev/null +++ b/apps/web/src/app/auth/complete-profile/page.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; + +const ROLES = [ + { value: 'CLOSING_OWNER', label: '폐업자', desc: '매장을 양도하고 싶어요' }, + { value: 'FOUNDER', label: '창업자', desc: '매장을 인수하고 싶어요' }, + { value: 'VENDOR_MANAGER', label: '업체 담당자', desc: '철거/인테리어 서비스를 제공해요' }, +] as const; + +export default function CompleteProfilePage() { + const router = useRouter(); + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(''); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsPending(true); + setError(''); + + const formData = new FormData(e.currentTarget); + + try { + const res = await fetch('/api/auth/complete-profile', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + role: formData.get('role'), + termsOfService: formData.get('termsOfService') === 'on', + privacyPolicy: formData.get('privacyPolicy') === 'on', + marketingConsent: formData.get('marketingConsent') === 'on', + kakaoNotification: formData.get('kakaoNotification') === 'on', + }), + }); + + if (res.ok) { + router.push('/'); + router.refresh(); + } else { + const data = await res.json(); + setError(data.error || '프로필 설정에 실패했습니다'); + setIsPending(false); + } + } catch { + setError('네트워크 오류가 발생했습니다. 다시 시도해주세요.'); + setIsPending(false); + } + } + + return ( +
+

프로필 완성

+

+ 소셜 로그인으로 가입하셨습니다. 역할을 선택해주세요. +

+ + {error &&
{error}
} + +
+
+ +
+ {ROLES.map((role) => ( + + ))} +
+
+ +
+

약관 동의

+ + + + +
+ + +
+
+ ); +} diff --git a/apps/web/src/app/auth/invite/[token]/page.tsx b/apps/web/src/app/auth/invite/[token]/page.tsx new file mode 100644 index 0000000..7aad26e --- /dev/null +++ b/apps/web/src/app/auth/invite/[token]/page.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; + +export default function InviteAcceptPage() { + const params = useParams(); + const router = useRouter(); + const [error, setError] = useState(''); + const [isPending, setIsPending] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsPending(true); + setError(''); + + const formData = new FormData(e.currentTarget); + const password = formData.get('password') as string; + const confirmPassword = formData.get('confirmPassword') as string; + + if (password !== confirmPassword) { + setError('비밀번호가 일치하지 않습니다'); + setIsPending(false); + return; + } + + try { + const res = await fetch('/api/auth/invite/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: params.token, + password, + name: formData.get('name'), + }), + }); + + if (res.ok) { + router.push('/auth/login?invited=true'); + } else { + const data = await res.json(); + setError(data.error || '초대 수락에 실패했습니다'); + setIsPending(false); + } + } catch { + setError('네트워크 오류가 발생했습니다. 다시 시도해주세요.'); + setIsPending(false); + } + } + + return ( +
+

운영자 초대

+

+ 비밀번호를 설정하여 가입을 완료하세요. +

+ + {error &&
{error}
} + +
+
+ + +
+
+ + +

8자 이상, 영문+숫자 포함

+
+
+ + +
+ +
+
+ ); +} diff --git a/apps/web/src/app/auth/login/page.tsx b/apps/web/src/app/auth/login/page.tsx new file mode 100644 index 0000000..436292f --- /dev/null +++ b/apps/web/src/app/auth/login/page.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { Suspense, useState } from 'react'; +import { signIn } from 'next-auth/react'; +import { useSearchParams } from 'next/navigation'; +import Link from 'next/link'; + +function LoginForm() { + const searchParams = useSearchParams(); + const rawCallback = searchParams.get('callbackUrl') || '/'; + const callbackUrl = + rawCallback.startsWith('/') && !rawCallback.startsWith('//') ? rawCallback : '/'; + const verified = searchParams.get('verified'); + const invited = searchParams.get('invited'); + const [error, setError] = useState(''); + const [isPending, setIsPending] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsPending(true); + setError(''); + + const formData = new FormData(e.currentTarget); + const result = await signIn('credentials', { + email: formData.get('email') as string, + password: formData.get('password') as string, + redirect: false, + }); + + if (result?.error) { + setError('이메일 또는 비밀번호가 올바르지 않습니다'); + setIsPending(false); + } else { + window.location.href = callbackUrl; + } + } + + return ( +
+

로그인

+ + {verified && ( +
+ 이메일 인증이 완료되었습니다. 로그인해주세요. +
+ )} + + {invited && ( +
+ 가입이 완료되었습니다. 로그인해주세요. +
+ )} + + {error &&
{error}
} + +
+
+ + +
+ +
+ + +
+ + +
+ +
+
+ 또는 +
+
+ + + +

+ 계정이 없으신가요?{' '} + + 회원가입 + +

+
+ ); +} + +export default function LoginPage() { + return ( + + + + ); +} diff --git a/apps/web/src/app/auth/register/actions.ts b/apps/web/src/app/auth/register/actions.ts new file mode 100644 index 0000000..4873484 --- /dev/null +++ b/apps/web/src/app/auth/register/actions.ts @@ -0,0 +1,167 @@ +'use server'; + +import { z } from 'zod'; +import argon2 from 'argon2'; +import { createPrismaClient } from '@relink/database'; + +const prisma = createPrismaClient(); +import { randomBytes } from 'crypto'; + +import type { ConsentType, ProfileType, UserRole } from '@prisma/client'; + +const registerSchema = z.object({ + email: z.string().email('올바른 이메일을 입력하세요'), + password: z + .string() + .min(8, '비밀번호는 8자 이상이어야 합니다') + .regex(/[a-zA-Z]/, '영문을 포함해야 합니다') + .regex(/[0-9]/, '숫자를 포함해야 합니다'), + name: z.string().min(1, '이름을 입력하세요'), + role: z.enum(['CLOSING_OWNER', 'FOUNDER', 'VENDOR_MANAGER']), + termsOfService: z.literal(true, { + errorMap: () => ({ message: '이용약관에 동의해야 합니다' }), + }), + privacyPolicy: z.literal(true, { + errorMap: () => ({ message: '개인정보 처리방침에 동의해야 합니다' }), + }), + marketingConsent: z.boolean().default(false), + kakaoNotification: z.boolean().default(false), +}); + +export type RegisterFormState = { + success: boolean; + error?: string; + fieldErrors?: Record; +}; + +const ROLE_TO_PROFILE_TYPE: Record = { + CLOSING_OWNER: 'CLOSING_OWNER', + FOUNDER: 'FOUNDER', + VENDOR_MANAGER: 'VENDOR_MANAGER', +}; + +export async function registerAction( + _prevState: RegisterFormState, + formData: FormData, +): Promise { + const raw = { + email: formData.get('email'), + password: formData.get('password'), + name: formData.get('name'), + role: formData.get('role'), + termsOfService: formData.get('termsOfService') === 'on', + privacyPolicy: formData.get('privacyPolicy') === 'on', + marketingConsent: formData.get('marketingConsent') === 'on', + kakaoNotification: formData.get('kakaoNotification') === 'on', + }; + + const parsed = registerSchema.safeParse(raw); + if (!parsed.success) { + return { + success: false, + fieldErrors: parsed.error.flatten().fieldErrors as Record, + }; + } + + const { email, password, name, role, marketingConsent, kakaoNotification } = parsed.data; + const emailNormalized = email.toLowerCase().trim(); + + const existing = await prisma.user.findFirst({ where: { emailNormalized } }); + if (existing) { + return { success: false, error: '이미 가입된 이메일입니다' }; + } + + const passwordHash = await argon2.hash(password, { type: argon2.argon2id }); + + // 기본 정책 버전 조회 (없으면 생성) + 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, + }, + }); + } + + const verificationToken = randomBytes(32).toString('hex'); + + try { + await prisma.$transaction(async (tx) => { + const user = await tx.user.create({ + data: { + email, + emailNormalized, + name, + passwordHash, + primaryRole: role as UserRole, + status: 'PENDING_VERIFICATION', + }, + }); + + await tx.userProfile.create({ + data: { + userId: user.id, + profileType: ROLE_TO_PROFILE_TYPE[role]!, + }, + }); + + const consentItems: { type: ConsentType; granted: boolean }[] = [ + { type: 'TERMS_OF_SERVICE', granted: true }, + { type: 'PRIVACY_POLICY_REQUIRED', granted: true }, + { type: 'PRIVACY_POLICY_MARKETING', granted: marketingConsent }, + { type: 'NOTIFICATION_KAKAO', granted: kakaoNotification }, + ]; + + await tx.userConsent.createMany({ + data: consentItems.map((item) => ({ + userId: user.id, + consentType: item.type, + policyVersionId: policyVersion!.id, + isGranted: item.granted, + grantedAt: new Date(), + })), + }); + + await tx.verificationToken.create({ + data: { + identifier: emailNormalized, + token: verificationToken, + expires: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + }); + + await tx.auditLog.create({ + data: { + actorUserId: user.id, + resourceType: 'User', + resourceId: user.id.toString(), + actionType: 'USER_REGISTERED', + afterJson: { role, email: emailNormalized }, + }, + }); + }); + } catch (err: unknown) { + if ( + typeof err === 'object' && + err !== null && + 'code' in err && + (err as { code: string }).code === 'P2002' + ) { + return { success: false, error: '이미 가입된 이메일입니다' }; + } + throw err; + } + + // MVP: 콘솔 로그로 인증 링크 출력 + const verifyUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/auth/verify?token=${verificationToken}`; + console.log(`[이메일 인증] ${email} → ${verifyUrl}`); + + return { success: true }; +} diff --git a/apps/web/src/app/auth/register/page.tsx b/apps/web/src/app/auth/register/page.tsx new file mode 100644 index 0000000..ac2dfe1 --- /dev/null +++ b/apps/web/src/app/auth/register/page.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useActionState } from 'react'; +import Link from 'next/link'; +import { registerAction, type RegisterFormState } from './actions'; + +const initialState: RegisterFormState = { success: false }; + +const ROLES = [ + { value: 'CLOSING_OWNER', label: '폐업자', desc: '매장을 양도하고 싶어요' }, + { value: 'FOUNDER', label: '창업자', desc: '매장을 인수하고 싶어요' }, + { value: 'VENDOR_MANAGER', label: '업체 담당자', desc: '철거/인테리어 서비스를 제공해요' }, +] as const; + +export default function RegisterPage() { + const [state, formAction, isPending] = useActionState(registerAction, initialState); + + if (state.success) { + return ( +
+

가입 완료!

+

+ 입력하신 이메일로 인증 링크를 발송했습니다. +
+ 이메일을 확인하여 인증을 완료해주세요. +

+ + 로그인하기 + +
+ ); + } + + return ( +
+

회원가입

+ + {state.error && ( +
{state.error}
+ )} + +
+
+ +
+ {ROLES.map((role) => ( + + ))} +
+ {state.fieldErrors?.role && ( +

{state.fieldErrors.role[0]}

+ )} +
+ +
+ + + {state.fieldErrors?.name && ( +

{state.fieldErrors.name[0]}

+ )} +
+ +
+ + + {state.fieldErrors?.email && ( +

{state.fieldErrors.email[0]}

+ )} +
+ +
+ + +

8자 이상, 영문+숫자 포함

+ {state.fieldErrors?.password && ( +

{state.fieldErrors.password[0]}

+ )} +
+ +
+

약관 동의

+ + {state.fieldErrors?.termsOfService && ( +

{state.fieldErrors.termsOfService[0]}

+ )} + + {state.fieldErrors?.privacyPolicy && ( +

{state.fieldErrors.privacyPolicy[0]}

+ )} + + +
+ + +
+ +

+ 이미 계정이 있으신가요?{' '} + + 로그인 + +

+
+ ); +} diff --git a/apps/web/src/app/auth/verify-pending/page.tsx b/apps/web/src/app/auth/verify-pending/page.tsx new file mode 100644 index 0000000..2b1adad --- /dev/null +++ b/apps/web/src/app/auth/verify-pending/page.tsx @@ -0,0 +1,23 @@ +import Link from 'next/link'; + +export default function VerifyPendingPage() { + return ( +
+

이메일 인증이 필요합니다

+

+ 가입 시 입력한 이메일로 인증 링크를 발송했습니다. +
+ 이메일을 확인하고 인증을 완료해주세요. +

+

+ 이메일을 받지 못하셨나요? 스팸함을 확인하거나 잠시 후 다시 시도해주세요. +

+ + 홈으로 + +
+ ); +} diff --git a/apps/web/src/app/auth/verify/page.tsx b/apps/web/src/app/auth/verify/page.tsx new file mode 100644 index 0000000..dfb5742 --- /dev/null +++ b/apps/web/src/app/auth/verify/page.tsx @@ -0,0 +1,108 @@ +import { redirect } from 'next/navigation'; +import Link from 'next/link'; +import { createPrismaClient } from '@relink/database'; + +const prisma = createPrismaClient(); + +export default async function VerifyPage({ + searchParams, +}: { + searchParams: Promise<{ token?: string }>; +}) { + const { token } = await searchParams; + + if (!token) { + return ( +
+

잘못된 접근

+

인증 토큰이 없습니다.

+
+ ); + } + + const verificationToken = await prisma.verificationToken.findUnique({ + where: { token }, + }); + + if (!verificationToken) { + return ( +
+

유효하지 않은 토큰

+

이미 사용되었거나 존재하지 않는 인증 토큰입니다.

+ + 로그인으로 이동 + +
+ ); + } + + if (verificationToken.expires < new Date()) { + await prisma.verificationToken.delete({ + where: { identifier_token: { identifier: verificationToken.identifier, token } }, + }); + + return ( +
+

토큰 만료

+

+ 인증 토큰이 만료되었습니다. 다시 로그인하여 인증 메일을 재발송해주세요. +

+ + 로그인으로 이동 + +
+ ); + } + + const user = await prisma.user.findFirst({ + where: { emailNormalized: verificationToken.identifier }, + }); + + if (!user) { + // 토큰은 유효하지만 사용자가 삭제된 경우 + await prisma.verificationToken.delete({ + where: { identifier_token: { identifier: verificationToken.identifier, token } }, + }); + + return ( +
+

사용자를 찾을 수 없습니다

+

해당 계정이 존재하지 않습니다.

+ + 회원가입으로 이동 + +
+ ); + } + + // 이미 ACTIVE인 경우 중복 처리 방지 + if (user.status !== 'PENDING_VERIFICATION') { + await prisma.verificationToken.delete({ + where: { identifier_token: { identifier: verificationToken.identifier, token } }, + }); + redirect('/auth/login?verified=true'); + } + + // 인증 처리 (트랜잭션) + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: user.id }, + data: { emailVerifiedAt: new Date(), status: 'ACTIVE' }, + }); + + await tx.verificationToken.delete({ + where: { identifier_token: { identifier: verificationToken.identifier, token } }, + }); + + await tx.auditLog.create({ + data: { + actorUserId: user.id, + resourceType: 'User', + resourceId: user.id.toString(), + actionType: 'EMAIL_VERIFIED', + }, + }); + }); + + redirect('/auth/login?verified=true'); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 281d6b3..40fdc0c 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,5 +1,7 @@ import type { Metadata } from 'next'; import Link from 'next/link'; +import { auth } from '@/lib/auth'; +import { AuthButtons } from './auth-buttons'; import './globals.css'; @@ -8,7 +10,18 @@ export const metadata: Metadata = { description: 'Re:Link - 폐업 · 양도 · 창업을 잇는 중개 플랫폼', }; -function Navigation() { +const OPERATOR_ROLES = [ + 'OPS_MANAGER', + 'SUBSIDY_OPERATOR', + 'TRUST_OPERATOR', + 'FINANCE_OPERATOR', + 'SUPER_ADMIN', +]; + +async function Navigation() { + const session = await auth(); + const isOperator = session?.user && OPERATOR_ROLES.includes(session.user.primaryRole); + return (
diff --git a/apps/web/src/lib/auth.config.ts b/apps/web/src/lib/auth.config.ts new file mode 100644 index 0000000..4e69ef2 --- /dev/null +++ b/apps/web/src/lib/auth.config.ts @@ -0,0 +1,28 @@ +import type { NextAuthConfig } from 'next-auth'; + +export const authConfig: NextAuthConfig = { + session: { strategy: 'jwt' }, + pages: { + signIn: '/auth/login', + newUser: '/auth/complete-profile', + }, + callbacks: { + authorized() { + return true; // authorization은 middleware.ts에서 직접 처리 + }, + // JWT 토큰에 이미 저장된 커스텀 claim을 세션으로 전달 (Edge Runtime 호환) + async jwt({ token }) { + return token; + }, + async session({ session, token }) { + if (session.user) { + session.user.publicId = token.publicId as string; + session.user.primaryRole = token.primaryRole as string; + session.user.userStatus = token.userStatus as string; + session.user.dbId = token.dbId as string; + } + return session; + }, + }, + providers: [], // providers는 auth.ts에서 추가 +} satisfies NextAuthConfig; diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 0000000..b483efa --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,291 @@ +import NextAuth from 'next-auth'; +import type { NextAuthConfig } from 'next-auth'; +import Credentials from 'next-auth/providers/credentials'; +import Kakao from 'next-auth/providers/kakao'; +import { createPrismaClient } from '@relink/database'; +import argon2 from 'argon2'; + +import type { UserRole } from '@prisma/client'; +import { authConfig } from './auth.config'; + +const prisma = createPrismaClient(); + +// BigInt PK 호환: Auth.js adapter는 String ID를 기대하므로 변환 +const customAdapter = { + createUser: async (data: Record) => { + const emailNormalized = (data.email as string).toLowerCase().trim(); + const user = await prisma.user.create({ + data: { + email: data.email as string, + emailNormalized, + name: (data.name as string) || '', + primaryRole: 'CLOSING_OWNER' as UserRole, + status: 'ACTIVE', + emailVerifiedAt: data.emailVerified ? new Date() : null, + image: (data.image as string) || null, + }, + }); + return { + id: user.id.toString(), + email: user.email, + emailVerified: user.emailVerifiedAt, + name: user.name, + image: user.image, + }; + }, + getUser: async (id: string) => { + const user = await prisma.user.findUnique({ where: { id: BigInt(id) } }); + if (!user) return null; + return { + id: user.id.toString(), + email: user.email, + emailVerified: user.emailVerifiedAt, + name: user.name, + image: user.image, + }; + }, + getUserByEmail: async (email: string) => { + const user = await prisma.user.findFirst({ + where: { emailNormalized: email.toLowerCase().trim() }, + }); + if (!user) return null; + return { + id: user.id.toString(), + email: user.email, + emailVerified: user.emailVerifiedAt, + name: user.name, + image: user.image, + }; + }, + getUserByAccount: async (providerAccountId: { provider: string; providerAccountId: string }) => { + const account = await prisma.account.findUnique({ + where: { + provider_providerAccountId: { + provider: providerAccountId.provider, + providerAccountId: providerAccountId.providerAccountId, + }, + }, + include: { user: true }, + }); + if (!account) return null; + const user = account.user; + return { + id: user.id.toString(), + email: user.email, + emailVerified: user.emailVerifiedAt, + name: user.name, + image: user.image, + }; + }, + linkAccount: async (data: Record) => { + const account = await prisma.account.create({ + data: { + userId: BigInt(data.userId as string), + type: data.type as string, + provider: data.provider as string, + providerAccountId: data.providerAccountId as string, + refresh_token: (data.refresh_token as string) ?? null, + access_token: (data.access_token as string) ?? null, + expires_at: (data.expires_at as number) ?? null, + token_type: (data.token_type as string) ?? null, + scope: (data.scope as string) ?? null, + id_token: (data.id_token as string) ?? null, + session_state: (data.session_state as string) ?? null, + }, + }); + return { + ...data, + id: account.id.toString(), + userId: account.userId.toString(), + }; + }, + updateUser: async (data: Record) => { + const userId = BigInt(data.id as string); + const user = await prisma.user.update({ + where: { id: userId }, + data: { + name: data.name as string | undefined, + email: data.email as string | undefined, + emailVerifiedAt: data.emailVerified ? new Date(data.emailVerified as string) : undefined, + image: data.image as string | undefined, + }, + }); + return { + id: user.id.toString(), + email: user.email, + emailVerified: user.emailVerifiedAt, + name: user.name, + image: user.image, + }; + }, + createVerificationToken: async (data: { identifier: string; token: string; expires: Date }) => { + const token = await prisma.verificationToken.create({ + data: { + identifier: data.identifier, + token: data.token, + expires: data.expires, + }, + }); + return token; + }, + useVerificationToken: async (params: { identifier: string; token: string }) => { + try { + const token = await prisma.verificationToken.delete({ + where: { + identifier_token: { + identifier: params.identifier, + token: params.token, + }, + }, + }); + return token; + } catch { + return null; + } + }, +}; + +const fullConfig: NextAuthConfig = { + ...authConfig, + adapter: customAdapter as unknown as NextAuthConfig['adapter'], + providers: [ + Credentials({ + name: 'credentials', + credentials: { + email: { label: '이메일', type: 'email' }, + password: { label: '비밀번호', type: 'password' }, + }, + async authorize(credentials) { + if (!credentials?.email || !credentials?.password) return null; + + const email = (credentials.email as string).toLowerCase().trim(); + const user = await prisma.user.findFirst({ + where: { emailNormalized: email }, + }); + + if (!user || !user.passwordHash) return null; + if (user.status === 'SUSPENDED' || user.status === 'DEACTIVATED') return null; + + const isValid = await argon2.verify(user.passwordHash, credentials.password as string); + if (!isValid) return null; + + await prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() }, + }); + + return { + id: user.id.toString(), + email: user.email, + name: user.name, + image: user.image, + }; + }, + }), + Kakao({ + clientId: process.env.AUTH_KAKAO_CLIENT_ID, + clientSecret: process.env.AUTH_KAKAO_CLIENT_SECRET, + checks: ['state'], + // 비즈 앱 전환 전에는 이메일이 제공되지 않으므로 카카오 ID 기반 placeholder 사용 + profile(profile) { + const kakaoAccount = profile.kakao_account as Record | undefined; + const kakaoProfile = kakaoAccount?.profile as Record | undefined; + return { + id: String(profile.id), + name: kakaoProfile?.nickname ?? null, + email: (kakaoAccount?.email as string) || `kakao_${profile.id}@placeholder.relink`, + image: kakaoProfile?.profile_image_url ?? null, + }; + }, + }), + ], + callbacks: { + ...authConfig.callbacks, + async jwt({ token, user }) { + if (user?.id) { + try { + const dbUser = await prisma.user.findUnique({ + where: { id: BigInt(user.id) }, + }); + if (dbUser) { + token.publicId = dbUser.publicId; + token.primaryRole = dbUser.primaryRole; + token.userStatus = dbUser.status; + token.dbId = dbUser.id.toString(); + } + } catch { + // BigInt 변환 실패 시 email fallback + const dbUser = await prisma.user.findFirst({ + where: { emailNormalized: (user.email ?? '').toLowerCase().trim() }, + }); + if (dbUser) { + token.publicId = dbUser.publicId; + token.primaryRole = dbUser.primaryRole; + token.userStatus = dbUser.status; + token.dbId = dbUser.id.toString(); + } + } + } + return token; + }, + async session({ session, token }) { + if (session.user) { + session.user.publicId = token.publicId as string; + session.user.primaryRole = token.primaryRole as string; + session.user.userStatus = token.userStatus as string; + session.user.dbId = token.dbId as string; + } + return session; + }, + async signIn({ user, account }) { + if (account?.provider === 'kakao') { + // placeholder 이메일(@placeholder.relink)은 비즈 앱 전환 전 임시 처리 + const email = user.email; + if (!email) return false; + + const isPlaceholder = email.endsWith('@placeholder.relink'); + const existing = !isPlaceholder + ? await prisma.user.findFirst({ + where: { emailNormalized: email.toLowerCase().trim() }, + }) + : null; + if (existing) { + if (existing.status === 'SUSPENDED' || existing.status === 'DEACTIVATED') { + return false; + } + // emailVerifiedAt + lastLoginAt 한 번에 업데이트 + await prisma.user.update({ + where: { id: existing.id }, + data: { + emailVerifiedAt: existing.emailVerifiedAt ?? new Date(), + status: existing.status === 'PENDING_VERIFICATION' ? 'ACTIVE' : existing.status, + lastLoginAt: new Date(), + }, + }); + } + } + return true; + }, + }, +}; + +const nextAuth = NextAuth(fullConfig); +export const handlers: typeof nextAuth.handlers = nextAuth.handlers; +export const auth: typeof nextAuth.auth = nextAuth.auth; +export const signIn: typeof nextAuth.signIn = nextAuth.signIn; +export const signOut: typeof nextAuth.signOut = nextAuth.signOut; + +declare module 'next-auth' { + interface Session { + user: { + id?: string; + name?: string | null; + email?: string | null; + image?: string | null; + publicId: string; + primaryRole: string; + userStatus: string; + dbId: string; + }; + } +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts new file mode 100644 index 0000000..98296b3 --- /dev/null +++ b/apps/web/src/middleware.ts @@ -0,0 +1,65 @@ +import NextAuth from 'next-auth'; +import { NextResponse } from 'next/server'; +import type { NextMiddleware } from 'next/server'; +import { authConfig } from '@/lib/auth.config'; + +const PROTECTED_ROUTES = ['/stores/new', '/matching', '/contracts', '/auth/complete-profile']; +const ADMIN_ROUTES = ['/admin']; +const AUTH_ROUTES = ['/auth/login', '/auth/register']; +const OPERATOR_ROLES = [ + 'OPS_MANAGER', + 'SUBSIDY_OPERATOR', + 'TRUST_OPERATOR', + 'FINANCE_OPERATOR', + 'SUPER_ADMIN', +]; + +const { auth } = NextAuth(authConfig); + +const authMiddleware = auth((req) => { + const { pathname } = req.nextUrl; + const session = req.auth; + + // 인증 페이지: 이미 로그인 상태면 홈으로 + if (AUTH_ROUTES.some((route) => pathname.startsWith(route))) { + if (session?.user) { + return NextResponse.redirect(new URL('/', req.url)); + } + return NextResponse.next(); + } + + // admin 라우트: 운영자 역할 필수 + if (ADMIN_ROUTES.some((route) => pathname.startsWith(route))) { + if (!session?.user) { + return NextResponse.redirect(new URL('/auth/login', req.url)); + } + if (!OPERATOR_ROLES.includes(session.user.primaryRole)) { + return NextResponse.rewrite(new URL('/403', req.url)); + } + if (session.user.userStatus === 'PENDING_VERIFICATION') { + return NextResponse.redirect(new URL('/auth/verify-pending', req.url)); + } + return NextResponse.next(); + } + + // 보호 라우트: 로그인 필수 + if (PROTECTED_ROUTES.some((route) => pathname.startsWith(route))) { + if (!session?.user) { + const loginUrl = new URL('/auth/login', req.url); + loginUrl.searchParams.set('callbackUrl', pathname); + return NextResponse.redirect(loginUrl); + } + if (session.user.userStatus === 'PENDING_VERIFICATION') { + return NextResponse.redirect(new URL('/auth/verify-pending', req.url)); + } + return NextResponse.next(); + } + + return NextResponse.next(); +}); + +export default authMiddleware as unknown as NextMiddleware; + +export const config = { + matcher: ['/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'], +}; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index f75d2c9..87215da 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -64,11 +64,13 @@ enum ProfileType { } enum ConsentType { - PRIVACY_POLICY TERMS_OF_SERVICE - MARKETING - THIRD_PARTY_SHARING - INFORMATION_DISCLOSURE + PRIVACY_POLICY_REQUIRED + PRIVACY_POLICY_MARKETING + STORE_PUBLICATION_CONSENT + MATCHED_INFO_DISCLOSURE + THIRD_PARTY_MATCHED_PARTY + NOTIFICATION_KAKAO @@map("consent_type") } @@ -378,6 +380,8 @@ model User { phone String? @map("phone") phoneNormalized String? @map("phone_normalized") name String @map("name") + passwordHash String? @map("password_hash") + image String? @map("image") primaryRole UserRole @map("primary_role") status UserStatus @default(PENDING_VERIFICATION) @map("status") emailVerifiedAt DateTime? @map("email_verified_at") @db.Timestamptz(6) @@ -412,6 +416,8 @@ model User { disputesResolvedBy DisputeCase[] @relation("DisputeResolvedByUser") certificationReviewedBy VendorCertification[] @relation("CertificationReviewedByUser") auditLogs AuditLog[] + accounts Account[] + invitesCreated InviteToken[] @relation("InviteCreator") @@index([primaryRole, status], map: "idx_user_role_status") @@map("users") @@ -452,6 +458,53 @@ model UserConsent { @@map("user_consents") } +/// Auth.js 소셜 로그인 계정 연동 +model Account { + id BigInt @id @default(autoincrement()) @map("id") + userId BigInt @map("user_id") + type String @map("type") + provider String @map("provider") + providerAccountId String @map("provider_account_id") + refresh_token String? @map("refresh_token") @db.Text + access_token String? @map("access_token") @db.Text + expires_at Int? @map("expires_at") + token_type String? @map("token_type") + scope String? @map("scope") + id_token String? @map("id_token") @db.Text + session_state String? @map("session_state") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) + @@map("accounts") +} + +/// 이메일 인증 토큰 +model VerificationToken { + identifier String @map("identifier") + token String @unique @map("token") + expires DateTime @map("expires") @db.Timestamptz(6) + + @@id([identifier, token]) + @@map("verification_tokens") +} + +/// 운영자 초대 토큰 +model InviteToken { + id BigInt @id @default(autoincrement()) @map("id") + email String @map("email") + role UserRole @map("role") + token String @unique @default(cuid()) @map("token") + expires DateTime @map("expires") @db.Timestamptz(6) + usedAt DateTime? @map("used_at") @db.Timestamptz(6) + createdBy BigInt @map("created_by") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + creator User @relation("InviteCreator", fields: [createdBy], references: [id]) + + @@map("invite_tokens") +} + // ============================================================================= // 2. 매장 공급 도메인 // ============================================================================= diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b92b574..3f0d32b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: apps/web: dependencies: + '@auth/prisma-adapter': + specifier: ^2.11.1 + version: 2.11.1(@prisma/client@6.19.2) '@prisma/client': specifier: ^6.1.0 version: 6.19.2(prisma@6.19.2)(typescript@5.9.3) @@ -105,9 +108,15 @@ importers: '@relink/ui': specifier: workspace:* version: link:../../packages/ui + argon2: + specifier: ^0.44.0 + version: 0.44.0 next: specifier: ^15.1.0 version: 15.5.12(react-dom@19.2.4)(react@19.2.4) + next-auth: + specifier: 5.0.0-beta.30 + version: 5.0.0-beta.30(next@15.5.12)(react@19.2.4) react: specifier: ^19.0.0 version: 19.2.4 @@ -301,6 +310,61 @@ packages: engines: {node: '>=10'} dev: true + /@auth/core@0.41.0: + resolution: {integrity: sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^6.8.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + dependencies: + '@panva/hkdf': 1.2.1 + jose: 6.2.0 + oauth4webapi: 3.8.5 + preact: 10.24.3 + preact-render-to-string: 6.5.11(preact@10.24.3) + dev: false + + /@auth/core@0.41.1: + resolution: {integrity: sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^7.0.7 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + dependencies: + '@panva/hkdf': 1.2.1 + jose: 6.2.0 + oauth4webapi: 3.8.5 + preact: 10.24.3 + preact-render-to-string: 6.5.11(preact@10.24.3) + dev: false + + /@auth/prisma-adapter@2.11.1(@prisma/client@6.19.2): + resolution: {integrity: sha512-Ke7DXP0Fy0Mlmjz/ZJLXwQash2UkA4621xCM0rMtEczr1kppLc/njCbUkHkIQ/PnmILjqSPEKeTjDPsYruvkug==} + peerDependencies: + '@prisma/client': '>=2.26.0 || >=3 || >=4 || >=5 || >=6' + dependencies: + '@auth/core': 0.41.1 + '@prisma/client': 6.19.2(prisma@6.19.2)(typescript@5.9.3) + transitivePeerDependencies: + - '@simplewebauthn/browser' + - '@simplewebauthn/server' + - nodemailer + dev: false + /@emnapi/core@1.8.1: resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} requiresBuild: true @@ -325,6 +389,10 @@ packages: dev: true optional: true + /@epic-web/invariant@1.0.0: + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + dev: false + /@esbuild/aix-ppc64@0.21.5: resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -1207,6 +1275,15 @@ packages: engines: {node: '>=12.4.0'} dev: true + /@panva/hkdf@1.2.1: + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + dev: false + + /@phc/format@1.0.0: + resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} + engines: {node: '>=10'} + dev: false + /@prisma/client@6.19.2(prisma@6.19.2)(typescript@5.9.3): resolution: {integrity: sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==} engines: {node: '>=18.18'} @@ -2053,6 +2130,17 @@ packages: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} dev: true + /argon2@0.44.0: + resolution: {integrity: sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==} + engines: {node: '>=16.17.0'} + requiresBuild: true + dependencies: + '@phc/format': 1.0.0 + cross-env: 10.1.0 + node-addon-api: 8.6.0 + node-gyp-build: 4.8.4 + dev: false + /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} dev: true @@ -2357,6 +2445,15 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + /cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + dev: false + /cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2364,7 +2461,6 @@ packages: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - dev: true /csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -3569,7 +3665,6 @@ packages: /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - dev: true /iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} @@ -3587,6 +3682,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + /jose@6.2.0: + resolution: {integrity: sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==} + dev: false + /joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -3888,6 +3987,27 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true + /next-auth@5.0.0-beta.30(next@15.5.12)(react@19.2.4): + resolution: {integrity: sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + next: ^14.0.0-0 || ^15.0.0 || ^16.0.0 + nodemailer: ^7.0.7 + react: ^18.2.0 || ^19.0.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + dependencies: + '@auth/core': 0.41.0 + next: 15.5.12(react-dom@19.2.4)(react@19.2.4) + react: 19.2.4 + dev: false + /next@15.5.12(react-dom@19.2.4)(react@19.2.4): resolution: {integrity: sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -3931,6 +4051,11 @@ packages: - babel-plugin-macros dev: false + /node-addon-api@8.6.0: + resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==} + engines: {node: ^18 || ^20 || >= 21} + dev: false + /node-exports-info@1.6.0: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} @@ -3944,6 +4069,11 @@ packages: /node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + /node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + dev: false + /nypm@0.6.5: resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} engines: {node: '>=18'} @@ -3953,6 +4083,10 @@ packages: pathe: 2.0.3 tinyexec: 1.0.2 + /oauth4webapi@3.8.5: + resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} + dev: false + /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -4083,7 +4217,6 @@ packages: /path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - dev: true /path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -4187,6 +4320,18 @@ packages: source-map-js: 1.2.1 dev: true + /preact-render-to-string@6.5.11(preact@10.24.3): + resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} + peerDependencies: + preact: '>=10' + dependencies: + preact: 10.24.3 + dev: false + + /preact@10.24.3: + resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} + dev: false + /prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -4494,12 +4639,10 @@ packages: engines: {node: '>=8'} dependencies: shebang-regex: 3.0.0 - dev: true /shebang-regex@3.0.0: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - dev: true /side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} @@ -5210,7 +5353,6 @@ packages: hasBin: true dependencies: isexe: 2.0.0 - dev: true /why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}