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}
}
+
+
+
+ );
+}
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}
}
+
+
+
+ );
+}
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}
+ )}
+
+
+
+
+ 이미 계정이 있으신가요?{' '}
+
+ 로그인
+
+
+
+ );
+}
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==}