[common] feat: useSessionExtendConfirm 생성
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user