From eb84a23d7ed5eb9d54dd13bcb7bf237bdd9bacb5 Mon Sep 17 00:00:00 2001 From: bicochan Date: Wed, 8 Jul 2026 15:43:42 +0900 Subject: [PATCH] =?UTF-8?q?[common]=20feat:=20useSessionExtendConfirm=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + docs/auth-service-refactoring-guide.md | 18 +- docs/session-extend-confirm-guide.md | 245 ++++++++++++++++++ packages/common/auth/README.md | 15 +- packages/common/auth/index.ts | 7 +- packages/common/auth/jwt-utils.ts | 14 +- packages/common/auth/token-manager.ts | 6 +- packages/common/auth/types.ts | 1 + packages/common/components/src/lib/index.ts | 1 + .../src/lib/use-session-extend-confirm.ts | 95 +++++++ .../common/components/src/ui/header/index.tsx | 107 ++++++-- pnpm-lock.yaml | 13 +- 12 files changed, 478 insertions(+), 45 deletions(-) create mode 100644 docs/session-extend-confirm-guide.md create mode 100644 packages/common/components/src/lib/use-session-extend-confirm.ts diff --git a/.gitignore b/.gitignore index ee2cfd052..871b209f0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,5 @@ settings.json .agent apps/**/tsconfig.tsbuildinfo +packages/**/tsconfig.tsbuildinfo logs/ diff --git a/docs/auth-service-refactoring-guide.md b/docs/auth-service-refactoring-guide.md index 8f06974e8..bc092147d 100644 --- a/docs/auth-service-refactoring-guide.md +++ b/docs/auth-service-refactoring-guide.md @@ -29,10 +29,8 @@ 예시 코드: ```ts -export const CLAIM_NAME = - process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; -export const AUTH_USER_ID = - process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; +export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; +export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; export const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token"; export const REFRESH_TOKEN = @@ -82,6 +80,7 @@ export const { setToken, removeToken, setupRefreshTimer, + clearRefreshTimer, clearAuthToken, getAccessToken, getRefreshToken, @@ -175,3 +174,14 @@ export { tokenManager } from "./lib/token-manager"; - 로그인/갱신 로직이 `getRemainingMs` 기반 타이머를 사용한다. - BaseLayout 컴포넌트에 `tokenManager`가 주입되어 있다. - 외부 참조 경로가 `entities/auth` 배럴 경로로 통일되어 있다. + +## 5) 세션 연장 Confirm 적용 (후속 단계) + +리팩토링이 완료된 서비스(`authService` + `AuthWrapper` + `ConfirmProvider`)는 [세션 연장 Confirm 적용 가이드](./session-extend-confirm-guide.md)를 따라 JWT 만료 5분 전 confirm UX를 추가한다. + +추가 체크 항목: + +- `token-manager`에서 `clearRefreshTimer`를 re-export한다. +- `AuthWrapper`에 `useSessionExtendConfirm`을 연결한다. +- `clearRefreshTimer`는 앱 `token-manager` 싱글톤을 통해 전달한다 (`authService`의 `setupRefreshTimer`와 동일 인스턴스). +- `ConfirmProvider` 하위에 `AuthWrapper`와 `ConfirmDialog`가 마운트되어 있다. diff --git a/docs/session-extend-confirm-guide.md b/docs/session-extend-confirm-guide.md new file mode 100644 index 000000000..0668f1f3d --- /dev/null +++ b/docs/session-extend-confirm-guide.md @@ -0,0 +1,245 @@ +# 세션 연장 Confirm 적용 가이드 + +## 1) 문서 목적 + +JWT 만료 **5분 전**에 `"로그인을 연장하시겠습니까?"` confirm 창을 띄우고, 사용자 선택에 따라 세션을 연장하거나 자동 갱신을 중단하는 기능을 서비스 앱에 적용하기 위한 가이드입니다. + +이 문서는 [서비스별 인증 처리 리팩토링 가이드](./auth-service-refactoring-guide.md)의 **후속 단계**로 읽습니다. +`@common/auth` 기반 `authService` + `token-manager` + `AuthWrapper` 패턴이 먼저 갖춰져 있어야 합니다. + +## 2) 선행 조건 + +아래 항목이 완료된 서비스만 이 가이드를 적용합니다. + +| 항목 | 확인 | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| `createTokenManager(authConfig)` 사용 | [3-3. 토큰 매니저 공통화](./auth-service-refactoring-guide.md) | +| `authService.login` / `silentRefresh`에서 `getRemainingMs` + `setupRefreshTimer` | [3-4. 로그인 성공 처리 표준화](./auth-service-refactoring-guide.md) | +| `authService.declineSessionExtend` 구현 | confirm 취소 시 refresh 타이머 해제 | +| `AuthWrapper`에서 새로고침 시 refresh 타이머 복구 | cs / as / st 참고 | +| `ConfirmProvider` + `ConfirmDialog` 마운트 | AppProvider | +| `ConfirmProvider` **하위**에 `BaseLayout` → `Header` 렌더링 | `useConfirm` 사용 조건 | +| `BaseLayout`에 `authService` prop 전달 | Header까지 전달됨 | + +**적용 완료 앱**: `cs`, `as`, `st` +**미적용**: `wr`, `sn`, `fo` 등 legacy `LoginApi` 패턴 앱 + +## 3) 전체 흐름 + +로그인 이후 JWT `exp`를 기준으로 **두 개의 독립 타이머**가 동작합니다. + +```mermaid +sequenceDiagram + participant Login as authService.login + participant RefreshTimer as refreshTimer + participant Header as Header + participant User as 사용자 + participant API as authService.silentRefresh + + Login->>RefreshTimer: setupRefreshTimer(exp - 1분) + Header->>Header: confirm 예약(exp - 5분) + Note over Header: 만료 5분 전 + Header->>User: 로그인을 연장하시겠습니까? + alt 확인 + User->>API: silentRefresh() + API->>RefreshTimer: 새 토큰 기준 타이머 재설정 + Header->>Header: confirm 타이머 재설정 + else 취소 + Header->>API: declineSessionExtend() + API->>RefreshTimer: clearRefreshTimer() + Note over RefreshTimer: 만료까지 자동 갱신 없음 + end +``` + +| 시점 | 동작 | 담당 | +| --------------------------- | ------------------------------------- | ------------------------------------------- | +| 로그인 / silentRefresh 성공 | 만료 1분 전 자동 `silentRefresh` 예약 | `authService` → `setupRefreshTimer` | +| 만료 5분 전 | confirm 표시 | `Header` → `useSessionExtendConfirm` | +| confirm **확인** | 즉시 `silentRefresh` + 타이머 재설정 | Header → `authService.silentRefresh` | +| confirm **취소** | 자동 `silentRefresh` 타이머 해제 | Header → `authService.declineSessionExtend` | + +### 3-1. 시간 계산 (`getRemainingMs`) + +`[packages/common/auth/jwt-utils.ts](../packages/common/auth/jwt-utils.ts)` + +```typescript +export const REFRESH_BUFFER_MS = 60_000; // silent refresh: 만료 1분 전 +export const SESSION_EXTEND_CONFIRM_BUFFER_MS = 5 * 60_000; // confirm: 만료 5분 전 + +getRemainingMs(token); // → refresh 타이머 delay +getRemainingMs(token, SESSION_EXTEND_CONFIRM_BUFFER_MS); // → confirm 타이머 delay +getRemainingMs(token, 0) > 0; // → 토큰 미만료 여부 +``` + +`bufferMs` 인자 하나로 용도를 구분합니다. 기존 `getRemainingMs(token)` 호출부는 변경하지 않아도 됩니다. + +## 4) 공통 모듈 변경 사항 + +### 4-1. `@common/auth` — `AuthService` / `clearRefreshTimer` + +`[packages/common/auth/types.ts](../packages/common/auth/types.ts)` + +```typescript +export interface AuthService { + login(payload: AuthPayload): Promise; + silentRefresh(): Promise; + declineSessionExtend(): void; // confirm 취소 시 refresh 타이머 해제 + logout(): Promise; +} +``` + +`[packages/common/auth/token-manager.ts](../packages/common/auth/token-manager.ts)` + +- `setupRefreshTimer`: 만료 1분 전 `silentRefresh` 예약 (기존) +- `clearRefreshTimer`: 예약된 refresh 타이머 해제 (신규) +- `clearAuthToken`: 내부에서 `clearRefreshTimer` 호출 + +`refreshTimer`는 `createTokenManager()` 클로저 안의 **앱당 싱글톤** 변수입니다. +`authService.declineSessionExtend()`가 내부에서 `clearRefreshTimer()`를 호출하므로, Header는 token-manager를 직접 import하지 않습니다. + +### 4-2. `@common/components` — Header + `useSessionExtendConfirm` + +`[packages/common/components/src/ui/header/index.tsx](../packages/common/components/src/ui/header/index.tsx)`에서 훅을 실행합니다. + +```typescript +useSessionExtendConfirm({ + enabled: + mounted && + isLoggedIn && + !viewOnly && + !!authService && + !isAtLoginRedirectUrl(getAuthLoginRedirectUrl()), + getAccessToken: () => getCookie(appConfig.CLAIM_NAME ?? ""), + silentRefresh: authService.silentRefresh, + clearRefreshTimer: authService.declineSessionExtend, +}); +``` + +| enabled 조건 | 이유 | +| ---------------------------- | ------------------------------- | +| `mounted` | hydration 후 타이머 시작 | +| `isLoggedIn` | 비로그인 제외 | +| `!viewOnly` | `VBaseLayout` 등 조회 전용 제외 | +| `!!authService` | legacy 앱 자동 비활성 | +| `!isAtLoginRedirectUrl(...)` | 로그인 페이지 제외 | + +세션 만료 **카운트다운 UI**와 confirm 로직이 Header에 응집됩니다. + +## 5) 서비스별 적용 단계 + +### 5-1. `auth-service`에 `declineSessionExtend` 추가 + +`[apps/cs/src/entities/auth/api/auth-service.ts](../apps/cs/src/entities/auth/api/auth-service.ts)` 참고: + +```typescript +import { clearRefreshTimer } from "../lib/token-manager"; + +function declineSessionExtend(): void { + clearRefreshTimer(); +} + +export const authService: AuthService = { + login, + silentRefresh, + declineSessionExtend, + logout, +}; +``` + +### 5-2. `BaseLayout`에 `authService` 전달 + +`[apps/cs/app/(main)/(with-base-layout)/layout.tsx](../apps/cs/app/(main)`/(with-base-layout)/layout.tsx) 참고: + +```tsx + +``` + +`BaseLayout`은 `authService`를 `Header`에 그대로 전달합니다. **AuthWrapper에서 훅을 연결하지 않습니다.** + +### 5-3. `AuthWrapper` — refresh 타이머 복구만 유지 + +`[apps/cs/src/entities/auth/lib/auth-wrapper.tsx](../apps/cs/src/entities/auth/lib/auth-wrapper.tsx)`: + +- `useSessionExtendConfirm` **사용하지 않음** +- 페이지 새로고침 시 `setupRefreshTimer(authService.silentRefresh, remainingMs)` 복구 유지 +- 인증 체크, `auth:logout` 수신 유지 + +### 5-4. `AppProvider` 구조 확인 + +`[apps/cs/src/app/providers/app-provider.tsx](../apps/cs/src/app/providers/app-provider.tsx)` 참고: + +```tsx + + + {children} + + + +``` + +`Header`는 `ConfirmProvider` 하위에서 렌더링되므로 `useConfirm()`이 동작합니다. + +## 6) 책임 분리 요약 + +| 레이어 | 파일 | 역할 | +| ------------------ | ------------------------------------ | -------------------------------------------------- | +| JWT 시간 계산 | `@common/auth` `jwt-utils.ts` | `getRemainingMs`, 버퍼 상수 | +| refresh 타이머 | `@common/auth` `token-manager.ts` | `setupRefreshTimer`, `clearRefreshTimer` | +| API 오케스트레이션 | `entities/auth/api/auth-service.ts` | login, silentRefresh, declineSessionExtend, logout | +| confirm UX | `@common/components` `Header` | 카운트다운 UI + 5분 전 confirm | +| 앱 진입점 | `entities/auth/lib/auth-wrapper.tsx` | 타이머 복구, auth 체크 | +| 레이아웃 | `app/.../layout.tsx` | `authService` → `BaseLayout` → `Header` | +| UI 쉘 | `app/providers/app-provider.tsx` | ConfirmProvider, ConfirmDialog | + +## 7) 엣지 케이스 + +| 상황 | 동작 | +| -------------------- | --------------------------------------------------------------- | +| 토큰 유효 시간 < 5분 | `confirmMs === 0`이면 페이지 진입 즉시 confirm | +| confirm **취소** | `declineSessionExtend()` + 동일 JWT에 재표시 안 함 | +| confirm **확인** | `silentRefresh()` → 새 토큰 기준 confirm/refresh 타이머 재설정 | +| `silentRefresh` 실패 | `authService`가 `auth:logout` 발행 → AuthWrapper 리다이렉트 | +| 로그아웃 | `clearAuthToken`이 refresh 타이머 정리, 훅 cleanup | +| `viewOnly` Header | confirm 비활성 | +| `authService` 미전달 | confirm 비활성 (wr/fo 등) | +| 페이지 새로고침 | AuthWrapper가 refresh 타이머 복구, Header가 confirm 타이머 복구 | + +## 8) 적용 체크리스트 + +[리팩토링 가이드 체크리스트](./auth-service-refactoring-guide.md#4-서비스별-적용-체크리스트)에 더해 아래를 확인합니다. + +- [ ] `auth-service`에 `declineSessionExtend` 구현 +- [ ] `token-manager`에서 `clearRefreshTimer` re-export +- [ ] `BaseLayout`에 `authService` prop 전달 (→ Header까지) +- [ ] `ConfirmProvider` > `AuthWrapper` > `BaseLayout` > `Header` 계층 확인 +- [ ] `AuthWrapper`에 `useSessionExtendConfirm` **없음** 확인 +- [ ] 만료 5분 전 confirm 노출 확인 +- [ ] 확인 클릭 시 `SILENT_REFRESH` API 호출 확인 +- [ ] 취소 클릭 시 만료 1분 전 자동 refresh 미발생 확인 + +## 9) 검증 방법 + +1. 로그인 후 JWT `exp`가 5분 이내가 되도록 대기하거나 테스트 토큰 사용 +2. confirm 문구 `"로그인을 연장하시겠습니까?"` 표시 확인 +3. **확인** → Network 탭에서 `SILENT_REFRESH` PUT 호출, 쿠키 갱신, confirm 재예약 +4. **취소** → 만료 1분 전까지 `SILENT_REFRESH` 자동 호출 없음 +5. 취소 후 세션 만료 → 401 / 로그인 리다이렉트 +6. 새로고침 후 refresh·confirm 타이머 복구 + +## 10) 참고 구현 + +| 앱 | Header (공통) | auth-service | BaseLayout layout | +| --- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| cs | `[packages/common/components/src/ui/header/index.tsx](../packages/common/components/src/ui/header/index.tsx)` | `[apps/cs/src/entities/auth/api/auth-service.ts](../apps/cs/src/entities/auth/api/auth-service.ts)` | `[apps/cs/app/(main)/(with-base-layout)/layout.tsx](../apps/cs/app/(main)`/(with-base-layout)/layout.tsx) | +| as | (동일) | `[apps/as/src/entities/auth/api/auth-service.ts](../apps/as/src/entities/auth/api/auth-service.ts)` | `[apps/as/src/app/layouts/base-layout.tsx](../apps/as/src/app/layouts/base-layout.tsx)` | +| st | (동일) | `[apps/st/src/entities/auth/api/auth-service.ts](../apps/st/src/entities/auth/api/auth-service.ts)` | `[apps/st/app/(main)/(with-base-layout)/layout.tsx](../apps/st/app/(main)`/(with-base-layout)/layout.tsx) | + +## 11) 관련 문서 + +- [서비스별 인증 처리 리팩토링 가이드](./auth-service-refactoring-guide.md) — 선행 리팩토링 +- `[packages/common/auth/README.md](../packages/common/auth/README.md)` — `getRemainingMs`, token-manager API diff --git a/packages/common/auth/README.md b/packages/common/auth/README.md index 5cd54d787..fbc8742c5 100644 --- a/packages/common/auth/README.md +++ b/packages/common/auth/README.md @@ -200,17 +200,20 @@ if (expiry) { } ``` -#### `getRemainingMs(token: string): number` +#### `getRemainingMs(token: string, bufferMs?: number): number` -토큰 만료 1분 전까지 남은 시간(밀리초)을 반환합니다. +토큰 만료 `bufferMs` 전까지 남은 시간(밀리초)을 반환합니다. 기본 `bufferMs`는 `REFRESH_BUFFER_MS`(1분)입니다. ```typescript -import { getRemainingMs } from "@common/auth"; +import { + getRemainingMs, + REFRESH_BUFFER_MS, + SESSION_EXTEND_CONFIRM_BUFFER_MS, +} from "@common/auth"; const remaining = getRemainingMs(token); -if (remaining > 0) { - console.log("Token valid for:", remaining, "ms"); -} +const confirmIn = getRemainingMs(token, SESSION_EXTEND_CONFIRM_BUFFER_MS); +const isValid = getRemainingMs(token, 0) > 0; ``` ### 쿠키 유틸리티 diff --git a/packages/common/auth/index.ts b/packages/common/auth/index.ts index e069f7c6c..a3d7024da 100644 --- a/packages/common/auth/index.ts +++ b/packages/common/auth/index.ts @@ -2,7 +2,12 @@ export { createTokenManager } from "./token-manager"; export type { TokenManager } from "./token-manager"; export { getCookie, setCookie, removeCookie } from "./cookie"; -export { getJwtExpiry, getRemainingMs } from "./jwt-utils"; +export { + getJwtExpiry, + getRemainingMs, + REFRESH_BUFFER_MS, + SESSION_EXTEND_CONFIRM_BUFFER_MS, +} from "./jwt-utils"; export type { AuthConfig, diff --git a/packages/common/auth/jwt-utils.ts b/packages/common/auth/jwt-utils.ts index 05d2b0ff8..d75acfbd2 100644 --- a/packages/common/auth/jwt-utils.ts +++ b/packages/common/auth/jwt-utils.ts @@ -19,14 +19,20 @@ export function getJwtExpiry(token: string): number | null { } } +export const REFRESH_BUFFER_MS = 60_000; +export const SESSION_EXTEND_CONFIRM_BUFFER_MS = 5 * 60_000; + /** - * 토큰 만료 1분 전까지 남은 시간(ms)을 반환합니다. - * 이미 만료됐거나 파싱 실패 시 0을 반환합니다. + * 토큰 만료 bufferMs 전까지 남은 시간(ms)을 반환합니다. + * 파싱 실패 시 0. bufferMs 기본값은 silent refresh용 1분. */ -export function getRemainingMs(token: string): number { +export function getRemainingMs( + token: string, + bufferMs: number = REFRESH_BUFFER_MS, +): number { const exp = getJwtExpiry(token); if (!exp) return 0; - const remainingMs = exp * 1000 - Date.now() - 60_000; + const remainingMs = exp * 1000 - Date.now() - bufferMs; return Math.max(0, remainingMs); } diff --git a/packages/common/auth/token-manager.ts b/packages/common/auth/token-manager.ts index fed36c366..6347efcb4 100644 --- a/packages/common/auth/token-manager.ts +++ b/packages/common/auth/token-manager.ts @@ -47,11 +47,15 @@ export function createTokenManager(config: AuthConfig) { }, delayMs); }, - clearAuthToken(): void { + clearRefreshTimer(): void { if (refreshTimer) { clearTimeout(refreshTimer); refreshTimer = null; } + }, + + clearAuthToken(): void { + this.clearRefreshTimer(); this.removeToken(config.claimName); this.removeToken(config.authUserId); diff --git a/packages/common/auth/types.ts b/packages/common/auth/types.ts index 292c8edd7..424ed3db0 100644 --- a/packages/common/auth/types.ts +++ b/packages/common/auth/types.ts @@ -6,6 +6,7 @@ export interface AuthPayload { export interface AuthService { login(payload: AuthPayload): Promise; silentRefresh(): Promise; + declineSessionExtend(): void; logout(): Promise; } diff --git a/packages/common/components/src/lib/index.ts b/packages/common/components/src/lib/index.ts index e00099877..7e6c4a74b 100644 --- a/packages/common/components/src/lib/index.ts +++ b/packages/common/components/src/lib/index.ts @@ -1,3 +1,4 @@ export * from "./cookie"; export * from "./utils"; export * from "./use-mobile"; +export * from "./use-session-extend-confirm"; diff --git a/packages/common/components/src/lib/use-session-extend-confirm.ts b/packages/common/components/src/lib/use-session-extend-confirm.ts new file mode 100644 index 000000000..30c08584a --- /dev/null +++ b/packages/common/components/src/lib/use-session-extend-confirm.ts @@ -0,0 +1,95 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { getRemainingMs, SESSION_EXTEND_CONFIRM_BUFFER_MS } from "@common/auth"; + +interface UseSessionExtendConfirmOptions { + enabled: boolean; + getAccessToken: () => string | undefined; + silentRefresh: () => Promise; + declineSessionExtend: () => void; + message?: string; + bufferMs?: number; +} + +const DEFAULT_MESSAGE = "로그인을 연장하시겠습니까?"; + +function useSessionExtendConfirm({ + enabled, + getAccessToken, + silentRefresh, + declineSessionExtend, + message = DEFAULT_MESSAGE, +}: UseSessionExtendConfirmOptions) { + const { confirm } = useConfirm(); + const confirmTimerRef = useRef | null>(null); + const dismissedTokenRef = useRef(null); + const isConfirmingRef = useRef(false); + + const scheduleConfirm = useCallback(() => { + if (confirmTimerRef.current) { + clearTimeout(confirmTimerRef.current); + confirmTimerRef.current = null; + } + + const token = getAccessToken(); + if (!token) return; + + if (dismissedTokenRef.current === token) return; + + const confirmMs = getRemainingMs(token, SESSION_EXTEND_CONFIRM_BUFFER_MS); + const isTokenValid = getRemainingMs(token, 0) > 0; + + const showConfirm = async () => { + if (isConfirmingRef.current) return; + + const currentToken = getAccessToken(); + if (!currentToken || getRemainingMs(currentToken, 0) <= 0) return; + if (dismissedTokenRef.current === currentToken) return; + + isConfirmingRef.current = true; + try { + const accepted = await confirm(message); + if (accepted) { + dismissedTokenRef.current = null; + await silentRefresh(); + scheduleConfirm(); + return; + } + + declineSessionExtend(); + dismissedTokenRef.current = currentToken; + } finally { + isConfirmingRef.current = false; + } + }; + + if (!isTokenValid) return; + + if (confirmMs <= 0) { + void showConfirm(); + return; + } + + confirmTimerRef.current = setTimeout(() => { + void showConfirm(); + }, confirmMs); + }, [message, confirm, getAccessToken, silentRefresh, declineSessionExtend]); + + useEffect(() => { + if (!enabled) return; + + scheduleConfirm(); + + return () => { + if (confirmTimerRef.current) { + clearTimeout(confirmTimerRef.current); + confirmTimerRef.current = null; + } + }; + }, [enabled, scheduleConfirm]); +} + +export { useSessionExtendConfirm }; +export type { UseSessionExtendConfirmOptions }; diff --git a/packages/common/components/src/ui/header/index.tsx b/packages/common/components/src/ui/header/index.tsx index b303b5e9f..99b7977cd 100644 --- a/packages/common/components/src/ui/header/index.tsx +++ b/packages/common/components/src/ui/header/index.tsx @@ -4,9 +4,12 @@ import { useEffect, useCallback, useState, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import { images } from "@common/assets"; import type { AuthService, TokenManager } from "@common/auth"; +import { getRemainingMs } from "@common/auth"; import { getConfig } from "@common/config"; +import { getAuthLoginRedirectUrl, isAtLoginRedirectUrl } from "@common/network"; import { ENDPOINT } from "../../config"; import { setCookie, getCookie } from "../../lib"; +import { useSessionExtendConfirm } from "../../lib/use-session-extend-confirm"; import { getPopupInfo, getUserPopupList, @@ -97,6 +100,35 @@ async function legacyLogout(tokenManager?: TokenManager) { } } +function formatRemainingMs(remainingMs: number): string { + if (remainingMs <= 0) return "만료됨"; + + const totalSeconds = Math.floor(remainingMs / 1000); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + return `${minutes}분 ${seconds}초`; +} + +function useTokenRemainingMs(enabled: boolean): number { + const [remainingMs, setRemainingMs] = useState(0); + + useEffect(() => { + if (!enabled) return; + + const update = () => { + const token = getCookie(appConfig.CLAIM_NAME ?? ""); + setRemainingMs(token ? getRemainingMs(token) : 0); + }; + + update(); + const id = setInterval(update, 1000); + return () => clearInterval(id); + }, [enabled]); + + return remainingMs; +} + export function Header({ authService, tokenManager, @@ -111,6 +143,19 @@ export function Header({ const [logoWidth, setLogoWidth] = useState(0); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const logoRef = useRef(null); + const remainingMs = useTokenRemainingMs(mounted && isLoggedIn && !viewOnly); + + useSessionExtendConfirm({ + enabled: + mounted && + isLoggedIn && + !viewOnly && + !!authService && + !isAtLoginRedirectUrl(getAuthLoginRedirectUrl()), + getAccessToken: () => getCookie(appConfig.CLAIM_NAME ?? ""), + silentRefresh: authService?.silentRefresh ?? (async () => {}), + declineSessionExtend: authService?.declineSessionExtend ?? (() => {}), + }); const { data: userInfo } = useQuery({ queryKey: [ENDPOINT.USER_SERVICE.USERS], @@ -261,18 +306,11 @@ export function Header({ - -
    -
  • - -
  • -
  • - 로그아웃 -
  • -
