Merge branch 'iipbs/wr/dev'
This commit is contained in:
@@ -1,220 +1,222 @@
|
||||
/**
|
||||
* JWT를 decoding하면 만료시간을 읽을 수 있다.
|
||||
* JWT는 3부분으로 구성 : Header.Payload.Signature
|
||||
* Payload 부분에 만료시간(exp)이 있다.
|
||||
* 티베로(하수도정보시스템) 조회 콘솔
|
||||
*
|
||||
* - reuse-service 의 티베로 데이터소스로 조회 SQL 한 문장(SELECT, WITH)을 실행하고 결과를 표로 보여준다.
|
||||
* - 서버에서 조회 문장만 통과시키고, 읽기 전용 연결과 최대 행 수(기본 100, 최대 1000) 제한을 적용한다.
|
||||
* - 서버 설정 tibero.sql-console.enabled=true 일 때만 동작한다.
|
||||
*
|
||||
* 🔗 사용 API
|
||||
* 티베로 조회 SQL 실행 POST /ones/sql body: { sql, maxRows }
|
||||
*
|
||||
* 이전 JWT 만료시간 테스트 화면은 같은 폴더의 page.tsx.bak 에 보관되어 있다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { authService } from "@/src/entities/auth";
|
||||
import { CLAIM_NAME } from "@/src/shared/config";
|
||||
import { getCookie } from "@/src/shared/lib";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { fetchType, postFectch } from "@/components/lib/api";
|
||||
|
||||
interface JwtPayload {
|
||||
sub?: string; // 토큰 subject. 일반적으로 사용자 식별자나 주체 정보
|
||||
exp?: number; // 만료 시각(Unix timestamp, 초 단위)
|
||||
iat?: number; // 토큰 발급 시각(Unix timestamp, 초 단위)
|
||||
[key: string]: unknown; // 그 외 JWT payload에 포함될 수 있는 추가 클레임
|
||||
}
|
||||
|
||||
interface JwtState {
|
||||
token: string; // 쿠키에서 읽어온 JWT 원본 문자열
|
||||
payload: JwtPayload | null; // JWT를 파싱한 payload 객체
|
||||
exp: number | null; // payload에서 추출한 exp 값
|
||||
expDateLocal: string | null; // exp를 로컬 시간 문자열로 변환한 값
|
||||
remainingMs: number | null; // 현재 시각 기준 남은 시간(ms)
|
||||
error: string | null; // 쿠키 없음, 파싱 실패 등 오류 메시지
|
||||
}
|
||||
|
||||
const initialJwtState: JwtState = {
|
||||
token: "",
|
||||
payload: null,
|
||||
exp: null,
|
||||
expDateLocal: null,
|
||||
remainingMs: null,
|
||||
error: null,
|
||||
type TiberoSqlResult = {
|
||||
success: boolean;
|
||||
message: string | null;
|
||||
columns: string[];
|
||||
rows: (string | null)[][];
|
||||
rowCount: number;
|
||||
truncated: boolean;
|
||||
maxRows: number;
|
||||
elapsedMs: number;
|
||||
};
|
||||
|
||||
function parseJwt(token: string): JwtPayload {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("유효한 JWT 형식이 아닙니다.");
|
||||
}
|
||||
type TiberoSqlResponse = {
|
||||
status: number;
|
||||
data: TiberoSqlResult | null;
|
||||
count: number;
|
||||
msg: string | null;
|
||||
};
|
||||
|
||||
const base64Url = parts[1] ?? ""; // Payload를 가져온다.
|
||||
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(padded)
|
||||
.split("")
|
||||
.map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`)
|
||||
.join("")
|
||||
);
|
||||
const DEFAULT_SQL = `SELECT BSC_FCT_CD, BSC_FCT_NM, RGN_CD, ADST_DSTR_CD, FCT_CPC
|
||||
FROM ONSEMIRO_NEW.OM_MS_VW
|
||||
WHERE ROWNUM <= 20`;
|
||||
|
||||
return JSON.parse(jsonPayload) as JwtPayload;
|
||||
}
|
||||
const MAX_ROWS_LIMIT = 1000;
|
||||
|
||||
function formatRemainingMs(remainingMs: number | null): string {
|
||||
if (remainingMs === null) return "-";
|
||||
if (remainingMs <= 0) return "만료됨";
|
||||
const toTsv = (columns: string[], rows: (string | null)[][]) =>
|
||||
[columns, ...rows]
|
||||
.map((row) => row.map((value) => (value ?? "").replace(/[\t\r\n]+/g, " ")).join("\t"))
|
||||
.join("\n");
|
||||
|
||||
const totalSeconds = Math.floor(remainingMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
export default function TiberoSqlConsolePage() {
|
||||
const [sql, setSql] = useState<string>(DEFAULT_SQL);
|
||||
const [maxRows, setMaxRows] = useState<number>(100);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [result, setResult] = useState<TiberoSqlResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
|
||||
return `${hours}시간 ${minutes}분 ${seconds}초`;
|
||||
}
|
||||
|
||||
function readJwtState(): JwtState {
|
||||
const token = getCookie(CLAIM_NAME) ?? "";
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
token: "",
|
||||
payload: null,
|
||||
exp: null,
|
||||
expDateLocal: null,
|
||||
remainingMs: null,
|
||||
error: `${CLAIM_NAME} 쿠키가 없습니다.`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = parseJwt(token);
|
||||
|
||||
if (typeof payload.exp !== "number") {
|
||||
return {
|
||||
token,
|
||||
payload,
|
||||
exp: null,
|
||||
expDateLocal: null,
|
||||
remainingMs: null,
|
||||
error: "JWT payload에 exp 값이 없습니다.",
|
||||
};
|
||||
const runQuery = useCallback(async () => {
|
||||
if (loading) return;
|
||||
if (!sql.trim()) {
|
||||
setError("SQL을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const expMs = payload.exp * 1000;
|
||||
|
||||
return {
|
||||
token,
|
||||
payload,
|
||||
exp: payload.exp,
|
||||
expDateLocal: new Date(expMs).toLocaleString(),
|
||||
remainingMs: Math.max(expMs - Date.now(), 0),
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
token,
|
||||
payload: null,
|
||||
exp: null,
|
||||
expDateLocal: null,
|
||||
remainingMs: null,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "JWT 파싱 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [jwtState, setJwtState] = useState<JwtState>(initialJwtState);
|
||||
const isRefreshingRef = useRef(false);
|
||||
|
||||
const handleSilentRefresh = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCopied(false);
|
||||
try {
|
||||
await authService.silentRefresh();
|
||||
} catch (error) {
|
||||
console.error("Manual silent refresh failed:", error);
|
||||
const response = (await postFectch(
|
||||
"/ones/sql",
|
||||
{ sql, maxRows },
|
||||
fetchType.WR
|
||||
)) as TiberoSqlResponse;
|
||||
const data = response?.data ?? null;
|
||||
setResult(data);
|
||||
if (!data || !data.success) {
|
||||
setError(response?.msg || data?.message || "조회에 실패했습니다.");
|
||||
}
|
||||
} catch (e) {
|
||||
setResult(null);
|
||||
setError(e instanceof Error ? e.message : "요청 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setJwtState(readJwtState());
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [loading, sql, maxRows]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncJwtState = async () => {
|
||||
const nextState = readJwtState();
|
||||
const copyTsv = useCallback(async () => {
|
||||
if (!result?.success) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(toTsv(result.columns, result.rows));
|
||||
setCopied(true);
|
||||
} catch {
|
||||
setError("클립보드 복사에 실패했습니다.");
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
if (
|
||||
nextState.remainingMs !== null &&
|
||||
nextState.remainingMs > 0 &&
|
||||
nextState.remainingMs < 5 * 60 * 1000 &&
|
||||
!isRefreshingRef.current
|
||||
) {
|
||||
isRefreshingRef.current = true;
|
||||
try {
|
||||
await authService.silentRefresh();
|
||||
} catch (error) {
|
||||
console.error("JWT test silent refresh failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setJwtState(readJwtState());
|
||||
|
||||
if (
|
||||
nextState.remainingMs === null ||
|
||||
nextState.remainingMs >= 5 * 60 * 1000 ||
|
||||
nextState.remainingMs <= 0
|
||||
) {
|
||||
isRefreshingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
void syncJwtState();
|
||||
const timer = window.setInterval(() => {
|
||||
void syncJwtState();
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
const summary = useMemo(() => {
|
||||
if (!result?.success) return null;
|
||||
return `${result.rowCount.toLocaleString()}행 · ${result.elapsedMs.toLocaleString()}ms · 최대 ${result.maxRows.toLocaleString()}행`;
|
||||
}, [result]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-8 text-base">
|
||||
<h1 className="text-2xl font-bold">JWT Exp Test</h1>
|
||||
<div className="flex h-full flex-col gap-4 p-6 text-base">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="text-2xl font-bold">티베로 조회 콘솔</h1>
|
||||
<span className="text-sm text-gray-500">
|
||||
SELECT, WITH 한 문장만 실행됩니다 · Ctrl+Enter 실행
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||
onClick={() => {
|
||||
void handleSilentRefresh();
|
||||
<textarea
|
||||
className="h-48 w-full resize-y rounded border border-gray-300 p-3 font-mono text-xl focus:border-blue-500 focus:outline-none"
|
||||
value={sql}
|
||||
spellCheck={false}
|
||||
placeholder="SELECT ... FROM ONSEMIRO_NEW.테이블명"
|
||||
onChange={(e) => setSql(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
void runQuery();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Silent Refresh 호출
|
||||
</button>
|
||||
/>
|
||||
|
||||
<div className="rounded border p-4">
|
||||
<div className="font-semibold">쿠키명</div>
|
||||
<div>{CLAIM_NAME}</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
최대 행 수
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_ROWS_LIMIT}
|
||||
className="w-24 rounded border border-gray-300 px-2 py-1 text-right"
|
||||
value={maxRows}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value);
|
||||
if (Number.isFinite(next)) {
|
||||
setMaxRows(Math.min(Math.max(Math.trunc(next), 1), MAX_ROWS_LIMIT));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-blue-600 px-5 py-2 text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
|
||||
disabled={loading}
|
||||
onClick={() => void runQuery()}
|
||||
>
|
||||
{loading ? "실행 중..." : "실행"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-gray-300 px-4 py-2 hover:bg-gray-50 disabled:cursor-not-allowed disabled:text-gray-400"
|
||||
disabled={!result?.success || result.rowCount === 0}
|
||||
onClick={() => void copyTsv()}
|
||||
>
|
||||
{copied ? "복사됨" : "결과 복사(TSV)"}
|
||||
</button>
|
||||
{summary && <span className="text-sm text-gray-600">{summary}</span>}
|
||||
</div>
|
||||
|
||||
<div className="rounded border p-4">
|
||||
<div className="font-semibold">exp</div>
|
||||
<div>{jwtState.exp ?? "-"}</div>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="whitespace-pre-wrap break-all rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{result?.success && result.truncated && (
|
||||
<div className="rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
{result.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded border p-4">
|
||||
<div className="font-semibold">만료시각</div>
|
||||
<div>{jwtState.expDateLocal ?? "-"}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded border p-4">
|
||||
<div className="font-semibold">남은시간</div>
|
||||
<div>{formatRemainingMs(jwtState.remainingMs)}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded border p-4">
|
||||
<div className="font-semibold">상태</div>
|
||||
<div>{jwtState.error ?? "정상"}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded border p-4">
|
||||
<div className="font-semibold">payload</div>
|
||||
<pre className="overflow-auto whitespace-pre-wrap break-all text-sm">
|
||||
{jwtState.payload ? JSON.stringify(jwtState.payload, null, 2) : "-"}
|
||||
</pre>
|
||||
</div>
|
||||
{result?.success && (
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded border border-gray-300">
|
||||
{result.columns.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-500">컬럼이 없습니다.</div>
|
||||
) : (
|
||||
<table className="min-w-full border-collapse text-sm">
|
||||
<thead className="sticky top-0 bg-gray-100">
|
||||
<tr>
|
||||
<th className="border-b border-r border-gray-300 px-2 py-1 text-right text-gray-500">#</th>
|
||||
{result.columns.map((column, index) => (
|
||||
<th
|
||||
key={`${column}-${index}`}
|
||||
className="whitespace-nowrap border-b border-r border-gray-300 px-2 py-1 text-left font-semibold"
|
||||
>
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.rows.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
className="px-3 py-4 text-center text-gray-500"
|
||||
colSpan={result.columns.length + 1}
|
||||
>
|
||||
조회 결과가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
result.rows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="odd:bg-white even:bg-gray-50 hover:bg-blue-50">
|
||||
<td className="border-b border-r border-gray-200 px-2 py-1 text-right text-gray-400">
|
||||
{rowIndex + 1}
|
||||
</td>
|
||||
{row.map((value, colIndex) => (
|
||||
<td
|
||||
key={colIndex}
|
||||
className="max-w-md truncate whitespace-nowrap border-b border-r border-gray-200 px-2 py-1"
|
||||
title={value ?? "NULL"}
|
||||
>
|
||||
{value === null ? <span className="italic text-gray-400">NULL</span> : value}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* JWT를 decoding하면 만료시간을 읽을 수 있다.
|
||||
* JWT는 3부분으로 구성 : Header.Payload.Signature
|
||||
* Payload 부분에 만료시간(exp)이 있다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { authService } from "@/src/entities/auth";
|
||||
import { CLAIM_NAME } from "@/src/shared/config";
|
||||
import { getCookie } from "@/src/shared/lib";
|
||||
|
||||
interface JwtPayload {
|
||||
sub?: string; // 토큰 subject. 일반적으로 사용자 식별자나 주체 정보
|
||||
exp?: number; // 만료 시각(Unix timestamp, 초 단위)
|
||||
iat?: number; // 토큰 발급 시각(Unix timestamp, 초 단위)
|
||||
[key: string]: unknown; // 그 외 JWT payload에 포함될 수 있는 추가 클레임
|
||||
}
|
||||
|
||||
interface JwtState {
|
||||
token: string; // 쿠키에서 읽어온 JWT 원본 문자열
|
||||
payload: JwtPayload | null; // JWT를 파싱한 payload 객체
|
||||
exp: number | null; // payload에서 추출한 exp 값
|
||||
expDateLocal: string | null; // exp를 로컬 시간 문자열로 변환한 값
|
||||
remainingMs: number | null; // 현재 시각 기준 남은 시간(ms)
|
||||
error: string | null; // 쿠키 없음, 파싱 실패 등 오류 메시지
|
||||
}
|
||||
|
||||
const initialJwtState: JwtState = {
|
||||
token: "",
|
||||
payload: null,
|
||||
exp: null,
|
||||
expDateLocal: null,
|
||||
remainingMs: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function parseJwt(token: string): JwtPayload {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("유효한 JWT 형식이 아닙니다.");
|
||||
}
|
||||
|
||||
const base64Url = parts[1] ?? ""; // Payload를 가져온다.
|
||||
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(padded)
|
||||
.split("")
|
||||
.map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`)
|
||||
.join("")
|
||||
);
|
||||
|
||||
return JSON.parse(jsonPayload) as JwtPayload;
|
||||
}
|
||||
|
||||
function formatRemainingMs(remainingMs: number | null): string {
|
||||
if (remainingMs === null) return "-";
|
||||
if (remainingMs <= 0) return "만료됨";
|
||||
|
||||
const totalSeconds = Math.floor(remainingMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
return `${hours}시간 ${minutes}분 ${seconds}초`;
|
||||
}
|
||||
|
||||
function readJwtState(): JwtState {
|
||||
const token = getCookie(CLAIM_NAME) ?? "";
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
token: "",
|
||||
payload: null,
|
||||
exp: null,
|
||||
expDateLocal: null,
|
||||
remainingMs: null,
|
||||
error: `${CLAIM_NAME} 쿠키가 없습니다.`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = parseJwt(token);
|
||||
|
||||
if (typeof payload.exp !== "number") {
|
||||
return {
|
||||
token,
|
||||
payload,
|
||||
exp: null,
|
||||
expDateLocal: null,
|
||||
remainingMs: null,
|
||||
error: "JWT payload에 exp 값이 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
const expMs = payload.exp * 1000;
|
||||
|
||||
return {
|
||||
token,
|
||||
payload,
|
||||
exp: payload.exp,
|
||||
expDateLocal: new Date(expMs).toLocaleString(),
|
||||
remainingMs: Math.max(expMs - Date.now(), 0),
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
token,
|
||||
payload: null,
|
||||
exp: null,
|
||||
expDateLocal: null,
|
||||
remainingMs: null,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "JWT 파싱 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [jwtState, setJwtState] = useState<JwtState>(initialJwtState);
|
||||
const isRefreshingRef = useRef(false);
|
||||
|
||||
const handleSilentRefresh = async () => {
|
||||
try {
|
||||
await authService.silentRefresh();
|
||||
} catch (error) {
|
||||
console.error("Manual silent refresh failed:", error);
|
||||
} finally {
|
||||
setJwtState(readJwtState());
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const syncJwtState = async () => {
|
||||
const nextState = readJwtState();
|
||||
|
||||
if (
|
||||
nextState.remainingMs !== null &&
|
||||
nextState.remainingMs > 0 &&
|
||||
nextState.remainingMs < 5 * 60 * 1000 &&
|
||||
!isRefreshingRef.current
|
||||
) {
|
||||
isRefreshingRef.current = true;
|
||||
try {
|
||||
await authService.silentRefresh();
|
||||
} catch (error) {
|
||||
console.error("JWT test silent refresh failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setJwtState(readJwtState());
|
||||
|
||||
if (
|
||||
nextState.remainingMs === null ||
|
||||
nextState.remainingMs >= 5 * 60 * 1000 ||
|
||||
nextState.remainingMs <= 0
|
||||
) {
|
||||
isRefreshingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
void syncJwtState();
|
||||
const timer = window.setInterval(() => {
|
||||
void syncJwtState();
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-8 text-base">
|
||||
<h1 className="text-2xl font-bold">JWT Exp Test</h1>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||
onClick={() => {
|
||||
void handleSilentRefresh();
|
||||
}}
|
||||
>
|
||||
Silent Refresh 호출
|
||||
</button>
|
||||
|
||||
<div className="rounded border p-4">
|
||||
<div className="font-semibold">쿠키명</div>
|
||||
<div>{CLAIM_NAME}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded border p-4">
|
||||
<div className="font-semibold">exp</div>
|
||||
<div>{jwtState.exp ?? "-"}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded border p-4">
|
||||
<div className="font-semibold">만료시각</div>
|
||||
<div>{jwtState.expDateLocal ?? "-"}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded border p-4">
|
||||
<div className="font-semibold">남은시간</div>
|
||||
<div>{formatRemainingMs(jwtState.remainingMs)}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded border p-4">
|
||||
<div className="font-semibold">상태</div>
|
||||
<div>{jwtState.error ?? "정상"}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded border p-4">
|
||||
<div className="font-semibold">payload</div>
|
||||
<pre className="overflow-auto whitespace-pre-wrap break-all text-sm">
|
||||
{jwtState.payload ? JSON.stringify(jwtState.payload, null, 2) : "-"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user