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"
|
"clean": "rm -rf .next"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
"@prisma/client": "^6.1.0",
|
"@prisma/client": "^6.1.0",
|
||||||
"@relink/database": "workspace:*",
|
"@relink/database": "workspace:*",
|
||||||
"@relink/domain": "workspace:*",
|
"@relink/domain": "workspace:*",
|
||||||
"@relink/infrastructure": "workspace:*",
|
"@relink/infrastructure": "workspace:*",
|
||||||
"@relink/shared": "workspace:*",
|
"@relink/shared": "workspace:*",
|
||||||
"@relink/ui": "workspace:*",
|
"@relink/ui": "workspace:*",
|
||||||
|
"argon2": "^0.44.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
|
"next-auth": "5.0.0-beta.30",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"eslint": "^8.57.1",
|
|
||||||
"eslint-config-next": "^15.1.0",
|
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-config-next": "^15.1.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.2",
|
"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 type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { AuthButtons } from './auth-buttons';
|
||||||
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
@@ -8,7 +10,18 @@ export const metadata: Metadata = {
|
|||||||
description: 'Re:Link - 폐업 · 양도 · 창업을 잇는 중개 플랫폼',
|
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 (
|
return (
|
||||||
<nav className="border-b border-gray-200 bg-white">
|
<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">
|
<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 href="/contracts" className="text-sm text-gray-700 hover:text-blue-600">
|
||||||
계약
|
계약
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
{isOperator && (
|
||||||
href="/admin"
|
<Link
|
||||||
className="rounded-md bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200"
|
href="/admin"
|
||||||
>
|
className="rounded-md bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200"
|
||||||
운영자
|
>
|
||||||
</Link>
|
운영자
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<AuthButtons session={session} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</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 {
|
enum ConsentType {
|
||||||
PRIVACY_POLICY
|
|
||||||
TERMS_OF_SERVICE
|
TERMS_OF_SERVICE
|
||||||
MARKETING
|
PRIVACY_POLICY_REQUIRED
|
||||||
THIRD_PARTY_SHARING
|
PRIVACY_POLICY_MARKETING
|
||||||
INFORMATION_DISCLOSURE
|
STORE_PUBLICATION_CONSENT
|
||||||
|
MATCHED_INFO_DISCLOSURE
|
||||||
|
THIRD_PARTY_MATCHED_PARTY
|
||||||
|
NOTIFICATION_KAKAO
|
||||||
|
|
||||||
@@map("consent_type")
|
@@map("consent_type")
|
||||||
}
|
}
|
||||||
@@ -378,6 +380,8 @@ model User {
|
|||||||
phone String? @map("phone")
|
phone String? @map("phone")
|
||||||
phoneNormalized String? @map("phone_normalized")
|
phoneNormalized String? @map("phone_normalized")
|
||||||
name String @map("name")
|
name String @map("name")
|
||||||
|
passwordHash String? @map("password_hash")
|
||||||
|
image String? @map("image")
|
||||||
primaryRole UserRole @map("primary_role")
|
primaryRole UserRole @map("primary_role")
|
||||||
status UserStatus @default(PENDING_VERIFICATION) @map("status")
|
status UserStatus @default(PENDING_VERIFICATION) @map("status")
|
||||||
emailVerifiedAt DateTime? @map("email_verified_at") @db.Timestamptz(6)
|
emailVerifiedAt DateTime? @map("email_verified_at") @db.Timestamptz(6)
|
||||||
@@ -412,6 +416,8 @@ model User {
|
|||||||
disputesResolvedBy DisputeCase[] @relation("DisputeResolvedByUser")
|
disputesResolvedBy DisputeCase[] @relation("DisputeResolvedByUser")
|
||||||
certificationReviewedBy VendorCertification[] @relation("CertificationReviewedByUser")
|
certificationReviewedBy VendorCertification[] @relation("CertificationReviewedByUser")
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
|
accounts Account[]
|
||||||
|
invitesCreated InviteToken[] @relation("InviteCreator")
|
||||||
|
|
||||||
@@index([primaryRole, status], map: "idx_user_role_status")
|
@@index([primaryRole, status], map: "idx_user_role_status")
|
||||||
@@map("users")
|
@@map("users")
|
||||||
@@ -452,6 +458,53 @@ model UserConsent {
|
|||||||
@@map("user_consents")
|
@@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. 매장 공급 도메인
|
// 2. 매장 공급 도메인
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
Generated
+148
-6
@@ -87,6 +87,9 @@ importers:
|
|||||||
|
|
||||||
apps/web:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@auth/prisma-adapter':
|
||||||
|
specifier: ^2.11.1
|
||||||
|
version: 2.11.1(@prisma/client@6.19.2)
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^6.1.0
|
specifier: ^6.1.0
|
||||||
version: 6.19.2(prisma@6.19.2)(typescript@5.9.3)
|
version: 6.19.2(prisma@6.19.2)(typescript@5.9.3)
|
||||||
@@ -105,9 +108,15 @@ importers:
|
|||||||
'@relink/ui':
|
'@relink/ui':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/ui
|
version: link:../../packages/ui
|
||||||
|
argon2:
|
||||||
|
specifier: ^0.44.0
|
||||||
|
version: 0.44.0
|
||||||
next:
|
next:
|
||||||
specifier: ^15.1.0
|
specifier: ^15.1.0
|
||||||
version: 15.5.12(react-dom@19.2.4)(react@19.2.4)
|
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:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
@@ -301,6 +310,61 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
dev: true
|
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:
|
/@emnapi/core@1.8.1:
|
||||||
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
|
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
|
||||||
requiresBuild: true
|
requiresBuild: true
|
||||||
@@ -325,6 +389,10 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: 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:
|
/@esbuild/aix-ppc64@0.21.5:
|
||||||
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1207,6 +1275,15 @@ packages:
|
|||||||
engines: {node: '>=12.4.0'}
|
engines: {node: '>=12.4.0'}
|
||||||
dev: true
|
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):
|
/@prisma/client@6.19.2(prisma@6.19.2)(typescript@5.9.3):
|
||||||
resolution: {integrity: sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==}
|
resolution: {integrity: sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==}
|
||||||
engines: {node: '>=18.18'}
|
engines: {node: '>=18.18'}
|
||||||
@@ -2053,6 +2130,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
||||||
dev: true
|
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:
|
/argparse@2.0.1:
|
||||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -2357,6 +2445,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
|
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
|
||||||
engines: {node: ^14.18.0 || >=16.10.0}
|
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:
|
/cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -2364,7 +2461,6 @@ packages:
|
|||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
dev: true
|
|
||||||
|
|
||||||
/csstype@3.2.3:
|
/csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
@@ -3569,7 +3665,6 @@ packages:
|
|||||||
|
|
||||||
/isexe@2.0.0:
|
/isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/iterator.prototype@1.1.5:
|
/iterator.prototype@1.1.5:
|
||||||
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
|
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
|
||||||
@@ -3587,6 +3682,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
/jose@6.2.0:
|
||||||
|
resolution: {integrity: sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/joycon@3.1.1:
|
/joycon@3.1.1:
|
||||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3888,6 +3987,27 @@ packages:
|
|||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||||
dev: true
|
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):
|
/next@15.5.12(react-dom@19.2.4)(react@19.2.4):
|
||||||
resolution: {integrity: sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==}
|
resolution: {integrity: sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==}
|
||||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||||
@@ -3931,6 +4051,11 @@ packages:
|
|||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
dev: false
|
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:
|
/node-exports-info@1.6.0:
|
||||||
resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
|
resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3944,6 +4069,11 @@ packages:
|
|||||||
/node-fetch-native@1.6.7:
|
/node-fetch-native@1.6.7:
|
||||||
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
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:
|
/nypm@0.6.5:
|
||||||
resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==}
|
resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3953,6 +4083,10 @@ packages:
|
|||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
tinyexec: 1.0.2
|
tinyexec: 1.0.2
|
||||||
|
|
||||||
|
/oauth4webapi@3.8.5:
|
||||||
|
resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/object-assign@4.1.1:
|
/object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -4083,7 +4217,6 @@ packages:
|
|||||||
/path-key@3.1.1:
|
/path-key@3.1.1:
|
||||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/path-parse@1.0.7:
|
/path-parse@1.0.7:
|
||||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||||
@@ -4187,6 +4320,18 @@ packages:
|
|||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
dev: true
|
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:
|
/prelude-ls@1.2.1:
|
||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -4494,12 +4639,10 @@ packages:
|
|||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 3.0.0
|
shebang-regex: 3.0.0
|
||||||
dev: true
|
|
||||||
|
|
||||||
/shebang-regex@3.0.0:
|
/shebang-regex@3.0.0:
|
||||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/side-channel-list@1.0.0:
|
/side-channel-list@1.0.0:
|
||||||
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
||||||
@@ -5210,7 +5353,6 @@ packages:
|
|||||||
hasBin: true
|
hasBin: true
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
dev: true
|
|
||||||
|
|
||||||
/why-is-node-running@2.3.0:
|
/why-is-node-running@2.3.0:
|
||||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
|
|||||||
Reference in New Issue
Block a user