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:
Johngreen
2026-03-08 12:59:21 +09:00
parent 636eaaca23
commit 5dea44046d
22 changed files with 1912 additions and 19 deletions
+5 -2
View File
@@ -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",
+17
View File
@@ -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 });
}
+62
View File
@@ -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>
);
}
+122
View File
@@ -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>
);
}
+167
View File
@@ -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 };
}
+164
View File
@@ -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>
);
}
+108
View File
@@ -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');
}
+23 -7
View File
@@ -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>
+28
View File
@@ -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;
+291
View File
@@ -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;
};
}
}
+65
View File
@@ -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).*)'],
};
+57 -4
View File
@@ -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. 매장 공급 도메인
// ============================================================================= // =============================================================================
+148 -6
View File
@@ -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==}