Skip to content
21 changes: 18 additions & 3 deletions src/app/movers/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
import type { ReactNode } from "react";
import { cookies } from "next/headers";

import BlockMoverFromMoversBrowse from "@/components/auth/BlockMoverFromMoversBrowse";
import { LoginRequiredModalProvider } from "@/components/auth/LoginRequiredModalProvider";
import { ROLE_STORAGE_KEY, parseAuthRole } from "@/lib/auth/role";
import { safeDecodeCookieValue } from "@/lib/auth/nickname";

interface MoversLayoutProps {
children: ReactNode;
}

export default function MoversLayout({ children }: MoversLayoutProps) {
return <LoginRequiredModalProvider>{children}</LoginRequiredModalProvider>;
}
const MoversLayout = async ({ children }: MoversLayoutProps) => {
const cookieStore = await cookies();
const rawRole = cookieStore.get(ROLE_STORAGE_KEY)?.value;
const decodedRole = rawRole ? safeDecodeCookieValue(rawRole) : null;
const initialRole = parseAuthRole(decodedRole);

return (
<LoginRequiredModalProvider>
<BlockMoverFromMoversBrowse initialRole={initialRole}>{children}</BlockMoverFromMoversBrowse>
</LoginRequiredModalProvider>
);
};

export default MoversLayout;
50 changes: 50 additions & 0 deletions src/components/auth/BlockMoverFromMoversBrowse.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use client";

import { useEffect, type ReactNode } from "react";
import { useRouter } from "next/navigation";

import { useResolvedAuthRole } from "@/hooks/auth/useResolvedAuthRole";
import { getRoleHomePath } from "@/lib/auth/redirect";
import type { AuthRole } from "@/lib/auth/role";
import { useAuthStore } from "@/stores/useAuthStore";

interface BlockMoverFromMoversBrowseProps {
children: ReactNode;
/** Server role 쿠키 힌트 — SSR/CSR 첫 페인트 일치·목록 미노출용 */
initialRole?: AuthRole | null;
}

/**
* 기사님 찾기·상세 등 `/movers` 공개 탐색은 고객/비로그인 전용.
* MOVER가 히스토리·URL로 진입하면 역할 홈으로 보냅니다.
* initialRole(서버 쿠키)이 MOVER이면 checkAuth 전·SSR에서도 목록을 그리지 않습니다.
*/
const BlockMoverFromMoversBrowse = ({
children,
initialRole = null,
}: BlockMoverFromMoversBrowseProps) => {
const router = useRouter();
const hasHydrated = useAuthStore((state) => state.hasHydrated);
const isCheckingAuth = useAuthStore((state) => state.isCheckingAuth);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const resolvedRole = useResolvedAuthRole(initialRole);

const isAuthReady = hasHydrated && !isCheckingAuth;
/** 세션 확정 후: 실제 MOVER만 차단·리다이렉트 */
const shouldBlock = isAuthReady && isAuthenticated && resolvedRole === "MOVER";
/** checkAuth 전: resolvedRole(SSR initialRole)이 MOVER면 목록 미노출 */
const shouldHideContent = shouldBlock || (!isAuthReady && resolvedRole === "MOVER");

useEffect(() => {
if (!shouldBlock) return;
router.replace(getRoleHomePath("MOVER"));
}, [shouldBlock, router]);

if (shouldHideContent) {
return null;
}

return children;
};

export default BlockMoverFromMoversBrowse;
41 changes: 35 additions & 6 deletions src/components/auth/GuestOnly.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
import { useEffect, type ReactNode } from "react";
import { useRouter } from "next/navigation";

import { getAuthenticatedAuthPageRedirectPath } from "@/lib/auth/redirect";
import {
getAuthAudienceFromRole,
getPostAuthRedirectPath,
getRoleHomePath,
} from "@/lib/auth/redirect";
import { loadRole } from "@/lib/auth/role";
import { useAuthStore } from "@/stores/useAuthStore";

Expand All @@ -12,7 +16,8 @@ interface GuestOnlyProps {
}

