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>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="rounded-md bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
운영자
|
||||
</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).*)'],
|
||||
};
|
||||
Reference in New Issue
Block a user