Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import CloudSpinnerPage from "@/components/ui/CloudSpinnerPage";
import { showIconToast } from "@/lib/toast/showIconToast";
import useAuthStore from "@/lib/zustand/useAuthStore";
import { IconImage } from "@/public/svgs";
import { buildLoginPathWithRedirect } from "@/utils/authRedirect";
import { buildLoginPathWithRedirect, LOGIN_REQUIRED_MESSAGE } from "@/utils/authRedirect";

type PostFormProps = {
boardCode: string;
Expand Down Expand Up @@ -50,6 +50,8 @@ const PostForm = ({ boardCode }: PostFormProps) => {
}

if (!isAuthenticated || !accessToken) {
// SPA 이동이라 토스트가 로그인 페이지까지 유지된다.
showIconToast("logo", LOGIN_REQUIRED_MESSAGE);
router.replace(buildLoginPathWithRedirect(createPath));
}
}, [accessToken, createPath, isAuthenticated, isInitialized, refreshStatus, router]);
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import GlobalLayout from "@/components/layout/GlobalLayout";
import ReissueProvider from "@/components/layout/ReissueProvider";
import QueryProvider from "@/lib/react-query/QueryProvider";
import AppleScriptLoader from "@/lib/ScriptLoader/AppleScriptLoader";
import PendingToastPresenter from "@/lib/toast/PendingToastPresenter";
import "@/styles/globals.css";
import { GoogleAnalytics } from "@next/third-parties/google";
import { SpeedInsights } from "@vercel/speed-insights/next";
Expand Down Expand Up @@ -85,6 +86,7 @@ const RootLayout = ({ children }: { children: ReactNode }) => (
duration: 3000,
}}
/>
<PendingToastPresenter />
</QueryProvider>
</body>
</html>
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/app/mentor/_ui/MentorClient/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useGetMyInfo } from "@/apis/MyPage";
import CloudSpinnerPage from "@/components/ui/CloudSpinnerPage";
import { showIconToast } from "@/lib/toast/showIconToast";
import useAuthStore from "@/lib/zustand/useAuthStore";
import { UserRole } from "@/types/mentor";
import { LOGIN_REQUIRED_MESSAGE } from "@/utils/authRedirect";
import MentorPageSkeleton from "../MentorPageSkeleton";
import MenteePage from "./_ui/MenteePage";
import MentorPage from "./_ui/MentorPage";
Expand All @@ -23,6 +25,8 @@ const MentorClient = () => {
useEffect(() => {
if (isAuthResolving) return;
if (isUnauthorized || (!isError && !role)) {
// SPA 이동이라 토스트가 로그인 페이지까지 유지된다.
showIconToast("logo", LOGIN_REQUIRED_MESSAGE);
router.replace("/login");
}
}, [isAuthResolving, isUnauthorized, isError, role, router]);
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/app/my/_ui/MyProfileContent/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
IconUniversity,
} from "@/public/svgs/my";
import { UserRole } from "@/types/mentor";
import { LOGIN_REQUIRED_MESSAGE } from "@/utils/authRedirect";
import { openKakaoOpenChat } from "@/utils/openKakaoOpenChat";

