Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions apps/web/src/app/(home)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"), {
Expand All @@ -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}/`,
Expand All @@ -45,7 +45,7 @@ export const metadata: Metadata = {
},
twitter: {
card: "summary_large_image",
title: "교환학생 사이트 | 솔리드 커넥션 – 교환학생 커뮤니티",
title: homeMetaTitle,
description: "교환학생 사이트 솔리드커넥션. 교환학생 커뮤니티에서 학교 검색, 성적 입력, 지원 현황 확인까지.",
images: [ogImageUrl],
},
Expand Down Expand Up @@ -150,8 +150,6 @@ const HomePage = async () => {
</div>

<NewsSectionDynamic newsList={newsList} />

<SiteFooter />
</div>
Comment on lines 152 to 153
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore home footer render

This change removes <SiteFooter /> from the home page render, but apps/web/src/app/(home)/_ui/SiteFooter/index.tsx is still the only place that exposes the site's business information; after this commit that footer is no longer reachable anywhere in apps/web/src/app. This is a user-facing regression from the previous behavior and can hide required disclosure content on the main entry page.

Useful? React with 👍 / 👎.

</>
);
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/app/login/LoginContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -22,12 +23,35 @@ const loginSchema = z.object({
type LoginFormData = z.infer<typeof loginSchema>;

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();
Expand Down Expand Up @@ -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 (
<div>
<div className="mt-[-56px] h-[77px] border-b border-bg-200 py-[21px] pl-5">
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/lib/zustand/useAuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -57,6 +60,7 @@ const useAuthStore = create<AuthState>()(
serverRole: null,
clientRole: null,
isAuthenticated: false,
isNeedLogin: false,
isLoading: false,
isInitialized: false,
refreshStatus: "idle",
Expand All @@ -70,6 +74,7 @@ const useAuthStore = create<AuthState>()(
serverRole,
clientRole: resolveClientRole(serverRole, state.clientRole),
isAuthenticated: true,
isNeedLogin: false,
isLoading: false,
isInitialized: true,
refreshStatus: "success",
Expand All @@ -83,6 +88,7 @@ const useAuthStore = create<AuthState>()(
serverRole: null,
clientRole: null,
isAuthenticated: false,
isNeedLogin: false,
isLoading: false,
isInitialized: true,
refreshStatus: "idle",
Expand All @@ -99,6 +105,14 @@ const useAuthStore = create<AuthState>()(
});
},

setNeedLogin: (needLogin) => {
set({ isNeedLogin: needLogin });
},

clearNeedLogin: () => {
set({ isNeedLogin: false });
},

setLoading: (loading) => {
set({ isLoading: loading });
},
Expand Down
48 changes: 42 additions & 6 deletions apps/web/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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,
Comment on lines +43 to +48
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip need-login cookie on prefetch redirects

buildLoginRedirectResponse always sets the isNeedLogin cookie when redirecting unauthenticated requests, including speculative requests. Since the app uses default-prefetch Links in bottom navigation to protected routes (/community, /mentor, /my), middleware can set this cookie during background prefetch and later trigger a misleading "로그인이 필요합니다" toast even when the user did not explicitly attempt a protected action. The cookie write should be limited to real navigations (non-prefetch requests).

Useful? React with 👍 / 👎.

});

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: {
Expand Down Expand Up @@ -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();
Expand Down
33 changes: 22 additions & 11 deletions apps/web/src/utils/jwtUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,37 @@ 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 => {
if (typeof window === "undefined") return 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);
};
Loading