From 6acacbfcf49dc6ee87688318e1e6dfae5defa9c5 Mon Sep 17 00:00:00 2001 From: dongsilove Date: Wed, 15 Jul 2026 22:04:26 +0900 Subject: [PATCH] =?UTF-8?q?sn:=20=EA=B3=B5=ED=86=B5=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=20=EA=B8=B0=EB=B0=98=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/(auth)/auth/layout.tsx | 17 + apps/sn/app/(auth)/auth/login/page.tsx | 2 + apps/sn/app/auth/login/page.tsx | 2 - apps/sn/app/auth/login/route-bak.ts | 14 - apps/sn/app/page.tsx | 4 +- apps/sn/package.json | 4 +- .../sn/src/{features => }/auth/login/index.ts | 0 apps/sn/src/auth/login/ui/login-page.tsx | 156 ++++++++ .../{features => }/auth/login/ui/login.css | 19 +- apps/sn/src/entities/auth/api/auth-service.ts | 109 ++++++ apps/sn/src/entities/auth/index.ts | 3 +- .../sn/src/entities/auth/lib/auth-wrapper.tsx | 121 +++--- .../sn/src/entities/auth/lib/token-manager.ts | 138 +------ apps/sn/src/entities/menu-authority/index.ts | 7 + apps/sn/src/entities/menu/index.ts | 3 +- apps/sn/src/entities/user/api/user-service.ts | 225 +++++++++++ apps/sn/src/entities/user/index.ts | 17 + .../src/entities/user/model/user-interface.ts | 362 ++++++++++++++++++ .../user/ui/password-reset-dialog.tsx | 124 ++++++ .../src/features/auth/login/ui/login-page.tsx | 139 ------- apps/sn/src/shared/config/api.ts | 34 +- apps/sn/src/shared/config/auth.ts | 17 + apps/sn/src/shared/config/env.ts | 17 +- apps/sn/src/shared/config/index.ts | 2 + apps/sn/src/shared/lib/bbs-notice-menu.ts | 12 + apps/sn/src/shared/lib/cookie.ts | 2 + apps/sn/src/shared/lib/index.ts | 8 +- apps/sn/src/shared/lib/statistics-menu-url.ts | 84 ++++ apps/sn/src/shared/ui/form.tsx | 175 +++++++++ apps/sn/src/store/admin-store.ts | 29 ++ apps/sn/src/store/tab-store.ts | 327 ++++++++++++++++ pnpm-lock.yaml | 6 + 32 files changed, 1831 insertions(+), 348 deletions(-) create mode 100644 apps/sn/app/(auth)/auth/layout.tsx create mode 100644 apps/sn/app/(auth)/auth/login/page.tsx delete mode 100644 apps/sn/app/auth/login/page.tsx delete mode 100644 apps/sn/app/auth/login/route-bak.ts rename apps/sn/src/{features => }/auth/login/index.ts (100%) create mode 100644 apps/sn/src/auth/login/ui/login-page.tsx rename apps/sn/src/{features => }/auth/login/ui/login.css (94%) create mode 100644 apps/sn/src/entities/auth/api/auth-service.ts create mode 100644 apps/sn/src/entities/user/api/user-service.ts create mode 100644 apps/sn/src/entities/user/index.ts create mode 100644 apps/sn/src/entities/user/model/user-interface.ts create mode 100644 apps/sn/src/entities/user/ui/password-reset-dialog.tsx delete mode 100644 apps/sn/src/features/auth/login/ui/login-page.tsx create mode 100644 apps/sn/src/shared/config/auth.ts create mode 100644 apps/sn/src/shared/lib/bbs-notice-menu.ts create mode 100644 apps/sn/src/shared/lib/statistics-menu-url.ts create mode 100644 apps/sn/src/shared/ui/form.tsx create mode 100644 apps/sn/src/store/admin-store.ts create mode 100644 apps/sn/src/store/tab-store.ts diff --git a/apps/sn/app/(auth)/auth/layout.tsx b/apps/sn/app/(auth)/auth/layout.tsx new file mode 100644 index 000000000..42f63e71a --- /dev/null +++ b/apps/sn/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/sn/app/(auth)/auth/login/page.tsx b/apps/sn/app/(auth)/auth/login/page.tsx new file mode 100644 index 000000000..8b642ac57 --- /dev/null +++ b/apps/sn/app/(auth)/auth/login/page.tsx @@ -0,0 +1,2 @@ +import { LoginPage } from "@/auth/login"; +export default LoginPage; diff --git a/apps/sn/app/auth/login/page.tsx b/apps/sn/app/auth/login/page.tsx deleted file mode 100644 index 7a35b0ddd..000000000 --- a/apps/sn/app/auth/login/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { LoginPage } from "@/features/auth/login"; -export default LoginPage; diff --git a/apps/sn/app/auth/login/route-bak.ts b/apps/sn/app/auth/login/route-bak.ts deleted file mode 100644 index 796537cc3..000000000 --- a/apps/sn/app/auth/login/route-bak.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { tokenManager } from "@/entities/auth/lib/token-manager"; - -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") { - tokenManager.clearAuthToken(); - return NextResponse.redirect(new URL("/", request.url)); // 로컬 및 개발 환경 - } else { - return NextResponse.redirect(new URL(redirectUrl)); // 공단 환경 - } -} \ No newline at end of file diff --git a/apps/sn/app/page.tsx b/apps/sn/app/page.tsx index b02c4bdca..a81b401db 100644 --- a/apps/sn/app/page.tsx +++ b/apps/sn/app/page.tsx @@ -15,7 +15,7 @@ * - 로그인: POST /api/v1/auth/login (user-service) */ "use client"; -import { LoginApi } from "@/entities/auth"; +import { authService } from "@/entities/auth"; import { useEffect } from "react"; const DEV_HOSTNAMES = new Set(["localhost", "127.0.0.1", "con.mind-one.co.kr"]); @@ -38,7 +38,7 @@ export default function Home() { async function doLogin() { try { // 개발 환경에서는 남아 있는 만료/부분 쿠키로 인한 /map <-> / 루프를 막기 위해 항상 토큰을 재발급한다. - await LoginApi.login(DEV_LOGIN_PAYLOAD); + await authService.login(DEV_LOGIN_PAYLOAD); window.location.replace("/map"); } catch (err) { console.error("로그인 실패", err); diff --git a/apps/sn/package.json b/apps/sn/package.json index 5b89e93db..ec64de9ce 100644 --- a/apps/sn/package.json +++ b/apps/sn/package.json @@ -10,9 +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:*", "@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/sn/src/features/auth/login/index.ts b/apps/sn/src/auth/login/index.ts similarity index 100% rename from apps/sn/src/features/auth/login/index.ts rename to apps/sn/src/auth/login/index.ts diff --git a/apps/sn/src/auth/login/ui/login-page.tsx b/apps/sn/src/auth/login/ui/login-page.tsx new file mode 100644 index 000000000..c41ca5f0a --- /dev/null +++ b/apps/sn/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 "@/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/sn/src/features/auth/login/ui/login.css b/apps/sn/src/auth/login/ui/login.css similarity index 94% rename from apps/sn/src/features/auth/login/ui/login.css rename to apps/sn/src/auth/login/ui/login.css index af73c9c68..e65180ae7 100644 --- a/apps/sn/src/features/auth/login/ui/login.css +++ b/apps/sn/src/auth/login/ui/login.css @@ -1,4 +1,4 @@ -/** +/** * OLYM Cascading Style Sheets * File Name : login.css * Description : 로그인 스타일시트 @@ -24,7 +24,8 @@ body { } .login .left_box { width: 100%; - background: url(/images/bg_login.jpg) no-repeat center/cover; + background: url(../../../../public/images/bg_login.jpg) no-repeat + center/cover; flex-direction: column; justify-content: space-between; padding: 7% 5% 5% 5%; @@ -117,6 +118,20 @@ body { 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; diff --git a/apps/sn/src/entities/auth/api/auth-service.ts b/apps/sn/src/entities/auth/api/auth-service.ts new file mode 100644 index 000000000..b407c2f37 --- /dev/null +++ b/apps/sn/src/entities/auth/api/auth-service.ts @@ -0,0 +1,109 @@ +import { + ACCESS_TOKEN, + AUTH_USER_ID, + CLAIM_NAME, + ENDPOINT, +} from "@/shared/config"; +import { getCookie } from "@/shared/lib"; +import { + type AuthPayload, + type AuthService, + getRemainingMs, +} from "@common/auth"; + +import { + clearRefreshTimer, + removeToken, + setToken, + setupRefreshTimer, + tokenManager, +} from "../lib/token-manager"; + +import { clearTabStorePersist } from "../../../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/sn/src/entities/auth/index.ts b/apps/sn/src/entities/auth/index.ts index 97e142225..7f0d0081a 100644 --- a/apps/sn/src/entities/auth/index.ts +++ b/apps/sn/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/sn/src/entities/auth/lib/auth-wrapper.tsx b/apps/sn/src/entities/auth/lib/auth-wrapper.tsx index f37ff4041..534e1c776 100644 --- a/apps/sn/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/sn/src/entities/auth/lib/auth-wrapper.tsx @@ -1,71 +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 { tokenManager } from "./token-manager"; +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 "@/shared/api"; +import { + AUTH_LOGIN_REDIRECT_URL, + AUTH_USER_ID, + CLAIM_NAME, + ENDPOINT, +} from "@/shared/config"; +import { useUserStore } from "@/shared/lib"; +import type { IUserInfo } from "@/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" - // ) - // window.location.href = "/"; + 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.href = 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/sn/src/entities/auth/lib/token-manager.ts b/apps/sn/src/entities/auth/lib/token-manager.ts index 5b24c8bd3..83affbcaf 100644 --- a/apps/sn/src/entities/auth/lib/token-manager.ts +++ b/apps/sn/src/entities/auth/lib/token-manager.ts @@ -1,127 +1,15 @@ -import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config"; -import { getCookie, setCookie, removeCookie } from "@/shared/lib";; +import { authConfig } from "@/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 { - // 환경에 따른 도메인 설정 - //const domain = this.getCookieDomain(); - - setCookie(key, value, { - secure: window.location.protocol === "https:", - sameSite: "lax", - //domain: domain, - path: "/", - }); - } - - private getCookieDomain(): string | undefined { - const hostname = window.location.hostname; - - // 개발 환경에서는 localhost 도메인 사용 - if (hostname === "localhost" || hostname === "127.0.0.1") return undefined; // localhost에서는 도메인 설정하지 않음 - - // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용 - if (hostname.includes("hasuon.or.kr")) return ".hasuon.or.kr"; // 모든 서브도메인에서 공유 - - // 기타 환경에서는 현재 호스트명 사용 - return hostname; - } - - 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/sn/src/entities/menu-authority/index.ts b/apps/sn/src/entities/menu-authority/index.ts index 2a555b77e..d5991d01c 100644 --- a/apps/sn/src/entities/menu-authority/index.ts +++ b/apps/sn/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/sn/src/entities/menu/index.ts b/apps/sn/src/entities/menu/index.ts index 04f63e38b..2bd1b1787 100644 --- a/apps/sn/src/entities/menu/index.ts +++ b/apps/sn/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/sn/src/entities/user/api/user-service.ts b/apps/sn/src/entities/user/api/user-service.ts new file mode 100644 index 000000000..482d51e16 --- /dev/null +++ b/apps/sn/src/entities/user/api/user-service.ts @@ -0,0 +1,225 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + UserService, + UserSearchParams, + UserList, + UserDetailResponse, + UserSttsStatsList, + UserUpdateData, + UserPasswordUpdateData, + UserFindPasswordUpdateData, + UserPasswordMatchData, + UserUpdateInfoData, + UserSaveData, + UserFindPasswordSaveData, + UserVerifyData, + UserEmailData, + UserIdData, + AdminUserList, +} from "../model/user-interface"; + +export class UserApiService implements UserService { + // 사용자 목록 조회 + public async findPageUser(params: UserSearchParams): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}`, + { params }, + ); + return response.data; + } + + // 사용자 단건 조회 + public async findByUserUnqId(userUnqId: string): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`, + ); + return response.data; + } + + // 로그인한 사용자 정보 조회 + public async findByUserUnqIdByLogin(): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/info/me`, + ); + return response.data; + } + + // 사용자 회원상태 통계 조회 + public async selectUserSttsStats(): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/stts/stats`, + ); + return response.data; + } + + // 사용자 저장 + public async saveUser(data: UserSaveData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}`, + data, + ); + return response.data; + } + + // 사용자 수정 + public async updateUser( + userUnqId: string, + data: UserUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`, + data, + ); + return response.data; + } + + // 사용자 삭제 + public async deleteUser(userUnqId: string): Promise { + const response = await client.delete( + `${ENDPOINT.USER_SERVICE.USERS}/delete/${userUnqId}`, + ); + return response.data; + } + + // 사용자 계정잠금 처리 + public async lockUser(userUnqId: string): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/lock/${userUnqId}`, + ); + return response.data; + } + + // 사용자 계정잠금 해제처리 + public async unlockUser(userUnqId: string): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/unlock/${userUnqId}`, + ); + return response.data; + } + + // 리프레시 토큰 정보 재발급 + public async refreshToken(): Promise { + await client.put(`${ENDPOINT.USER_SERVICE.USERS}/token/refresh`); + } + + // 사용자 비밀번호 변경 + public async updatePassword(data: UserPasswordUpdateData): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/password/update`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 초기화(관리자용) + public async initializePassword( + userUnqId: string, + data: UserPasswordUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/password/initialize/${userUnqId}`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 찾기 변경 + public async changePassword( + data: UserFindPasswordUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/password/change`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 확인 + public async matchPassword(data: UserPasswordMatchData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/password/match`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 찾기 + public async findPasswordUser( + data: UserFindPasswordSaveData, + ): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/password/find`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 찾기 유효성 확인 + public async validPassword(token: string): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/password/valid/${token}`, + ); + return response.data; + } + + // 사용자 회원정보 변경 + public async updateInfo( + userUnqId: string, + data: UserUpdateInfoData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/info/${userUnqId}`, + data, + ); + return response.data; + } + + // 사용자 승인처리 + public async applyUser(userUnqId: string): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/apply/${userUnqId}`, + ); + return response.data; + } + + // 사용자 회원탈퇴 + public async leaveUser(data: UserVerifyData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/leave`, + data, + ); + return response.data; + } + + // 사용자 이메일 중복 체크 + public async existsEmail(data: UserEmailData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/exists`, + data, + ); + return response.data; + } + + // 사용자 아이디 중복 체크 + public async existsUserId(data: UserIdData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/existsUserId`, + data, + ); + return response.data; + } + + // 관리자 목록 조회 + public async findPageAdminUser( + params: UserSearchParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/admin/list`, + { params }, + ); + return response.data; + } +} + +export const UserApi = new UserApiService(); diff --git a/apps/sn/src/entities/user/index.ts b/apps/sn/src/entities/user/index.ts new file mode 100644 index 000000000..8927256f9 --- /dev/null +++ b/apps/sn/src/entities/user/index.ts @@ -0,0 +1,17 @@ +export { UserApi } from "./api/user-service"; +export { + USER_STATE_CODE, + userSaveSchema, + userPasswordUpdateSchema, + userUpdateSchema, + type UserStateCode, + type UserSearchParams, + type UserListResponse, + type UserSaveData, + type UserDetailResponse, + type UserPasswordUpdateData, + type UserUpdateData, + type AdminUserList, + type AdminUserListResponse, +} from "./model/user-interface"; +export { PasswordResetDialog } from "./ui/password-reset-dialog"; diff --git a/apps/sn/src/entities/user/model/user-interface.ts b/apps/sn/src/entities/user/model/user-interface.ts new file mode 100644 index 000000000..012fe9e92 --- /dev/null +++ b/apps/sn/src/entities/user/model/user-interface.ts @@ -0,0 +1,362 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "@/shared/model"; + +export const USER_STATE_CODE = { + 대기: "00", + 정상: "01", + 정지: "07", + 탈퇴: "08", + 삭제: "09", +} as const; + +export type UserStateCode = + (typeof USER_STATE_CODE)[keyof typeof USER_STATE_CODE]; + +// 사용자 검색 파라미터 +export interface UserSearchParams extends PageableParams { + keywordType?: string; + keyword?: string; + userStateCode?: string; + aprvYn?: string; + grpId?: string; + frDateYmd?: string; + toDateYmd?: string; + userName?: string; +} + +// 사용자 목록 응답 인터페이스 +export interface UserListResponse { + userId: string; + userName: string; + email: string; + roleId: string; + roleName: string; + userStateCode: UserStateCode; + userStateCodeName: string; + lastLoginDate: string; + loginFailCount: number; + userUnqId: string; + mphn: string; + telno: string; + aprvYn: string; + regDt: string; + laeSeCd: string; + laeSeNm: string; + ogdpSeCd: string; + ogdpSeNm: string; + instSeCd: string; + instSeNm: string; + aprvYmd: string; +} + +// 사용자 상세 응답 인터페이스 +export interface UserDetailResponse { + regDt: string; + mdfcnDt: string; + rgtr: string; + mdfr: string; + userUnqId: string; + userId: string; + userName: string; + email: string; + encryptedPassword: string; + refreshToken: string; + userStateCode: UserStateCode; + lastLoginDate: string; + loginFailCount: number; + pswdHnt: string; + pswdAns: string; + mphn: string; + telno: string; + zip: string; + addr: string; + daddr: string; + laeSeCd: string; + ogdpSeCd: string; + aprvYn: string; + autzrId: string; + expryYmd: string; + lastPswdChgDt: string; + googleId: string; + kakaoId: string; + naverId: string; + isSocialUser: boolean; + hasPassword: boolean; + twoFactorEnabled: boolean; + adminRoleYn: string; + instSeCd: string; + aprvYmd: string; + enoCd: string; + dtlInstCd: string; + laeSeNm: string; + ogdpSeNm: string; + instSeNm: string; + userStateNm: string; + pswdHntNm: string; + ecoNm: string; + dtlInstNm: string; +} + +// 사용자 상태 통계 응답 인터페이스 +export interface UserSttsStatsResponse { + sttsCd: string; + sttsNm: string; + cnt: number; +} + +// 관리자 목록 응답 인터페이스 +export interface AdminUserListResponse { + userUnqId: string; + userId: string; + userName: string; + groupId: string; + groupNm: string; + userStateCode: string; + userStateCodeName: string; + laeSeCd: string; + laeSeNm: string; + ogdpSeCd: string; + ogdpSeNm: string; + instSeCd: string; + instSeNm: string; + dtlInstCd: string; + dtlInstNm: string; + mdfr: string; + mdfrNm: string; + mdfcnDt: string; +} + +// 사용자 수정 요청 스키마 +export const userUpdateSchema = z.object({ + userId: z.string().optional(), + userName: z.string(), + email: z.string().email().optional(), + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ) + .optional(), + roleId: z.string().optional(), + userStateCode: z.string(), + pswdHnt: z.string().max(10), + pswdAns: z.string().max(100).optional(), + mphn: z.string().max(11).optional(), + telno: z.string().max(11).optional(), + zip: z.string().max(5).optional(), + addr: z.string().max(200).optional(), + daddr: z.string().max(200).optional(), + laeSeCd: z.string().max(10), + ogdpSeCd: z.string().max(10), + aprvYn: z.string(), + autzrId: z.string().uuid().optional(), + expryYmd: z.string().max(8), + instSeCd: z.string().optional(), + dtlInstCd: z.string().optional(), + aprvYmd: z.string().max(8).optional(), + enoCd: z.string().optional(), +}); + +// 사용자 비밀번호 변경 스키마 +export const userPasswordUpdateSchema = z + .object({ + password: z.string().optional(), + provider: z.string().optional(), + token: z.string().optional(), + newPassword: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), + newPasswordConfirm: z.string(), + }) + .refine((data) => data.newPassword === data.newPasswordConfirm, { + message: "비밀번호가 일치하지 않습니다.", + path: ["newPasswordConfirm"], + }); + +// 사용자 비밀번호 찾기 변경 스키마 +export const userFindPasswordUpdateSchema = z.object({ + tokenValue: z.string(), + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), +}); + +// 사용자 비밀번호 확인 스키마 +export const userPasswordMatchSchema = z.object({ + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), +}); + +// 사용자 회원정보 변경 스키마 +export const userUpdateInfoSchema = z.object({ + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ) + .optional(), + provider: z.string().optional(), + token: z.string().optional(), + userName: z.string(), + email: z.string().email().optional(), +}); + +// 사용자 저장 스키마 +export const userSaveSchema = z.object({ + userId: z.string(), + userName: z.string(), + email: z.string().email().optional(), + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), + pswdHnt: z.string().max(10), + pswdAns: z.string().max(100).optional(), + mphn: z.string().max(11).optional(), + telno: z.string().max(11).optional(), + zip: z.string().max(5).optional(), + addr: z.string().max(200).optional(), + daddr: z.string().max(200).optional(), + laeSeCd: z.string().max(10), + ogdpSeCd: z.string().max(10), + expryYmd: z.string().max(8), + instSeCd: z.string().optional(), + dtlInstCd: z.string().optional(), + enoCd: z.string().optional(), +}); + +// 사용자 비밀번호 찾기 저장 스키마 +export const userFindPasswordSaveSchema = z.object({ + userName: z.string(), + emailAddr: z.string().email(), + mainUrl: z.string(), + changePasswordUrl: z.string(), +}); + +// 사용자 인증 요청 스키마 +export const userVerifySchema = z.object({ + password: z + .string() + .regex(/((?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20})|()/) + .optional(), + provider: z.string().optional(), + token: z.string().optional(), +}); + +// 사용자 이메일 요청 스키마 +export const userEmailSchema = z.object({ + userId: z.string().optional(), + email: z.string().email(), +}); + +// 사용자 ID 요청 스키마 +export const userIdSchema = z.object({ + userId: z.string(), +}); + +// Zod 스키마 타입 정의 +export type UserUpdateData = z.infer; +export type UserPasswordUpdateData = z.infer; +export type UserFindPasswordUpdateData = z.infer< + typeof userFindPasswordUpdateSchema +>; +export type UserPasswordMatchData = z.infer; +export type UserUpdateInfoData = z.infer; +export type UserSaveData = z.infer; +export type UserFindPasswordSaveData = z.infer< + typeof userFindPasswordSaveSchema +>; +export type UserVerifyData = z.infer; +export type UserEmailData = z.infer; +export type UserIdData = z.infer; + +// 페이지네이션 타입 정의 +export type UserList = PageableList; +export type UserSttsStatsList = UserSttsStatsResponse[]; +export type AdminUserList = PageableList; + +// 사용자 서비스 인터페이스 +export interface UserService { + // 사용자 목록 조회 + findPageUser: (params: UserSearchParams) => Promise; + + // 사용자 단건 조회 + findByUserUnqId: (userUnqId: string) => Promise; + + // 로그인한 사용자 정보 조회 + findByUserUnqIdByLogin: () => Promise; + + // 사용자 회원상태 통계 조회 + selectUserSttsStats: () => Promise; + + // 사용자 저장 + saveUser: (data: UserSaveData) => Promise; + + // 사용자 수정 + updateUser: (userUnqId: string, data: UserUpdateData) => Promise; + + // 사용자 삭제 + deleteUser: (userUnqId: string) => Promise; + + // 사용자 계정잠금 처리 + lockUser: (userUnqId: string) => Promise; + + // 사용자 계정잠금 해제처리 + unlockUser: (userUnqId: string) => Promise; + + // 리프레시 토큰 정보 재발급 + refreshToken: () => Promise; + + // 사용자 비밀번호 변경 + updatePassword: (data: UserPasswordUpdateData) => Promise; + + // 사용자 비밀번호 초기화(관리자용) + initializePassword: ( + userUnqId: string, + data: UserPasswordUpdateData, + ) => Promise; + + // 사용자 비밀번호 찾기 변경 + changePassword: (data: UserFindPasswordUpdateData) => Promise; + + // 사용자 비밀번호 확인 + matchPassword: (data: UserPasswordMatchData) => Promise; + + // 사용자 비밀번호 찾기 + findPasswordUser: (data: UserFindPasswordSaveData) => Promise; + + // 사용자 비밀번호 찾기 유효성 확인 + validPassword: (token: string) => Promise; + + // 사용자 회원정보 변경 + updateInfo: (userUnqId: string, data: UserUpdateInfoData) => Promise; + + // 사용자 승인처리 + applyUser: (userUnqId: string) => Promise; + + // 사용자 회원탈퇴 + leaveUser: (data: UserVerifyData) => Promise; + + // 사용자 이메일 중복 체크 + existsEmail: (data: UserEmailData) => Promise; + + // 사용자 아이디 중복 체크 + existsUserId: (data: UserIdData) => Promise; + + // 관리자 목록 조회 + findPageAdminUser: (params: UserSearchParams) => Promise; +} diff --git a/apps/sn/src/entities/user/ui/password-reset-dialog.tsx b/apps/sn/src/entities/user/ui/password-reset-dialog.tsx new file mode 100644 index 000000000..548e8fa1a --- /dev/null +++ b/apps/sn/src/entities/user/ui/password-reset-dialog.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, + Button, + Input, + DialogTrigger, +} from "@common/components"; +import { + UserApi, + userPasswordUpdateSchema, + type UserPasswordUpdateData, +} from "@/entities/user"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/shared/ui/form"; +import { useAlertStore, useMutationWrapper } from "@/shared/lib"; + +interface PasswordResetDialogProps { + userUnqId: string; + children: ReactNode; +} + +export function PasswordResetDialog({ + userUnqId, + children, +}: PasswordResetDialogProps) { + const alert = useAlertStore((state) => state.setMessage); + + const [open, setOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(userPasswordUpdateSchema), + defaultValues: { + newPassword: "", + newPasswordConfirm: "", + provider: "password", + }, + }); + + const { mutate: onPasswordReset } = useMutationWrapper({ + mutationFn: (data: UserPasswordUpdateData) => + UserApi.initializePassword(userUnqId, data), + onSuccess: () => { + alert("비밀번호가 초기화되었습니다.", () => { + setOpen(false); + form.reset(); + }); + }, + }); + + return ( + + {children} + + + 비밀번호 초기화 + +
+ onPasswordReset(data))} + className="space-y-6" + > + ( + + 새 비밀번호 + + + + + + )} + /> + ( + + 새 비밀번호 확인 + + + + + + )} + /> + + + + + + + + +
+
+ ); +} diff --git a/apps/sn/src/features/auth/login/ui/login-page.tsx b/apps/sn/src/features/auth/login/ui/login-page.tsx deleted file mode 100644 index dea3c90f1..000000000 --- a/apps/sn/src/features/auth/login/ui/login-page.tsx +++ /dev/null @@ -1,139 +0,0 @@ -"use client"; - -import Image from "next/image"; -import { useEffect } from "react"; -import { images } from "@common/assets"; -import { LoginApi } from "@/entities/auth"; -import "./login.css"; - -function LoginPage() { - useEffect(() => { - const hostname = window.location.hostname; - if (hostname.includes("hasuon.or.kr")) { - window.location.href = - process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login"; - } - }, []); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - const formData = new FormData(e.target as HTMLFormElement); - const userId = formData.get("id") as string; - const password = formData.get("password") as string; - - await LoginApi.login({ userId, password }); - window.location.href = "/map"; - }; - - return ( -
-
-
-

- 하수도정보시스템 -
- CMS 통합관리시스템 -

-

하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다.

-
-
- logo -
-
-
-
-
    -
  • -

    ID

    -
    - - -
    -
  • -
  • -

    PASSWORD

    -
    - - -
    -
  • -
-
-
- - -
- {/* 비밀번호 또는 아이디를 잃어버리셨나요? */} -
- -
-
-

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/sn/src/shared/config/api.ts b/apps/sn/src/shared/config/api.ts index 16d1b5452..eebb15c19 100644 --- a/apps/sn/src/shared/config/api.ts +++ b/apps/sn/src/shared/config/api.ts @@ -1,9 +1,6 @@ import { PROXY_HOST } from "./env"; -// 브라우저에서는 동일 오리진으로 상대경로 호출 → CORS/localhost 고정 문제 방지 -// (Next middleware가 /server/* 요청을 백엔드로 rewrite) -// 서버 환경(SSR 등)에서는 절대 URL이 필요할 수 있어 기존 PROXY_HOST 기반 유지 -export const BASE_URL = `${PROXY_HOST}/server`; +export const BASE_URL = `${PROXY_HOST}`; export const ENDPOINT = { ATTACH_SERVICE: { @@ -17,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", @@ -32,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", @@ -47,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/sn/src/shared/config/auth.ts b/apps/sn/src/shared/config/auth.ts new file mode 100644 index 000000000..7c35ad9f0 --- /dev/null +++ b/apps/sn/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/sn/src/shared/config/env.ts b/apps/sn/src/shared/config/env.ts index ad982d727..2778a4b34 100644 --- a/apps/sn/src/shared/config/env.ts +++ b/apps/sn/src/shared/config/env.ts @@ -1,8 +1,17 @@ +export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "SN"; 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_URL = process.env.NEXT_PUBLIC_SERVER_API_URL; -export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "SN"; +export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL||'http://dev.onasys.co.kr:8000'; + +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 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/sn/src/shared/config/index.ts b/apps/sn/src/shared/config/index.ts index cd83447d5..04e4f2df5 100644 --- a/apps/sn/src/shared/config/index.ts +++ b/apps/sn/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/sn/src/shared/lib/bbs-notice-menu.ts b/apps/sn/src/shared/lib/bbs-notice-menu.ts new file mode 100644 index 000000000..a621ed669 --- /dev/null +++ b/apps/sn/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/sn/src/shared/lib/cookie.ts b/apps/sn/src/shared/lib/cookie.ts index 8f5d0011b..4bc0fe142 100644 --- a/apps/sn/src/shared/lib/cookie.ts +++ b/apps/sn/src/shared/lib/cookie.ts @@ -1,5 +1,7 @@ + import Cookies from "js-cookie"; + export function getCookie(name: string): string | undefined { if (typeof window === "undefined") { return undefined; diff --git a/apps/sn/src/shared/lib/index.ts b/apps/sn/src/shared/lib/index.ts index 061346946..1d0c4a3a8 100644 --- a/apps/sn/src/shared/lib/index.ts +++ b/apps/sn/src/shared/lib/index.ts @@ -2,6 +2,12 @@ export { useMutationWrapper } from "./use-mutation-wrapper"; export { useAlertStore } from "./use-alert-store"; export { useMenuStore } from "./use-menu-store"; export { useUserStore } from "./use-user-store"; -export { useDataStore } from "./use-data-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/sn/src/shared/lib/statistics-menu-url.ts b/apps/sn/src/shared/lib/statistics-menu-url.ts new file mode 100644 index 000000000..c20dded4c --- /dev/null +++ b/apps/sn/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/sn/src/shared/ui/form.tsx b/apps/sn/src/shared/ui/form.tsx new file mode 100644 index 000000000..bd579d263 --- /dev/null +++ b/apps/sn/src/shared/ui/form.tsx @@ -0,0 +1,175 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + FormProvider, + useFormContext, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { cn, Label } from "@common/components"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +