수정
Some checks failed
deploy-on-main / deploy (push) Failing after 22s

This commit is contained in:
koreacomp5
2025-11-09 19:53:42 +09:00
parent 1c2222da67
commit cfbb3d50ee
12 changed files with 289 additions and 153 deletions

View File

@@ -134,7 +134,7 @@ async function createRandomUsers(count = 100) {
name,
birth,
phone,
passwordHash: hashPassword("1234"),
passwordHash: hashPassword("12341234"),
agreementTermsAt: new Date(),
authLevel: "USER",
isAdultVerified: Math.random() < 0.6,
@@ -145,7 +145,7 @@ async function createRandomUsers(count = 100) {
// 기존 사용자도 패스워드를 1234로 업데이트
await prisma.user.update({
where: { userId: user.userId },
data: { passwordHash: hashPassword("1234") },
data: { passwordHash: hashPassword("12341234") },
});
}
if (roleUser && user) {
@@ -238,7 +238,7 @@ async function upsertAdmin() {
const admin = await prisma.user.upsert({
where: { nickname: "admin" },
update: {
passwordHash: hashPassword("1234"),
passwordHash: hashPassword("12341234"),
grade: 7,
points: 1650000,
level: 200,
@@ -248,7 +248,7 @@ async function upsertAdmin() {
name: "Administrator",
birth: new Date("1990-01-01"),
phone: "010-0000-0001",
passwordHash: hashPassword("1234"),
passwordHash: hashPassword("12341234"),
agreementTermsAt: new Date(),
authLevel: "ADMIN",
grade: 7,

View File

@@ -35,17 +35,42 @@ export async function POST(req: Request) {
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 });
}
// 사용자의 관리자 권한 여부 확인
let isAdmin = false;
const userRoles = await prisma.userRole.findMany({
where: { userId: user.userId },
select: { roleId: true },
});
if (userRoles.length > 0) {
const roleIds = userRoles.map((r) => r.roleId);
const hasAdmin = await prisma.rolePermission.findFirst({
where: {
roleId: { in: roleIds },
resource: "ADMIN",
action: "ADMINISTER",
allowed: true,
},
select: { id: true },
});
isAdmin = !!hasAdmin;
}
const res = NextResponse.json({ ok: true, user: { userId: user.userId, nickname: user.nickname } });
res.headers.append(
"Set-Cookie",
`uid=${encodeURIComponent(user.userId)}; Path=/; HttpOnly; SameSite=Lax`
);
res.headers.append(
"Set-Cookie",
`isAdmin=${isAdmin ? "1" : "0"}; Path=/; HttpOnly; SameSite=Lax`
);
return res;
}
export async function DELETE() {
const res = NextResponse.json({ ok: true });
res.headers.append("Set-Cookie", `uid=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`);
res.headers.append("Set-Cookie", `isAdmin=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`);
return res;
}

View File

@@ -31,6 +31,11 @@ export function AppHeader() {
const [indicatorLeft, setIndicatorLeft] = React.useState<number>(0);
const [indicatorWidth, setIndicatorWidth] = React.useState<number>(0);
const [indicatorVisible, setIndicatorVisible] = React.useState<boolean>(false);
// 로그인 상태 확인 (전역 버튼 노출용)
const { data: authData } = useSWR<{ user: { userId: string; nickname: string; profileImage: string | null; points: number; level: number; grade: number } | null }>(
"/api/me",
(u: string) => fetch(u).then((r) => r.json())
);
// 모바일 사이드바 열릴 때만 현재 사용자 정보를 가져옵니다(훅은 항상 동일한 순서로 호출)
const { data: meData } = useSWR<{ user: { userId: string; nickname: string; profileImage: string | null; points: number; level: number; grade: number } | null }>(
mobileOpen ? "/api/me" : null,
@@ -468,13 +473,30 @@ export function AppHeader() {
<div id="dummy" className="block"></div>
<div className="hidden xl:flex xl:flex-1 justify-end">
<SearchBar/>
<Link
href="/admin"
className="ml-3 inline-flex items-center px-3 h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
aria-label="어드민(임시)"
>
()
</Link>
{authData?.user && (
<button
onClick={async () => {
try {
await fetch("/api/auth/session", { method: "DELETE" });
} finally {
window.location.reload();
}
}}
className="ml-3 inline-flex items-center px-3 h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
aria-label="로그아웃"
>
</button>
)}
{!authData?.user && (
<Link
href={`/login?next=${encodeURIComponent((pathname || "/") + (searchParams?.toString() ? `?${searchParams.toString()}` : ""))}`}
className="ml-3 inline-flex items-center px-3 h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
aria-label="로그인"
>
</Link>
)}
</div>
</nav>
<div
@@ -505,29 +527,45 @@ export function AppHeader() {
</div>
</div>
) : (
<div className="flex items-center gap-3 animate-pulse">
<div className="w-12 h-12 rounded-full bg-neutral-200" />
<div className="flex-1 min-w-0">
<div className="h-3 w-1/2 bg-neutral-200 rounded" />
<div className="mt-2 h-3 w-1/3 bg-neutral-200 rounded" />
</div>
<div className="flex items-center justify-between">
<div className="text-sm text-neutral-700"> </div>
<Link
href={`/login?next=${encodeURIComponent((pathname || "/") + (searchParams?.toString() ? `?${searchParams.toString()}` : ""))}`}
onClick={() => setMobileOpen(false)}
className="h-9 px-3 inline-flex items-center rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
aria-label="로그인"
>
</Link>
</div>
)}
{meData?.user && (
<div className="mt-3 flex justify-end">
<button
onClick={async () => {
try {
await fetch("/api/auth/session", { method: "DELETE" });
} finally {
setMobileOpen(false);
window.location.reload();
}
}}
className="h-9 px-3 inline-flex items-center rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
aria-label="로그아웃"
>
</button>
</div>
)}
{meData?.user && (
<div className="grid grid-cols-3 gap-2 mt-3">
<Link href="/my-page?tab=points" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100"> </Link>
<Link href="/my-page?tab=posts" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100"> </Link>
<Link href="/my-page?tab=comments" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100"> </Link>
</div>
)}
<div className="grid grid-cols-3 gap-2 mt-3">
<Link href="/my-page?tab=points" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100"> </Link>
<Link href="/my-page?tab=posts" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100"> </Link>
<Link href="/my-page?tab=comments" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100"> </Link>
</div>
</div>
<SearchBar />
<Link
href="/admin"
onClick={() => setMobileOpen(false)}
className="inline-flex items-center justify-center h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
aria-label="어드민(임시)"
>
()
</Link>
<div className="grid grid-cols-2 gap-4">
{categories.map((cat) => (
<div key={cat.id}>

View File

@@ -318,7 +318,7 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
onDragOver={(e) => e.preventDefault()}
data-placeholder={placeholder}
style={{
minHeight: 160,
minHeight: 500,
border: "1px solid #ddd",
borderRadius: 6,
padding: 12,

View File

@@ -146,8 +146,8 @@ export function HeroBanner({ subItems, activeSubId, hideSubOnMobile, showPartner
)
)}
{!usePartnerCats && (!Array.isArray(subItems) || subItems.length === 0) && (
<div className="flex items-center gap-[8px]">
<span className="px-3 h-[28px] rounded-full bg-transparent text-white/85 text-[12px] leading-[28px] whitespace-nowrap cursor-default"></span>
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
<span className="px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap"></span>
</div>
)}
</div>
@@ -268,8 +268,8 @@ export function HeroBanner({ subItems, activeSubId, hideSubOnMobile, showPartner
)
)}
{!usePartnerCats && (!Array.isArray(subItems) || subItems.length === 0) && (
<div className="flex items-center gap-[8px]">
<span className="px-3 h-[28px] rounded-full bg-transparent text-white/85 text-[12px] leading-[28px] whitespace-nowrap cursor-default"></span>
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
<span className="px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap"></span>
</div>
)}
</div>

View File

@@ -5,7 +5,6 @@ import QueryProvider from "@/app/QueryProvider";
import { AppHeader } from "@/app/components/AppHeader";
import { AppFooter } from "@/app/components/AppFooter";
import { ToastProvider } from "@/app/components/ui/ToastProvider";
import { AutoLoginAdmin } from "@/app/components/AutoLoginAdmin";
export const metadata: Metadata = {
@@ -23,7 +22,6 @@ export default function RootLayout({
<body className="min-h-screen bg-background text-foreground antialiased">
<QueryProvider>
<ToastProvider>
<AutoLoginAdmin />
<div className="min-h-screen flex flex-col">
<div className="sticky top-0 z-50 bg-white/90 backdrop-blur">
<div className="mx-auto w-full">

View File

@@ -3,12 +3,15 @@ import Link from "next/link";
import React from "react";
import { Button } from "@/app/components/ui/Button";
import { useToast } from "@/app/components/ui/ToastProvider";
import { useSearchParams } from "next/navigation";
export default function LoginPage() {
const { show } = useToast();
const [nickname, setNickname] = React.useState("");
const [password, setPassword] = React.useState("");
const [loading, setLoading] = React.useState(false);
const sp = useSearchParams();
const next = sp?.get("next") || "/";
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@@ -21,7 +24,7 @@ export default function LoginPage() {
const data = await res.json();
if (!res.ok) throw new Error(data?.error || "로그인 실패");
show("로그인되었습니다");
location.href = "/";
location.href = next;
} catch (err: any) {
show(err.message || "로그인 실패");
} finally {

View File

@@ -50,14 +50,7 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
// 에러 무시
}
// 로그인되지 않은 경우 어드민 사용자 가져오기
if (!currentUser) {
const admin = await prisma.user.findUnique({
where: { nickname: "admin" },
select: { userId: true, nickname: true, profileImage: true, points: true, level: true, grade: true },
});
if (admin) currentUser = admin;
}
// 로그인되지 않은 경우 어드민 사용자로 대체하지 않음 (요청사항)
// 내가 쓴 게시글/댓글 수
let myPostsCount = 0;
@@ -219,84 +212,97 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
<div className="grid grid-cols-1 md:grid-cols-2 xl:flex xl:gap-[23px] gap-4 h-full min-h-0">
<div className="hidden xl:grid relative overflow-hidden rounded-xl bg-white px-[25px] py-[34px] grid-rows-[120px_120px_1fr] gap-y-[32px] h-[514px] w-[350px] shrink-0">
<div className="absolute inset-x-0 top-0 h-[56px] bg-[#d5d5d5] z-0" />
<div className="h-[120px] flex items-center justify-center relative z-10">
<div className="flex items-center justify-center gap-[8px]">
<UserAvatar
src={currentUser?.profileImage || null}
alt={currentUser?.nickname || "프로필"}
width={120}
height={120}
className="rounded-full"
/>
{currentUser && (
<div className="w-[62px] h-[62px] flex items-center justify-center">
<GradeIcon grade={currentUser.grade} width={62} height={62} />
{currentUser ? (
<>
<div className="h-[120px] flex items-center justify-center relative z-10">
<div className="flex items-center justify-center gap-[8px]">
<UserAvatar
src={currentUser.profileImage || null}
alt={currentUser.nickname || "프로필"}
width={120}
height={120}
className="rounded-full"
/>
<div className="w-[62px] h-[62px] flex items-center justify-center">
<GradeIcon grade={currentUser.grade} width={62} height={62} />
</div>
</div>
)}
</div>
<div className="h-[120px] flex flex-col items-center relative z-10">
<div className="text-[18px] text-[#5c5c5c] font-[700] truncate text-center mb-[20px]">{currentUser.nickname}</div>
<div className="w-[300px] pl-[67px] flex flex-col gap-[12px]">
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
<div className="w-[64px] flex items-center">
<ProfileLabelIcon width={16} height={16} />
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]"></span>
</div>
<div className="text-[16px] text-[#5c5c5c] font-[700]">Lv. {currentUser.level}</div>
</div>
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
<div className="w-[64px] flex items-center">
<ProfileLabelIcon width={16} height={16} />
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]"></span>
</div>
<div className="text-[16px] text-[#5c5c5c] font-[700]">{getGradeName(currentUser.grade)}</div>
</div>
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
<div className="w-[64px] flex items-center">
<ProfileLabelIcon width={16} height={16} />
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]"></span>
</div>
<div className="text-[16px] text-[#5c5c5c] font-[700]">{currentUser.points.toLocaleString()}</div>
</div>
</div>
</div>
<div className="flex flex-col gap-[12px] relative z-10">
<Link href="/my-page" className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
<span className="flex items-center w-full pl-[88px]">
<span className="flex items-center gap-[8px]">
<SearchIcon width={16} height={16} />
<span> </span>
</span>
</span>
</Link>
<Link href="/my-page?tab=points" className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
<span className="flex items-center w-full pl-[88px]">
<span className="flex items-center gap-[8px]">
<SearchIcon width={16} height={16} />
<span> </span>
</span>
</span>
</Link>
<Link href={`/my-page?tab=posts`} className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
<span className="flex items-center w-full pl-[88px]">
<span className="flex items-center gap-[8px]">
<SearchIcon width={16} height={16} />
<span> </span>
</span>
<span className="ml-auto inline-flex items-center justify-center h-[20px] px-[8px] rounded-full bg-white text-[#5c5c5c] text-[12px] leading-[20px] shrink-0">{myPostsCount.toLocaleString()}</span>
</span>
</Link>
<Link href={`/my-page?tab=comments`} className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
<span className="flex items-center w-full pl-[88px]">
<span className="flex items-center gap-[8px]">
<SearchIcon width={16} height={16} />
<span> </span>
</span>
<span className="ml-auto inline-flex items-center justify-center h-[20px] px-[8px] rounded-full bg-white text-[#5c5c5c] text-[12px] leading-[20px] shrink-0">{myCommentsCount.toLocaleString()}</span>
</span>
</Link>
</div>
</>
) : (
<div className="flex flex-col items-center justify-center gap-6 relative z-10">
<div className="mt-[56px] flex flex-col items-center gap-4">
<div className="w-[120px] h-[120px] rounded-full bg-[#e9e9e9] border border-neutral-200" />
<div className="text-[18px] text-[#5c5c5c] font-[700]"> </div>
<div className="text-[13px] text-neutral-600"> / .</div>
</div>
<Link href={`/login?next=/`} className="w-[300px] h-[40px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[14px] font-[700] flex items-center justify-center">
</Link>
</div>
</div>
<div className="h-[120px] flex flex-col items-center relative z-10">
<div className="text-[18px] text-[#5c5c5c] font-[700] truncate text-center mb-[20px]">{currentUser?.nickname || "사용자"}</div>
<div className="w-[300px] pl-[67px] flex flex-col gap-[12px]">
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
<div className="w-[64px] flex items-center">
<ProfileLabelIcon width={16} height={16} />
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]"></span>
</div>
<div className="text-[16px] text-[#5c5c5c] font-[700]">Lv. {currentUser?.level || 1}</div>
</div>
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
<div className="w-[64px] flex items-center">
<ProfileLabelIcon width={16} height={16} />
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]"></span>
</div>
<div className="text-[16px] text-[#5c5c5c] font-[700]">{getGradeName(currentUser?.grade || 0)}</div>
</div>
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
<div className="w-[64px] flex items-center">
<ProfileLabelIcon width={16} height={16} />
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]"></span>
</div>
<div className="text-[16px] text-[#5c5c5c] font-[700]">{(currentUser?.points || 0).toLocaleString()}</div>
</div>
</div>
</div>
<div className="flex flex-col gap-[12px] relative z-10">
<Link href="/my-page" className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
<span className="flex items-center w-full pl-[88px]">
<span className="flex items-center gap-[8px]">
<SearchIcon width={16} height={16} />
<span> </span>
</span>
</span>
</Link>
<Link href="/my-page?tab=points" className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
<span className="flex items-center w-full pl-[88px]">
<span className="flex items-center gap-[8px]">
<SearchIcon width={16} height={16} />
<span> </span>
</span>
</span>
</Link>
<Link href={`/my-page?tab=posts`} className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
<span className="flex items-center w-full pl-[88px]">
<span className="flex items-center gap-[8px]">
<SearchIcon width={16} height={16} />
<span> </span>
</span>
<span className="ml-auto inline-flex items-center justify-center h-[20px] px-[8px] rounded-full bg-white text-[#5c5c5c] text-[12px] leading-[20px] shrink-0">{myPostsCount.toLocaleString()}</span>
</span>
</Link>
<Link href={`/my-page?tab=comments`} className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
<span className="flex items-center w-full pl-[88px]">
<span className="flex items-center gap-[8px]">
<SearchIcon width={16} height={16} />
<span> </span>
</span>
<span className="ml-auto inline-flex items-center justify-center h-[20px] px-[8px] rounded-full bg-white text-[#5c5c5c] text-[12px] leading-[20px] shrink-0">{myCommentsCount.toLocaleString()}</span>
</span>
</Link>
</div>
)}
</div>
{(await Promise.all(firstTwo.map((b) => prepareBoardPanelData(b)))).map((panelData, idx) => (
<div key={firstTwo[idx].id} className="overflow-hidden xl:h-[514px] h-full min-h-0 flex flex-col flex-1">

View File

@@ -23,12 +23,44 @@ export default async function PostDetail({ params }: { params: any }) {
const parsed = settingRow ? JSON.parse(settingRow.value as string) : {};
const showBanner: boolean = parsed.showBanner ?? true;
// 현재 게시글이 속한 카테고리의 보드들을 서브카테고리로 구성
let subItems:
| { id: string; name: string; href: string }[]
| undefined = undefined;
if (post?.boardId) {
const categories = await prisma.boardCategory.findMany({
where: { status: "active" },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
include: {
boards: {
where: { status: "active" },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: { id: true, name: true, slug: true },
},
},
});
const categoryOfPost = categories.find((c) =>
c.boards.some((b) => b.id === post.boardId)
);
if (categoryOfPost) {
subItems = categoryOfPost.boards.map((b) => ({
id: b.id,
name: b.name,
href: `/boards/${b.slug}`,
}));
}
}
return (
<div className="space-y-6">
{/* 상단 배너 */}
{showBanner && (
<section>
<HeroBanner />
<HeroBanner
subItems={subItems}
activeSubId={post?.boardId}
showPartnerCats={false}
/>
</section>
)}

View File

@@ -6,6 +6,8 @@ import { useToast } from "@/app/components/ui/ToastProvider";
import { UploadButton } from "@/app/components/UploadButton";
import { Editor } from "@/app/components/Editor";
import { HeroBanner } from "@/app/components/HeroBanner";
import useSWR from "swr";
import Link from "next/link";
export default function NewPostPage() {
const router = useRouter();
@@ -16,6 +18,7 @@ export default function NewPostPage() {
const [form, setForm] = useState({ boardId: initialBoardId, title: "", content: "" });
const [isSecret, setIsSecret] = useState(false);
const [loading, setLoading] = useState(false);
const { data: meData } = useSWR<{ user: { userId: string } | null }>("/api/me", (u: string) => fetch(u).then((r) => r.json()));
async function submit() {
try {
if (!form.boardId.trim()) {
@@ -52,6 +55,40 @@ export default function NewPostPage() {
}
const plainLength = (form.content || "").replace(/<[^>]*>/g, "").length;
const MAX_LEN = 10000;
if (!meData) {
return (
<div className="space-y-6">
<section>
<HeroBanner />
</section>
<section className="mx-auto max-w-2xl bg-white rounded-2xl border border-neutral-300 px-6 sm:px-8 py-8 text-center">
<div className="text-lg font-semibold text-neutral-900"> ...</div>
</section>
</div>
);
}
if (!meData.user) {
const next = `/posts/new${sp.toString() ? `?${sp.toString()}` : ""}`;
return (
<div className="space-y-6">
<section>
<HeroBanner />
</section>
<section className="mx-auto max-w-2xl bg-white rounded-2xl border border-neutral-300 px-6 sm:px-8 py-8 text-center">
<div className="text-[22px] md:text-[26px] font-semibold text-neutral-900"> </div>
<p className="mt-2 text-neutral-600"> .</p>
<div className="mt-6">
<Link
href={`/login?next=${encodeURIComponent(next)}`}
className="inline-flex items-center px-5 h-12 rounded-xl border border-neutral-300 text-neutral-800 hover:bg-neutral-100"
>
</Link>
</div>
</section>
</div>
);
}
return (
<div className="space-y-6">
<section>

View File

@@ -19,23 +19,9 @@ export function getUserIdFromRequest(req: Request): string | null {
}
}
// 개발 환경에서만: 인증 정보가 없으면 admin 사용자 ID를 반환
// DB 조회 결과를 프로세스 전역에 캐싱해 과도한 쿼리를 방지
export async function getUserIdOrAdmin(req: Request): Promise<string | null> {
const uid = getUserIdFromRequest(req);
if (uid) return uid;
if (process.env.NODE_ENV === "production") return null;
try {
const globalAny = global as unknown as { __ADMIN_UID?: string };
if (globalAny.__ADMIN_UID) return globalAny.__ADMIN_UID;
const admin = await prisma.user.findUnique({ where: { nickname: "admin" }, select: { userId: true } });
if (admin?.userId) {
globalAny.__ADMIN_UID = admin.userId;
return admin.userId;
}
} catch {
// ignore
}
return null;
// 비로그인 시 admin으로 대체하지 않고 null 반환 (익명 처리)
return uid ?? null;
}

View File

@@ -8,22 +8,34 @@ const protectedApi = [
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const response = NextResponse.next();
// 쿠키에 uid가 없으면 어드민으로 자동 로그인 (기본값)
const uid = req.cookies.get("uid")?.value;
if (!uid) {
// 어드민 사용자 ID 가져오기 (DB 조회 대신 하드코딩 - 실제 환경에서는 다른 방식 사용 권장)
// 어드민 nickname은 "admin"으로 고정되어 있다고 가정
// 실제 userId는 DB에서 가져와야 하지만, middleware는 비동기 DB 호출을 제한적으로 지원
// 대신 page.tsx에서 처리하도록 함
// 페이지 요청일 경우만 쿠키 설정 시도
// API는 제외하고 페이지만 처리
if (!pathname.startsWith("/api")) {
// 페이지 레벨에서 처리하도록 함 (쿠키는 클라이언트 측에서 설정 필요)
const isAdmin = req.cookies.get("isAdmin")?.value;
// Admin 페이지 보호
if (pathname.startsWith("/admin")) {
// 비로그인 사용자는 로그인 페이지로
if (!uid) {
const loginUrl = req.nextUrl.clone();
loginUrl.pathname = "/login";
return NextResponse.redirect(loginUrl);
}
// 로그인했지만 관리자가 아니면 홈으로
if (isAdmin !== "1") {
const homeUrl = req.nextUrl.clone();
homeUrl.pathname = "/";
return NextResponse.redirect(homeUrl);
}
}
// 로그인된 상태에서 로그인 페이지 접근 시 홈으로 리다이렉트
if (pathname === "/login" || pathname.startsWith("/login/")) {
if (uid) {
const homeUrl = req.nextUrl.clone();
homeUrl.pathname = "/";
return NextResponse.redirect(homeUrl);
}
}
const needAuth = protectedApi.some((re) => re.test(pathname));
if (needAuth && !uid) {
return new NextResponse(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
@@ -36,4 +48,3 @@ export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"],
};