+ + )} @@ -337,18 +375,11 @@ export function Header({ - -
    -
  • - -
  • -
  • - 로그아웃 -
  • -
+ + ) @@ -359,3 +390,31 @@ export function Header({ ); } + +function UserMenuContent({ + remainingMs, + onLogout, +}: { + remainingMs: number; + onLogout: () => void; +}) { + return ( +
    +
  • + 로그인 만료까지 + + {formatRemainingMs(remainingMs)} + +
  • +
  • + +
  • +
  • + 로그아웃 +
  • +
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d5dea9d4..d914bd2bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,10 +104,10 @@ importers: devDependencies: eslint: specifier: ^9.0.0 - version: 9.39.4(jiti@2.6.1) + version: 9.39.4(jiti@1.21.7) eslint-config-next: specifier: 15.3.8 - version: 15.3.8(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 15.3.8(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) postcss: specifier: ^8.4.49 version: 8.5.8 @@ -204,13 +204,13 @@ importers: version: 3.0.6 eslint: specifier: ^9.0.0 - version: 9.39.4(jiti@1.21.7) + version: 9.39.4(jiti@2.6.1) eslint-config-next: specifier: 15.3.8 - version: 15.3.8(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + version: 15.3.8(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-react-hooks: specifier: ^5.0.0 - version: 5.2.0(eslint@9.39.4(jiti@1.21.7)) + version: 5.2.0(eslint@9.39.4(jiti@2.6.1)) postcss: specifier: ^8.4.49 version: 8.5.8 @@ -531,6 +531,9 @@ importers: '@common/config': specifier: workspace:* version: link:../../packages/common/config + '@common/network': + specifier: workspace:* + version: link:../../packages/common/network '@fontsource-variable/noto-sans-kr': specifier: ^5.2.5 version: 5.2.10