From 5ef84613e8448766ee7ab91c1372b27bb6918cb1 Mon Sep 17 00:00:00 2001 From: Wibaek Park Date: Wed, 11 Mar 2026 01:52:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=BB=A4=EB=AE=A4=EB=8B=88=ED=8B=B0=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=EC=A0=84=EC=9A=A9=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/AUTHENTICATION.md | 37 +++++++++++++------------ apps/web/src/app/login/LoginContent.tsx | 21 +++++++++++++- apps/web/src/middleware.ts | 19 +++++++------ 3 files changed, 51 insertions(+), 26 deletions(-) diff --git a/apps/web/AUTHENTICATION.md b/apps/web/AUTHENTICATION.md index 2ebe3031..a7e1e3e9 100644 --- a/apps/web/AUTHENTICATION.md +++ b/apps/web/AUTHENTICATION.md @@ -11,7 +11,7 @@ The application implements a comprehensive login redirect system that ensures us The following pages require authentication: - `/mentor/*` - All mentor-related pages - `/my/*` - All user profile pages -- `/community/[boardCode]/*` - Community sub-routes (post creation, modification) +- `/community` and `/community/*` - Entire community experience, including board lists, post detail, creation, and modification ### How It Works @@ -20,45 +20,48 @@ The following pages require authentication: The middleware (`apps/web/src/middleware.ts`) checks for authentication on every request: ```typescript -const loginNeedPages = ["/mentor", "/my"]; +const loginNeedPages = ["/mentor", "/my", "/community"]; const needLogin = loginNeedPages.some((path) => { return url.pathname === path || url.pathname.startsWith(`${path}/`); -}) || isCommunitySubRoute; +}); ``` -#### 2. Redirect Parameter +#### 2. Community Redirect Reason When an unauthenticated user tries to access a protected page: - Middleware redirects to `/login` -- Original URL is preserved in the `redirect` query parameter -- Example: `/login?redirect=/mentor/chat/123` +- Community routes include a reason marker so the login page can explain why access was blocked +- Example: `/login?reason=community-members-only` ```typescript if (needLogin && !refreshToken) { + const isCommunityRoute = url.pathname === "/community" || url.pathname.startsWith("/community/"); url.pathname = "/login"; - const redirectUrl = request.nextUrl.pathname + request.nextUrl.search; - url.searchParams.set("redirect", redirectUrl); + if (isCommunityRoute) { + url.searchParams.set("reason", "community-members-only"); + } return NextResponse.redirect(url); } ``` #### 3. Toast Notification -The login page displays a toast message when users are redirected: +The login page displays a one-time toast message when users are redirected from community routes: ```typescript // apps/web/src/app/login/LoginContent.tsx useEffect(() => { - const redirect = searchParams.get("redirect"); - if (redirect) { - toast.info("로그인이 필요합니다."); + const reason = searchParams.get("reason"); + if (reason === "community-members-only") { + toast.info("커뮤니티는 회원 전용입니다. 로그인 후 이용해주세요."); + router.replace(pathname); } -}, [searchParams]); +}, [pathname, router, searchParams]); ``` #### 4. Post-Login Redirect -After successful authentication, users are redirected back to their original destination. +After successful authentication, users continue to be redirected to `/`. ### Configuration @@ -112,7 +115,7 @@ const needLogin = loginNeedPages.some(...) || isNewRouteSubPath; - Check middleware matcher pattern excludes static files #### Toast not showing? -- Ensure `redirect` query parameter is present +- Ensure `reason=community-members-only` query parameter is present for community access - Check `LoginContent.tsx` useEffect is running - Verify toast store is initialized @@ -121,7 +124,7 @@ const needLogin = loginNeedPages.some(...) || isNewRouteSubPath; 1. **HTTP-Only Cookies**: Refresh tokens are never accessible to JavaScript 2. **Middleware Protection**: Server-side check before page renders 3. **Token Expiry**: Short-lived access tokens minimize exposure -4. **Redirect Validation**: Only internal URLs are allowed in redirect parameter +4. **Scoped Login Reasons**: Community-only messaging is controlled by a fixed internal `reason` value ### Related Files @@ -134,4 +137,4 @@ const needLogin = loginNeedPages.some(...) || isNewRouteSubPath; This implementation resolves issue #302: "로그인 필요 페이지 분리 작업 + proxy 에서 리디렉션 처리" -The redirect parameter and toast notification features ensure users understand why they were redirected and can seamlessly return to their intended destination after login. +The login reason marker and toast notification help users understand why community access was blocked. diff --git a/apps/web/src/app/login/LoginContent.tsx b/apps/web/src/app/login/LoginContent.tsx index 4a8d1493..06d49c93 100644 --- a/apps/web/src/app/login/LoginContent.tsx +++ b/apps/web/src/app/login/LoginContent.tsx @@ -2,10 +2,12 @@ import { zodResolver } from "@hookform/resolvers/zod"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useRef } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { usePostEmailAuth } from "@/apis/Auth"; +import { toast } from "@/lib/zustand/useToastStore"; import { IconSolidConnectionFullBlackLogo } from "@/public/svgs"; import { IconAppleLogo, IconEmailIcon, IconKakaoLogo } from "@/public/svgs/auth"; import { appleLogin, kakaoLogin } from "@/utils/authUtils"; @@ -19,8 +21,13 @@ const loginSchema = z.object({ type LoginFormData = z.infer; +const COMMUNITY_LOGIN_REASON = "community-members-only"; + const LoginContent = () => { const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const hasShownCommunityOnlyToast = useRef(false); const { mutate: postEmailAuth, isPending } = usePostEmailAuth(); const { showPasswordField, handleEmailChange } = useInputHandler(); @@ -47,6 +54,18 @@ const LoginContent = () => { } }; + useEffect(() => { + const reason = searchParams.get("reason"); + + if (reason !== COMMUNITY_LOGIN_REASON || hasShownCommunityOnlyToast.current) { + return; + } + + hasShownCommunityOnlyToast.current = true; + toast.info("커뮤니티는 회원 전용입니다. 로그인 후 이용해주세요."); + router.replace(pathname); + }, [pathname, router, searchParams]); + return (
diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index cea8bd2e..279d052a 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,7 +1,8 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -const loginNeedPages = ["/mentor", "/my"]; // 로그인 필요페이지 +const loginNeedPages = ["/mentor", "/my", "/community"]; // 로그인 필요페이지 +const COMMUNITY_LOGIN_REASON = "community-members-only"; export function middleware(request: NextRequest) { const url = request.nextUrl.clone(); @@ -21,17 +22,19 @@ export function middleware(request: NextRequest) { // HTTP-only 쿠키의 refreshToken 확인 const refreshToken = request.cookies.get("refreshToken")?.value; - // /community는 통과, /community/ 하위 경로는 로그인 필요 - const isCommunitySubRoute = url.pathname.startsWith("/community/"); - // 정확한 경로 매칭 - const needLogin = - loginNeedPages.some((path) => { - return url.pathname === path || url.pathname.startsWith(`${path}/`); - }) || isCommunitySubRoute; // /community/ 하위 경로도 로그인 필요 + const needLogin = loginNeedPages.some((path) => { + return url.pathname === path || url.pathname.startsWith(`${path}/`); + }); if (needLogin && !refreshToken) { + const isCommunityRoute = url.pathname === "/community" || url.pathname.startsWith("/community/"); url.pathname = "/login"; + if (isCommunityRoute) { + url.searchParams.set("reason", COMMUNITY_LOGIN_REASON); + } else { + url.searchParams.delete("reason"); + } return NextResponse.redirect(url); }