feat: Auth.js v5 인증 시스템 구현 - 카카오 소셜 + 이메일/비번 로그인
- 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)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function ForbiddenPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-4 py-16 text-center">
|
||||
<h1 className="mb-4 text-6xl font-bold text-gray-300">403</h1>
|
||||
<h2 className="mb-4 text-xl font-bold">접근 권한이 없습니다</h2>
|
||||
<p className="mb-8 text-gray-600">이 페이지에 접근할 수 있는 권한이 없습니다.</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
홈으로
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className="rounded-md border border-gray-200 p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold">새 운영자 초대</h2>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`mb-4 rounded-md p-3 text-sm ${
|
||||
message.type === 'success' ? 'bg-green-50 text-green-600' : 'bg-red-50 text-red-600'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="email" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
이메일
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="role" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
역할
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
name="role"
|
||||
required
|
||||
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
{OPERATOR_ROLES.map((role) => (
|
||||
<option key={role.value} value={role.value}>
|
||||
{role.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? '발송 중...' : '초대'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-2xl px-4 py-8">
|
||||
<h1 className="mb-8 text-2xl font-bold">운영자 초대</h1>
|
||||
|
||||
<InviteForm />
|
||||
|
||||
<div className="mt-12">
|
||||
<h2 className="mb-4 text-lg font-semibold">초대 내역</h2>
|
||||
{invites.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">초대 내역이 없습니다.</p>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-700">이메일</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-700">역할</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-700">상태</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-700">생성일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{invites.map((invite) => (
|
||||
<tr key={invite.id.toString()}>
|
||||
<td className="px-4 py-2">{invite.email}</td>
|
||||
<td className="px-4 py-2">{invite.role}</td>
|
||||
<td className="px-4 py-2">
|
||||
{invite.usedAt ? (
|
||||
<span className="text-green-600">수락됨</span>
|
||||
) : invite.expires < new Date() ? (
|
||||
<span className="text-gray-400">만료</span>
|
||||
) : (
|
||||
<span className="text-yellow-600">대기중</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">
|
||||
{invite.createdAt.toLocaleDateString('ko-KR')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { handlers } from '@/lib/auth';
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -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<string, ProfileType> = {
|
||||
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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
CLOSING_OWNER: '폐업자',
|
||||
FOUNDER: '창업자',
|
||||
VENDOR_MANAGER: '업체',
|
||||
OPS_MANAGER: '운영',
|
||||
SUBSIDY_OPERATOR: '지원금',
|
||||
TRUST_OPERATOR: '신뢰',
|
||||
FINANCE_OPERATOR: '재무',
|
||||
SUPER_ADMIN: '관리자',
|
||||
};
|
||||
|
||||
export function AuthButtons({ session }: AuthButtonsProps) {
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="rounded-md px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
로그인
|
||||
</Link>
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
|
||||
>
|
||||
회원가입
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600">
|
||||
{session.user.name}
|
||||
<span className="ml-1 text-xs text-gray-400">
|
||||
({ROLE_LABELS[session.user.primaryRole] || session.user.primaryRole})
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: '/' })}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className="mx-auto max-w-md px-4 py-16">
|
||||
<h1 className="mb-2 text-center text-2xl font-bold">프로필 완성</h1>
|
||||
<p className="mb-8 text-center text-sm text-gray-500">
|
||||
소셜 로그인으로 가입하셨습니다. 역할을 선택해주세요.
|
||||
</p>
|
||||
|
||||
{error && <div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-600">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">역할 선택</label>
|
||||
<div className="space-y-2">
|
||||
{ROLES.map((role) => (
|
||||
<label
|
||||
key={role.value}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md border border-gray-200 p-3 hover:bg-gray-50"
|
||||
>
|
||||
<input type="radio" name="role" value={role.value} required className="h-4 w-4" />
|
||||
<div>
|
||||
<div className="font-medium">{role.label}</div>
|
||||
<div className="text-sm text-gray-500">{role.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-md border border-gray-200 p-4">
|
||||
<p className="text-sm font-medium text-gray-700">약관 동의</p>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="termsOfService" required className="h-4 w-4" />
|
||||
<span className="text-sm">
|
||||
<span className="text-red-500">[필수]</span> 이용약관 동의
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="privacyPolicy" required className="h-4 w-4" />
|
||||
<span className="text-sm">
|
||||
<span className="text-red-500">[필수]</span> 개인정보 처리방침 동의
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="marketingConsent" className="h-4 w-4" />
|
||||
<span className="text-sm">[선택] 마케팅 정보 수신 동의</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="kakaoNotification" className="h-4 w-4" />
|
||||
<span className="text-sm">[선택] 카카오 알림톡 수신 동의</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full rounded-md bg-blue-600 py-2.5 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? '처리 중...' : '완료'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className="mx-auto max-w-md px-4 py-16">
|
||||
<h1 className="mb-2 text-center text-2xl font-bold">운영자 초대</h1>
|
||||
<p className="mb-8 text-center text-sm text-gray-500">
|
||||
비밀번호를 설정하여 가입을 완료하세요.
|
||||
</p>
|
||||
|
||||
{error && <div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-600">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
이름
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">8자 이상, 영문+숫자 포함</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
비밀번호 확인
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full rounded-md bg-blue-600 py-2.5 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? '처리 중...' : '가입 완료'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className="mx-auto max-w-md px-4 py-16">
|
||||
<h1 className="mb-8 text-center text-2xl font-bold">로그인</h1>
|
||||
|
||||
{verified && (
|
||||
<div className="mb-4 rounded-md bg-green-50 p-3 text-sm text-green-600">
|
||||
이메일 인증이 완료되었습니다. 로그인해주세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invited && (
|
||||
<div className="mb-4 rounded-md bg-green-50 p-3 text-sm text-green-600">
|
||||
가입이 완료되었습니다. 로그인해주세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-600">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
이메일
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full rounded-md bg-blue-600 py-2.5 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? '로그인 중...' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="my-6 flex items-center gap-4">
|
||||
<div className="h-px flex-1 bg-gray-200" />
|
||||
<span className="text-sm text-gray-400">또는</span>
|
||||
<div className="h-px flex-1 bg-gray-200" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signIn('kakao', { callbackUrl })}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md bg-[#FEE500] py-2.5 text-sm font-medium text-[#191919] hover:bg-[#FDD800]"
|
||||
>
|
||||
카카오로 로그인
|
||||
</button>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-500">
|
||||
계정이 없으신가요?{' '}
|
||||
<Link href="/auth/register" className="text-blue-600 hover:underline">
|
||||
회원가입
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string[]>;
|
||||
};
|
||||
|
||||
const ROLE_TO_PROFILE_TYPE: Record<string, ProfileType> = {
|
||||
CLOSING_OWNER: 'CLOSING_OWNER',
|
||||
FOUNDER: 'FOUNDER',
|
||||
VENDOR_MANAGER: 'VENDOR_MANAGER',
|
||||
};
|
||||
|
||||
export async function registerAction(
|
||||
_prevState: RegisterFormState,
|
||||
formData: FormData,
|
||||
): Promise<RegisterFormState> {
|
||||
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<string, string[]>,
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-md px-4 py-16 text-center">
|
||||
<h1 className="mb-4 text-2xl font-bold text-green-600">가입 완료!</h1>
|
||||
<p className="mb-6 text-gray-600">
|
||||
입력하신 이메일로 인증 링크를 발송했습니다.
|
||||
<br />
|
||||
이메일을 확인하여 인증을 완료해주세요.
|
||||
</p>
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="inline-block rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
로그인하기
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-4 py-16">
|
||||
<h1 className="mb-8 text-center text-2xl font-bold">회원가입</h1>
|
||||
|
||||
{state.error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-600">{state.error}</div>
|
||||
)}
|
||||
|
||||
<form action={formAction} className="space-y-6">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">역할 선택</label>
|
||||
<div className="space-y-2">
|
||||
{ROLES.map((role) => (
|
||||
<label
|
||||
key={role.value}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md border border-gray-200 p-3 hover:bg-gray-50"
|
||||
>
|
||||
<input type="radio" name="role" value={role.value} required className="h-4 w-4" />
|
||||
<div>
|
||||
<div className="font-medium">{role.label}</div>
|
||||
<div className="text-sm text-gray-500">{role.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{state.fieldErrors?.role && (
|
||||
<p className="mt-1 text-sm text-red-500">{state.fieldErrors.role[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
이름
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
{state.fieldErrors?.name && (
|
||||
<p className="mt-1 text-sm text-red-500">{state.fieldErrors.name[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
이메일
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
{state.fieldErrors?.email && (
|
||||
<p className="mt-1 text-sm text-red-500">{state.fieldErrors.email[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">8자 이상, 영문+숫자 포함</p>
|
||||
{state.fieldErrors?.password && (
|
||||
<p className="mt-1 text-sm text-red-500">{state.fieldErrors.password[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-md border border-gray-200 p-4">
|
||||
<p className="text-sm font-medium text-gray-700">약관 동의</p>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="termsOfService" required className="h-4 w-4" />
|
||||
<span className="text-sm">
|
||||
<span className="text-red-500">[필수]</span> 이용약관 동의
|
||||
</span>
|
||||
</label>
|
||||
{state.fieldErrors?.termsOfService && (
|
||||
<p className="text-sm text-red-500">{state.fieldErrors.termsOfService[0]}</p>
|
||||
)}
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="privacyPolicy" required className="h-4 w-4" />
|
||||
<span className="text-sm">
|
||||
<span className="text-red-500">[필수]</span> 개인정보 처리방침 동의
|
||||
</span>
|
||||
</label>
|
||||
{state.fieldErrors?.privacyPolicy && (
|
||||
<p className="text-sm text-red-500">{state.fieldErrors.privacyPolicy[0]}</p>
|
||||
)}
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="marketingConsent" className="h-4 w-4" />
|
||||
<span className="text-sm">[선택] 마케팅 정보 수신 동의</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="kakaoNotification" className="h-4 w-4" />
|
||||
<span className="text-sm">[선택] 카카오 알림톡 수신 동의</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full rounded-md bg-blue-600 py-2.5 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? '처리 중...' : '가입하기'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-500">
|
||||
이미 계정이 있으신가요?{' '}
|
||||
<Link href="/auth/login" className="text-blue-600 hover:underline">
|
||||
로그인
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function VerifyPendingPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-4 py-16 text-center">
|
||||
<h1 className="mb-4 text-2xl font-bold">이메일 인증이 필요합니다</h1>
|
||||
<p className="mb-6 text-gray-600">
|
||||
가입 시 입력한 이메일로 인증 링크를 발송했습니다.
|
||||
<br />
|
||||
이메일을 확인하고 인증을 완료해주세요.
|
||||
</p>
|
||||
<p className="mb-8 text-sm text-gray-400">
|
||||
이메일을 받지 못하셨나요? 스팸함을 확인하거나 잠시 후 다시 시도해주세요.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block rounded-md bg-gray-100 px-6 py-2 text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
홈으로
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-md px-4 py-16 text-center">
|
||||
<h1 className="mb-4 text-2xl font-bold text-red-600">잘못된 접근</h1>
|
||||
<p className="text-gray-600">인증 토큰이 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const verificationToken = await prisma.verificationToken.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
if (!verificationToken) {
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-4 py-16 text-center">
|
||||
<h1 className="mb-4 text-2xl font-bold text-red-600">유효하지 않은 토큰</h1>
|
||||
<p className="mb-6 text-gray-600">이미 사용되었거나 존재하지 않는 인증 토큰입니다.</p>
|
||||
<Link href="/auth/login" className="text-blue-600 hover:underline">
|
||||
로그인으로 이동
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (verificationToken.expires < new Date()) {
|
||||
await prisma.verificationToken.delete({
|
||||
where: { identifier_token: { identifier: verificationToken.identifier, token } },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-4 py-16 text-center">
|
||||
<h1 className="mb-4 text-2xl font-bold text-red-600">토큰 만료</h1>
|
||||
<p className="mb-6 text-gray-600">
|
||||
인증 토큰이 만료되었습니다. 다시 로그인하여 인증 메일을 재발송해주세요.
|
||||
</p>
|
||||
<Link href="/auth/login" className="text-blue-600 hover:underline">
|
||||
로그인으로 이동
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { emailNormalized: verificationToken.identifier },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// 토큰은 유효하지만 사용자가 삭제된 경우
|
||||
await prisma.verificationToken.delete({
|
||||
where: { identifier_token: { identifier: verificationToken.identifier, token } },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-4 py-16 text-center">
|
||||
<h1 className="mb-4 text-2xl font-bold text-red-600">사용자를 찾을 수 없습니다</h1>
|
||||
<p className="mb-6 text-gray-600">해당 계정이 존재하지 않습니다.</p>
|
||||
<Link href="/auth/register" className="text-blue-600 hover:underline">
|
||||
회원가입으로 이동
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 이미 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');
|
||||
}
|
||||
@@ -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 (
|
||||
<nav className="border-b border-gray-200 bg-white">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
|
||||
@@ -34,12 +47,15 @@ function Navigation() {
|
||||
<Link href="/contracts" className="text-sm text-gray-700 hover:text-blue-600">
|
||||
계약
|
||||
</Link>
|
||||
{isOperator && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className="rounded-md bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
운영자
|
||||
</Link>
|
||||
)}
|
||||
<AuthButtons session={session} />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -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;
|
||||
@@ -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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown> | undefined;
|
||||
const kakaoProfile = kakaoAccount?.profile as Record<string, string> | 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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).*)'],
|
||||
};
|
||||
@@ -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. 매장 공급 도메인
|
||||
// =============================================================================
|
||||
|
||||
Generated
+148
-6
@@ -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==}
|
||||
|
||||
Reference in New Issue
Block a user