diff --git a/apps/web/src/app/(home)/page.tsx b/apps/web/src/app/(home)/page.tsx index 35e818fc..f1947f50 100644 --- a/apps/web/src/app/(home)/page.tsx +++ b/apps/web/src/app/(home)/page.tsx @@ -8,7 +8,6 @@ import { RegionEnumExtend } from "@/types/university"; import FindLastYearScoreBar from "./_ui/FindLastYearScoreBar"; import NewsSectionSkeleton from "./_ui/NewsSection/skeleton"; import PopularUniversitySection from "./_ui/PopularUniversitySection"; -import SiteFooter from "./_ui/SiteFooter"; import UniversityList from "./_ui/UniversityList"; const NewsSectionDynamic = dynamic(() => import("./_ui/NewsSection"), { @@ -18,16 +17,17 @@ const NewsSectionDynamic = dynamic(() => import("./_ui/NewsSection"), { const baseUrl = process.env.NEXT_PUBLIC_WEB_URL || "https://solid-connection.com"; const ogImageUrl = `${baseUrl}/opengraph-image.png`; +const homeMetaTitle = "교환학생 사이트 | 솔리드 커넥션 – 교환학생 커뮤니티, 플랫폼"; export const metadata: Metadata = { - title: "교환학생 사이트 | 솔리드 커넥션 – 교환학생 커뮤니티", + title: homeMetaTitle, description: "교환학생 사이트 솔리드커넥션. 교환학생 커뮤니티에서 학교 검색, 성적 입력, 지원 현황 확인까지 한 번에. 교환학생 준비를 위한 모든 정보를 제공합니다.", alternates: { canonical: `${baseUrl}/`, }, openGraph: { - title: "교환학생 사이트 | 솔리드 커넥션 – 교환학생 커뮤니티", + title: homeMetaTitle, description: "교환학생 사이트 솔리드커넥션. 교환학생 커뮤니티에서 학교 검색, 성적 입력, 지원 현황 확인까지 한 번에. 교환학생 준비를 위한 모든 정보를 제공합니다.", url: `${baseUrl}/`, @@ -45,7 +45,7 @@ export const metadata: Metadata = { }, twitter: { card: "summary_large_image", - title: "교환학생 사이트 | 솔리드 커넥션 – 교환학생 커뮤니티", + title: homeMetaTitle, description: "교환학생 사이트 솔리드커넥션. 교환학생 커뮤니티에서 학교 검색, 성적 입력, 지원 현황 확인까지.", images: [ogImageUrl], }, @@ -150,8 +150,6 @@ const HomePage = async () => { - - ); diff --git a/apps/web/src/app/login/LoginContent.tsx b/apps/web/src/app/login/LoginContent.tsx index 06d49c93..c6715161 100644 --- a/apps/web/src/app/login/LoginContent.tsx +++ b/apps/web/src/app/login/LoginContent.tsx @@ -7,6 +7,7 @@ import { useEffect, useRef } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { usePostEmailAuth } from "@/apis/Auth"; +import useAuthStore from "@/lib/zustand/useAuthStore"; import { toast } from "@/lib/zustand/useToastStore"; import { IconSolidConnectionFullBlackLogo } from "@/public/svgs"; import { IconAppleLogo, IconEmailIcon, IconKakaoLogo } from "@/public/svgs/auth"; @@ -22,12 +23,35 @@ const loginSchema = z.object({ type LoginFormData = z.infer; const COMMUNITY_LOGIN_REASON = "community-members-only"; +const NEED_LOGIN_COOKIE_KEY = "isNeedLogin"; + +const hasCookie = (cookieKey: string): boolean => { + if (typeof document === "undefined") { + return false; + } + + return document.cookie + .split(";") + .map((item) => item.trim()) + .some((item) => item.startsWith(`${cookieKey}=`)); +}; + +const clearCookie = (cookieKey: string) => { + if (typeof document === "undefined") { + return; + } + + // biome-ignore lint/suspicious/noDocumentCookie: Cookie Store API 미지원 브라우저 대응을 위한 안전한 fallback입니다. + document.cookie = `${cookieKey}=; path=/; max-age=0; SameSite=Lax`; +}; const LoginContent = () => { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const hasShownCommunityOnlyToast = useRef(false); + const hasShownNeedLoginToast = useRef(false); + const { isNeedLogin, setNeedLogin, clearNeedLogin } = useAuthStore(); const { mutate: postEmailAuth, isPending } = usePostEmailAuth(); const { showPasswordField, handleEmailChange } = useInputHandler(); @@ -66,6 +90,25 @@ const LoginContent = () => { router.replace(pathname); }, [pathname, router, searchParams]); + useEffect(() => { + if (!hasCookie(NEED_LOGIN_COOKIE_KEY)) { + return; + } + + setNeedLogin(true); + clearCookie(NEED_LOGIN_COOKIE_KEY); + }, [setNeedLogin]); + + useEffect(() => { + if (!isNeedLogin || hasShownNeedLoginToast.current) { + return; + } + + hasShownNeedLoginToast.current = true; + toast.info("로그인이 필요합니다. 다시 로그인해주세요."); + clearNeedLogin(); + }, [clearNeedLogin, isNeedLogin]); + return (
diff --git a/apps/web/src/lib/zustand/useAuthStore.ts b/apps/web/src/lib/zustand/useAuthStore.ts index 3c5e5647..dff27ddc 100644 --- a/apps/web/src/lib/zustand/useAuthStore.ts +++ b/apps/web/src/lib/zustand/useAuthStore.ts @@ -39,12 +39,15 @@ interface AuthState { serverRole: UserRole | null; clientRole: ClientRole | null; isAuthenticated: boolean; + isNeedLogin: boolean; isLoading: boolean; isInitialized: boolean; refreshStatus: RefreshStatus; setAccessToken: (token: string) => void; clearAccessToken: () => void; setClientRole: (role: ClientRole) => void; + setNeedLogin: (needLogin: boolean) => void; + clearNeedLogin: () => void; setLoading: (loading: boolean) => void; setInitialized: (initialized: boolean) => void; setRefreshStatus: (status: RefreshStatus) => void; @@ -57,6 +60,7 @@ const useAuthStore = create()( serverRole: null, clientRole: null, isAuthenticated: false, + isNeedLogin: false, isLoading: false, isInitialized: false, refreshStatus: "idle", @@ -70,6 +74,7 @@ const useAuthStore = create()( serverRole, clientRole: resolveClientRole(serverRole, state.clientRole), isAuthenticated: true, + isNeedLogin: false, isLoading: false, isInitialized: true, refreshStatus: "success", @@ -83,6 +88,7 @@ const useAuthStore = create()( serverRole: null, clientRole: null, isAuthenticated: false, + isNeedLogin: false, isLoading: false, isInitialized: true, refreshStatus: "idle", @@ -99,6 +105,14 @@ const useAuthStore = create()( }); }, + setNeedLogin: (needLogin) => { + set({ isNeedLogin: needLogin }); + }, + + clearNeedLogin: () => { + set({ isNeedLogin: false }); + }, + setLoading: (loading) => { set({ isLoading: loading }); }, diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 4062638a..7646dbf5 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,7 +1,9 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { isTokenExpired } from "@/utils/jwtUtils"; const loginNeedPages = ["/mentor", "/my", "/community"]; // 로그인 필요페이지 +const NEED_LOGIN_COOKIE_KEY = "isNeedLogin"; const blockedExactPaths = new Set([ "/database.php", "/db.php", @@ -26,11 +28,43 @@ const isProbePath = (pathname: string) => { return blockedPathPrefixes.some((prefix) => pathname.startsWith(prefix)); }; +const buildLoginRedirectResponse = ( + request: NextRequest, + options: { + clearRefreshToken?: boolean; + } = {}, +) => { + const { clearRefreshToken = false } = options; + const redirectUrl = request.nextUrl.clone(); + redirectUrl.pathname = "/login"; + redirectUrl.search = ""; + + const response = NextResponse.redirect(redirectUrl); + response.cookies.set({ + name: NEED_LOGIN_COOKIE_KEY, + value: "true", + path: "/", + sameSite: "lax", + maxAge: 60, + }); + + if (clearRefreshToken) { + response.cookies.set({ + name: "refreshToken", + value: "", + path: "/", + expires: new Date(0), + maxAge: 0, + }); + } + + return response; +}; + export function middleware(request: NextRequest) { - const url = request.nextUrl.clone(); - const pathname = url.pathname; + const pathname = request.nextUrl.pathname; - if (pathname === "/robots.txt" && isStageHostname(url.hostname)) { + if (pathname === "/robots.txt" && isStageHostname(request.nextUrl.hostname)) { return new NextResponse("User-agent: *\nDisallow: /\n", { status: 200, headers: { @@ -64,9 +98,11 @@ export function middleware(request: NextRequest) { }); if (needLogin && !refreshToken) { - url.pathname = "/login"; - url.searchParams.delete("reason"); - return NextResponse.redirect(url); + return buildLoginRedirectResponse(request); + } + + if (needLogin && isTokenExpired(refreshToken ?? null)) { + return buildLoginRedirectResponse(request, { clearRefreshToken: true }); } return NextResponse.next(); diff --git a/apps/web/src/utils/jwtUtils.ts b/apps/web/src/utils/jwtUtils.ts index b7955405..5d39c2a1 100644 --- a/apps/web/src/utils/jwtUtils.ts +++ b/apps/web/src/utils/jwtUtils.ts @@ -5,15 +5,31 @@ interface JwtPayload { exp: number; } +const decodeJwtPayload = (token: string): JwtPayload | null => { + try { + const payloadSegment = token.split(".")[1]; + if (!payloadSegment) { + return null; + } + + const normalized = payloadSegment.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + return JSON.parse(atob(padded)) as JwtPayload; + } catch { + return null; + } +}; + export const isTokenExpired = (token: string | null): boolean => { if (!token) return true; - try { - const payload = JSON.parse(atob(token.split(".")[1])) as JwtPayload; - const currentTime = Math.floor(Date.now() / 1000); - return payload.exp < currentTime; - } catch (error) { + + const payload = decodeJwtPayload(token); + if (!payload?.exp) { return true; // 토큰이 유효하지 않으면 만료된 것으로 간주 } + + const currentTime = Math.floor(Date.now() / 1000); + return payload.exp < currentTime; }; export const tokenParse = (token: string | null): JwtPayload | null => { @@ -21,10 +37,5 @@ export const tokenParse = (token: string | null): JwtPayload | null => { if (!token) return null; - try { - const payload = JSON.parse(atob(token.split(".")[1])) as JwtPayload; - return payload; - } catch (error) { - return null; - } + return decodeJwtPayload(token); };