UI 수정, SMS 클래스 추가
This commit is contained in:
@@ -4,6 +4,7 @@ import { useSidebar } from "@/context/SidebarContext";
|
||||
import AppHeader from "@/layout/AppHeader";
|
||||
import AppSidebar from "@/layout/AppSidebar";
|
||||
import Backdrop from "@/layout/Backdrop";
|
||||
import AuthGate from "@/components/AuthGate";
|
||||
import React from "react";
|
||||
|
||||
export default function AdminLayout({
|
||||
@@ -21,23 +22,25 @@ export default function AdminLayout({
|
||||
: "lg:ml-[90px]";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen xl:flex">
|
||||
{/* Sidebar and Backdrop */}
|
||||
<AppSidebar />
|
||||
<Backdrop />
|
||||
{/* Main Content Area */}
|
||||
<div
|
||||
className={`flex-1 transition-all duration-300 ease-in-out ${mainContentMargin}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<AppHeader />
|
||||
{/* Page Content */}
|
||||
<div className="p-4 mx-auto md:p-6">
|
||||
<div className="sm:min-w-[1280px]">
|
||||
{children}
|
||||
<AuthGate>
|
||||
<div className="min-h-screen xl:flex">
|
||||
{/* Sidebar and Backdrop */}
|
||||
<AppSidebar />
|
||||
<Backdrop />
|
||||
{/* Main Content Area */}
|
||||
<div
|
||||
className={`flex-1 transition-all duration-300 ease-in-out ${mainContentMargin}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<AppHeader />
|
||||
{/* Page Content */}
|
||||
<div className="p-4 mx-auto md:p-6">
|
||||
<div className="sm:min-w-[1280px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthGate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -206,6 +206,17 @@
|
||||
body {
|
||||
@apply relative font-normal font-outfit z-1 bg-gray-50 dark:bg-gray-900;
|
||||
}
|
||||
/*
|
||||
Tells the browser which palette to use for native widgets: scrollbars,
|
||||
the date/time picker panel, select popups and number spinners.
|
||||
Without this they keep rendering light while the page is in dark mode.
|
||||
*/
|
||||
html {
|
||||
color-scheme: light;
|
||||
}
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
@utility menu-item {
|
||||
|
||||
@@ -21,8 +21,23 @@ export default function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
// Applies the saved theme before the first paint, so a reload in dark mode
|
||||
// never flashes the light palette while React hydrates.
|
||||
const themeScript = `
|
||||
try {
|
||||
var t = localStorage.getItem("theme");
|
||||
if (t === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
document.documentElement.dataset.theme = "dark";
|
||||
}
|
||||
} catch (e) {}
|
||||
`;
|
||||
|
||||
return (
|
||||
<html lang="ko" className={pretendard.className}>
|
||||
<html lang="ko" className={pretendard.className} suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
</head>
|
||||
<body>
|
||||
<ThemeProvider>
|
||||
<SidebarProvider>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
import React, { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { bootstrapAuth, useAuthStore, SIGN_IN_PAGE_URI } from "@/lib/_AG";
|
||||
import CtLoadingScreen from "./CtLoadingScreen";
|
||||
|
||||
/**
|
||||
* Restores the session before the admin pages are mounted.
|
||||
*
|
||||
* The access token lives in memory only, so a full page reload starts with an empty
|
||||
* auth store. Children are held back until the token is restored from the sval2
|
||||
* cookie, which keeps pages from rendering (and firing API calls) without a token.
|
||||
*/
|
||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const authStatus = useAuthStore((s) => s.authStatus);
|
||||
|
||||
useEffect(() => {
|
||||
if (authStatus === "idle")
|
||||
bootstrapAuth();
|
||||
}, [authStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (authStatus === "guest")
|
||||
router.replace(SIGN_IN_PAGE_URI);
|
||||
}, [authStatus, router]);
|
||||
|
||||
if (authStatus !== "authed")
|
||||
return <CtLoadingScreen message={authStatus === "guest" ? "로그인 화면으로 이동 중..." : "불러오는 중..."} />;
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
|
||||
/** Full screen loading overlay, used while the page is not ready to be shown yet. */
|
||||
export default function CtLoadingScreen({ message = "불러오는 중..." }: { message?: string }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-99999 flex items-center justify-center bg-white dark:bg-gray-900">
|
||||
<div className="flex flex-col items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 dark:border-gray-700 border-t-brand-500 dark:border-t-brand-500"></span>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,12 +7,16 @@ import { Dropdown } from "../ui/dropdown/Dropdown";
|
||||
import { MoreDotIcon } from "@/icons";
|
||||
import { useState } from "react";
|
||||
import { DropdownItem } from "../ui/dropdown/DropdownItem";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
// Dynamically import the ReactApexChart component
|
||||
const ReactApexChart = dynamic(() => import("react-apexcharts"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function MonthlyTarget() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const series = [75.55];
|
||||
const options: ApexOptions = {
|
||||
colors: ["#465FFF"],
|
||||
@@ -32,7 +36,7 @@ export default function MonthlyTarget() {
|
||||
size: "80%",
|
||||
},
|
||||
track: {
|
||||
background: "#E4E7EC",
|
||||
background: isDark ? "#344054" : "#E4E7EC",
|
||||
strokeWidth: "100%",
|
||||
margin: 5, // margin is in pixels
|
||||
},
|
||||
@@ -44,7 +48,7 @@ export default function MonthlyTarget() {
|
||||
fontSize: "36px",
|
||||
fontWeight: "600",
|
||||
offsetY: -40,
|
||||
color: "#1D2939",
|
||||
color: isDark ? "#F2F4F7" : "#1D2939",
|
||||
formatter: function (val) {
|
||||
return val + "%";
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
|
||||
import { Dropdown } from "../ui/dropdown/Dropdown";
|
||||
import { DropdownItem } from "../ui/dropdown/DropdownItem";
|
||||
|
||||
import api, { useAuthStore, clearAuthStore, refreshAuthStore, SIGN_IN_PAGE_URI } from '@/lib/_AG';
|
||||
import api, { useAuthStore, clearAuthStore, SIGN_IN_PAGE_URI } from '@/lib/_AG';
|
||||
import { Modal } from "../ui/modal";
|
||||
import { useModal } from "@/hooks/useModal";
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function UserDropdown() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshAuthStore();
|
||||
// Session restore is handled by AuthGate in the admin layout
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (!tokenPayload)
|
||||
|
||||
@@ -41,21 +41,29 @@ type JwtPayload = {
|
||||
permission: number;
|
||||
}
|
||||
|
||||
// "idle": not restored yet, "loading": restoring from the refresh cookie,
|
||||
// "authed": access token available, "guest": no valid session
|
||||
export type AuthStatus = "idle" | "loading" | "authed" | "guest";
|
||||
|
||||
type AuthState = {
|
||||
accessToken: string | null;
|
||||
tokenPayload: JwtPayload | null;
|
||||
authStatus: AuthStatus;
|
||||
setAccessToken: (token: string) => void;
|
||||
clearAuth: () => void;
|
||||
setAuthStatus: (status: AuthStatus) => void;
|
||||
};
|
||||
|
||||
export const useAuthStore = create<AuthState>((set: any) => ({
|
||||
accessToken: null,
|
||||
tokenPayload: null,
|
||||
authStatus: "idle",
|
||||
|
||||
setAccessToken: (token: string) => {
|
||||
set({ accessToken: token, tokenPayload: jwtDecode<JwtPayload>(token) });
|
||||
set({ accessToken: token, tokenPayload: jwtDecode<JwtPayload>(token), authStatus: "authed" });
|
||||
},
|
||||
clearAuth: () => set({ accessToken: null, tokenPayload: null }),
|
||||
clearAuth: () => set({ accessToken: null, tokenPayload: null, authStatus: "guest" }),
|
||||
setAuthStatus: (status: AuthStatus) => set({ authStatus: status }),
|
||||
}));
|
||||
|
||||
type UiLoadingState = {
|
||||
@@ -143,11 +151,31 @@ api.interceptors.request.use((config) => {
|
||||
});
|
||||
|
||||
// refresh token part
|
||||
let g_isRefreshing = false;
|
||||
let g_queue_refresh_tok_value: {
|
||||
resolve: (token: string) => void;
|
||||
reject: (err: any) => void;
|
||||
}[] = [];
|
||||
// A single in-flight refresh call is shared by the 401 retry path and by the
|
||||
// startup restore, so concurrent 401s never hit /api/sval2.do more than once.
|
||||
let g_refreshPromise: Promise<string> | null = null;
|
||||
|
||||
const requestNewAccessToken = async (): Promise<string> => {
|
||||
const resp = await api.post(TOK_REFRESH_URI);
|
||||
|
||||
// The refresh endpoint answers 200 with an error code when the account is gone or stopped
|
||||
const newToken = resp?.data?.result?.sval1;
|
||||
if (resp?.data?.errCode != 0 || !newToken)
|
||||
throw new Error("token refresh failed, errCode: " + resp?.data?.errCode);
|
||||
|
||||
useAuthStore.getState().setAccessToken(newToken);
|
||||
return newToken;
|
||||
};
|
||||
|
||||
export const refreshAccessToken = (): Promise<string> => {
|
||||
if (g_refreshPromise == null) {
|
||||
g_refreshPromise = requestNewAccessToken().finally(() => {
|
||||
g_refreshPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
return g_refreshPromise;
|
||||
};
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
@@ -165,58 +193,36 @@ api.interceptors.response.use(
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (original._retry) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const status = error.response?.status;
|
||||
|
||||
if (status !== 401) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// The refresh call itself failed: drop the session and let the caller decide where to go
|
||||
if (original.url === TOK_REFRESH_URI) {
|
||||
useAuthStore.getState().clearAuth();
|
||||
window.location.href = SIGN_IN_PAGE_URI;
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (g_isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
g_queue_refresh_tok_value.push({ resolve, reject });
|
||||
}).then((newToken) => {
|
||||
original.headers = original.headers ?? {};
|
||||
original.headers.Authorization = `Bearer ${newToken}`;
|
||||
return api(original);
|
||||
});
|
||||
// Already retried once with a fresh token, do not loop
|
||||
if (original._retry) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
original._retry = true;
|
||||
g_isRefreshing = true;
|
||||
|
||||
try {
|
||||
const resp = await api.post(TOK_REFRESH_URI);
|
||||
const newToken = resp.data.result.sval1;
|
||||
|
||||
useAuthStore.getState().setAccessToken(newToken);
|
||||
|
||||
g_queue_refresh_tok_value.forEach(({ resolve }) => resolve(newToken));
|
||||
g_queue_refresh_tok_value = [];
|
||||
const newToken = await refreshAccessToken();
|
||||
|
||||
original.headers = original.headers ?? {};
|
||||
original.headers.Authorization = `Bearer ${newToken}`;
|
||||
return api(original);
|
||||
|
||||
} catch (refreshError) {
|
||||
g_queue_refresh_tok_value.forEach(({ reject }) => reject(refreshError));
|
||||
g_queue_refresh_tok_value = [];
|
||||
|
||||
useAuthStore.getState().clearAuth();
|
||||
window.location.href = SIGN_IN_PAGE_URI;
|
||||
return Promise.reject(refreshError);
|
||||
|
||||
} finally {
|
||||
g_isRefreshing = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -225,17 +231,28 @@ export const clearAuthStore = async () => {
|
||||
useAuthStore.getState().clearAuth();
|
||||
};
|
||||
|
||||
export const refreshAuthStore = async () => {
|
||||
if (useAuthStore.getState().tokenPayload != null)
|
||||
return;
|
||||
/**
|
||||
* Restores the session from the sval2 refresh cookie on a full page load.
|
||||
* Returns true when an access token is available afterwards.
|
||||
*/
|
||||
export const bootstrapAuth = async (): Promise<boolean> => {
|
||||
if (useAuthStore.getState().accessToken != null)
|
||||
return true;
|
||||
|
||||
useAuthStore.getState().setAuthStatus("loading");
|
||||
|
||||
try {
|
||||
const resp = await api.post(TOK_REFRESH_URI);
|
||||
const newToken = resp.data.result.sval1;
|
||||
|
||||
useAuthStore.getState().setAccessToken(newToken);
|
||||
await refreshAccessToken();
|
||||
return true;
|
||||
}
|
||||
catch (e) {}
|
||||
catch (e) {
|
||||
useAuthStore.getState().clearAuth();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const refreshAuthStore = async () => {
|
||||
await bootstrapAuth();
|
||||
};
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
Reference in New Issue
Block a user