fo: 공통 모듈 기반 로그인 변경

This commit is contained in:
2026-07-15 18:22:39 +09:00
parent 2eb1809f36
commit 52a79eca60
30 changed files with 1341 additions and 355 deletions
+2 -2
View File
@@ -7,8 +7,8 @@ NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PROXY_HOST = http://localhost:3000
NEXT_PUBLIC_SUB_DOMAIN = http://localhost:13000
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://localhost:3001/auth/login
NEXT_PUBLIC_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service
NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service
NEXT_PUBLIC_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
+2 -2
View File
@@ -7,8 +7,8 @@ NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11300
NEXT_PUBLIC_SUB_DOMAIN = http://con.mind-one.co.kr:13000
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service
NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/
NEXT_PUBLIC_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
+2 -2
View File
@@ -7,8 +7,8 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
#NEXT_PUBLIC_PROXY_HOST = https://devlarva.hasuon.or.kr
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = https://devqueen.hasuon.or.kr/auth/login
NEXT_PUBLIC_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service
NEXT_PUBLIC_WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service
NEXT_PUBLIC_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
NEXT_PUBLIC_WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service/api/v1
NEXT_PUBLIC_CS_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
NEXT_PUBLIC_GEOSERVER_URL = http://172.29.143.87:8080/geoserver
+17
View File
@@ -0,0 +1,17 @@
import "@/src/app/styles/base.css";
export const metadata = {
title: "유역하수도 통합 정보플랫폼",
};
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
}
+2
View File
@@ -0,0 +1,2 @@
import { LoginPage } from "@/src/auth/login";
export default LoginPage;
+86 -91
View File
@@ -1,111 +1,106 @@
/**
* 📄 Proxy Route
* API 프록시 라우트
*
* - 서버 API로의 요청 프록시 처리
* - 쿠키 기반 인증 토큰 및 리프레시 토큰 관리
* - POST 및 PUT 요청 핸들링
* - 사용자 인증 정보 검증 및 응답 처리
* - 에러 핸들링 및 상태 코드 반환
* - 사이트 ID 및 헤더 설정
*/
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
REFRESH_TOKEN,
SERVER_API_URL,
SITE_ID,
} from "@/src/shared/config";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
const REFRESH_TOKEN = process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL || "";
const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "SN";
async function handler(
request: Request,
params: { url: string[] },
method: "GET" | "POST" | "PUT" | "DELETE",
request: Request,
params: { url: string[] },
method: "GET" | "POST" | "PUT" | "DELETE",
) {
const cookieStore = await cookies();
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
};
let body = {};
const isRefreshHandler = params.url.includes("refresh");
const cookieStore = await cookies();
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
};
let body = {};
const isRefreshHandler = params.url.includes("refresh");
// silent refresh
if (isRefreshHandler) {
const refreshToken = cookieStore.get(REFRESH_TOKEN);
headers[CLAIM_NAME] = refreshToken?.value ?? "";
// silent refresh
if (isRefreshHandler) {
const refreshToken = cookieStore.get(REFRESH_TOKEN);
headers[CLAIM_NAME] = refreshToken?.value ?? "";
if (!headers[CLAIM_NAME]) {
return NextResponse.json({ message: "Can't refresh" }, { status: 401 });
}
}
try {
if (!isRefreshHandler) {
body = await request.json();
if (!headers[CLAIM_NAME]) {
return NextResponse.json(
{ message: "Can't refresh" },
{ status: 401 },
);
}
}
const result = await fetch(`${SERVER_API_URL}/${params.url.join("/")}`, {
method,
headers,
body: JSON.stringify(body),
});
try {
if (!isRefreshHandler) {
body = await request.json();
}
if (!result.ok) {
console.log("Invalid Credentials ");
return NextResponse.json(
{ message: "Invalid Credentials" },
{ status: 401 },
);
const result = await fetch(
`${SERVER_API_URL}/${params.url.join("/")}`,
{
method,
headers,
body: JSON.stringify(body),
},
);
if (!result.ok) {
return NextResponse.json(
{ message: "Invalid Credentials" },
{ status: 401 },
);
}
const accessToken = result.headers.get(ACCESS_TOKEN);
const refreshToken = result.headers.get(REFRESH_TOKEN);
const userId = result.headers.get(AUTH_USER_ID);
if (accessToken && userId) {
const response = NextResponse.json({
[ACCESS_TOKEN]: accessToken,
[AUTH_USER_ID]: userId,
});
if (refreshToken) {
cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "lax",
path: "/",
});
}
return response;
}
return NextResponse.json(
{ message: "Invalid Credentials" },
{ status: 401 },
);
} catch (error) {
return NextResponse.json(
{ message: "Server Error", error },
{ status: 500 },
);
}
const accessToken = result.headers.get(ACCESS_TOKEN);
const refreshToken = result.headers.get(REFRESH_TOKEN);
const userId = result.headers.get(AUTH_USER_ID);
if (accessToken && userId) {
const response = NextResponse.json({
[ACCESS_TOKEN]: accessToken,
[AUTH_USER_ID]: userId,
});
if (refreshToken) {
cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "lax",
path: "/",
});
}
return response;
}
return NextResponse.json(
{ message: "Invalid Credentials" },
{ status: 401 },
);
} catch (error) {
return NextResponse.json(
{ message: "Server Error", error },
{ status: 500 },
);
}
}
export async function POST(
request: Request,
props: { params: Promise<{ url: string[] }> },
request: Request,
props: { params: Promise<{ url: string[] }> },
) {
const params = await props.params;
return handler(request, params, "POST");
const params = await props.params;
return handler(request, params, "POST");
}
export async function PUT(
request: Request,
props: { params: Promise<{ url: string[] }> },
request: Request,
props: { params: Promise<{ url: string[] }> },
) {
const params = await props.params;
return handler(request, params, "PUT");
const params = await props.params;
return handler(request, params, "PUT");
}
-2
View File
@@ -1,2 +0,0 @@
import { LoginPage } from "@/src/features/auth/login";
export default LoginPage;
-12
View File
@@ -1,12 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const hostname = request.nextUrl.hostname;
const redirectUrl = process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "https://portal.hasuon.or.kr";
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "con.mind-one.co.kr") {
return NextResponse.redirect(new URL("/", request.url)); // 로컬 및 개발 환경
} else {
return NextResponse.redirect(new URL(redirectUrl)); // 공단 환경
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { PROXY_HOST } from "@/src/shared/config/env";
export const DEV_URL_FO = "/operation-service/api/v1";
export const DEV_URL_FO = "/operation-service";
export const LOCAL_URL = PROXY_HOST;
export const DEV_URL_WR = "/reuse-service/api/v1";
export const DEV_URL_WR = "/reuse-service";
+4 -1
View File
@@ -10,8 +10,11 @@
},
"dependencies": {
"@common/assets": "workspace:*",
"@common/business_components": "workspace:*",
"@common/auth": "workspace:*",
"@common/components": "workspace:*",
"@common/config": "workspace:*",
"@common/network": "workspace:*",
"@common/business_components": "workspace:*",
"@tailwindcss/vite": "^4.0.10",
"html2canvas": "^1.4.1",
"js-cookie": "^3.0.5",
+1
View File
@@ -0,0 +1 @@
export { LoginPage } from "./ui/login-page";
+156
View File
@@ -0,0 +1,156 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { authService } from "@/src/entities/auth";
import { images } from "@common/assets";
import "./login.css";
function LoginPage() {
const [errorMessage, setErrorMessage] = useState<string>("");
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMessage("");
const formData = new FormData(e.target as HTMLFormElement);
const userId = formData.get("id") as string;
const password = formData.get("password") as string;
try {
await authService.login({ userId, password });
window.location.href = "/";
} catch (error) {
const message =
error instanceof Error
? error.message
: "로그인에 실패했습니다.";
setErrorMessage(message);
}
};
return (
<div className="login fx">
<div className="left_box fx">
<div className="text">
<h1>
유역하수도통합플랫폼
<br />
<span>하수도통계 관리시스템</span>
</h1>
<p>
유역하수도통합플랫폼 하수도통계 관리시스템에 오신 것을
환영합니다.
</p>
</div>
<div className="img">
<Image
src={images.logo}
width={229}
height={46}
alt="logo"
/>
</div>
</div>
<form
id="loginForm"
name="loginForm"
method="post"
onSubmit={handleSubmit}
>
<div className="login_box fx">
<ul className="fx">
<li>
<h2>ID</h2>
<div className="input_st st1">
<i className="diveicon di-account-box" />
<input
type="text"
className="id"
title="아이디를 입력하세요."
id="id"
name="id"
maxLength={12}
/>
</div>
</li>
<li>
<h2>PASSWORD</h2>
<div className="input_st st2">
<i className="diveicon di-key" />
<input
type="password"
className="input_style"
maxLength={25}
title="비밀번호를 입력하세요."
id="password"
name="password"
/>
</div>
</li>
</ul>
<div className="bottom_box fx">
<div className="checks fx">
<input
type="checkbox"
name="checkId"
title="로그인ID 저장여부"
id="checkId"
/>
<label htmlFor="checkId">아이디저장</label>
</div>
{/* <a href="javascript:fnUserSearch();" className=" pw_message fx"><i className="xi-external-link"></i>비밀번호 또는 아이디를 잃어버리셨나요?</a> */}
</div>
{errorMessage && (
<div className="error_message">
<span>{errorMessage}</span>
</div>
)}
<button type="submit" className="login_btn fx">
<span>LOGIN</span>
</button>
<div className="footer">
<div className="copy fx">
<p>copyright © 2023 KECO. All rights reserved.</p>
</div>
<div className="fot_btn fx">
<a
href="https://keco.or.kr/web/lay1/S1T6C1348/contents.do"
target="_blank"
>
개인정보처리방침
</a>
<a
href="https://keco.or.kr/web/lay1/S1T6C1349/contents.do"
target="_blank"
>
저작권정책안내
</a>
<a
href="https://keco.or.kr/web/lay1/S1T6C1350/contents.do"
target="_blank"
>
이메일무단수집거부
</a>
</div>
<p className="addr fx">
<span>
주소 : (22689) 인천광역시 서구 환경로 42(경서동
종합환경연구단지)
</span>
<span>
전화 : (시스템) 032-590-4321, 3752, 4314 / (통
계) 032-590-3723, 3722
</span>
<span>팩스 :032-590-3729</span>
</p>
</div>
</div>
</form>
</div>
);
}
export { LoginPage };
+325
View File
@@ -0,0 +1,325 @@
/**
* OLYM Cascading Style Sheets
* File Name : login.css
* Description : 로그인 스타일시트
* Author : Web business Team / Choi Hyun Mi
* Email : desingtj@olym.co.kr
* Date : 2023.07.20
* Update : 2023.07.26
* Copyright(c) 2023 OLYM Communications. All Rights Reserved.
*/
html,
body {
background: #fff;
}
.fx {
display: flex;
-ms-display: flex;
-webkit-display: flex;
box-sizing: border-box;
}
.login {
width: 100%;
height: 100vh;
}
.login .left_box {
width: 100%;
background: url(../../../../public/images/bg_login.jpg) no-repeat
center/cover;
flex-direction: column;
justify-content: space-between;
padding: 7% 5% 5% 5%;
}
.login .left_box .text h1 {
font-size: 53px;
color: #fff;
}
.login .left_box .text h1 span {
font-weight: 800;
}
.login .left_box .text p {
margin-top: 40px;
font-size: 18px;
color: #fff;
}
.login .login_box {
width: 100%;
flex-direction: column;
justify-content: center;
padding: 4% 12% 4% 6%;
}
.login .login_box > ul {
flex-direction: column;
gap: 40px;
}
.login .login_box > ul > li h2 {
font-size: 22px;
font-weight: 600;
color: #2640a5;
margin-bottom: 14px;
}
.login .login_box > ul > li .input_st {
position: relative;
}
.login .login_box > ul > li .input_st .diveicon {
position: absolute;
top: 50%;
left: 28px;
transform: translateY(-50%);
font-family: "Xeicon";
font-size: 25px;
color: #003288;
}
.login .login_box > ul > li input {
width: 100%;
padding: 20px 20px 20px 70px;
background: #f7f7f7;
box-sizing: border-box;
border: none;
border-radius: 5px;
font-size: 18px;
font-weight: 700;
color: #000;
height: auto;
}
.login .login_box > ul > li input:focus {
outline: 1px solid #3c5fe5;
}
.login .login_box > ul > li input::placeholder {
color: #afabab;
font-weight: 500;
}
.login .login_box .bottom_box {
justify-content: space-between;
gap: 20px;
padding: 50px 0 45px 0;
}
.login .login_box .bottom_box .checks {
display: flex;
align-items: center;
gap: 8px;
}
.login .login_box .bottom_box .checks input[type="checkbox"] {
width: 18px;
height: 18px;
}
.login .login_box .bottom_box .checks label {
font-size: 14px;
}
.login .login_box .bottom_box .pw_message {
align-items: center;
gap: 5px;
color: #636363;
font-weight: 500;
border-bottom: 1px solid #ccc;
padding-bottom: 5px;
}
.login .login_box .bottom_box .pw_message i {
color: #327af5;
font-size: 20px;
}
.login .login_box .error_message {
margin: 20px 0;
padding: 12px 20px;
background: #fee;
border: 1px solid #fcc;
border-radius: 5px;
color: #c33;
font-size: 14px;
text-align: center;
}
.login .login_box .error_message span {
display: block;
font-weight: 500;
}
.login .login_box .login_btn {
position: relative;
border-radius: 5px;
padding: 24px 15px;
background: linear-gradient(
to left,
#1d3969 0%,
#264078 10%,
#172558 40%,
#162254 50%,
#111c43 60%,
#111c42 100%
);
overflow: hidden;
justify-content: center;
align-items: center;
gap: 10px;
color: #fff;
cursor: pointer;
}
.login .login_box .login_btn span {
font-family: "Montserrat";
font-size: 19px;
font-weight: 700;
position: relative;
}
.login .login_box .login_btn i {
font-size: 22px;
transform: translateY(-2px);
}
.login .login_box .login_btn:before {
display: block;
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
background: linear-gradient(
to right,
#1d3969 0%,
#264078 10%,
#172558 40%,
#162254 50%,
#111c43 60%,
#111c42 100%
);
transition: opacity 0.5s;
}
.login .login_box .login_btn:hover::before {
opacity: 1;
}
.login .footer .copy {
margin-top: 36px;
justify-content: space-between;
align-items: center;
gap: 3%;
}
.login .footer .copy p {
font-size: 13px;
color: #d4d4d4;
width: 44%;
text-align: center;
}
.login .footer .copy::before,
.login .login_box .copy::after {
content: "";
width: 20%;
height: 1px;
background: #d4d4d4;
}
.login .footer .fot_btn {
gap: 20px;
margin: 30px 0 10px 0;
}
.login .footer .fot_btn a {
color: #333;
font-weight: 700;
font-size: 13px;
}
.login .footer .fot_btn a:hover {
color: #003288;
}
.login .footer .addr {
color: #666;
font-size: 13px;
gap: 3px 20px;
flex-wrap: wrap;
}
/* 비밀번호 변경 팝업창 */
.login .modal .modal_container {
width: 750px;
}
.login .pw_desc {
padding: 10px 10px 30px 10px;
color: #666;
}
.login .pw_desc p {
font-size: 1.25rem;
color: #000;
font-weight: 700;
margin-bottom: 10px;
}
.login .pw_desc span {
color: #f60;
font-weight: 700;
}
.login .modal .form_write {
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
padding: 20px 50px;
}
@media (max-width: 1024px) {
.login {
flex-direction: column;
height: auto;
}
.login .left_box {
padding: 30px;
flex-direction: column-reverse;
gap: 20px;
}
.login .left_box .text h1 {
font-size: 30px;
line-height: 1.2em;
}
.login .left_box .text p {
margin-top: 20px;
font-size: 14px;
}
.login .left_box .img img {
height: 40px;
}
.login .login_box {
padding: 30px;
}
.login .login_box > ul {
gap: 20px;
}
.login .login_box > ul > li h2 {
margin-bottom: 7px;
}
.login .login_box > ul > li .input_st::after {
left: 15px;
font-size: 20px;
}
.login .login_box > ul > li input {
padding: 15px 10px 15px 50px;
font-size: 16px;
}
.login .login_box .bottom_box {
flex-direction: column;
align-items: flex-start;
padding: 30px 0;
}
.login .login_box .bottom_box .id_save label {
font-size: 14px;
}
.login .login_box .login_btn {
padding: 15px;
}
.login .login_box .login_btn span {
font-size: 16px;
}
.login .login_box .login_btn i {
font-size: 18px;
transform: translateY(-1px);
}
.login .login_box .copy p {
width: 100%;
}
.login .login_box .copy::before,
.login .login_box .copy::after {
display: none;
}
.login .footer .addr {
font-size: 11px;
gap: 3px 10px;
}
.login .modal .modal_container {
width: 90%;
}
.login .pw_desc {
padding: 0 0 20px 0;
}
.login .modal .form_write {
padding: 10px;
}
}
/* // login */
@@ -0,0 +1,109 @@
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
ENDPOINT,
} from "@/src/shared/config";
import { getCookie } from "@/src/shared/lib";
import {
type AuthPayload,
type AuthService,
getRemainingMs,
} from "@common/auth";
import {
clearRefreshTimer,
removeToken,
setToken,
setupRefreshTimer,
tokenManager,
} from "../lib/token-manager";
import { clearTabStorePersist } from "@/src/store/tab-store";
async function login(payload: AuthPayload): Promise<void> {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await response.json();
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("인증 토큰을 받지 못했습니다.");
}
const prevTokenId = getCookie(AUTH_USER_ID);
if (prevTokenId !== undefined && prevTokenId !== tokenId) {
clearTabStorePersist();
}
setToken(CLAIM_NAME, accessToken);
setToken(AUTH_USER_ID, tokenId);
const remainingMs = getRemainingMs(accessToken);
if (remainingMs > 0) {
setupRefreshTimer(silentRefresh, remainingMs);
}
}
async function silentRefresh(): Promise<void> {
console.log(`[silentRefresh] Called at ${new Date().toISOString()}`);
try {
const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
{ method: "PUT" },
);
const data = await response.json();
if (!response.ok) {
throw new Error(data?.message ?? "토큰 갱신에 실패했습니다.");
}
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("갱신된 인증 토큰을 받지 못했습니다.");
}
setToken(CLAIM_NAME, accessToken);
setToken(AUTH_USER_ID, tokenId);
const remainingMs = getRemainingMs(accessToken);
if (remainingMs > 0) {
setupRefreshTimer(silentRefresh, remainingMs);
}
} catch (error) {
console.error(
`[silentRefresh] Failed at ${new Date().toISOString()}`,
error,
);
tokenManager.clearAuthToken();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:logout"));
}
}
}
export async function logout(): Promise<void> {
tokenManager.clearAuthToken();
removeToken("site-id-key");
removeToken("site-theme-key");
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:logout"));
}
}
export const authService: AuthService = {
login,
silentRefresh,
declineSessionExtend: clearRefreshTimer,
logout,
};
@@ -1,76 +0,0 @@
import type { LoginService, ILogin } from "../model/login-interface";
import { tokenManager } from "../lib/token-manager";
import {PROXY_HOST, ACCESS_TOKEN, AUTH_USER_ID,SITE_ID, CLAIM_NAME, ENDPOINT, JWT_EXPIRATION_TIME } from "../../../shared/config";
export class LoginApiService implements LoginService {
private static instance: LoginApiService;
private constructor() {}
public static getInstance(): LoginApiService {
if (!LoginApiService.instance) {
LoginApiService.instance = new LoginApiService();
}
return LoginApiService.instance;
}
public async login(payload: ILogin): Promise<void> {
try {
const response = await fetch( PROXY_HOST+ ENDPOINT.USER_SERVICE.LOGIN, {
method: "POST",
body: JSON.stringify({
userId: payload.userId,
password: payload.password,
}),
});
// const data = response.headers.get(ACCESS_TOKEN);
const accessToken = response.headers.get(ACCESS_TOKEN);
const tokenId = response.headers.get(AUTH_USER_ID);
if (!accessToken || !tokenId) {
throw new Error("Missing required authentication tokens");
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
tokenManager.setToken("site-id-key", SITE_ID);
tokenManager.setupRefreshTimer(
() => this.silentRefresh(),
JWT_EXPIRATION_TIME,
);
} catch (error) {
console.log(' login err ' , error)
throw new Error(`Login failed: ${error}`);
}
}
public async silentRefresh(): Promise<void> {
console.log(' silentRefresh ')
try {
const response = await fetch(PROXY_HOST+
`${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
{ method: "PUT" },
);
const data = await response.json();
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("Missing required authentication tokens");
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
} catch (error) {
tokenManager.clearAuthToken();
throw new Error(`Silent refresh failed: ${error}`);
}
}
}
export const LoginApi = LoginApiService.getInstance();
+2 -1
View File
@@ -1,2 +1,3 @@
export { LoginApi } from "./api/login-service";
export { authService } from "./api/auth-service";
export { AuthWrapper } from "./lib/auth-wrapper";
export { tokenManager } from "./lib/token-manager";
+72 -52
View File
@@ -1,68 +1,88 @@
"use client";
import { useEffect } from "react";
import { client } from "@/src/shared/api";
import type { IUserInfo } from "@/src/shared/store";
import { isAtLoginRedirectUrl } from "@common/network";
import { useUserStore, getCookie } from "@/src/shared/lib";
import { ENDPOINT, AUTH_USER_ID } from "@/src/shared/config";
import { useRouter } from "next/navigation";
const LOGIN_PAGE_URL =
process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login";
import { isAxiosError } from "axios";
import { client } from "@/src/shared/api";
import {
AUTH_LOGIN_REDIRECT_URL,
AUTH_USER_ID,
CLAIM_NAME,
ENDPOINT,
} from "@/src/shared/config";
import { useUserStore } from "@/src/shared/lib";
import type { IUserInfo } from "@/src/shared/store";
import { getRemainingMs } from "@common/auth";
import { isAtLoginRedirectUrl } from "@common/network";
import { authService } from "../api/auth-service";
import { getToken, setupRefreshTimer } from "./token-manager";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const { userInfo, setUserInfo } = useUserStore((state) => state);
const router = useRouter();
const userInfo = useUserStore((state) => state.userInfo);
const setUserInfo = useUserStore((state) => state.setUserInfo);
async function authCheck() {
if (isAtLoginRedirectUrl(LOGIN_PAGE_URL)) {
return;
}
const userId = getCookie(AUTH_USER_ID);
if (!userId) {
throw new Error("Missing required authentication tokens");
}
const response = await client.get<IUserInfo>(
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
);
if (!userInfo) setUserInfo(response.data);
}
useEffect(() => {
(async () => {
try {
await authCheck();
} catch (error) {
console.error(error);
setUserInfo(null);
if (isAtLoginRedirectUrl(LOGIN_PAGE_URL)) {
return;
useEffect(() => {
// 페이지 새로고침 시 silentRefresh 타이머 복구
const token = getToken(CLAIM_NAME);
if (token) {
const remainingMs = getRemainingMs(token);
if (remainingMs > 0) {
console.log(
`[AuthWrapper] Refresh timer set: ${remainingMs}ms (${remainingMs / 1000}s)`,
);
setupRefreshTimer(authService.silentRefresh, remainingMs);
}
}
// const hostname = window.location.hostname;
if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) return;
// // 개발 환경에서는 localhost 도메인 사용
// if (
// hostname === "localhost" ||
// hostname === "127.0.0.1" ||
// hostname === "con.mind-one.co.kr"
// ) {
// router.push("/"); // 개발시 홈으로 돌아가기
// }
async function authCheck() {
const userId = getToken(AUTH_USER_ID);
if (!userId) {
router.replace(AUTH_LOGIN_REDIRECT_URL);
return;
}
// // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용
// // router.push는 같은 Next.js 앱 내에서만 작동하므로 다른 앱으로 이동할 때는 window.location.href 사용
// if (hostname.includes("hasuon.or.kr"))
// window.location.assign(LOGIN_PAGE_URL); // 세션 만료 시 CM 로그인 페이지로 리다이렉트
if (userInfo) return;
// 개발/운영 환경에서 로그인 URL 분기는 환경 변수로 처리 (.env.*)
window.location.assign(LOGIN_PAGE_URL);
}
})();
}, []);
const response = await client.get<IUserInfo>(
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
);
setUserInfo(response.data);
}
return <>{children}</>;
authCheck().catch((error) => {
// 401은 @common/network error-interceptor에서 이미 리다이렉트 처리됨
if (isAxiosError(error) && error.response?.status === 401) return;
console.error(error);
setUserInfo(null);
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
router.replace(AUTH_LOGIN_REDIRECT_URL);
}
});
}, []);
// silentRefresh 실패 시 auth:logout 이벤트를 수신해 로그인으로 이동
useEffect(() => {
function handleAuthLogout() {
setUserInfo(null);
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
router.replace(AUTH_LOGIN_REDIRECT_URL);
}
}
window.addEventListener("auth:logout", handleAuthLogout);
return () =>
window.removeEventListener("auth:logout", handleAuthLogout);
}, []);
return <>{children}</>;
};
export { AuthWrapper };
+13 -107
View File
@@ -1,109 +1,15 @@
import { AUTH_USER_ID, CLAIM_NAME } from "@/src/shared/config";
import { getCookie, removeCookie, setCookie } from "@/src/shared/lib/cookie";
import { authConfig } from "@/src/shared/config/auth";
import { createTokenManager } from "@common/auth";
type JwtPayload = {
sub?: string;
exp?: number;
iat?: number;
[key: string]: unknown;
};
export const tokenManager = createTokenManager(authConfig);
export class TokenManager {
private static instance: TokenManager;
private refreshTimer: NodeJS.Timeout | null = null;
private constructor() {}
public static getInstance(): TokenManager {
if (!TokenManager.instance) {
TokenManager.instance = new TokenManager();
}
return TokenManager.instance;
}
public getToken(key: string): string | undefined {
return getCookie(key);
}
private parseJwt(token: string): JwtPayload {
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("유효한 JWT 형식이 아닙니다.");
}
const base64Url = parts[1] ?? "";
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;
}
public getRemainingMsFromToken(key: string = CLAIM_NAME): number | null {
const token = this.getToken(key);
if (!token) {
return null;
}
try {
const payload = this.parseJwt(token);
if (typeof payload.exp !== "number") {
return null;
}
return payload.exp * 1000 - Date.now();
} catch (error) {
console.error("JWT 파싱 오류:", error);
return null;
}
}
public isExpiredToken(key: string = CLAIM_NAME): boolean {
const remainingMs = this.getRemainingMsFromToken(key);
return remainingMs === null || remainingMs <= 0;
}
public setToken(key: string, value: string): void {
setCookie(key, value, {
secure: location.protocol === "https:",
sameSite: "lax",
});
}
public removeToken(key: string): void {
removeCookie(key);
}
public setupRefreshTimer(
callback: () => Promise<void>,
expirationTime: number,
): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(() => {
callback().catch((error) => {
console.error("Silent refresh failed:", error);
this.clearAuthToken();
});
}, expirationTime - 60000);
}
public clearAuthToken(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.removeToken(CLAIM_NAME);
this.removeToken(AUTH_USER_ID);
}
}
export const tokenManager = TokenManager.getInstance();
export const {
getToken,
setToken,
removeToken,
setupRefreshTimer,
clearRefreshTimer,
getAccessToken,
getRefreshToken,
getUserId,
} = tokenManager;
@@ -1,2 +1,9 @@
export { MenuAuthorityApi } from "./api/menu-authority-service";
export {
menuAuthorityFormSchema,
type MenuAuthority,
type MenuAuthorityPayload,
type MenuAuthorityFormData,
} from "./model/menu-authority-interface";
export { AuthorityButton } from "./ui/authority-button";
export { ManagementAuthorityDialog } from "./ui/management-authority-dialog";
+2 -1
View File
@@ -1,2 +1,3 @@
export { MenuApi } from "./api/menu-service";
export type { MenuItem, MenuService } from "./model/menu-interface";
export { MenuWrapper } from "./lib/menu-wrapper";
export type { MenuItem } from "./model/menu-interface";
+30 -1
View File
@@ -1,6 +1,6 @@
import { PROXY_HOST } from "./env";
export const BASE_URL = `${PROXY_HOST}/server`;
export const BASE_URL = `${PROXY_HOST}`;
export const ENDPOINT = {
ATTACH_SERVICE: {
@@ -14,13 +14,21 @@ export const ENDPOINT = {
},
USER_SERVICE: {
LOGIN: "/user-service/login",
LOGIN_2FA: "/user-service/api/v1/auth/login",
USERS: "/user-service/api/v1/users",
SILENT_REFRESH: "/user-service/api/v1/users/token/refresh",
AUTHORIZATIONS: "/user-service/api/v1/authorizations",
ROLE_AUTHORIZATIONS: "/user-service/api/v1/role-authorizations",
ROLES: "/user-service/api/v1/roles",
LOGINLOG: "/user-service/api/v1/login-logs",
PRIVACYLOG: "/user-service//api/v1/prvc-inq-logs",
OTP_VERIFY: "/user-service/api/v1/auth/2fa/verify",
TWOFA_SETUP: "/user-service/api/v1/auth/2fa/setup",
TWOFA_ENABLE: "/user-service/api/v1/auth/2fa/enable",
USER_JOIN: "/user-service/api/v1/anonymouse/users/join",
},
CMS_SERVICE: {
DASHBOARD: "/cms-service/api/v1/dashboard",
SITES: "/cms-service/api/v1/sites",
IPS: "/cms-service/api/v1/site-ips",
DOMAINS: "/cms-service/api/v1/site-dmns",
@@ -29,6 +37,8 @@ export const ENDPOINT = {
MENUS: "/cms-service/api/v1/menus",
MENU_ROLES: "/cms-service/api/v1/menu-roles",
MENU_AUTHRTS: "/cms-service/api/v1/menu-authrts",
MENU_TEMPORARY_STORAGE: "/cms-service/api/v1/menu-tmpr-strg",
MENU_HISTORY: "/cms-service/api/v1/menu-hstry",
CONTENTS: "/cms-service/api/v1/contents",
CONTENTS_DETAILS: "/cms-service/api/v1/contents-details",
ORGANIZATIONS: "/cms-service/api/v1/ognzs",
@@ -44,13 +54,32 @@ export const ENDPOINT = {
SURVEY: "/cms-service/api/v1/srvy",
SURVEY_QUESTION: "/cms-service/api/v1/srvy-qitem",
SURVEY_ARTICLE: "/cms-service/api/v1/srvy-artcl",
ZIPS: "/cms-service/api/v1/zips",
GROUPS: "/cms-service/api/v1/groups",
GROUP_USERS: "/cms-service/api/v1/group-users",
MANAGER_IP: "/cms-service/api/v1/mgrip",
APILOG: "/cms-service/api/v1/api-logs",
SITE_AUTHORITY: "/cms-service/api/v1/site-authrt",
CONNECTION_LOG: "/cms-service/api/v1/cntnlog",
},
BOARD_SERVICE: {
BBS: "/board-service/api/v1/bbs",
BBS_TEMPLATES: "/board-service/api/v1/bbsTmplt",
POSTS: "/board-service/api/v1/posts",
POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts",
POST_EVALUATION: "/board-service/api/v1/pstEvl",
},
SEWERAGE_SERVICE: {
PPL_COUNTS: "/sewerage-service/api/v1/ms/pplCounts",
OLD_PPL_COUNTS: "/sewerage-service/api/v1/ms/oldPplCounts",
},
IC_SERVICE: {
GROUPING_STATISTICS: "/ic-service/api/v1/nti/grouping-statistics",
},
DC_SERVICE: {
DATA_STATUS: "/dc-service/api/v1/dc/dataStatus",
},
};
export const JWT_EXPIRATION_TIME = 1800000;
+17
View File
@@ -0,0 +1,17 @@
import type { AuthConfig } from "@common/auth";
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
COOKIE_DOMAIN,
REFRESH_TOKEN,
} from "./env";
export const authConfig: AuthConfig = {
authUserId: AUTH_USER_ID || "token-id",
claimName: CLAIM_NAME || "Authorization",
cookieDomain: COOKIE_DOMAIN,
accessToken: ACCESS_TOKEN || "access-token",
refreshToken: REFRESH_TOKEN || "refresh-token",
};
+20 -3
View File
@@ -1,14 +1,31 @@
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "FO";
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
export const PROXY_HOST = process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
export const SERVER_API_WR_URL = process.env.NEXT_PUBLIC_WR_SERVER_API_URL ||'http://con.mind-one.co.kr:11080/api/v1';
export const SERVER_API_FO_URL = process.env.NEXT_PUBLIC_SERVER_API_URL||' http://con.mind-one.co.kr:11082/api/v1';
export const SERVER_API_CS_URL = process.env.NEXT_PUBLIC_CS_SERVER_API_URL||'http://dev.onasys.co.kr:8000';
export const SERVER_API_WR_URL =
process.env.NEXT_PUBLIC_WR_SERVER_API_URL ||
"http://con.mind-one.co.kr:11083/reuse-service/api/v1";
export const SERVER_API_FO_URL =
process.env.NEXT_PUBLIC_FO_SERVER_API_URL ||
process.env.NEXT_PUBLIC_SERVER_API_URL ||
"http://con.mind-one.co.kr:11083/operation-service/api/v1";
export const SERVER_API_CS_URL =
process.env.NEXT_PUBLIC_CS_SERVER_API_URL ||
"http://dev.onasys.co.kr:8000";
export const SERVER_API_URL =
process.env.NEXT_PUBLIC_CS_SERVER_API_URL ||
"http://dev.onasys.co.kr:8000";
export const SERVER_API_CS_TUS_URL =
process.env.NEXT_PUBLIC_CS_TUS_UPLOAD ||
"/attach-service/api/v1/tus/upload";
export const AUTH_LOGIN_REDIRECT_URL =
process.env.NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL || "/auth/login";
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
export const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
export const REFRESH_TOKEN =process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
export const SERVER_SEWERAGE_API_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL;
export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN;
+2
View File
@@ -1,2 +1,4 @@
export * from "./api";
export * from "./env";
export const DEFAULT_LIST_SIZE = 10;
+12
View File
@@ -0,0 +1,12 @@
import type { MenuItem } from "@/src/entities/menu";
export const BBS_NOTICE_MENU_MARKER = "bbsnotice";
export function isBbsNoticeMenu(item: MenuItem): boolean {
const marker = BBS_NOTICE_MENU_MARKER;
return (
item.urlPath?.toLowerCase().includes(marker) ||
item.menuEngNm?.toLowerCase().includes(marker) ||
item.menuPath?.toLowerCase().includes(marker)
);
}
+7
View File
@@ -4,3 +4,10 @@ export { useMenuStore } from "./use-menu-store";
export { useUserStore } from "./use-user-store";
export { usePrompt } from "./use-prompt";
export { getCookie, setCookie, removeCookie } from "./cookie";
export {
buildStatisticsComponentImportPath,
menuMatchesStatisticsTabId,
parseStatisticsMenuUrlPath,
resolveStatisticsCategory,
resolveStatisticsTabId,
} from "./statistics-menu-url";
@@ -0,0 +1,84 @@
import type { MenuItem } from "@/src/entities/menu";
export type ParsedStatisticsMenuUrl = {
categoryFromPath: string | null;
componentName: string | null;
rawUrlPath: string;
};
/** 메뉴 `urlPath`에서 통계 화면 카테고리·컴포넌트명을 파싱합니다. */
export function parseStatisticsMenuUrlPath(
urlPath: string,
): ParsedStatisticsMenuUrl {
const trimmed = (urlPath ?? "").trim();
if (!trimmed) {
return { categoryFromPath: null, componentName: null, rawUrlPath: trimmed };
}
let pathPart = trimmed;
let queryString = "";
const queryIndex = trimmed.indexOf("?");
if (queryIndex >= 0) {
pathPart = trimmed.slice(0, queryIndex);
queryString = trimmed.slice(queryIndex + 1);
}
const tabParam = new URLSearchParams(queryString).get("tab")?.trim() || null;
const segments = pathPart.replace(/^\/+/, "").split("/").filter(Boolean);
let categoryFromPath: string | null = null;
if (segments[0] === "statistics" && segments.length >= 2) {
categoryFromPath = segments[1] ?? null;
}
let componentName = tabParam;
if (!componentName) {
if (!pathPart.includes("/") && !queryString) {
componentName = trimmed;
} else if (segments.length === 1 && segments[0] !== "statistics") {
componentName = segments[0] ?? null;
} else if (segments[0] === "statistics" && segments.length >= 3) {
componentName = segments[segments.length - 1] ?? null;
}
}
return { categoryFromPath, componentName, rawUrlPath: trimmed };
}
/** 동적 import용 카테고리 폴더명 (`input`, `review` 등) */
export function resolveStatisticsCategory(
urlPath: string,
parentEngNm: string,
): string {
const { categoryFromPath } = parseStatisticsMenuUrlPath(urlPath);
return categoryFromPath || parentEngNm || "";
}
/** 탭 ID·URL `?tab=` 값 - 컴포넌트 파일명(확장자 제외) */
export function resolveStatisticsTabId(urlPath: string): string {
const { componentName, rawUrlPath } = parseStatisticsMenuUrlPath(urlPath);
return componentName || rawUrlPath;
}
/** `@/src/components/statistics/{category}/{Component}` 또는 루트 컴포넌트 경로 */
export function buildStatisticsComponentImportPath(
urlPath: string,
parentEngNm: string,
): string {
const { componentName } = parseStatisticsMenuUrlPath(urlPath);
const category = resolveStatisticsCategory(urlPath, parentEngNm);
const name = componentName || urlPath;
if (!category) {
return `@/src/components/statistics/${name}`;
}
return `@/src/components/statistics/${category}/${name}`;
}
export function menuMatchesStatisticsTabId(
menu: MenuItem,
tabId: string,
): boolean {
if (!tabId) return false;
if (menu.urlPath === tabId) return true;
return resolveStatisticsTabId(menu.urlPath) === tabId;
}
+29
View File
@@ -0,0 +1,29 @@
import { create } from "zustand";
import { UserApi } from "@/src/entities/user";
import { getUserInfo } from "../../../../packages/common/components/src/api";
interface AdminStore {
/** 현재 사용자가 관리자 목록에 있는지 */
isAdminInfo: boolean;
setAdminInfo: (value: boolean) => void;
/** API로 관리자 목록 조회 후 현재 사용자 일치 여부 설정 */
fetchAdminInfo: () => Promise<void>;
}
const useAdminStoreHook = create<AdminStore>((set) => ({
isAdminInfo: false,
setAdminInfo: (value) => set({ isAdminInfo: value }),
fetchAdminInfo: async () => {
try {
const userInfo = await getUserInfo();
const adminList = await UserApi.findPageAdminUser({ userName: userInfo.userName });
const matched = adminList.content?.filter((a) => a.userName === userInfo.userName) ?? [];
set({ isAdminInfo: matched.length > 0 });
} catch {
set({ isAdminInfo: false });
}
},
}));
export const useAdminStore = useAdminStoreHook;
export const getAdminStoreState = () => useAdminStoreHook.getState();
+327
View File
@@ -0,0 +1,327 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { MenuItem } from "@/src/entities/menu";
import { MenuAuthorityApi } from "@/src/entities/menu-authority";
import type { PageMenuAuthority } from "@/src/entities/menu-authority/model/menu-authority-interface";
import { isBbsNoticeMenu } from "@/src/shared/lib/bbs-notice-menu";
import { resolveStatisticsTabId } from "@/src/shared/lib/statistics-menu-url";
export interface Tab {
id: string; // 고유 ID (urlPath)
label: string; // 탭에 표시될 이름
path: string; // 메뉴 경로
urlPath: string; // url 경로
parentEngNm: string; // 부모 menuEngNm
menuItem: MenuItem; // 메뉴 정보
/** undefined: 미조회, null: 조회했으나 없음/실패 */
pageMenuAuthority?: PageMenuAuthority | null;
}
async function fetchTabPageMenuAuthority(
menuCd: number,
apply: (authority: PageMenuAuthority | null) => void,
): Promise<void> {
try {
const response = await MenuAuthorityApi.getPageMenuAuthority(menuCd);
apply(response?.[0] ?? null);
} catch {
apply(null);
}
}
function scheduleTabPageMenuAuthorityFetch(
get: () => TabStore,
set: (
partial:
| Partial<TabStore>
| ((state: TabStore) => Partial<TabStore>),
) => void,
tabId: string,
menuCd: number,
): void {
void fetchTabPageMenuAuthority(menuCd, (authority) => {
const { tabs } = get();
if (!tabs.some((tab) => tab.id === tabId)) return;
set((state) => ({
tabs: state.tabs.map((tab) =>
tab.id === tabId ? { ...tab, pageMenuAuthority: authority } : tab,
),
}));
});
}
type TabStoreSetState = (
partial:
| Partial<TabStore>
| ((state: TabStore) => Partial<TabStore>),
) => void;
const tabStoreSetState: TabStoreSetState = (partial) => {
useTabStoreHook.setState(partial);
};
function ensureTabPageMenuAuthority(
get: () => TabStore,
set: TabStoreSetState,
tab: Tab,
): void {
if (tab.pageMenuAuthority !== undefined) return;
scheduleTabPageMenuAuthorityFetch(get, set, tab.id, tab.menuItem.menuCd);
}
/** persist 복원·새로고침 후 활성 탭 메뉴 권한이 없으면 조회 */
export function hydrateActiveTabPageMenuAuthority(): void {
const { tabs, activeTabId } = getTabStoreState();
if (!activeTabId) return;
const tab = tabs.find((t) => t.id === activeTabId);
if (!tab) return;
ensureTabPageMenuAuthority(getTabStoreState, tabStoreSetState, tab);
}
interface TabStore {
tabs: Tab[];
activeTabId: string | null;
menuList: MenuItem[] | null;
addTab: (menuItem: MenuItem) => boolean;
removeTab: (tabId: string) => void;
setActiveTab: (tabId: string) => void;
setMenuList: (menuList: MenuItem[] | null) => void;
closeAllTabs: () => void;
closeOtherTabs: (tabId: string) => void;
closeTabsToLeft: (tabId: string) => void;
closeTabsToRight: (tabId: string) => void;
}
export const TAB_STORE_PERSIST_KEY = "st-tab-store";
const TAB_STORE_VERSION = 1;
const MAX_TABS = 10;
function tabForPersist(tab: Tab): Tab {
return {
...tab,
menuItem: { ...tab.menuItem, children: null },
pageMenuAuthority: undefined,
};
}
function normalizePersistedTab(tab: Tab): Tab {
const tabId = resolveStatisticsTabId(tab.urlPath || tab.id);
return tabId === tab.id ? tab : { ...tab, id: tabId };
}
function sanitizePersistedTabsAndActive(
tabs: Tab[],
activeTabId: string | null,
): { tabs: Tab[]; activeTabId: string | null } {
let nextTabs = Array.isArray(tabs)
? tabs.filter((tab) => !isBbsNoticeMenu(tab.menuItem)).map(normalizePersistedTab)
: [];
if (nextTabs.length > MAX_TABS) nextTabs = nextTabs.slice(0, MAX_TABS);
if (nextTabs.length === 0) {
return { tabs: [], activeTabId: null };
}
let nextActive = activeTabId;
if (nextActive && !nextTabs.some((t) => t.id === nextActive)) {
nextActive = nextTabs[0]?.id ?? null;
}
if (nextActive == null && nextTabs.length > 0) {
nextActive = nextTabs[0]?.id ?? null;
}
return { tabs: nextTabs, activeTabId: nextActive };
}
const useTabStoreHook = create<TabStore>()(
persist(
(set, get) => ({
tabs: [],
activeTabId: null,
menuList: null,
addTab: (menuItem: MenuItem) => {
if (isBbsNoticeMenu(menuItem)) {
return false;
}
const { tabs, menuList } = get();
const tabId = resolveStatisticsTabId(menuItem.urlPath || "");
const existingTab = tabs.find((tab) => tab.id === tabId);
if (existingTab) {
set({ activeTabId: tabId });
ensureTabPageMenuAuthority(get, set, existingTab);
return true;
}
if (tabs.length < MAX_TABS) {
const newTab: Tab = {
id: tabId,
label: menuItem.menuKornNm,
path: menuItem.menuPath,
urlPath: menuItem.urlPath,
parentEngNm:
menuList?.find(
(m) => m.menuCd === menuItem.parentId,
)?.menuEngNm || "",
menuItem,
pageMenuAuthority: undefined,
};
set({
tabs: [...tabs, newTab],
activeTabId: tabId,
});
scheduleTabPageMenuAuthorityFetch(
get,
set,
tabId,
menuItem.menuCd,
);
return true;
}
return false;
},
removeTab: (tabId: string): void => {
const { tabs, activeTabId } = get();
const newTabs = tabs.filter((tab) => tab.id !== tabId);
let newActiveTabId = activeTabId;
if (activeTabId === tabId) {
const closedTabIndex = tabs.findIndex(
(tab) => tab.id === tabId,
);
if (newTabs.length > 0) {
if (closedTabIndex > 0) {
const prevTab = tabs[closedTabIndex - 1];
newActiveTabId = prevTab ? prevTab.id : null;
} else {
const firstTab = newTabs[0];
newActiveTabId = firstTab ? firstTab.id : null;
}
} else {
newActiveTabId = null;
}
}
set({
tabs: newTabs,
activeTabId: newActiveTabId,
});
if (newActiveTabId) {
const nextActive = get().tabs.find((t) => t.id === newActiveTabId);
if (nextActive) ensureTabPageMenuAuthority(get, set, nextActive);
}
},
setActiveTab: (tabId: string) => {
set({ activeTabId: tabId });
const tab = get().tabs.find((t) => t.id === tabId);
if (tab) ensureTabPageMenuAuthority(get, set, tab);
},
setMenuList: (menuList: MenuItem[] | null) => {
set({ menuList });
},
closeAllTabs: () => {
set({
tabs: [],
activeTabId: null,
});
},
closeOtherTabs: (tabId: string) => {
const { tabs } = get();
const tabToKeep = tabs.find((tab) => tab.id === tabId);
if (tabToKeep) {
set({
tabs: [tabToKeep],
activeTabId: tabId,
});
}
},
closeTabsToLeft: (tabId: string) => {
const { tabs, activeTabId } = get();
const index = tabs.findIndex((tab) => tab.id === tabId);
if (index <= 0) return;
const newTabs = tabs.slice(index);
let newActiveTabId = activeTabId;
if (activeTabId && !newTabs.some((tab) => tab.id === activeTabId)) {
newActiveTabId = tabId;
}
set({
tabs: newTabs,
activeTabId: newActiveTabId,
});
if (newActiveTabId) {
const nextActive = get().tabs.find((t) => t.id === newActiveTabId);
if (nextActive) ensureTabPageMenuAuthority(get, set, nextActive);
}
},
closeTabsToRight: (tabId: string) => {
const { tabs, activeTabId } = get();
const index = tabs.findIndex((tab) => tab.id === tabId);
if (index === -1 || index >= tabs.length - 1) return;
const newTabs = tabs.slice(0, index + 1);
let newActiveTabId = activeTabId;
if (activeTabId && !newTabs.some((tab) => tab.id === activeTabId)) {
newActiveTabId = tabId;
}
set({
tabs: newTabs,
activeTabId: newActiveTabId,
});
if (newActiveTabId) {
const nextActive = get().tabs.find((t) => t.id === newActiveTabId);
if (nextActive) ensureTabPageMenuAuthority(get, set, nextActive);
}
},
}),
{
name: TAB_STORE_PERSIST_KEY,
version: TAB_STORE_VERSION,
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
tabs: state.tabs.map(tabForPersist),
activeTabId: state.activeTabId,
}),
merge: (persistedState, currentState) => {
const p = persistedState as Partial<
Pick<TabStore, "tabs" | "activeTabId">
> | null;
const rawTabs = p?.tabs;
const rawActive = p?.activeTabId;
const { tabs, activeTabId } = sanitizePersistedTabsAndActive(
(rawTabs as Tab[]) ?? [],
typeof rawActive === "string" || rawActive === null
? rawActive
: null,
);
return {
...currentState,
tabs,
activeTabId,
};
},
onRehydrateStorage: () => (state, error) => {
if (error || !state) return;
queueMicrotask(() => hydrateActiveTabPageMenuAuthority());
},
},
),
);
export const useTabStore = useTabStoreHook;
export const getTabStoreState = () => useTabStoreHook.getState();
// 로그아웃 또는 사용자 전환 시 로컬 탭 스냅샷 제거 및 메모리 상태 초기화
export function clearTabStorePersist(): void {
useTabStoreHook.getState().closeAllTabs();
useTabStoreHook.persist.clearStorage();
}
+9
View File
@@ -873,12 +873,21 @@ importers:
'@common/assets':
specifier: workspace:*
version: link:../../packages/common/assets
'@common/auth':
specifier: workspace:*
version: link:../../packages/common/auth
'@common/business_components':
specifier: workspace:*
version: link:../../packages/common/business_components
'@common/components':
specifier: workspace:*
version: link:../../packages/common/components
'@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