diff --git a/apps/wr/.env.local b/apps/wr/.env.local index 36faf6c47..96b66b536 100644 --- a/apps/wr/.env.local +++ b/apps/wr/.env.local @@ -13,8 +13,8 @@ NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000 NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver -# NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1 -NEXT_PUBLIC_WR_SERVER_API_URL = http://localhost:8883/reuse-service/api/v1 +NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1 +# NEXT_PUBLIC_WR_SERVER_API_URL = http://localhost:8883/reuse-service/api/v1 NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API # NEXT_PUBLIC_SEWERAGE_API_URL=http://localhost:8883/sewerage-service NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr diff --git a/apps/wr/app/(auth)/auth/layout.tsx b/apps/wr/app/(auth)/auth/layout.tsx new file mode 100644 index 000000000..2e676e5e0 --- /dev/null +++ b/apps/wr/app/(auth)/auth/layout.tsx @@ -0,0 +1,17 @@ +import "@/src/app/styles/base.css"; + +export const metadata = { + title: "유역하수도 통합 정보플랫폼", +}; + +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/apps/wr/app/(auth)/auth/login/page.tsx b/apps/wr/app/(auth)/auth/login/page.tsx new file mode 100644 index 000000000..52bab35c9 --- /dev/null +++ b/apps/wr/app/(auth)/auth/login/page.tsx @@ -0,0 +1,2 @@ +import { LoginPage } from "@/src/auth/login"; +export default LoginPage; diff --git a/apps/wr/app/(with-layout)/jwttest/page.tsx b/apps/wr/app/(with-layout)/jwttest/page.tsx index a0d9735a0..c9df447ef 100644 --- a/apps/wr/app/(with-layout)/jwttest/page.tsx +++ b/apps/wr/app/(with-layout)/jwttest/page.tsx @@ -6,7 +6,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { LoginApi } from "@/src/entities/auth/api/login-service"; +import { authService } from "@/src/entities/auth"; import { CLAIM_NAME } from "@/src/shared/config"; import { getCookie } from "@/src/shared/lib"; @@ -125,7 +125,7 @@ export default function Page() { const handleSilentRefresh = async () => { try { - await LoginApi.silentRefresh(); + await authService.silentRefresh(); } catch (error) { console.error("Manual silent refresh failed:", error); } finally { @@ -145,7 +145,7 @@ export default function Page() { ) { isRefreshingRef.current = true; try { - await LoginApi.silentRefresh(); + await authService.silentRefresh(); } catch (error) { console.error("JWT test silent refresh failed:", error); } diff --git a/apps/wr/app/api/proxy/[...url]/route.ts b/apps/wr/app/api/proxy/[...url]/route.ts index 8fabc89dc..48dd3c72c 100644 --- a/apps/wr/app/api/proxy/[...url]/route.ts +++ b/apps/wr/app/api/proxy/[...url]/route.ts @@ -1,125 +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 COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN; -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 || "WR"; - 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 = { - "Content-Type": "application/json", - "X-Site-Id": SITE_ID, - }; - let body = {}; - const isRefreshHandler = params.url.includes("refresh"); + const cookieStore = await cookies(); + const headers: Record = { + "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); // "refresh-token" - console.log("refreshToken", refreshToken?.value); - headers[CLAIM_NAME] = refreshToken?.value ?? ""; // "Authorization" + // silent refresh + if (isRefreshHandler) { + const refreshToken = cookieStore.get(REFRESH_TOKEN); + headers[CLAIM_NAME] = refreshToken?.value ?? ""; - if (!headers[CLAIM_NAME]) { // "Authorization" - 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), - }); -// console.log(result); - if (!result.ok) { - return NextResponse.json( - { message: "first Invalid Credentials" }, - { status: result.status }, - ); + try { + if (!isRefreshHandler) { + body = await request.json(); + } + + 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); // "access-token" - const refreshToken = result.headers.get(REFRESH_TOKEN); // "refresh-token" 신규 받아온 token - const userId = result.headers.get(AUTH_USER_ID); // "token-id" - - if (accessToken && userId) { - const response = NextResponse.json({ - [ACCESS_TOKEN]: accessToken, - [AUTH_USER_ID]: userId, - }); - - if (refreshToken) { - response.cookies.set(REFRESH_TOKEN, refreshToken, { - httpOnly: true, - sameSite: "lax", // 다른 사이트에서 들어오는 모든 요청에 대해 쿠키를 보내지 않는 가장 강력한 설정 - domain: getCookieDomain(), - path: "/", - }); - console.log("refresh cookie setting : " + refreshToken); - } - - 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"); -} - -function getCookieDomain(): string | undefined { - if (typeof window === "undefined") return undefined; - - if (COOKIE_DOMAIN) return COOKIE_DOMAIN; - - const { hostname } = window.location; - if (hostname === "localhost" || hostname === "127.0.0.1") return undefined; - - return hostname; + const params = await props.params; + return handler(request, params, "PUT"); } diff --git a/apps/wr/app/auth/login/page.tsx b/apps/wr/app/auth/login/page.tsx deleted file mode 100644 index 0dbc42284..000000000 --- a/apps/wr/app/auth/login/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { LoginPage } from "@/src/features/auth/login"; -export default LoginPage; diff --git a/apps/wr/app/auth/login/route-bak.ts b/apps/wr/app/auth/login/route-bak.ts deleted file mode 100644 index 65bbce15e..000000000 --- a/apps/wr/app/auth/login/route-bak.ts +++ /dev/null @@ -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)); // 공단 환경 - } -} \ No newline at end of file diff --git a/apps/wr/package.json b/apps/wr/package.json index 864de626c..3ec9cecfb 100644 --- a/apps/wr/package.json +++ b/apps/wr/package.json @@ -15,8 +15,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:*", "@fontsource-variable/noto-sans-kr": "^5.2.5", "@frontend-opensource/use-react-hooks": "^1.1.4", "@hookform/resolvers": "^4.1.3", diff --git a/apps/wr/src/auth/login/index.ts b/apps/wr/src/auth/login/index.ts new file mode 100644 index 000000000..2093c3d8d --- /dev/null +++ b/apps/wr/src/auth/login/index.ts @@ -0,0 +1 @@ +export { LoginPage } from "./ui/login-page"; diff --git a/apps/wr/src/auth/login/ui/login-page.tsx b/apps/wr/src/auth/login/ui/login-page.tsx new file mode 100644 index 000000000..6398700b9 --- /dev/null +++ b/apps/wr/src/auth/login/ui/login-page.tsx @@ -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(""); + + const handleSubmit = async (e: React.SubmitEvent) => { + 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 ( +
+
+
+

