From cfbb3d50ee91a081f8e8a40ff9ded6be0930d47d Mon Sep 17 00:00:00 2001 From: koreacomp5 Date: Sun, 9 Nov 2025 19:53:42 +0900 Subject: [PATCH] =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/seed.js | 8 +- src/app/api/auth/session/route.ts | 25 +++++ src/app/components/AppHeader.tsx | 90 +++++++++++----- src/app/components/Editor.tsx | 2 +- src/app/components/HeroBanner.tsx | 8 +- src/app/layout.tsx | 2 - src/app/login/page.tsx | 5 +- src/app/page.tsx | 174 +++++++++++++++--------------- src/app/posts/[id]/page.tsx | 34 +++++- src/app/posts/new/page.tsx | 37 +++++++ src/lib/auth.ts | 18 +--- src/middleware.ts | 39 ++++--- 12 files changed, 289 insertions(+), 153 deletions(-) diff --git a/prisma/seed.js b/prisma/seed.js index 488fa22..5ed6f48 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -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, diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts index 15885ec..f9b2285 100644 --- a/src/app/api/auth/session/route.ts +++ b/src/app/api/auth/session/route.ts @@ -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; } diff --git a/src/app/components/AppHeader.tsx b/src/app/components/AppHeader.tsx index d0085bd..157bf95 100644 --- a/src/app/components/AppHeader.tsx +++ b/src/app/components/AppHeader.tsx @@ -31,6 +31,11 @@ export function AppHeader() { const [indicatorLeft, setIndicatorLeft] = React.useState(0); const [indicatorWidth, setIndicatorWidth] = React.useState(0); const [indicatorVisible, setIndicatorVisible] = React.useState(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() {
- - 어드민(임시) - + {authData?.user && ( + + )} + {!authData?.user && ( + + 로그인 + + )}
) : ( -
-
-
-
-
-
+
+
로그인이 필요합니다
+ 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="로그인" + > + 로그인 + +
+ )} + {meData?.user && ( +
+ +
+ )} + {meData?.user && ( +
+ setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">포인트 히스토리 + setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">내가 쓴 글 + setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">내가 쓴 댓글
)} -
- setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">포인트 히스토리 - setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">내가 쓴 글 - setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">내가 쓴 댓글 -
- 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="어드민(임시)" - > - 어드민(임시) -
{categories.map((cat) => (
diff --git a/src/app/components/Editor.tsx b/src/app/components/Editor.tsx index 59f81f7..711d6e4 100644 --- a/src/app/components/Editor.tsx +++ b/src/app/components/Editor.tsx @@ -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, diff --git a/src/app/components/HeroBanner.tsx b/src/app/components/HeroBanner.tsx index 20e1757..4c6ad97 100644 --- a/src/app/components/HeroBanner.tsx +++ b/src/app/components/HeroBanner.tsx @@ -146,8 +146,8 @@ export function HeroBanner({ subItems, activeSubId, hideSubOnMobile, showPartner ) )} {!usePartnerCats && (!Array.isArray(subItems) || subItems.length === 0) && ( -
- 암실소문 +
+ 암실소문
)}
@@ -268,8 +268,8 @@ export function HeroBanner({ subItems, activeSubId, hideSubOnMobile, showPartner ) )} {!usePartnerCats && (!Array.isArray(subItems) || subItems.length === 0) && ( -
- 암실소문 +
+ 암실소문
)}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5cd12b0..22cb90e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ -
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 4c9ab53..2efa075 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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 { diff --git a/src/app/page.tsx b/src/app/page.tsx index aa58f68..3eddf26 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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
-
-
- - {currentUser && ( -
- + {currentUser ? ( + <> +
+
+ +
+ +
- )} +
+
+
{currentUser.nickname}
+
+
+
+ + 레벨 +
+
Lv. {currentUser.level}
+
+
+
+ + 등급 +
+
{getGradeName(currentUser.grade)}
+
+
+
+ + 포인트 +
+
{currentUser.points.toLocaleString()}
+
+
+
+
+ + + + + 내 정보 페이지 + + + + + + + + 포인트 히스토리 + + + + + + + + 내가 쓴 게시글 + + {myPostsCount.toLocaleString()}개 + + + + + + + 내가 쓴 댓글 + + {myCommentsCount.toLocaleString()}개 + + +
+ + ) : ( +
+
+
+
로그인이 필요합니다
+
내 정보와 등급/포인트를 확인하려면 로그인하세요.
+
+ + 로그인 하러 가기 +
-
-
-
{currentUser?.nickname || "사용자"}
-
-
-
- - 레벨 -
-
Lv. {currentUser?.level || 1}
-
-
-
- - 등급 -
-
{getGradeName(currentUser?.grade || 0)}
-
-
-
- - 포인트 -
-
{(currentUser?.points || 0).toLocaleString()}
-
-
-
-
- - - - - 내 정보 페이지 - - - - - - - - 포인트 히스토리 - - - - - - - - 내가 쓴 게시글 - - {myPostsCount.toLocaleString()}개 - - - - - - - 내가 쓴 댓글 - - {myCommentsCount.toLocaleString()}개 - - -
+ )}
{(await Promise.all(firstTwo.map((b) => prepareBoardPanelData(b)))).map((panelData, idx) => (
diff --git a/src/app/posts/[id]/page.tsx b/src/app/posts/[id]/page.tsx index ad9a83b..86f2dcb 100644 --- a/src/app/posts/[id]/page.tsx +++ b/src/app/posts/[id]/page.tsx @@ -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 (
{/* 상단 배너 */} {showBanner && (
- +
)} diff --git a/src/app/posts/new/page.tsx b/src/app/posts/new/page.tsx index a783395..9e3da4b 100644 --- a/src/app/posts/new/page.tsx +++ b/src/app/posts/new/page.tsx @@ -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 ( +
+
+ +
+
+
확인 중...
+
+
+ ); + } + if (!meData.user) { + const next = `/posts/new${sp.toString() ? `?${sp.toString()}` : ""}`; + return ( +
+
+ +
+
+
로그인이 필요합니다
+

게시글 작성은 로그인 후 이용할 수 있어요.

+
+ + 로그인 하러 가기 + +
+
+
+ ); + } return (
diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4a892fa..525605a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -19,23 +19,9 @@ export function getUserIdFromRequest(req: Request): string | null { } } -// 개발 환경에서만: 인증 정보가 없으면 admin 사용자 ID를 반환 -// DB 조회 결과를 프로세스 전역에 캐싱해 과도한 쿼리를 방지 export async function getUserIdOrAdmin(req: Request): Promise { 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; } diff --git a/src/middleware.ts b/src/middleware.ts index 0956fbe..011d525 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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)$).*)"], }; -