const NEXT_PUBLIC_CONTACT_LINK = process.env.NEXT_PUBLIC_CONTACT_LINK;
Expand All @@ -38,6 +39,8 @@ const MyProfileContent = () => {
useEffect(() => {
if (!isInitialized || isAuthenticated) return;

// SPA 이동이라 토스트가 로그인 페이지까지 유지된다.
showIconToast("logo", LOGIN_REQUIRED_MESSAGE);
router.replace("/login");
}, [isInitialized, isAuthenticated, router]);

Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/lib/toast/PendingToastPresenter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client";

import { useEffect } from "react";

import { consumePendingToast } from "./pendingToast";
import { showIconToast } from "./showIconToast";

/**
* 이전 페이지에서 예약해 둔 토스트를 페이지 진입 시 한 번 띄운다.
* (하드 내비게이션으로 사라졌을 토스트를 도착 페이지에서 대신 보여주는 역할)
*/
const PendingToastPresenter = () => {
useEffect(() => {
const pendingToast = consumePendingToast();
if (!pendingToast) return;

showIconToast(pendingToast.icon, pendingToast.message);
}, []);

return null;
};

export default PendingToastPresenter;
47 changes: 47 additions & 0 deletions apps/web/src/lib/toast/pendingToast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { ToastIconKey } from "./showIconToast";

const PENDING_TOAST_STORAGE_KEY = "pendingToast";

type PendingToast = {
icon: ToastIconKey;
message: string;
};

/**
* 다음 페이지 로드 때 보여줄 토스트를 예약한다.
*
* `window.location.replace()` 같은 하드 내비게이션은 React 트리를 통째로 버리기 때문에,
* 이동 직전에 띄운 토스트는 화면에 나타나기도 전에 사라진다.
* 그래서 메시지를 sessionStorage 에 넘겨두고 도착한 페이지에서 대신 띄운다.
*
* SPA 이동(next/navigation 의 router.push/replace)은 트리가 유지되므로
* 이 함수가 아니라 showIconToast 를 그대로 쓰면 된다.
*/
export const setPendingToast = (icon: ToastIconKey, message: string) => {
if (typeof window === "undefined") return;

try {
sessionStorage.setItem(PENDING_TOAST_STORAGE_KEY, JSON.stringify({ icon, message } satisfies PendingToast));
} catch {
// sessionStorage 를 못 쓰는 환경(프라이빗 모드 등)에서는 토스트를 포기한다.
}
};

/** 예약된 토스트를 읽고 즉시 비운다. (같은 메시지가 다음 이동에서 또 뜨지 않도록) */
export const consumePendingToast = (): PendingToast | null => {
if (typeof window === "undefined") return null;

try {
const raw = sessionStorage.getItem(PENDING_TOAST_STORAGE_KEY);
if (!raw) return null;

sessionStorage.removeItem(PENDING_TOAST_STORAGE_KEY);

const parsed = JSON.parse(raw) as Partial<PendingToast>;
if (!parsed?.message || !parsed?.icon) return null;

return { icon: parsed.icon, message: parsed.message };
Comment on lines +40 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

1. 손상된 저장 데이터의 형식을 검증하세요.

as Partial<PendingToast>는 런타임 검증을 하지 않습니다. {"icon":"invalid","message":"..."} 또는 {"icon":"logo","message":{}}는 현재 검사를 통과합니다. 이후 showIconToast가 유효하지 않은 Icon 또는 React 자식 값을 렌더링하여 로그인 페이지에서 오류를 발생시킬 수 있습니다.

iconToastIconKey 허용 목록으로 확인하고, message가 문자열인지 확인한 뒤에만 반환하세요.

수정 예시
+const TOAST_ICON_KEYS: readonly ToastIconKey[] = ["like", "link", "univ", "cap", "logo"];
+
+const isPendingToast = (value: unknown): value is PendingToast => {
+  if (!value || typeof value !== "object") return false;
+
+  const { icon, message } = value as Record<string, unknown>;
+  return typeof message === "string" && TOAST_ICON_KEYS.includes(icon as ToastIconKey);
+};
+
 export const consumePendingToast = (): PendingToast | null => {
   // ...
-  const parsed = JSON.parse(raw) as Partial<PendingToast>;
-  if (!parsed?.message || !parsed?.icon) return null;
+  const parsed: unknown = JSON.parse(raw);
+  if (!isPendingToast(parsed)) return null;

   return { icon: parsed.icon, message: parsed.message };
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const parsed = JSON.parse(raw) as Partial<PendingToast>;
if (!parsed?.message || !parsed?.icon) return null;
return { icon: parsed.icon, message: parsed.message };
const TOAST_ICON_KEYS: readonly ToastIconKey[] = ["like", "link", "univ", "cap", "logo"];
const isPendingToast = (value: unknown): value is PendingToast => {
if (!value || typeof value !== "object") return false;
const { icon, message } = value as Record<string, unknown>;
return typeof message === "string" && TOAST_ICON_KEYS.includes(icon as ToastIconKey);
};
const parsed: unknown = JSON.parse(raw);
if (!isPendingToast(parsed)) return null;
return { icon: parsed.icon, message: parsed.message };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/toast/pendingToast.ts` around lines 40 - 43, Update the
parsed-data validation in the pending toast parser to verify that parsed.icon is
one of the allowed ToastIconKey values and parsed.message is a string before
returning the toast. Reject invalid or malformed storage data by returning null,
while preserving the existing valid-toast return shape.

} catch {
return null;
}
};
3 changes: 3 additions & 0 deletions apps/web/src/utils/authRedirect.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export const AUTH_REDIRECT_PARAM = "redirect";

/** 로그인이 필요해 로그인 페이지로 보낼 때 사용자에게 안내할 메시지 */
export const LOGIN_REQUIRED_MESSAGE = "로그인이 필요한 페이지입니다.";

const FALLBACK_REDIRECT_PATH = "/";
const COMMUNITY_PATH_PREFIX = "/community/";

Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/utils/axiosInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import axios, { type AxiosError, type AxiosInstance } from "axios";
import { postReissueToken } from "@/apis/Auth/server";
import { QueryKeys } from "@/apis/queryKeys";
import queryClient from "@/lib/react-query/queryClient";
import { showIconToast } from "@/lib/toast/showIconToast";
import { setPendingToast } from "@/lib/toast/pendingToast";
import useAuthStore from "@/lib/zustand/useAuthStore";
import { isTokenExpired } from "@/utils/jwtUtils";

Expand Down Expand Up @@ -34,7 +34,9 @@ const redirectToLogin = (message: string) => {
try {
// 쿠키 유틸이 클라이언트에서만 동작하므로 window 가드 내에서 호출
} catch {}
showIconToast("logo", message);
// location.replace 는 하드 내비게이션이라 여기서 토스트를 띄우면 화면에 뜨기 전에 사라진다.
// 로그인 페이지에 도착한 뒤 PendingToastPresenter 가 대신 띄우도록 넘긴다.
setPendingToast("logo", message);
window.location.replace("/login");
}
};
Expand Down
Loading