+ 유역하수도통합플랫폼 +
+ 하수도통계 관리시스템 +

+

+ 유역하수도통합플랫폼 하수도통계 관리시스템에 오신 것을 + 환영합니다. +

+
+
+ logo +
+
+
+
+
    +
  • +

    ID

    +
    + + +
    +
  • +
  • +

    PASSWORD

    +
    + + +
    +
  • +
+
+
+ + +
+ {/* 비밀번호 또는 아이디를 잃어버리셨나요? */} +
+ {errorMessage && ( +
+ {errorMessage} +
+ )} + +
+
+

copyright © 2023 KECO. All rights reserved.

+
+ +

+ + 주소 : (22689) 인천광역시 서구 환경로 42(경서동 + 종합환경연구단지) + + + 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 + 계) 032-590-3723, 3722 + + 팩스 :032-590-3729 +

+
+
+ +
+ ); +} + +export { LoginPage }; diff --git a/apps/wr/src/auth/login/ui/login.css b/apps/wr/src/auth/login/ui/login.css new file mode 100644 index 000000000..e65180ae7 --- /dev/null +++ b/apps/wr/src/auth/login/ui/login.css @@ -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 */ diff --git a/apps/wr/src/entities/auth/api/auth-service.ts b/apps/wr/src/entities/auth/api/auth-service.ts new file mode 100644 index 000000000..1bc235e5e --- /dev/null +++ b/apps/wr/src/entities/auth/api/auth-service.ts @@ -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 { + 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 { + 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 { + 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, +}; diff --git a/apps/wr/src/entities/auth/api/login-service.ts b/apps/wr/src/entities/auth/api/login-service.ts deleted file mode 100644 index 92535f7b0..000000000 --- a/apps/wr/src/entities/auth/api/login-service.ts +++ /dev/null @@ -1,89 +0,0 @@ - -import type { LoginService, ILogin } from "../model/login-interface"; -import { tokenManager } from "../lib/token-manager"; -import { 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 { - try { - tokenManager.clearAuthToken(); - const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - userId: payload.userId, - password: payload.password, - }), - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data?.message ?? "Login failed"); - } - - 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); - tokenManager.setToken("site-id-key", SITE_ID); - tokenManager.setupRefreshTimer(() => this.silentRefresh(),JWT_EXPIRATION_TIME ); - } catch (error) { - - throw new Error(`Login failed: ${error}`); - } - } - - - public async silentRefresh(): Promise { - console.log(' silentRefresh ') - - 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 ?? "Silent refresh failed"); - } - - 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); - tokenManager.setupRefreshTimer(() => this.silentRefresh(),JWT_EXPIRATION_TIME); - } catch (error) { - tokenManager.clearAuthToken(); - throw new Error(`Silent refresh failed: ${error}`); - } - } -} - -export const LoginApi = LoginApiService.getInstance(); diff --git a/apps/wr/src/entities/auth/index.ts b/apps/wr/src/entities/auth/index.ts index 97e142225..7f0d0081a 100644 --- a/apps/wr/src/entities/auth/index.ts +++ b/apps/wr/src/entities/auth/index.ts @@ -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"; diff --git a/apps/wr/src/entities/auth/lib/auth-wrapper.tsx b/apps/wr/src/entities/auth/lib/auth-wrapper.tsx index 796cd44a9..0dc0661ea 100644 --- a/apps/wr/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/wr/src/entities/auth/lib/auth-wrapper.tsx @@ -1,69 +1,88 @@ "use client"; import { useEffect } from "react"; -import { usePathname } from "next/navigation"; -import { client } from "../../../shared/api"; -import type { IUserInfo } from "../../../shared/store"; -import { useUserStore } from "../../../shared/lib"; -import { ENDPOINT, AUTH_USER_ID } from "../../../shared/config"; -import { isAtLoginRedirectUrl } from "@common/network"; -import { getCookie } from "../../../shared/lib"; +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 pathname = usePathname(); - 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; - } + 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 userId = getCookie(AUTH_USER_ID); - if (!userId) { - throw new Error("Missing required authentication tokens"); - } + if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) return; - const response = await client.get( - `${ENDPOINT.USER_SERVICE.USERS}/info/me`, - ); - if (!userInfo) setUserInfo(response.data); - } + async function authCheck() { + const userId = getToken(AUTH_USER_ID); + if (!userId) { + router.replace(AUTH_LOGIN_REDIRECT_URL); + return; + } - useEffect(() => { - (async () => { - try { - await authCheck(); - } catch (error) { - console.error(error); - setUserInfo(null); + if (userInfo) return; - if (isAtLoginRedirectUrl(LOGIN_PAGE_URL)) return; + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/info/me`, + ); + setUserInfo(response.data); + } - // const hostname = window.location.hostname; + authCheck().catch((error) => { + // 401은 @common/network error-interceptor에서 이미 리다이렉트 처리됨 + if (isAxiosError(error) && error.response?.status === 401) return; - // // 개발 환경에서는 localhost 도메인 사용 - // if ( - // hostname === "localhost" || - // hostname === "127.0.0.1" || - // hostname === "con.mind-one.co.kr" - // ) - // router.push("/auth/login"); + console.error(error); + setUserInfo(null); + if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { + router.replace(AUTH_LOGIN_REDIRECT_URL); + } + }); + }, []); - // // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용 - // // router.push는 같은 Next.js 앱 내에서만 작동하므로 다른 앱으로 이동할 때는 window.location.href 사용 - // if (hostname.includes("hasuon.or.kr")) - // window.location.assign(LOGIN_PAGE_URL); // 세션 만료 시 CM 로그인 페이지로 리다이렉트 + // silentRefresh 실패 시 auth:logout 이벤트를 수신해 로그인으로 이동 + useEffect(() => { + function handleAuthLogout() { + setUserInfo(null); + if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { + router.replace(AUTH_LOGIN_REDIRECT_URL); + } + } - // 개발/운영 환경에서 로그인 URL 분기는 환경 변수로 처리 (.env.*) - window.location.assign(LOGIN_PAGE_URL); - } - })(); - }, [pathname]); + window.addEventListener("auth:logout", handleAuthLogout); + return () => + window.removeEventListener("auth:logout", handleAuthLogout); + }, []); - return <>{children}; + return <>{children}; }; export { AuthWrapper }; diff --git a/apps/wr/src/entities/auth/lib/token-manager.ts b/apps/wr/src/entities/auth/lib/token-manager.ts index 919386622..172f2cc3a 100644 --- a/apps/wr/src/entities/auth/lib/token-manager.ts +++ b/apps/wr/src/entities/auth/lib/token-manager.ts @@ -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; // 토큰 subject - 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] ?? ""; // 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; - } - - 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: window.location.protocol === "https:", - sameSite: "lax", - }); - } - - public removeToken(key: string): void { - removeCookie(key); - } - - public setupRefreshTimer( - callback: () => Promise, - 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; diff --git a/apps/wr/src/entities/menu-authority/index.ts b/apps/wr/src/entities/menu-authority/index.ts index 2a555b77e..d5991d01c 100644 --- a/apps/wr/src/entities/menu-authority/index.ts +++ b/apps/wr/src/entities/menu-authority/index.ts @@ -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"; diff --git a/apps/wr/src/entities/menu/index.ts b/apps/wr/src/entities/menu/index.ts index 04f63e38b..2bd1b1787 100644 --- a/apps/wr/src/entities/menu/index.ts +++ b/apps/wr/src/entities/menu/index.ts @@ -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"; diff --git a/apps/wr/src/shared/config/auth.ts b/apps/wr/src/shared/config/auth.ts new file mode 100644 index 000000000..7c35ad9f0 --- /dev/null +++ b/apps/wr/src/shared/config/auth.ts @@ -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", +}; diff --git a/apps/wr/src/shared/config/env.ts b/apps/wr/src/shared/config/env.ts index 89dd8addd..857b2d197 100644 --- a/apps/wr/src/shared/config/env.ts +++ b/apps/wr/src/shared/config/env.ts @@ -4,12 +4,17 @@ export const PROXY_HOST = process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhos export const SERVER_API_WR_URL = process.env.NEXT_PUBLIC_WR_SERVER_API_URL ||'http://apigateway.iipbs-dev.svc.cluster.local:8000/api/v1'; export const SERVER_API_FO_URL = process.env.NEXT_PUBLIC_FO_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_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; + diff --git a/apps/wr/src/shared/lib/bbs-notice-menu.ts b/apps/wr/src/shared/lib/bbs-notice-menu.ts new file mode 100644 index 000000000..a621ed669 --- /dev/null +++ b/apps/wr/src/shared/lib/bbs-notice-menu.ts @@ -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) + ); +} diff --git a/apps/wr/src/shared/lib/index.ts b/apps/wr/src/shared/lib/index.ts index b8b4c52c0..1d0c4a3a8 100644 --- a/apps/wr/src/shared/lib/index.ts +++ b/apps/wr/src/shared/lib/index.ts @@ -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"; diff --git a/apps/wr/src/shared/lib/statistics-menu-url.ts b/apps/wr/src/shared/lib/statistics-menu-url.ts new file mode 100644 index 000000000..c20dded4c --- /dev/null +++ b/apps/wr/src/shared/lib/statistics-menu-url.ts @@ -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; +} diff --git a/apps/wr/src/store/admin-store.ts b/apps/wr/src/store/admin-store.ts new file mode 100644 index 000000000..098ff816e --- /dev/null +++ b/apps/wr/src/store/admin-store.ts @@ -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; +} + +const useAdminStoreHook = create((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(); diff --git a/apps/wr/src/store/tab-store.ts b/apps/wr/src/store/tab-store.ts new file mode 100644 index 000000000..0379b3601 --- /dev/null +++ b/apps/wr/src/store/tab-store.ts @@ -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 { + try { + const response = await MenuAuthorityApi.getPageMenuAuthority(menuCd); + apply(response?.[0] ?? null); + } catch { + apply(null); + } +} + +function scheduleTabPageMenuAuthorityFetch( + get: () => TabStore, + set: ( + partial: + | Partial + | ((state: TabStore) => Partial), + ) => 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 + | ((state: TabStore) => Partial), +) => 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()( + 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 + > | 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(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59ae08eb0..9fd7a2576 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,10 +104,10 @@ importers: devDependencies: eslint: specifier: ^9.0.0 - version: 9.39.4(jiti@2.6.1) + version: 9.39.4(jiti@1.21.7) eslint-config-next: specifier: 15.3.8 - version: 15.3.8(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 15.3.8(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) postcss: specifier: ^8.4.49 version: 8.5.8 @@ -370,10 +370,10 @@ importers: version: 5.28.5 eslint: specifier: ^9.0.0 - version: 9.39.4(jiti@1.21.7) + version: 9.39.4(jiti@2.6.1) eslint-config-next: specifier: 15.3.8 - version: 15.3.8(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + version: 15.3.8(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) postcss: specifier: ^8.4.49 version: 8.5.8 @@ -537,6 +537,9 @@ importers: '@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 @@ -851,10 +854,10 @@ importers: version: 5.28.5 eslint: specifier: ^9 - version: 9.39.4(jiti@1.21.7) + version: 9.39.4(jiti@2.6.1) eslint-config-next: specifier: 15.3.8 - version: 15.3.8(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + version: 15.3.8(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) postcss: specifier: ^8 version: 8.5.8 @@ -1299,12 +1302,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 @@ -1347,6 +1359,9 @@ importers: js-cookie: specifier: ^3.0.5 version: 3.0.5 + js-sha512: + specifier: ^0.9.0 + version: 0.9.0 lucide-react: specifier: ^0.474.0 version: 0.474.0(react@19.2.4)