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 (
+
+
+
+
+ 유역하수도통합플랫폼
+
+ 하수도통계 관리시스템
+
+
+ 유역하수도통합플랫폼 하수도통계 관리시스템에 오신 것을
+ 환영합니다.
+
+
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+ );
+}
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 통합관리시스템에 오신 것을 환영합니다.
-
-
-
-
-
-
-
- );
-}
-
-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 (
+
+ );
+});
+FormLabel.displayName = "FormLabel";
+
+const FormControl = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => {
+ const { error, formItemId, formDescriptionId, formMessageId } =
+ useFormField();
+
+ return (
+
+ );
+});
+FormControl.displayName = "FormControl";
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { formDescriptionId } = useFormField();
+
+ return (
+
+ );
+});
+FormDescription.displayName = "FormDescription";
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, children, ...props }, ref) => {
+ const { error, formMessageId } = useFormField();
+ const body = error ? String(error?.message ?? "") : children;
+
+ if (!body) {
+ return null;
+ }
+
+ return (
+
+ {body}
+
+ );
+});
+FormMessage.displayName = "FormMessage";
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+};
diff --git a/apps/sn/src/store/admin-store.ts b/apps/sn/src/store/admin-store.ts
new file mode 100644
index 000000000..098ff816e
--- /dev/null
+++ b/apps/sn/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/sn/src/store/tab-store.ts b/apps/sn/src/store/tab-store.ts
new file mode 100644
index 000000000..7e942879a
--- /dev/null
+++ b/apps/sn/src/store/tab-store.ts
@@ -0,0 +1,327 @@
+import { create } from "zustand";
+import { createJSONStorage, persist } from "zustand/middleware";
+
+import type { MenuItem } from "@/entities/menu";
+import { MenuAuthorityApi } from "@/entities/menu-authority";
+import type { PageMenuAuthority } from "@/entities/menu-authority/model/menu-authority-interface";
+import { isBbsNoticeMenu } from "@/shared/lib/bbs-notice-menu";
+import { resolveStatisticsTabId } from "@/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 4c7b558c7..ea942780c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -976,6 +976,9 @@ 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
@@ -985,6 +988,9 @@ importers:
'@common/config':
specifier: workspace:*
version: link:../../packages/common/config
+ '@common/network':
+ specifier: workspace:*
+ version: link:../../packages/common/network
'@fontsource-variable/noto-sans-kr':
specifier: ^5.2.5
version: 5.2.10