/**
* 로그인·회원가입 전용 — 이미 인증된 사용자는 예약 경로 또는 역할 홈으로 보냄
* 로그인·회원가입 전용 — 이미 인증된 사용자는 예약 경로 또는
* 프로필 완료 여부에 따른 경로(미완료 → 프로필 생성, 완료 → 역할 홈)로 보냄
* 로그인/가입 폼은 establishSession 전에 setPostAuthRedirectPath로 목적지를 예약합니다.
*/
const GuestOnly = ({ children }: GuestOnlyProps) => {
Expand All @@ -25,11 +30,35 @@ const GuestOnly = ({ children }: GuestOnlyProps) => {
useEffect(() => {
if (!hasHydrated || isCheckingAuth || !isAuthenticated) return;

const intentPath =
useAuthStore.getState().consumePostAuthRedirectPath() ??
getAuthenticatedAuthPageRedirectPath(role ?? loadRole());
let cancelled = false;

router.replace(intentPath);
// 예약된 경로가 있으면 예약된 경로로 이동
// 예약된 경로가 없으면 역할 + 프로필 완료 판단 후 경로 결정 및 이동
const redirect = async () => {
const reservedPath = useAuthStore.getState().postAuthRedirectPath;

if (reservedPath) {
if (cancelled) return;

router.replace(reservedPath);
useAuthStore.getState().consumePostAuthRedirectPath();
return;
}

const resolvedRole = role ?? loadRole();
const intentPath = await getPostAuthRedirectPath({
audience: getAuthAudienceFromRole(resolvedRole),
fallbackPath: getRoleHomePath(resolvedRole),
});

if (cancelled) return;
router.replace(intentPath);
};

void redirect();
return () => {
cancelled = true;
};
}, [hasHydrated, isCheckingAuth, isAuthenticated, role, router]);

if (!hasHydrated || isCheckingAuth) {
Expand Down
5 changes: 2 additions & 3 deletions src/components/common/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import ProfileMenuTrigger, {
type ProfileMenuItem,
} from "@/components/common/Header/ProfileMenuTrigger";
import { Text } from "@/components/common/Text";
import { useResolvedAuthRole } from "@/hooks/auth/useResolvedAuthRole";
import { useCloseOnPathnameChange } from "@/hooks/useCloseOnPathnameChange";
import { useProfileCompletionState } from "@/hooks/profile/useProfileCompletionState";
import { MenuIcon } from "@/icons";
import type { AuthRole } from "@/lib/auth/role";
import { loadRole } from "@/lib/auth/role";
import { getLoginRedirectPath } from "@/lib/auth/session";
import { APP_ROUTES } from "@/lib/constants/appRoutes";
import { cn } from "@/lib/utils/cn";
Expand Down Expand Up @@ -138,8 +138,7 @@ const Header = ({
// checkAuth 완료 후: 실제 세션(access) 기준
const isLogin = !hasHydrated || isCheckingAuth ? Boolean(initialIsLogin) : isAuthenticated;

const resolvedRole: AuthRole | null =
user?.role ?? (!hasHydrated || isCheckingAuth ? initialRole : loadRole());
const resolvedRole = useResolvedAuthRole(initialRole);

const navLinks = !isLogin
? LOGGED_OUT_LINKS
Expand Down
1 change: 1 addition & 0 deletions src/components/common/Input/PasswordInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
onClick={() => setShowPassword((prev) => !prev)}
className="text-icon-default"
aria-label={showPassword ? "비밀번호 숨기기" : "비밀번호 보이기"}
tabIndex={-1}
>
{showPassword ? <EyeIcon /> : <EyeOffIcon />}
</button>
Expand Down
17 changes: 17 additions & 0 deletions src/hooks/auth/useResolvedAuthRole.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"use client";

import { loadRole, type AuthRole } from "@/lib/auth/role";
import { useAuthStore } from "@/stores/useAuthStore";

/**
* Header·가드 공통 역할 힌트.
* - hydrate / checkAuth 중: SSR `initialRole` 쿠키 힌트
* - 세션 확정 후: store.user.role → 없으면 loadRole()
*/
export const useResolvedAuthRole = (initialRole: AuthRole | null = null): AuthRole | null => {
const userRole = useAuthStore((state) => state.user?.role);
const hasHydrated = useAuthStore((state) => state.hasHydrated);
const isCheckingAuth = useAuthStore((state) => state.isCheckingAuth);

return userRole ?? (!hasHydrated || isCheckingAuth ? initialRole : loadRole());
};
5 changes: 4 additions & 1 deletion src/lib/auth/redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,10 @@ export const getPostAuthRedirectPath = async (params?: {
}
};

/** 이미 로그인된 채 auth 페이지 재진입 — API 없이 역할 홈 */
/**
* 이미 로그인된 채 auth 페이지 재진입용 동기 fallback (역할 홈).
* 프로필 완료 여부가 필요하면 GuestOnly에서 getPostAuthRedirectPath를 사용한다.
*/
export const getAuthenticatedAuthPageRedirectPath = (role: AuthRole | null | undefined): string => {
return getRoleHomePath(role);
};
Expand Down