feat: 로그아웃 실패 시 사용자 알림 추가
로그아웃 Server Action이 백엔드 호출 실패를 /login?logoutError=1로 알리면, 새 클라이언트 컴포넌트 LogoutNotice가 마운트 시 기존 FeedbackProvider/Toast로 경고 토스트를 띄우고 쿼리 파라미터를 제거한다. redirect()는 Server Action에서 즉시 던져져 클라이언트가 결과를 돌려받을 수 없으므로, 토스트 표시를 목적지 페이지 마운트 이후로 미뤄 redirect와 경쟁하지 않게 한다. 내부 백엔드 메시지는 노출하지 않고 고정 문구만 보여준다. Co-Authored-By: Claude Opus 5
@4c90fb10e391791627b9de02830fc89b44be2f2a
+++ app/(public)/login/_components/logout-notice.tsx
... | ... | @@ -0,0 +1,42 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useEffect } from 'react'; | |
| 4 | +import { useRouter } from 'next/navigation'; | |
| 5 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 6 | + | |
| 7 | +interface LogoutNoticeProps { | |
| 8 | + /** 로그아웃 Server Action이 백엔드 호출 실패를 알리려고 실어 보낸 쿼리 파라미터가 있었는가. */ | |
| 9 | + showError: boolean; | |
| 10 | +} | |
| 11 | + | |
| 12 | +const LOGOUT_ERROR_MESSAGE = | |
| 13 | + '로그아웃 처리 중 일부 오류가 있었지만 로그아웃되었습니다.'; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * 화면에 그릴 UI가 없는 알림 트리거 컴포넌트 — `app/(protected)/_actions.ts`의 `logout()`이 | |
| 17 | + * 백엔드 로그아웃 호출 실패를 알리려고 `/login`에 실어 보낸 쿼리 파라미터를 Page(Server | |
| 18 | + * Component)가 읽어 `showError`로 넘겨주면, 마운트 시 1회 토스트로 보여준다. | |
| 19 | + * | |
| 20 | + * Server Action은 redirect() 이후 클라이언트에 값을 돌려줄 방법이 없다(redirect가 즉시 | |
| 21 | + * 던져짐) — 그래서 이 화면에 도착해 마운트된 뒤(=redirect가 끝난 뒤)에만 알릴 수 있다. 이 | |
| 22 | + * 컴포넌트는 오직 이 시점에만 반응하므로 redirect와 토스트 표시가 시점상 겹치지 않는다. | |
| 23 | + * | |
| 24 | + * 표시 후에는 쿼리 파라미터를 제거한다(router.replace) — 새로고침·북마크 재방문 시 지난 실패가 | |
| 25 | + * 다시 뜨는 것을 막는다. | |
| 26 | + */ | |
| 27 | +export function LogoutNotice({ showError }: LogoutNoticeProps) { | |
| 28 | + const router = useRouter(); | |
| 29 | + const { showToast } = useFeedback(); | |
| 30 | + | |
| 31 | + useEffect(() => { | |
| 32 | + if (!showError) { | |
| 33 | + return; | |
| 34 | + } | |
| 35 | + | |
| 36 | + // 내부 정보(백엔드 message 원문 등)는 노출하지 않는다 — 항상 이 고정 문구만 보여준다. | |
| 37 | + showToast({ variant: 'warning', message: LOGOUT_ERROR_MESSAGE }); | |
| 38 | + router.replace('/login'); | |
| 39 | + }, [showError, showToast, router]); | |
| 40 | + | |
| 41 | + return null; | |
| 42 | +} |
--- app/(public)/login/page.tsx
+++ app/(public)/login/page.tsx
... | ... | @@ -1,16 +1,31 @@ |
| 1 | 1 |
import { redirect } from 'next/navigation';
|
| 2 | 2 |
import { getSessionAdmin } from '@/lib/auth/dal';
|
| 3 |
+import { LOGOUT_ERROR_QUERY_PARAM } from '@/lib/constants/logout';
|
|
| 3 | 4 |
import { LoginForm } from './_components/login-form';
|
| 5 |
+import { LogoutNotice } from './_components/logout-notice';
|
|
| 4 | 6 |
|
| 5 | 7 |
// cookies()로 이미 동적이지만, 정적 프리렌더로 데이터가 빌드 산출물에 박히는 경로를 |
| 6 | 8 |
// 원천 차단하기 위해 명시적으로 강제한다. |
| 7 | 9 |
export const dynamic = 'force-dynamic'; |
| 8 | 10 |
|
| 9 |
-export default async function Page() {
|
|
| 11 |
+interface PageProps {
|
|
| 12 |
+ searchParams: Promise<Record<string, string | string[] | undefined>>; |
|
| 13 |
+} |
|
| 14 |
+ |
|
| 15 |
+export default async function Page({ searchParams }: PageProps) {
|
|
| 10 | 16 |
const admin = await getSessionAdmin(); |
| 11 | 17 |
if (admin) {
|
| 12 | 18 |
redirect('/');
|
| 13 | 19 |
} |
| 14 | 20 |
|
| 15 |
- return <LoginForm />; |
|
| 21 |
+ const resolvedSearchParams = await searchParams; |
|
| 22 |
+ const logoutError = resolvedSearchParams[LOGOUT_ERROR_QUERY_PARAM]; |
|
| 23 |
+ const showLogoutError = typeof logoutError === 'string' && logoutError.length > 0; |
|
| 24 |
+ |
|
| 25 |
+ return ( |
|
| 26 |
+ <> |
|
| 27 |
+ <LogoutNotice showError={showLogoutError} />
|
|
| 28 |
+ <LoginForm /> |
|
| 29 |
+ </> |
|
| 30 |
+ ); |
|
| 16 | 31 |
} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?