diff --git a/apps/fo/.env.local b/apps/fo/.env.local index 646d80c2c..3cbefb6a7 100644 --- a/apps/fo/.env.local +++ b/apps/fo/.env.local @@ -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 diff --git a/apps/fo/.env.mindone b/apps/fo/.env.mindone index 2fd7e91c2..28b7b3ac8 100644 --- a/apps/fo/.env.mindone +++ b/apps/fo/.env.mindone @@ -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 diff --git a/apps/fo/.env.production b/apps/fo/.env.production index bd0d0f04b..a28e86d7b 100644 --- a/apps/fo/.env.production +++ b/apps/fo/.env.production @@ -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 diff --git a/apps/fo/app/(auth)/auth/layout.tsx b/apps/fo/app/(auth)/auth/layout.tsx new file mode 100644 index 000000000..2e676e5e0 --- /dev/null +++ b/apps/fo/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/fo/app/(auth)/auth/login/page.tsx b/apps/fo/app/(auth)/auth/login/page.tsx new file mode 100644 index 000000000..52bab35c9 --- /dev/null +++ b/apps/fo/app/(auth)/auth/login/page.tsx @@ -0,0 +1,2 @@ +import { LoginPage } from "@/src/auth/login"; +export default LoginPage; diff --git a/apps/fo/app/api/proxy/[...url]/route.ts b/apps/fo/app/api/proxy/[...url]/route.ts index cf6447b1a..48dd3c72c 100644 --- a/apps/fo/app/api/proxy/[...url]/route.ts +++ b/apps/fo/app/api/proxy/[...url]/route.ts @@ -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 = { - "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); - 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"); } diff --git a/apps/fo/app/auth/login/page.tsx b/apps/fo/app/auth/login/page.tsx deleted file mode 100644 index 0dbc42284..000000000 --- a/apps/fo/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/fo/app/auth/login/route-bak.ts b/apps/fo/app/auth/login/route-bak.ts deleted file mode 100644 index 65bbce15e..000000000 --- a/apps/fo/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/fo/app/components/api/config/env.ts b/apps/fo/app/components/api/config/env.ts index 1c0ba8b0e..009916beb 100644 --- a/apps/fo/app/components/api/config/env.ts +++ b/apps/fo/app/components/api/config/env.ts @@ -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"; \ No newline at end of file +export const DEV_URL_WR = "/reuse-service"; diff --git a/apps/fo/package.json b/apps/fo/package.json index 4dd6c3e3c..8116359df 100644 --- a/apps/fo/package.json +++ b/apps/fo/package.json @@ -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", diff --git a/apps/fo/src/auth/login/index.ts b/apps/fo/src/auth/login/index.ts new file mode 100644 index 000000000..2093c3d8d --- /dev/null +++ b/apps/fo/src/auth/login/index.ts @@ -0,0 +1 @@ +export { LoginPage } from "./ui/login-page"; diff --git a/apps/fo/src/auth/login/ui/login-page.tsx b/apps/fo/src/auth/login/ui/login-page.tsx new file mode 100644 index 000000000..6398700b9 --- /dev/null +++ b/apps/fo/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/fo/src/auth/login/ui/login.css b/apps/fo/src/auth/login/ui/login.css new file mode 100644 index 000000000..e65180ae7 --- /dev/null +++ b/apps/fo/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/fo/src/entities/auth/api/auth-service.ts b/apps/fo/src/entities/auth/api/auth-service.ts new file mode 100644 index 000000000..1bc235e5e --- /dev/null +++ b/apps/fo/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/fo/src/entities/auth/api/login-service.ts b/apps/fo/src/entities/auth/api/login-service.ts deleted file mode 100644 index 760858b99..000000000 --- a/apps/fo/src/entities/auth/api/login-service.ts +++ /dev/null @@ -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 { - 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 { - 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(); diff --git a/apps/fo/src/entities/auth/index.ts b/apps/fo/src/entities/auth/index.ts index 97e142225..7f0d0081a 100644 --- a/apps/fo/src/entities/auth/index.ts +++ b/apps/fo/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/fo/src/entities/auth/lib/auth-wrapper.tsx b/apps/fo/src/entities/auth/lib/auth-wrapper.tsx index 21fd87180..0dc0661ea 100644 --- a/apps/fo/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/fo/src/entities/auth/lib/auth-wrapper.tsx @@ -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( - `${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( + `${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 }; diff --git a/apps/fo/src/entities/auth/lib/token-manager.ts b/apps/fo/src/entities/auth/lib/token-manager.ts index 5908dd091..172f2cc3a 100644 --- a/apps/fo/src/entities/auth/lib/token-manager.ts +++ b/apps/fo/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; - 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, - 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/fo/src/entities/menu-authority/index.ts b/apps/fo/src/entities/menu-authority/index.ts index 2a555b77e..d5991d01c 100644 --- a/apps/fo/src/entities/menu-authority/index.ts +++ b/apps/fo/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/fo/src/entities/menu/index.ts b/apps/fo/src/entities/menu/index.ts index 04f63e38b..2bd1b1787 100644 --- a/apps/fo/src/entities/menu/index.ts +++ b/apps/fo/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/fo/src/shared/config/api.ts b/apps/fo/src/shared/config/api.ts index 3eba9fa91..eebb15c19 100644 --- a/apps/fo/src/shared/config/api.ts +++ b/apps/fo/src/shared/config/api.ts @@ -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; diff --git a/apps/fo/src/shared/config/auth.ts b/apps/fo/src/shared/config/auth.ts new file mode 100644 index 000000000..7c35ad9f0 --- /dev/null +++ b/apps/fo/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/fo/src/shared/config/env.ts b/apps/fo/src/shared/config/env.ts index 6e3bf223e..6db578629 100644 --- a/apps/fo/src/shared/config/env.ts +++ b/apps/fo/src/shared/config/env.ts @@ -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; + diff --git a/apps/fo/src/shared/config/index.ts b/apps/fo/src/shared/config/index.ts index cd83447d5..04e4f2df5 100644 --- a/apps/fo/src/shared/config/index.ts +++ b/apps/fo/src/shared/config/index.ts @@ -1,2 +1,4 @@ export * from "./api"; export * from "./env"; + +export const DEFAULT_LIST_SIZE = 10; diff --git a/apps/fo/src/shared/lib/bbs-notice-menu.ts b/apps/fo/src/shared/lib/bbs-notice-menu.ts new file mode 100644 index 000000000..a621ed669 --- /dev/null +++ b/apps/fo/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/fo/src/shared/lib/index.ts b/apps/fo/src/shared/lib/index.ts index b8b4c52c0..1d0c4a3a8 100644 --- a/apps/fo/src/shared/lib/index.ts +++ b/apps/fo/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/fo/src/shared/lib/statistics-menu-url.ts b/apps/fo/src/shared/lib/statistics-menu-url.ts new file mode 100644 index 000000000..c20dded4c --- /dev/null +++ b/apps/fo/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/fo/src/store/admin-store.ts b/apps/fo/src/store/admin-store.ts new file mode 100644 index 000000000..098ff816e --- /dev/null +++ b/apps/fo/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/fo/src/store/tab-store.ts b/apps/fo/src/store/tab-store.ts new file mode 100644 index 000000000..0379b3601 --- /dev/null +++ b/apps/fo/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 9fd7a2576..4c7b558c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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