UI 수정, SMS 클래스 추가
This commit is contained in:
@@ -0,0 +1,197 @@
|
|||||||
|
package com.handong.smartservice.component;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.Charset;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Munja114 (www.munja114.co.kr) SMS/LMS URL integration.
|
||||||
|
*
|
||||||
|
* Posts an application/x-www-form-urlencoded body to the Munja114 remote endpoint.
|
||||||
|
* SMS or LMS is chosen automatically by the EUC-KR byte length of the message:
|
||||||
|
* up to 90 bytes is sent as SMS, longer text is sent as LMS (2000 bytes max).
|
||||||
|
*
|
||||||
|
* The response body is a pipe separated string: code|msg|nums|cols|etc1|etc2
|
||||||
|
*/
|
||||||
|
public class SMSSender {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(SMSSender.class);
|
||||||
|
|
||||||
|
// Munja114 account, issued and authorized through the Munja114 customer center
|
||||||
|
private static final String REMOTE_ID = "";
|
||||||
|
private static final String REMOTE_PASS = "";
|
||||||
|
|
||||||
|
private static final String URL_SMS = "https://www.munja114.co.kr/Remote/RemoteSms.html";
|
||||||
|
private static final String URL_LMS = "https://www.munja114.co.kr/Remote/RemoteMms.html";
|
||||||
|
|
||||||
|
// Munja114 sends and expects EUC-KR encoded text
|
||||||
|
private static final Charset CHARSET = Charset.forName("EUC-KR");
|
||||||
|
|
||||||
|
private static final int SMS_MAX_BYTES = 90;
|
||||||
|
private static final int LMS_MAX_BYTES = 2000;
|
||||||
|
|
||||||
|
// Subject applies to LMS only, 20 characters max, no special characters
|
||||||
|
private static final String LMS_SUBJECT = "알림";
|
||||||
|
|
||||||
|
private static final Duration TIMEOUT = Duration.ofSeconds(10);
|
||||||
|
|
||||||
|
// Result codes returned by the gateway
|
||||||
|
public static final String CODE_SUCCESS = "0000";
|
||||||
|
public static final String CODE_CONNECT_ERROR = "0001";
|
||||||
|
public static final String CODE_AUTH_ERROR = "0002";
|
||||||
|
public static final String CODE_NO_CALL = "0003";
|
||||||
|
public static final String CODE_MSG_FORMAT_ERROR = "0004";
|
||||||
|
public static final String CODE_CALLBACK_ERROR = "0005";
|
||||||
|
public static final String CODE_PHONE_COUNT_ERROR = "0006";
|
||||||
|
public static final String CODE_RESERVE_TIME_ERROR = "0007";
|
||||||
|
public static final String CODE_NOT_ENOUGH_CALL = "0008";
|
||||||
|
public static final String CODE_SEND_FAIL = "0009";
|
||||||
|
public static final String CODE_MSG_TOO_LONG = "0012";
|
||||||
|
public static final String CODE_CALLBACK_NOT_REGISTERED = "0030";
|
||||||
|
public static final String CODE_CALLBACK_TYPE_FAIL = "0033";
|
||||||
|
public static final String CODE_SEND_LIMITED = "0080";
|
||||||
|
public static final String CODE_BLOCKED = "6666";
|
||||||
|
public static final String CODE_UNPAID = "9999";
|
||||||
|
|
||||||
|
// Local codes, never returned by the gateway
|
||||||
|
public static final String CODE_INVALID_PARAMETER = "9001";
|
||||||
|
public static final String CODE_EXCEPTION = "9002";
|
||||||
|
|
||||||
|
private static final HttpClient httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(TIMEOUT)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
|
||||||
|
/** Send result parsed from the gateway response. */
|
||||||
|
public static class Result {
|
||||||
|
public final String code; // result code, "0000" on success
|
||||||
|
public final String msg; // result message
|
||||||
|
public final int sentCount; // number of messages sent
|
||||||
|
public final int remainCount; // remaining call count of the account
|
||||||
|
public final String rawResponse;
|
||||||
|
|
||||||
|
public Result(String code, String msg, int sentCount, int remainCount, String rawResponse) {
|
||||||
|
this.code = code;
|
||||||
|
this.msg = msg;
|
||||||
|
this.sentCount = sentCount;
|
||||||
|
this.remainCount = remainCount;
|
||||||
|
this.rawResponse = rawResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSuccess() {
|
||||||
|
return CODE_SUCCESS.equals(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "SMSSender.Result{code=" + code + ", msg=" + msg + ", sentCount=" + sentCount + ", remainCount=" + remainCount + "}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a text message.
|
||||||
|
*
|
||||||
|
* @param callback sender number, must be pre-registered with the carrier (digits only, other characters are stripped)
|
||||||
|
* @param phone receiver number, comma separated for multiple receivers
|
||||||
|
* @param msg message body, sent as SMS when it fits in 90 EUC-KR bytes, otherwise as LMS
|
||||||
|
*/
|
||||||
|
public static Result send(String callback, String phone, String msg) {
|
||||||
|
if (!hasText(callback) || !hasText(phone) || !hasText(msg)) {
|
||||||
|
logger.warn("send: missing parameter, callback={}, phone={}", callback, phone);
|
||||||
|
return new Result(CODE_INVALID_PARAMETER, "invalid parameter", 0, 0, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
String callbackNum = callback.replaceAll("[^0-9]", "");
|
||||||
|
String phoneList = phone.replaceAll("[^0-9,]", "");
|
||||||
|
int receiverCount = phoneList.split(",").length;
|
||||||
|
|
||||||
|
byte[] msgBytes = msg.getBytes(CHARSET);
|
||||||
|
boolean isLms = msgBytes.length > SMS_MAX_BYTES;
|
||||||
|
|
||||||
|
if (msgBytes.length > LMS_MAX_BYTES) {
|
||||||
|
logger.warn("send: message too long, bytes={}", msgBytes.length);
|
||||||
|
return new Result(CODE_MSG_TOO_LONG, "message too long", 0, 0, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder body = new StringBuilder();
|
||||||
|
appendParam(body, "remote_id", REMOTE_ID);
|
||||||
|
appendParam(body, "remote_pass", REMOTE_PASS);
|
||||||
|
appendParam(body, "remote_num", String.valueOf(receiverCount));
|
||||||
|
appendParam(body, "remote_reserve", "0");
|
||||||
|
appendParam(body, "remote_phone", phoneList);
|
||||||
|
appendParam(body, "remote_callback", callbackNum);
|
||||||
|
appendParam(body, "remote_msg", msg);
|
||||||
|
if (isLms)
|
||||||
|
appendParam(body, "remote_subject", LMS_SUBJECT);
|
||||||
|
|
||||||
|
String url = isLms ? URL_LMS : URL_SMS;
|
||||||
|
|
||||||
|
try {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.timeout(TIMEOUT)
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded;charset=ko")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body.toString(), CHARSET))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||||
|
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
logger.warn("send: http status={}, url={}", response.statusCode(), url);
|
||||||
|
return new Result(CODE_CONNECT_ERROR, "http status " + response.statusCode(), 0, 0, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
String raw = new String(response.body(), CHARSET).trim();
|
||||||
|
Result result = parseResult(raw);
|
||||||
|
|
||||||
|
if (result.isSuccess())
|
||||||
|
logger.info("send: ok, phone={}, type={}, remain={}", phoneList, isLms ? "lms" : "sms", result.remainCount);
|
||||||
|
else
|
||||||
|
logger.warn("send: failed, phone={}, result={}", phoneList, result);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception e) {
|
||||||
|
logger.warn("send: exception, url={}, msg={}", url, e.getMessage());
|
||||||
|
return new Result(CODE_EXCEPTION, e.getMessage(), 0, 0, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response format: code|msg|nums|cols|etc1|etc2
|
||||||
|
private static Result parseResult(String raw) {
|
||||||
|
String[] arr = raw.split("\\|");
|
||||||
|
|
||||||
|
String code = arr.length > 0 ? arr[0].trim() : "";
|
||||||
|
String msg = arr.length > 1 ? arr[1].trim() : "";
|
||||||
|
int sentCount = arr.length > 2 ? toInt(arr[2]) : 0;
|
||||||
|
int remainCount = arr.length > 3 ? toInt(arr[3]) : 0;
|
||||||
|
|
||||||
|
return new Result(code, msg, sentCount, remainCount, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendParam(StringBuilder body, String key, String value) {
|
||||||
|
if (body.length() > 0)
|
||||||
|
body.append('&');
|
||||||
|
|
||||||
|
body.append(key).append('=').append(URLEncoder.encode(value == null ? "" : value, CHARSET));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasText(String str) {
|
||||||
|
return str != null && !str.trim().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int toInt(String str) {
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(str.trim());
|
||||||
|
}
|
||||||
|
catch (NumberFormatException e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useSidebar } from "@/context/SidebarContext";
|
|||||||
import AppHeader from "@/layout/AppHeader";
|
import AppHeader from "@/layout/AppHeader";
|
||||||
import AppSidebar from "@/layout/AppSidebar";
|
import AppSidebar from "@/layout/AppSidebar";
|
||||||
import Backdrop from "@/layout/Backdrop";
|
import Backdrop from "@/layout/Backdrop";
|
||||||
|
import AuthGate from "@/components/AuthGate";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export default function AdminLayout({
|
export default function AdminLayout({
|
||||||
@@ -21,23 +22,25 @@ export default function AdminLayout({
|
|||||||
: "lg:ml-[90px]";
|
: "lg:ml-[90px]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen xl:flex">
|
<AuthGate>
|
||||||
{/* Sidebar and Backdrop */}
|
<div className="min-h-screen xl:flex">
|
||||||
<AppSidebar />
|
{/* Sidebar and Backdrop */}
|
||||||
<Backdrop />
|
<AppSidebar />
|
||||||
{/* Main Content Area */}
|
<Backdrop />
|
||||||
<div
|
{/* Main Content Area */}
|
||||||
className={`flex-1 transition-all duration-300 ease-in-out ${mainContentMargin}`}
|
<div
|
||||||
>
|
className={`flex-1 transition-all duration-300 ease-in-out ${mainContentMargin}`}
|
||||||
{/* Header */}
|
>
|
||||||
<AppHeader />
|
{/* Header */}
|
||||||
{/* Page Content */}
|
<AppHeader />
|
||||||
<div className="p-4 mx-auto md:p-6">
|
{/* Page Content */}
|
||||||
<div className="sm:min-w-[1280px]">
|
<div className="p-4 mx-auto md:p-6">
|
||||||
{children}
|
<div className="sm:min-w-[1280px]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AuthGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,6 +206,17 @@
|
|||||||
body {
|
body {
|
||||||
@apply relative font-normal font-outfit z-1 bg-gray-50 dark:bg-gray-900;
|
@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 {
|
@utility menu-item {
|
||||||
|
|||||||
@@ -21,8 +21,23 @@ export default function RootLayout({
|
|||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
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 (
|
return (
|
||||||
<html lang="ko" className={pretendard.className}>
|
<html lang="ko" className={pretendard.className} suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<SidebarProvider>
|
<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 { MoreDotIcon } from "@/icons";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { DropdownItem } from "../ui/dropdown/DropdownItem";
|
import { DropdownItem } from "../ui/dropdown/DropdownItem";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
// Dynamically import the ReactApexChart component
|
// Dynamically import the ReactApexChart component
|
||||||
const ReactApexChart = dynamic(() => import("react-apexcharts"), {
|
const ReactApexChart = dynamic(() => import("react-apexcharts"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function MonthlyTarget() {
|
export default function MonthlyTarget() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
const series = [75.55];
|
const series = [75.55];
|
||||||
const options: ApexOptions = {
|
const options: ApexOptions = {
|
||||||
colors: ["#465FFF"],
|
colors: ["#465FFF"],
|
||||||
@@ -32,7 +36,7 @@ export default function MonthlyTarget() {
|
|||||||
size: "80%",
|
size: "80%",
|
||||||
},
|
},
|
||||||
track: {
|
track: {
|
||||||
background: "#E4E7EC",
|
background: isDark ? "#344054" : "#E4E7EC",
|
||||||
strokeWidth: "100%",
|
strokeWidth: "100%",
|
||||||
margin: 5, // margin is in pixels
|
margin: 5, // margin is in pixels
|
||||||
},
|
},
|
||||||
@@ -44,7 +48,7 @@ export default function MonthlyTarget() {
|
|||||||
fontSize: "36px",
|
fontSize: "36px",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
offsetY: -40,
|
offsetY: -40,
|
||||||
color: "#1D2939",
|
color: isDark ? "#F2F4F7" : "#1D2939",
|
||||||
formatter: function (val) {
|
formatter: function (val) {
|
||||||
return val + "%";
|
return val + "%";
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { Dropdown } from "../ui/dropdown/Dropdown";
|
import { Dropdown } from "../ui/dropdown/Dropdown";
|
||||||
import { DropdownItem } from "../ui/dropdown/DropdownItem";
|
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 { Modal } from "../ui/modal";
|
||||||
import { useModal } from "@/hooks/useModal";
|
import { useModal } from "@/hooks/useModal";
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ export default function UserDropdown() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshAuthStore();
|
// Session restore is handled by AuthGate in the admin layout
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (!tokenPayload)
|
if (!tokenPayload)
|
||||||
|
|||||||
@@ -41,21 +41,29 @@ type JwtPayload = {
|
|||||||
permission: number;
|
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 = {
|
type AuthState = {
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
tokenPayload: JwtPayload | null;
|
tokenPayload: JwtPayload | null;
|
||||||
|
authStatus: AuthStatus;
|
||||||
setAccessToken: (token: string) => void;
|
setAccessToken: (token: string) => void;
|
||||||
clearAuth: () => void;
|
clearAuth: () => void;
|
||||||
|
setAuthStatus: (status: AuthStatus) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set: any) => ({
|
export const useAuthStore = create<AuthState>((set: any) => ({
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
tokenPayload: null,
|
tokenPayload: null,
|
||||||
|
authStatus: "idle",
|
||||||
|
|
||||||
setAccessToken: (token: string) => {
|
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 = {
|
type UiLoadingState = {
|
||||||
@@ -143,11 +151,31 @@ api.interceptors.request.use((config) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// refresh token part
|
// refresh token part
|
||||||
let g_isRefreshing = false;
|
// A single in-flight refresh call is shared by the 401 retry path and by the
|
||||||
let g_queue_refresh_tok_value: {
|
// startup restore, so concurrent 401s never hit /api/sval2.do more than once.
|
||||||
resolve: (token: string) => void;
|
let g_refreshPromise: Promise<string> | null = null;
|
||||||
reject: (err: any) => void;
|
|
||||||
}[] = [];
|
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(
|
api.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
@@ -165,58 +193,36 @@ api.interceptors.response.use(
|
|||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (original._retry) {
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
|
|
||||||
if (status !== 401) {
|
if (status !== 401) {
|
||||||
return Promise.reject(error);
|
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) {
|
if (original.url === TOK_REFRESH_URI) {
|
||||||
useAuthStore.getState().clearAuth();
|
useAuthStore.getState().clearAuth();
|
||||||
window.location.href = SIGN_IN_PAGE_URI;
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (g_isRefreshing) {
|
// Already retried once with a fresh token, do not loop
|
||||||
return new Promise((resolve, reject) => {
|
if (original._retry) {
|
||||||
g_queue_refresh_tok_value.push({ resolve, reject });
|
return Promise.reject(error);
|
||||||
}).then((newToken) => {
|
|
||||||
original.headers = original.headers ?? {};
|
|
||||||
original.headers.Authorization = `Bearer ${newToken}`;
|
|
||||||
return api(original);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
original._retry = true;
|
original._retry = true;
|
||||||
g_isRefreshing = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await api.post(TOK_REFRESH_URI);
|
const newToken = await refreshAccessToken();
|
||||||
const newToken = resp.data.result.sval1;
|
|
||||||
|
|
||||||
useAuthStore.getState().setAccessToken(newToken);
|
|
||||||
|
|
||||||
g_queue_refresh_tok_value.forEach(({ resolve }) => resolve(newToken));
|
|
||||||
g_queue_refresh_tok_value = [];
|
|
||||||
|
|
||||||
original.headers = original.headers ?? {};
|
original.headers = original.headers ?? {};
|
||||||
original.headers.Authorization = `Bearer ${newToken}`;
|
original.headers.Authorization = `Bearer ${newToken}`;
|
||||||
return api(original);
|
return api(original);
|
||||||
|
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
g_queue_refresh_tok_value.forEach(({ reject }) => reject(refreshError));
|
|
||||||
g_queue_refresh_tok_value = [];
|
|
||||||
|
|
||||||
useAuthStore.getState().clearAuth();
|
useAuthStore.getState().clearAuth();
|
||||||
window.location.href = SIGN_IN_PAGE_URI;
|
window.location.href = SIGN_IN_PAGE_URI;
|
||||||
return Promise.reject(refreshError);
|
return Promise.reject(refreshError);
|
||||||
|
|
||||||
} finally {
|
|
||||||
g_isRefreshing = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -225,17 +231,28 @@ export const clearAuthStore = async () => {
|
|||||||
useAuthStore.getState().clearAuth();
|
useAuthStore.getState().clearAuth();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const refreshAuthStore = async () => {
|
/**
|
||||||
if (useAuthStore.getState().tokenPayload != null)
|
* Restores the session from the sval2 refresh cookie on a full page load.
|
||||||
return;
|
* 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 {
|
try {
|
||||||
const resp = await api.post(TOK_REFRESH_URI);
|
await refreshAccessToken();
|
||||||
const newToken = resp.data.result.sval1;
|
return true;
|
||||||
|
|
||||||
useAuthStore.getState().setAccessToken(newToken);
|
|
||||||
}
|
}
|
||||||
catch (e) {}
|
catch (e) {
|
||||||
|
useAuthStore.getState().clearAuth();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const refreshAuthStore = async () => {
|
||||||
|
await bootstrapAuth();
|
||||||
};
|
};
|
||||||
//////////////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user