[common] feat: useSessionExtendConfirm 생성
This commit is contained in:
@@ -29,4 +29,5 @@ settings.json
|
||||
.agent
|
||||
|
||||
apps/**/tsconfig.tsbuildinfo
|
||||
packages/**/tsconfig.tsbuildinfo
|
||||
logs/
|
||||
|
||||
@@ -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`가 마운트되어 있다.
|
||||
|
||||
@@ -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<void>;
|
||||
silentRefresh(): Promise<void>;
|
||||
declineSessionExtend(): void; // confirm 취소 시 refresh 타이머 해제
|
||||
logout(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
`[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
|
||||
queryClient={queryClient}
|
||||
authService={authService}
|
||||
currentPath={pathname ?? ""}
|
||||
handleMenuClick={handleMenuClick}
|
||||
>
|
||||
```
|
||||
|
||||
`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
|
||||
<ConfirmProvider>
|
||||
<AuthWrapper>
|
||||
{children}
|
||||
<ConfirmDialog />
|
||||
</AuthWrapper>
|
||||
</ConfirmProvider>
|
||||
```
|
||||
|
||||
`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
|
||||
@@ -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;
|
||||
```
|
||||
|
||||
### 쿠키 유틸리티
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface AuthPayload {
|
||||
export interface AuthService {
|
||||
login(payload: AuthPayload): Promise<void>;
|
||||
silentRefresh(): Promise<void>;
|
||||
declineSessionExtend(): void;
|
||||
logout(): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./cookie";
|
||||
export * from "./utils";
|
||||
export * from "./use-mobile";
|
||||
export * from "./use-session-extend-confirm";
|
||||
|
||||
@@ -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<void>;
|
||||
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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const dismissedTokenRef = useRef<string | null>(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 };
|
||||
@@ -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<HTMLImageElement>(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({
|
||||
<i className="diveicon di-chevron-down static p-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-48 border-border p-1">
|
||||
<ul>
|
||||
<li className="cursor-pointer rounded-sm p-3 text-xl hover:bg-accent">
|
||||
<NoticePopover />
|
||||
</li>
|
||||
<li
|
||||
onClick={handleLogout}
|
||||
className="cursor-pointer rounded-sm p-3 text-xl hover:bg-accent"
|
||||
>
|
||||
로그아웃
|
||||
</li>
|
||||
</ul>
|
||||
<PopoverContent align="end" className="w-48 border-border p-1.5">
|
||||
<UserMenuContent
|
||||
remainingMs={remainingMs}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
@@ -337,18 +375,11 @@ export function Header({
|
||||
<i className="diveicon di-chevron-down static p-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-48 border-border p-1">
|
||||
<ul>
|
||||
<li className="cursor-pointer rounded-sm p-3 text-xl hover:bg-accent">
|
||||
<NoticePopover />
|
||||
</li>
|
||||
<li
|
||||
onClick={handleLogout}
|
||||
className="cursor-pointer rounded-sm p-3 text-xl hover:bg-accent"
|
||||
>
|
||||
로그아웃
|
||||
</li>
|
||||
</ul>
|
||||
<PopoverContent align="end" className="w-48 border-border p-1.5">
|
||||
<UserMenuContent
|
||||
remainingMs={remainingMs}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
@@ -359,3 +390,31 @@ export function Header({
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenuContent({
|
||||
remainingMs,
|
||||
onLogout,
|
||||
}: {
|
||||
remainingMs: number;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ul>
|
||||
<li className="rounded-sm px-3 py-2 text-sm text-muted-foreground">
|
||||
<span className="block text-sm">로그인 만료까지</span>
|
||||
<span className="block text-lg font-medium text-foreground">
|
||||
{formatRemainingMs(remainingMs)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="cursor-pointer rounded-sm px-3 py-2 text-xl hover:bg-accent">
|
||||
<NoticePopover />
|
||||
</li>
|
||||
<li
|
||||
onClick={onLogout}
|
||||
className="cursor-pointer rounded-sm px-3 py-2 text-xl hover:bg-accent"
|
||||
>
|
||||
로그아웃
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+8
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user