sn: 공통 모듈 기반 로그인 변경
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import "../../../src/app/styles/base.css";
|
||||
|
||||
export const metadata = {
|
||||
title: "유역하수도 통합 정보플랫폼",
|
||||
};
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { LoginPage } from "@/auth/login";
|
||||
export default LoginPage;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { LoginPage } from "@/features/auth/login";
|
||||
export default LoginPage;
|
||||
@@ -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)); // 공단 환경
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string>("");
|
||||
|
||||
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage("");
|
||||
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const userId = formData.get("id") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
try {
|
||||
await authService.login({ userId, password });
|
||||
window.location.href = "/";
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "로그인에 실패했습니다.";
|
||||
setErrorMessage(message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login fx">
|
||||
<div className="left_box fx">
|
||||
<div className="text">
|
||||
<h1>
|
||||
유역하수도통합플랫폼
|
||||
<br />
|
||||
<span>하수도통계 관리시스템</span>
|
||||
</h1>
|
||||
<p>
|
||||
유역하수도통합플랫폼 하수도통계 관리시스템에 오신 것을
|
||||
환영합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="img">
|
||||
<Image
|
||||
src={images.logo}
|
||||
width={229}
|
||||
height={46}
|
||||
alt="logo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
id="loginForm"
|
||||
name="loginForm"
|
||||
method="post"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="login_box fx">
|
||||
<ul className="fx">
|
||||
<li>
|
||||
<h2>ID</h2>
|
||||
<div className="input_st st1">
|
||||
<i className="diveicon di-account-box" />
|
||||
<input
|
||||
type="text"
|
||||
className="id"
|
||||
title="아이디를 입력하세요."
|
||||
id="id"
|
||||
name="id"
|
||||
maxLength={12}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<h2>PASSWORD</h2>
|
||||
<div className="input_st st2">
|
||||
<i className="diveicon di-key" />
|
||||
<input
|
||||
type="password"
|
||||
className="input_style"
|
||||
maxLength={25}
|
||||
title="비밀번호를 입력하세요."
|
||||
id="password"
|
||||
name="password"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="bottom_box fx">
|
||||
<div className="checks fx">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="checkId"
|
||||
title="로그인ID 저장여부"
|
||||
id="checkId"
|
||||
/>
|
||||
<label htmlFor="checkId">아이디저장</label>
|
||||
</div>
|
||||
{/* <a href="javascript:fnUserSearch();" className=" pw_message fx"><i className="xi-external-link"></i>비밀번호 또는 아이디를 잃어버리셨나요?</a> */}
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<div className="error_message">
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
<button type="submit" className="login_btn fx">
|
||||
<span>LOGIN</span>
|
||||
</button>
|
||||
<div className="footer">
|
||||
<div className="copy fx">
|
||||
<p>copyright © 2023 KECO. All rights reserved.</p>
|
||||
</div>
|
||||
<div className="fot_btn fx">
|
||||
<a
|
||||
href="https://keco.or.kr/web/lay1/S1T6C1348/contents.do"
|
||||
target="_blank"
|
||||
>
|
||||
개인정보처리방침
|
||||
</a>
|
||||
<a
|
||||
href="https://keco.or.kr/web/lay1/S1T6C1349/contents.do"
|
||||
target="_blank"
|
||||
>
|
||||
저작권정책안내
|
||||
</a>
|
||||
<a
|
||||
href="https://keco.or.kr/web/lay1/S1T6C1350/contents.do"
|
||||
target="_blank"
|
||||
>
|
||||
이메일무단수집거부
|
||||
</a>
|
||||
</div>
|
||||
<p className="addr fx">
|
||||
<span>
|
||||
주소 : (22689) 인천광역시 서구 환경로 42(경서동
|
||||
종합환경연구단지)
|
||||
</span>
|
||||
<span>
|
||||
전화 : (시스템) 032-590-4321, 3752, 4314 / (통
|
||||
계) 032-590-3723, 3722
|
||||
</span>
|
||||
<span>팩스 :032-590-3729</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { LoginPage };
|
||||
@@ -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;
|
||||
@@ -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<void> {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const accessToken = data[ACCESS_TOKEN];
|
||||
const tokenId = data[AUTH_USER_ID];
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("인증 토큰을 받지 못했습니다.");
|
||||
}
|
||||
|
||||
const prevTokenId = getCookie(AUTH_USER_ID);
|
||||
if (prevTokenId !== undefined && prevTokenId !== tokenId) {
|
||||
clearTabStorePersist();
|
||||
}
|
||||
|
||||
setToken(CLAIM_NAME, accessToken);
|
||||
setToken(AUTH_USER_ID, tokenId);
|
||||
|
||||
const remainingMs = getRemainingMs(accessToken);
|
||||
if (remainingMs > 0) {
|
||||
setupRefreshTimer(silentRefresh, remainingMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function silentRefresh(): Promise<void> {
|
||||
console.log(`[silentRefresh] Called at ${new Date().toISOString()}`);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
|
||||
{ method: "PUT" },
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.message ?? "토큰 갱신에 실패했습니다.");
|
||||
}
|
||||
|
||||
const accessToken = data[ACCESS_TOKEN];
|
||||
const tokenId = data[AUTH_USER_ID];
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("갱신된 인증 토큰을 받지 못했습니다.");
|
||||
}
|
||||
|
||||
setToken(CLAIM_NAME, accessToken);
|
||||
setToken(AUTH_USER_ID, tokenId);
|
||||
|
||||
const remainingMs = getRemainingMs(accessToken);
|
||||
if (remainingMs > 0) {
|
||||
setupRefreshTimer(silentRefresh, remainingMs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[silentRefresh] Failed at ${new Date().toISOString()}`,
|
||||
error,
|
||||
);
|
||||
tokenManager.clearAuthToken();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
tokenManager.clearAuthToken();
|
||||
removeToken("site-id-key");
|
||||
removeToken("site-theme-key");
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
}
|
||||
}
|
||||
|
||||
export const authService: AuthService = {
|
||||
login,
|
||||
silentRefresh,
|
||||
declineSessionExtend: clearRefreshTimer,
|
||||
logout,
|
||||
};
|
||||
@@ -1,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";
|
||||
|
||||
@@ -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<IUserInfo>(
|
||||
`${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<IUserInfo>(
|
||||
`${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 };
|
||||
|
||||
@@ -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<void>,
|
||||
expirationTime: number,
|
||||
): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
}
|
||||
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
callback().catch((error) => {
|
||||
console.error("Silent refresh failed:", error);
|
||||
this.clearAuthToken();
|
||||
});
|
||||
}, expirationTime - 60000);
|
||||
}
|
||||
|
||||
public clearAuthToken(): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
|
||||
this.removeToken(CLAIM_NAME);
|
||||
this.removeToken(AUTH_USER_ID);
|
||||
}
|
||||
}
|
||||
|
||||
export const tokenManager = TokenManager.getInstance();
|
||||
export const {
|
||||
getToken,
|
||||
setToken,
|
||||
removeToken,
|
||||
setupRefreshTimer,
|
||||
clearRefreshTimer,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
getUserId,
|
||||
} = tokenManager;
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
export { MenuAuthorityApi } from "./api/menu-authority-service";
|
||||
export {
|
||||
menuAuthorityFormSchema,
|
||||
type MenuAuthority,
|
||||
type MenuAuthorityPayload,
|
||||
type MenuAuthorityFormData,
|
||||
} from "./model/menu-authority-interface";
|
||||
export { AuthorityButton } from "./ui/authority-button";
|
||||
export { ManagementAuthorityDialog } from "./ui/management-authority-dialog";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<UserList> {
|
||||
const response = await client.get<UserList>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 단건 조회
|
||||
public async findByUserUnqId(userUnqId: string): Promise<UserDetailResponse> {
|
||||
const response = await client.get<UserDetailResponse>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 로그인한 사용자 정보 조회
|
||||
public async findByUserUnqIdByLogin(): Promise<UserDetailResponse> {
|
||||
const response = await client.get<UserDetailResponse>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 회원상태 통계 조회
|
||||
public async selectUserSttsStats(): Promise<UserSttsStatsList> {
|
||||
const response = await client.get<UserSttsStatsList>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/stts/stats`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 저장
|
||||
public async saveUser(data: UserSaveData): Promise<string> {
|
||||
const response = await client.post<string>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 수정
|
||||
public async updateUser(
|
||||
userUnqId: string,
|
||||
data: UserUpdateData,
|
||||
): Promise<string> {
|
||||
const response = await client.put<string>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 삭제
|
||||
public async deleteUser(userUnqId: string): Promise<boolean> {
|
||||
const response = await client.delete<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/delete/${userUnqId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 계정잠금 처리
|
||||
public async lockUser(userUnqId: string): Promise<boolean> {
|
||||
const response = await client.put<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/lock/${userUnqId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 계정잠금 해제처리
|
||||
public async unlockUser(userUnqId: string): Promise<boolean> {
|
||||
const response = await client.put<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/unlock/${userUnqId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 리프레시 토큰 정보 재발급
|
||||
public async refreshToken(): Promise<void> {
|
||||
await client.put(`${ENDPOINT.USER_SERVICE.USERS}/token/refresh`);
|
||||
}
|
||||
|
||||
// 사용자 비밀번호 변경
|
||||
public async updatePassword(data: UserPasswordUpdateData): Promise<boolean> {
|
||||
const response = await client.put<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/password/update`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 비밀번호 초기화(관리자용)
|
||||
public async initializePassword(
|
||||
userUnqId: string,
|
||||
data: UserPasswordUpdateData,
|
||||
): Promise<boolean> {
|
||||
const response = await client.put<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/password/initialize/${userUnqId}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 비밀번호 찾기 변경
|
||||
public async changePassword(
|
||||
data: UserFindPasswordUpdateData,
|
||||
): Promise<boolean> {
|
||||
const response = await client.put<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/password/change`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 비밀번호 확인
|
||||
public async matchPassword(data: UserPasswordMatchData): Promise<boolean> {
|
||||
const response = await client.post<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/password/match`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 비밀번호 찾기
|
||||
public async findPasswordUser(
|
||||
data: UserFindPasswordSaveData,
|
||||
): Promise<boolean> {
|
||||
const response = await client.post<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/password/find`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 비밀번호 찾기 유효성 확인
|
||||
public async validPassword(token: string): Promise<boolean> {
|
||||
const response = await client.get<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/password/valid/${token}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 회원정보 변경
|
||||
public async updateInfo(
|
||||
userUnqId: string,
|
||||
data: UserUpdateInfoData,
|
||||
): Promise<string> {
|
||||
const response = await client.put<string>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/info/${userUnqId}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 승인처리
|
||||
public async applyUser(userUnqId: string): Promise<boolean> {
|
||||
const response = await client.put<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/apply/${userUnqId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 회원탈퇴
|
||||
public async leaveUser(data: UserVerifyData): Promise<boolean> {
|
||||
const response = await client.post<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/leave`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 이메일 중복 체크
|
||||
public async existsEmail(data: UserEmailData): Promise<boolean> {
|
||||
const response = await client.post<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/exists`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 아이디 중복 체크
|
||||
public async existsUserId(data: UserIdData): Promise<boolean> {
|
||||
const response = await client.post<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/existsUserId`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 관리자 목록 조회
|
||||
public async findPageAdminUser(
|
||||
params: UserSearchParams,
|
||||
): Promise<AdminUserList> {
|
||||
const response = await client.get<AdminUserList>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/admin/list`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const UserApi = new UserApiService();
|
||||
@@ -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";
|
||||
@@ -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<typeof userUpdateSchema>;
|
||||
export type UserPasswordUpdateData = z.infer<typeof userPasswordUpdateSchema>;
|
||||
export type UserFindPasswordUpdateData = z.infer<
|
||||
typeof userFindPasswordUpdateSchema
|
||||
>;
|
||||
export type UserPasswordMatchData = z.infer<typeof userPasswordMatchSchema>;
|
||||
export type UserUpdateInfoData = z.infer<typeof userUpdateInfoSchema>;
|
||||
export type UserSaveData = z.infer<typeof userSaveSchema>;
|
||||
export type UserFindPasswordSaveData = z.infer<
|
||||
typeof userFindPasswordSaveSchema
|
||||
>;
|
||||
export type UserVerifyData = z.infer<typeof userVerifySchema>;
|
||||
export type UserEmailData = z.infer<typeof userEmailSchema>;
|
||||
export type UserIdData = z.infer<typeof userIdSchema>;
|
||||
|
||||
// 페이지네이션 타입 정의
|
||||
export type UserList = PageableList<UserListResponse>;
|
||||
export type UserSttsStatsList = UserSttsStatsResponse[];
|
||||
export type AdminUserList = PageableList<AdminUserListResponse>;
|
||||
|
||||
// 사용자 서비스 인터페이스
|
||||
export interface UserService {
|
||||
// 사용자 목록 조회
|
||||
findPageUser: (params: UserSearchParams) => Promise<UserList>;
|
||||
|
||||
// 사용자 단건 조회
|
||||
findByUserUnqId: (userUnqId: string) => Promise<UserDetailResponse>;
|
||||
|
||||
// 로그인한 사용자 정보 조회
|
||||
findByUserUnqIdByLogin: () => Promise<UserDetailResponse>;
|
||||
|
||||
// 사용자 회원상태 통계 조회
|
||||
selectUserSttsStats: () => Promise<UserSttsStatsList>;
|
||||
|
||||
// 사용자 저장
|
||||
saveUser: (data: UserSaveData) => Promise<string>;
|
||||
|
||||
// 사용자 수정
|
||||
updateUser: (userUnqId: string, data: UserUpdateData) => Promise<string>;
|
||||
|
||||
// 사용자 삭제
|
||||
deleteUser: (userUnqId: string) => Promise<boolean>;
|
||||
|
||||
// 사용자 계정잠금 처리
|
||||
lockUser: (userUnqId: string) => Promise<boolean>;
|
||||
|
||||
// 사용자 계정잠금 해제처리
|
||||
unlockUser: (userUnqId: string) => Promise<boolean>;
|
||||
|
||||
// 리프레시 토큰 정보 재발급
|
||||
refreshToken: () => Promise<void>;
|
||||
|
||||
// 사용자 비밀번호 변경
|
||||
updatePassword: (data: UserPasswordUpdateData) => Promise<boolean>;
|
||||
|
||||
// 사용자 비밀번호 초기화(관리자용)
|
||||
initializePassword: (
|
||||
userUnqId: string,
|
||||
data: UserPasswordUpdateData,
|
||||
) => Promise<boolean>;
|
||||
|
||||
// 사용자 비밀번호 찾기 변경
|
||||
changePassword: (data: UserFindPasswordUpdateData) => Promise<boolean>;
|
||||
|
||||
// 사용자 비밀번호 확인
|
||||
matchPassword: (data: UserPasswordMatchData) => Promise<boolean>;
|
||||
|
||||
// 사용자 비밀번호 찾기
|
||||
findPasswordUser: (data: UserFindPasswordSaveData) => Promise<boolean>;
|
||||
|
||||
// 사용자 비밀번호 찾기 유효성 확인
|
||||
validPassword: (token: string) => Promise<boolean>;
|
||||
|
||||
// 사용자 회원정보 변경
|
||||
updateInfo: (userUnqId: string, data: UserUpdateInfoData) => Promise<string>;
|
||||
|
||||
// 사용자 승인처리
|
||||
applyUser: (userUnqId: string) => Promise<boolean>;
|
||||
|
||||
// 사용자 회원탈퇴
|
||||
leaveUser: (data: UserVerifyData) => Promise<boolean>;
|
||||
|
||||
// 사용자 이메일 중복 체크
|
||||
existsEmail: (data: UserEmailData) => Promise<boolean>;
|
||||
|
||||
// 사용자 아이디 중복 체크
|
||||
existsUserId: (data: UserIdData) => Promise<boolean>;
|
||||
|
||||
// 관리자 목록 조회
|
||||
findPageAdminUser: (params: UserSearchParams) => Promise<AdminUserList>;
|
||||
}
|
||||
@@ -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<UserPasswordUpdateData>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>비밀번호 초기화</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => onPasswordReset(data))}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>새 비밀번호</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
className="!text-[1.3rem]"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newPasswordConfirm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>새 비밀번호 확인</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
className="!text-[1.3rem]"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" className="white">
|
||||
취소
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit">확인</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<div className="login fx">
|
||||
<div className="left_box fx">
|
||||
<div className="text">
|
||||
<h1>
|
||||
하수도정보시스템
|
||||
<br />
|
||||
<span>CMS 통합관리시스템</span>
|
||||
</h1>
|
||||
<p>하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다.</p>
|
||||
</div>
|
||||
<div className="img">
|
||||
<Image src={images.logo} width={229} height={46} alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
id="loginForm"
|
||||
name="loginForm"
|
||||
method="post"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="login_box fx">
|
||||
<ul className="fx">
|
||||
<li>
|
||||
<h2>ID</h2>
|
||||
<div className="input_st st1">
|
||||
<i className="diveicon di-account-box" />
|
||||
<input
|
||||
type="text"
|
||||
className="id"
|
||||
title="아이디를 입력하세요."
|
||||
id="id"
|
||||
name="id"
|
||||
maxLength={12}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<h2>PASSWORD</h2>
|
||||
<div className="input_st st2">
|
||||
<i className="diveicon di-key" />
|
||||
<input
|
||||
type="password"
|
||||
className="input_style"
|
||||
maxLength={25}
|
||||
title="비밀번호를 입력하세요."
|
||||
id="password"
|
||||
name="password"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="bottom_box fx">
|
||||
<div className="checks fx">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="checkId"
|
||||
title="로그인ID 저장여부"
|
||||
id="checkId"
|
||||
/>
|
||||
<label htmlFor="checkId">아이디저장</label>
|
||||
</div>
|
||||
{/* <a href="javascript:fnUserSearch();" className=" pw_message fx"><i className="xi-external-link"></i>비밀번호 또는 아이디를 잃어버리셨나요?</a> */}
|
||||
</div>
|
||||
<button type="submit" className="login_btn fx">
|
||||
<span>LOGIN</span>
|
||||
</button>
|
||||
<div className="footer">
|
||||
<div className="copy fx">
|
||||
<p>copyright © 2023 KECO. All rights reserved.</p>
|
||||
</div>
|
||||
<div className="fot_btn fx">
|
||||
<a
|
||||
href="https://keco.or.kr/web/lay1/S1T6C1348/contents.do"
|
||||
target="_blank"
|
||||
>
|
||||
개인정보처리방침
|
||||
</a>
|
||||
<a
|
||||
href="https://keco.or.kr/web/lay1/S1T6C1349/contents.do"
|
||||
target="_blank"
|
||||
>
|
||||
저작권정책안내
|
||||
</a>
|
||||
<a
|
||||
href="https://keco.or.kr/web/lay1/S1T6C1350/contents.do"
|
||||
target="_blank"
|
||||
>
|
||||
이메일무단수집거부
|
||||
</a>
|
||||
</div>
|
||||
<p className="addr fx">
|
||||
<span>
|
||||
주소 : (22689) 인천광역시 서구 환경로 42(경서동
|
||||
종합환경연구단지)
|
||||
</span>
|
||||
<span>
|
||||
전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723,
|
||||
3722
|
||||
</span>
|
||||
<span>팩스 :032-590-3729</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { LoginPage };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from "./api";
|
||||
export * from "./env";
|
||||
|
||||
export const DEFAULT_LIST_SIZE = 10;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
|
||||
export function getCookie(name: string): string | undefined {
|
||||
if (typeof window === "undefined") {
|
||||
return undefined;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
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 <FormField>");
|
||||
}
|
||||
|
||||
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<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
});
|
||||
FormItem.displayName = "FormItem";
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = "FormLabel";
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = "FormControl";
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormDescription.displayName = "FormDescription";
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? "") : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[1rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
FormMessage.displayName = "FormMessage";
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { create } from "zustand";
|
||||
import { UserApi } from "@/src/entities/user";
|
||||
import { getUserInfo } from "../../../../packages/common/components/src/api";
|
||||
|
||||
interface AdminStore {
|
||||
/** 현재 사용자가 관리자 목록에 있는지 */
|
||||
isAdminInfo: boolean;
|
||||
setAdminInfo: (value: boolean) => void;
|
||||
/** API로 관리자 목록 조회 후 현재 사용자 일치 여부 설정 */
|
||||
fetchAdminInfo: () => Promise<void>;
|
||||
}
|
||||
|
||||
const useAdminStoreHook = create<AdminStore>((set) => ({
|
||||
isAdminInfo: false,
|
||||
setAdminInfo: (value) => set({ isAdminInfo: value }),
|
||||
fetchAdminInfo: async () => {
|
||||
try {
|
||||
const userInfo = await getUserInfo();
|
||||
const adminList = await UserApi.findPageAdminUser({ userName: userInfo.userName });
|
||||
const matched = adminList.content?.filter((a) => a.userName === userInfo.userName) ?? [];
|
||||
set({ isAdminInfo: matched.length > 0 });
|
||||
} catch {
|
||||
set({ isAdminInfo: false });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export const useAdminStore = useAdminStoreHook;
|
||||
export const getAdminStoreState = () => useAdminStoreHook.getState();
|
||||
@@ -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<void> {
|
||||
try {
|
||||
const response = await MenuAuthorityApi.getPageMenuAuthority(menuCd);
|
||||
apply(response?.[0] ?? null);
|
||||
} catch {
|
||||
apply(null);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTabPageMenuAuthorityFetch(
|
||||
get: () => TabStore,
|
||||
set: (
|
||||
partial:
|
||||
| Partial<TabStore>
|
||||
| ((state: TabStore) => Partial<TabStore>),
|
||||
) => void,
|
||||
tabId: string,
|
||||
menuCd: number,
|
||||
): void {
|
||||
void fetchTabPageMenuAuthority(menuCd, (authority) => {
|
||||
const { tabs } = get();
|
||||
if (!tabs.some((tab) => tab.id === tabId)) return;
|
||||
set((state) => ({
|
||||
tabs: state.tabs.map((tab) =>
|
||||
tab.id === tabId ? { ...tab, pageMenuAuthority: authority } : tab,
|
||||
),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
type TabStoreSetState = (
|
||||
partial:
|
||||
| Partial<TabStore>
|
||||
| ((state: TabStore) => Partial<TabStore>),
|
||||
) => void;
|
||||
|
||||
const tabStoreSetState: TabStoreSetState = (partial) => {
|
||||
useTabStoreHook.setState(partial);
|
||||
};
|
||||
|
||||
function ensureTabPageMenuAuthority(
|
||||
get: () => TabStore,
|
||||
set: TabStoreSetState,
|
||||
tab: Tab,
|
||||
): void {
|
||||
if (tab.pageMenuAuthority !== undefined) return;
|
||||
scheduleTabPageMenuAuthorityFetch(get, set, tab.id, tab.menuItem.menuCd);
|
||||
}
|
||||
|
||||
/** persist 복원·새로고침 후 활성 탭 메뉴 권한이 없으면 조회 */
|
||||
export function hydrateActiveTabPageMenuAuthority(): void {
|
||||
const { tabs, activeTabId } = getTabStoreState();
|
||||
if (!activeTabId) return;
|
||||
const tab = tabs.find((t) => t.id === activeTabId);
|
||||
if (!tab) return;
|
||||
ensureTabPageMenuAuthority(getTabStoreState, tabStoreSetState, tab);
|
||||
}
|
||||
|
||||
interface TabStore {
|
||||
tabs: Tab[];
|
||||
activeTabId: string | null;
|
||||
menuList: MenuItem[] | null;
|
||||
addTab: (menuItem: MenuItem) => boolean;
|
||||
removeTab: (tabId: string) => void;
|
||||
setActiveTab: (tabId: string) => void;
|
||||
setMenuList: (menuList: MenuItem[] | null) => void;
|
||||
closeAllTabs: () => void;
|
||||
closeOtherTabs: (tabId: string) => void;
|
||||
closeTabsToLeft: (tabId: string) => void;
|
||||
closeTabsToRight: (tabId: string) => void;
|
||||
}
|
||||
|
||||
export const TAB_STORE_PERSIST_KEY = "st-tab-store";
|
||||
const TAB_STORE_VERSION = 1;
|
||||
const MAX_TABS = 10;
|
||||
|
||||
function tabForPersist(tab: Tab): Tab {
|
||||
return {
|
||||
...tab,
|
||||
menuItem: { ...tab.menuItem, children: null },
|
||||
pageMenuAuthority: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePersistedTab(tab: Tab): Tab {
|
||||
const tabId = resolveStatisticsTabId(tab.urlPath || tab.id);
|
||||
return tabId === tab.id ? tab : { ...tab, id: tabId };
|
||||
}
|
||||
|
||||
function sanitizePersistedTabsAndActive(
|
||||
tabs: Tab[],
|
||||
activeTabId: string | null,
|
||||
): { tabs: Tab[]; activeTabId: string | null } {
|
||||
let nextTabs = Array.isArray(tabs)
|
||||
? tabs.filter((tab) => !isBbsNoticeMenu(tab.menuItem)).map(normalizePersistedTab)
|
||||
: [];
|
||||
if (nextTabs.length > MAX_TABS) nextTabs = nextTabs.slice(0, MAX_TABS);
|
||||
|
||||
if (nextTabs.length === 0) {
|
||||
return { tabs: [], activeTabId: null };
|
||||
}
|
||||
|
||||
let nextActive = activeTabId;
|
||||
if (nextActive && !nextTabs.some((t) => t.id === nextActive)) {
|
||||
nextActive = nextTabs[0]?.id ?? null;
|
||||
}
|
||||
if (nextActive == null && nextTabs.length > 0) {
|
||||
nextActive = nextTabs[0]?.id ?? null;
|
||||
}
|
||||
|
||||
return { tabs: nextTabs, activeTabId: nextActive };
|
||||
}
|
||||
|
||||
const useTabStoreHook = create<TabStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
menuList: null,
|
||||
addTab: (menuItem: MenuItem) => {
|
||||
if (isBbsNoticeMenu(menuItem)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { tabs, menuList } = get();
|
||||
const tabId = resolveStatisticsTabId(menuItem.urlPath || "");
|
||||
|
||||
const existingTab = tabs.find((tab) => tab.id === tabId);
|
||||
|
||||
if (existingTab) {
|
||||
set({ activeTabId: tabId });
|
||||
ensureTabPageMenuAuthority(get, set, existingTab);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tabs.length < MAX_TABS) {
|
||||
const newTab: Tab = {
|
||||
id: tabId,
|
||||
label: menuItem.menuKornNm,
|
||||
path: menuItem.menuPath,
|
||||
urlPath: menuItem.urlPath,
|
||||
parentEngNm:
|
||||
menuList?.find(
|
||||
(m) => m.menuCd === menuItem.parentId,
|
||||
)?.menuEngNm || "",
|
||||
menuItem,
|
||||
pageMenuAuthority: undefined,
|
||||
};
|
||||
|
||||
set({
|
||||
tabs: [...tabs, newTab],
|
||||
activeTabId: tabId,
|
||||
});
|
||||
|
||||
scheduleTabPageMenuAuthorityFetch(
|
||||
get,
|
||||
set,
|
||||
tabId,
|
||||
menuItem.menuCd,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
removeTab: (tabId: string): void => {
|
||||
const { tabs, activeTabId } = get();
|
||||
const newTabs = tabs.filter((tab) => tab.id !== tabId);
|
||||
|
||||
let newActiveTabId = activeTabId;
|
||||
if (activeTabId === tabId) {
|
||||
const closedTabIndex = tabs.findIndex(
|
||||
(tab) => tab.id === tabId,
|
||||
);
|
||||
if (newTabs.length > 0) {
|
||||
if (closedTabIndex > 0) {
|
||||
const prevTab = tabs[closedTabIndex - 1];
|
||||
newActiveTabId = prevTab ? prevTab.id : null;
|
||||
} else {
|
||||
const firstTab = newTabs[0];
|
||||
newActiveTabId = firstTab ? firstTab.id : null;
|
||||
}
|
||||
} else {
|
||||
newActiveTabId = null;
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
tabs: newTabs,
|
||||
activeTabId: newActiveTabId,
|
||||
});
|
||||
if (newActiveTabId) {
|
||||
const nextActive = get().tabs.find((t) => t.id === newActiveTabId);
|
||||
if (nextActive) ensureTabPageMenuAuthority(get, set, nextActive);
|
||||
}
|
||||
},
|
||||
setActiveTab: (tabId: string) => {
|
||||
set({ activeTabId: tabId });
|
||||
const tab = get().tabs.find((t) => t.id === tabId);
|
||||
if (tab) ensureTabPageMenuAuthority(get, set, tab);
|
||||
},
|
||||
setMenuList: (menuList: MenuItem[] | null) => {
|
||||
set({ menuList });
|
||||
},
|
||||
closeAllTabs: () => {
|
||||
set({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
});
|
||||
},
|
||||
closeOtherTabs: (tabId: string) => {
|
||||
const { tabs } = get();
|
||||
const tabToKeep = tabs.find((tab) => tab.id === tabId);
|
||||
|
||||
if (tabToKeep) {
|
||||
set({
|
||||
tabs: [tabToKeep],
|
||||
activeTabId: tabId,
|
||||
});
|
||||
}
|
||||
},
|
||||
closeTabsToLeft: (tabId: string) => {
|
||||
const { tabs, activeTabId } = get();
|
||||
const index = tabs.findIndex((tab) => tab.id === tabId);
|
||||
if (index <= 0) return;
|
||||
|
||||
const newTabs = tabs.slice(index);
|
||||
let newActiveTabId = activeTabId;
|
||||
if (activeTabId && !newTabs.some((tab) => tab.id === activeTabId)) {
|
||||
newActiveTabId = tabId;
|
||||
}
|
||||
|
||||
set({
|
||||
tabs: newTabs,
|
||||
activeTabId: newActiveTabId,
|
||||
});
|
||||
if (newActiveTabId) {
|
||||
const nextActive = get().tabs.find((t) => t.id === newActiveTabId);
|
||||
if (nextActive) ensureTabPageMenuAuthority(get, set, nextActive);
|
||||
}
|
||||
},
|
||||
closeTabsToRight: (tabId: string) => {
|
||||
const { tabs, activeTabId } = get();
|
||||
const index = tabs.findIndex((tab) => tab.id === tabId);
|
||||
if (index === -1 || index >= tabs.length - 1) return;
|
||||
|
||||
const newTabs = tabs.slice(0, index + 1);
|
||||
let newActiveTabId = activeTabId;
|
||||
if (activeTabId && !newTabs.some((tab) => tab.id === activeTabId)) {
|
||||
newActiveTabId = tabId;
|
||||
}
|
||||
|
||||
set({
|
||||
tabs: newTabs,
|
||||
activeTabId: newActiveTabId,
|
||||
});
|
||||
if (newActiveTabId) {
|
||||
const nextActive = get().tabs.find((t) => t.id === newActiveTabId);
|
||||
if (nextActive) ensureTabPageMenuAuthority(get, set, nextActive);
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: TAB_STORE_PERSIST_KEY,
|
||||
version: TAB_STORE_VERSION,
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
tabs: state.tabs.map(tabForPersist),
|
||||
activeTabId: state.activeTabId,
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
const p = persistedState as Partial<
|
||||
Pick<TabStore, "tabs" | "activeTabId">
|
||||
> | null;
|
||||
const rawTabs = p?.tabs;
|
||||
const rawActive = p?.activeTabId;
|
||||
const { tabs, activeTabId } = sanitizePersistedTabsAndActive(
|
||||
(rawTabs as Tab[]) ?? [],
|
||||
typeof rawActive === "string" || rawActive === null
|
||||
? rawActive
|
||||
: null,
|
||||
);
|
||||
return {
|
||||
...currentState,
|
||||
tabs,
|
||||
activeTabId,
|
||||
};
|
||||
},
|
||||
onRehydrateStorage: () => (state, error) => {
|
||||
if (error || !state) return;
|
||||
queueMicrotask(() => hydrateActiveTabPageMenuAuthority());
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const useTabStore = useTabStoreHook;
|
||||
export const getTabStoreState = () => useTabStoreHook.getState();
|
||||
|
||||
// 로그아웃 또는 사용자 전환 시 로컬 탭 스냅샷 제거 및 메모리 상태 초기화
|
||||
export function clearTabStorePersist(): void {
|
||||
useTabStoreHook.getState().closeAllTabs();
|
||||
useTabStoreHook.persist.clearStorage();
|
||||
}
|
||||
Generated
+6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user