Merge commit '50ffc2d763ae5797dd41c068777c5546f1d971ce' into iipbs/common/main

This commit is contained in:
truthwing
2025-03-17 17:54:29 +09:00
98 changed files with 3554 additions and 6099 deletions
View File
+100
View File
@@ -0,0 +1,100 @@
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
REFRESH_TOKEN,
SERVER_API_URL,
SITE_ID,
} from "@/shared/config/env";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
async function handler(
request: Request,
params: { url: string[] },
method: "GET" | "POST" | "PUT" | "DELETE",
) {
const cookieStore = await cookies();
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
};
let body = {};
const isRefreshHandler = params.url.includes("refresh");
// silent refresh
if (isRefreshHandler) {
const refreshToken = cookieStore.get(REFRESH_TOKEN);
headers[CLAIM_NAME] = refreshToken?.value ?? "";
if (!headers[CLAIM_NAME]) {
return NextResponse.json({ message: "Can't refresh" }, { status: 401 });
}
}
try {
if (!isRefreshHandler) {
body = await request.json();
}
const result = await fetch(`${SERVER_API_URL}/${params.url.join("/")}`, {
method,
headers,
body: JSON.stringify(body),
});
if (!result.ok) {
return NextResponse.json(
{ message: "Invalid Credentials" },
{ status: 401 },
);
}
const accessToken = result.headers.get(ACCESS_TOKEN);
const refreshToken = result.headers.get(REFRESH_TOKEN);
const userId = result.headers.get(AUTH_USER_ID);
if (accessToken && userId) {
const response = NextResponse.json({
[ACCESS_TOKEN]: accessToken,
[AUTH_USER_ID]: userId,
});
if (refreshToken) {
cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "lax",
path: "/",
});
}
return response;
}
return NextResponse.json(
{ message: "Invalid Credentials" },
{ status: 401 },
);
} catch (error) {
return NextResponse.json(
{ message: "Server Error", error },
{ status: 500 },
);
}
}
export async function POST(
request: Request,
props: { params: Promise<{ url: string[] }> },
) {
const params = await props.params;
return handler(request, params, "POST");
}
export async function PUT(
request: Request,
props: { params: Promise<{ url: string[] }> },
) {
const params = await props.params;
return handler(request, params, "PUT");
}
+2
View File
@@ -0,0 +1,2 @@
import { LoginPage } from "@/pages/auth/login";
export default LoginPage;
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_PORT: string;
readonly VITE_PROXY_HOST: string;
readonly VITE_SERVER_API_URL: string;
readonly VITE_SITE_ID: string;
readonly VITE_CLAIM_NAME: string;
readonly VITE_ACCESS_TOKEN: string;
readonly VITE_REFRESH_TOKEN: string;
readonly VITE_AUTH_USER_ID: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+45 -1
View File
@@ -1,5 +1,49 @@
import type { NextConfig } from "next";
import webpack from "webpack";
const nextConfig: NextConfig = {};
const serverApiUrl =
process.env.NEXT_PUBLIC_SERVER_API_URL || "http://localhost:3000";
const nextConfig: NextConfig = {
webpack: (config) => {
config.plugins.push(
new webpack.DefinePlugin({
"import.meta.env.VITE_PORT": JSON.stringify(
process.env.NEXT_PUBLIC_PORT,
),
"import.meta.env.VITE_PROXY_HOST": JSON.stringify(
process.env.NEXT_PUBLIC_PROXY_HOST,
),
"import.meta.env.VITE_SERVER_API_URL": JSON.stringify(
process.env.NEXT_PUBLIC_SERVER_API_URL,
),
"import.meta.env.VITE_SITE_ID": JSON.stringify(process.env.SITE_ID),
"import.meta.env.VITE_CLAIM_NAME": JSON.stringify(
process.env.NEXT_PUBLIC_CLAIM_NAME,
),
"import.meta.env.VITE_ACCESS_TOKEN": JSON.stringify(
process.env.NEXT_PUBLIC_ACCESS_TOKEN,
),
"import.meta.env.VITE_REFRESH_TOKEN": JSON.stringify(
process.env.NEXT_PUBLIC_REFRESH_TOKEN,
),
"import.meta.env.VITE_AUTH_USER_ID": JSON.stringify(
process.env.NEXT_PUBLIC_AUTH_USER_ID,
),
"process.env.SITE_ID": JSON.stringify(process.env.NEXT_PUBLIC_SITE_ID),
}),
);
return config;
},
async rewrites() {
return [
{
source: "/server/:path*",
destination: `${serverApiUrl}/:path*`,
},
];
},
};
export default nextConfig;
+10 -2
View File
@@ -1,6 +1,6 @@
{
"name": "cs",
"version": "0.1.0",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "NODE_OPTIONS='--inspect' next dev",
@@ -11,13 +11,21 @@
"dependencies": {
"@common/assets": "workspace:*",
"@common/components": "workspace:*",
"@tanstack/react-query": "^5.67.1",
"@tanstack/react-table": "^8.21.2",
"next": "15.1.7"
"axios": "^1.8.1",
"chart.js": "^4.4.8",
"js-cookie": "^3.0.5",
"next": "15.1.7",
"react-chartjs-2": "^5.3.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@faker-js/faker": "^9.5.0",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
"eslint": "^9",
"eslint-config-next": "15.1.7",
"postcss": "^8",
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

+8 -1
View File
@@ -1,10 +1,17 @@
import "./styles/base.css";
import "./styles/components.css";
import { AuthProvider, CookieProvider, QueryProvider } from "@/shared/provider";
const App = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return (
<html lang="ko">
<body>{children}</body>
<body>
<QueryProvider>
<CookieProvider>
<AuthProvider>{children}</AuthProvider>
</CookieProvider>
</QueryProvider>
</body>
</html>
);
};
@@ -0,0 +1,85 @@
import { AxiosError } from "axios";
import { ENDPOINT, JWT_EXPIRATION_TIME } from "@/shared/config/api";
import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME } from "@/shared/config/env";
import type { LoginService, ILogin } from "../model/login-interface";
import { tokenManager } from "../lib/token-manager";
export class LoginApiService implements LoginService {
private static instance: LoginApiService;
private constructor() {}
public static getInstance(): LoginApiService {
if (!LoginApiService.instance) {
LoginApiService.instance = new LoginApiService();
}
return LoginApiService.instance;
}
public async login(payload: ILogin): Promise<void> {
try {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
body: JSON.stringify({
userId: payload.userId,
password: payload.password,
}),
});
const data = await response.json();
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("[login] Missing required authentication tokens");
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
tokenManager.setupRefreshTimer(
() => this.silentRefresh(),
JWT_EXPIRATION_TIME,
);
} catch (error) {
if (error instanceof AxiosError) {
throw new Error(
`Login failed: ${error.response?.data?.message || error.message}`,
);
}
throw error;
}
}
public async silentRefresh(): Promise<void> {
try {
const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
{ method: "PUT" },
);
const data = await response.json();
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error(
"[silentRefresh] Missing required authentication tokens",
);
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
} catch (error) {
console.warn("Token has expired, logging out.");
tokenManager.clearAuthToken();
if (error instanceof AxiosError) {
throw new Error(
`Silent refresh failed: ${error.response?.data?.message || error.message}`,
);
}
throw error;
}
}
}
export const LoginApi = LoginApiService.getInstance();
+1
View File
@@ -0,0 +1 @@
export { LoginApi } from "./api/login-service";
@@ -0,0 +1,59 @@
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config/env";
import { getCookie, setCookie, removeCookie } from "@/shared/lib/cookie";
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);
}
public setToken(key: string, value: string): void {
setCookie(key, value, {
secure: window.location.protocol === "https:",
sameSite: "lax",
});
}
public removeToken(key: string): void {
removeCookie(key);
}
public setupRefreshTimer(
callback: () => Promise<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();
@@ -0,0 +1,9 @@
export interface ILogin {
userId: string;
password: string;
}
export interface LoginService {
login(payload: ILogin): Promise<void>;
silentRefresh(): Promise<void>;
}
@@ -0,0 +1,20 @@
import { client } from "@/shared/api/client";
import { ENDPOINT } from "@/shared/config/api";
import type {
ISiteQuery,
ISiteInfo,
SiteService,
} from "../model/site-interface";
export class SiteApiService implements SiteService {
public async siteInfo(query: ISiteQuery): Promise<ISiteInfo> {
const response = await client.get<ISiteInfo>(
`${ENDPOINT.CMS_SERVICE.SITES}`,
{ params: query },
);
return response.data;
}
}
export const SiteApi = new SiteApiService();
+1
View File
@@ -0,0 +1 @@
export { SiteApi } from "./api/site-service";
@@ -0,0 +1,49 @@
export interface ISiteQuery {
siteNm: string;
siteKndCd: string;
useYn: string;
page: number;
size: number;
sort: string[];
}
export interface ISite {
siteId: string;
siteNm: string;
siteKindCode: string;
bscSiteYn: string;
useYn: string;
}
export interface ISiteInfo {
totalPages: number;
totalElements: number;
number: number;
sort: {
empty: boolean;
sorted: boolean;
unsorted: boolean;
};
size: number;
content: ISite[];
pageable: {
sort: {
empty: boolean;
sorted: boolean;
unsorted: boolean;
};
offset: number;
pageNumber: number;
pageSize: number;
paged: boolean;
unpaged: boolean;
};
numberOfElements: number;
first: boolean;
last: boolean;
empty: boolean;
}
export interface SiteService {
siteInfo(query: ISiteQuery): Promise<ISiteInfo>;
}
@@ -0,0 +1,15 @@
import { client } from "@/shared/api/client";
import { ENDPOINT } from "@/shared/config/api";
import type { IUser, IUserInfo, UserService } from "../model/user-interface";
export class UsersApiService implements UserService {
public async userInfo(payload: IUser): Promise<IUserInfo> {
const response = await client.get<IUserInfo>(
`${ENDPOINT.USER_SERVICE.USERS}/${payload.userUniqId}`,
);
return response.data;
}
}
export const UserApi = new UsersApiService();
+1
View File
@@ -0,0 +1 @@
export { useUser } from "./lib/use-user";
+20
View File
@@ -0,0 +1,20 @@
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config/api";
import { AUTH_USER_ID } from "@/shared/config/env";
import { useCookie } from "@/shared/provider";
import { UserApi } from "../api/user-service";
const useUser = () => {
const { getCookie } = useCookie();
const { data } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS],
queryFn: () =>
UserApi.userInfo({
userUniqId: getCookie(AUTH_USER_ID) ?? "",
}),
});
return { userInfo: data };
};
export { useUser };
@@ -0,0 +1,41 @@
export interface IUser {
userUniqId: string;
}
export interface IUserInfo {
regDt: string;
mdfcnDt: string;
rgtr: string;
mdfr: string;
userUnqId: string;
userId: string;
userName: string;
email: string;
encryptedPassword: string;
refreshToken: string;
userStateCode: string;
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;
}
export interface UserService {
userInfo: (payload: IUser) => Promise<IUserInfo>;
}
+1
View File
@@ -0,0 +1 @@
export { LoginPage } from "./ui/login-page";
@@ -0,0 +1,128 @@
"use client";
import Image from "next/image";
import { images } from "@common/assets";
import { LoginApi } from "@/entities/auth/login";
import "./login.css";
function LoginPage() {
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;
LoginApi.login({ userId, password });
};
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 };
+311
View File
@@ -0,0 +1,311 @@
/**
* OLYM Cascading Style Sheets
* File Name : login.css
* Description : 로그인 스타일시트
* Author : Web business Team / Choi Hyun Mi
* Email : desingtj@olym.co.kr
* Date : 2023.07.20
* Update : 2023.07.26
* Copyright(c) 2023 OLYM Communications. All Rights Reserved.
*/
html,
body {
background: #fff;
}
.fx {
display: flex;
-ms-display: flex;
-webkit-display: flex;
box-sizing: border-box;
}
.login {
width: 100%;
height: 100vh;
}
.login .left_box {
width: 100%;
background: url(../../../../../public/images/bg_login.jpg) no-repeat
center/cover;
flex-direction: column;
justify-content: space-between;
padding: 7% 5% 5% 5%;
}
.login .left_box .text h1 {
font-size: 53px;
color: #fff;
}
.login .left_box .text h1 span {
font-weight: 800;
}
.login .left_box .text p {
margin-top: 40px;
font-size: 18px;
color: #fff;
}
.login .login_box {
width: 100%;
flex-direction: column;
justify-content: center;
padding: 4% 12% 4% 6%;
}
.login .login_box > ul {
flex-direction: column;
gap: 40px;
}
.login .login_box > ul > li h2 {
font-size: 22px;
font-weight: 600;
color: #2640a5;
margin-bottom: 14px;
}
.login .login_box > ul > li .input_st {
position: relative;
}
.login .login_box > ul > li .input_st .diveicon {
position: absolute;
top: 50%;
left: 28px;
transform: translateY(-50%);
font-family: "Xeicon";
font-size: 25px;
color: #003288;
}
.login .login_box > ul > li input {
width: 100%;
padding: 20px 20px 20px 70px;
background: #f7f7f7;
box-sizing: border-box;
border: none;
border-radius: 5px;
font-size: 18px;
font-weight: 700;
color: #000;
height: auto;
}
.login .login_box > ul > li input:focus {
outline: 1px solid #3c5fe5;
}
.login .login_box > ul > li input::placeholder {
color: #afabab;
font-weight: 500;
}
.login .login_box .bottom_box {
justify-content: space-between;
gap: 20px;
padding: 50px 0 45px 0;
}
.login .login_box .bottom_box .checks {
display: flex;
align-items: center;
gap: 8px;
}
.login .login_box .bottom_box .checks input[type="checkbox"] {
width: 18px;
height: 18px;
}
.login .login_box .bottom_box .checks label {
font-size: 14px;
}
.login .login_box .bottom_box .pw_message {
align-items: center;
gap: 5px;
color: #636363;
font-weight: 500;
border-bottom: 1px solid #ccc;
padding-bottom: 5px;
}
.login .login_box .bottom_box .pw_message i {
color: #327af5;
font-size: 20px;
}
.login .login_box .login_btn {
position: relative;
border-radius: 5px;
padding: 24px 15px;
background: linear-gradient(
to left,
#1d3969 0%,
#264078 10%,
#172558 40%,
#162254 50%,
#111c43 60%,
#111c42 100%
);
overflow: hidden;
justify-content: center;
align-items: center;
gap: 10px;
color: #fff;
cursor: pointer;
}
.login .login_box .login_btn span {
font-family: "Montserrat";
font-size: 19px;
font-weight: 700;
position: relative;
}
.login .login_box .login_btn i {
font-size: 22px;
transform: translateY(-2px);
}
.login .login_box .login_btn:before {
display: block;
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
background: linear-gradient(
to right,
#1d3969 0%,
#264078 10%,
#172558 40%,
#162254 50%,
#111c43 60%,
#111c42 100%
);
transition: opacity 0.5s;
}
.login .login_box .login_btn:hover::before {
opacity: 1;
}
.login .footer .copy {
margin-top: 36px;
justify-content: space-between;
align-items: center;
gap: 3%;
}
.login .footer .copy p {
font-size: 13px;
color: #d4d4d4;
width: 44%;
text-align: center;
}
.login .footer .copy::before,
.login .login_box .copy::after {
content: "";
width: 20%;
height: 1px;
background: #d4d4d4;
}
.login .footer .fot_btn {
gap: 20px;
margin: 30px 0 10px 0;
}
.login .footer .fot_btn a {
color: #333;
font-weight: 700;
font-size: 13px;
}
.login .footer .fot_btn a:hover {
color: #003288;
}
.login .footer .addr {
color: #666;
font-size: 13px;
gap: 3px 20px;
flex-wrap: wrap;
}
/* 비밀번호 변경 팝업창 */
.login .modal .modal_container {
width: 750px;
}
.login .pw_desc {
padding: 10px 10px 30px 10px;
color: #666;
}
.login .pw_desc p {
font-size: 1.25rem;
color: #000;
font-weight: 700;
margin-bottom: 10px;
}
.login .pw_desc span {
color: #f60;
font-weight: 700;
}
.login .modal .form_write {
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
padding: 20px 50px;
}
@media (max-width: 1024px) {
.login {
flex-direction: column;
height: auto;
}
.login .left_box {
padding: 30px;
flex-direction: column-reverse;
gap: 20px;
}
.login .left_box .text h1 {
font-size: 30px;
line-height: 1.2em;
}
.login .left_box .text p {
margin-top: 20px;
font-size: 14px;
}
.login .left_box .img img {
height: 40px;
}
.login .login_box {
padding: 30px;
}
.login .login_box > ul {
gap: 20px;
}
.login .login_box > ul > li h2 {
margin-bottom: 7px;
}
.login .login_box > ul > li .input_st::after {
left: 15px;
font-size: 20px;
}
.login .login_box > ul > li input {
padding: 15px 10px 15px 50px;
font-size: 16px;
}
.login .login_box .bottom_box {
flex-direction: column;
align-items: flex-start;
padding: 30px 0;
}
.login .login_box .bottom_box .id_save label {
font-size: 14px;
}
.login .login_box .login_btn {
padding: 15px;
}
.login .login_box .login_btn span {
font-size: 16px;
}
.login .login_box .login_btn i {
font-size: 18px;
transform: translateY(-1px);
}
.login .login_box .copy p {
width: 100%;
}
.login .login_box .copy::before,
.login .login_box .copy::after {
display: none;
}
.login .footer .addr {
font-size: 11px;
gap: 3px 10px;
}
.login .modal .modal_container {
width: 90%;
}
.login .pw_desc {
padding: 0 0 20px 0;
}
.login .modal .form_write {
padding: 10px;
}
}
/* // login */
+20 -36
View File
@@ -1,59 +1,43 @@
import { createColumnHelper } from "@tanstack/react-table";
import { fakerKO as faker } from "@faker-js/faker";
import type { ISite } from "@/entities/site/model/site-interface";
type Site = {
id: number;
사이트코드: string;
사이트명: string;
대표_URL: string;
기본_사이트_여부: boolean;
사용_여부: boolean;
등록자: string;
등록일: string;
};
faker.seed(123);
const data: Site[] = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
사이트코드: faker.string.alpha(2).toUpperCase(),
사이트명: faker.lorem.word(),
대표_URL: faker.internet.url(),
기본_사이트_여부: faker.datatype.boolean(),
사용_여부: faker.datatype.boolean(),
등록자: faker.person.fullName(),
등록일: faker.date.recent().toISOString().split("T")[0] as string,
}));
const columnHelper = createColumnHelper<Site>();
const columnHelper = createColumnHelper<ISite>();
const columns = [
columnHelper.accessor("id", {
columnHelper.accessor((_, index) => index + 1, {
id: "no",
header: "NO",
}),
columnHelper.accessor("사이트코드", {
columnHelper.accessor("siteId", {
id: "siteId",
header: "사이트코드",
}),
columnHelper.accessor("사이트명", {
columnHelper.accessor("siteNm", {
id: "siteNm",
header: "사이트명",
}),
columnHelper.accessor("대표_URL", {
header: "대표_URL",
columnHelper.accessor("siteKindCode", {
id: "unknown1",
header: "대표 URL",
}),
columnHelper.accessor("기본_사이트_여부", {
columnHelper.accessor("bscSiteYn", {
id: "bscSiteYn",
header: "기본 사이트 여부",
cell: (info) => (info.getValue() ? "Y" : "N"),
}),
columnHelper.accessor("사용_여부", {
columnHelper.accessor("useYn", {
id: "useYn",
header: "사용 여부",
cell: (info) => (info.getValue() ? "Y" : "N"),
}),
columnHelper.accessor("등록자", {
columnHelper.accessor("siteId", {
id: "unknown2",
header: "등록자",
}),
columnHelper.accessor("등록일", {
columnHelper.accessor("siteId", {
id: "ununknown3",
header: "등록일",
}),
];
export { data, columns };
export { columns };
@@ -0,0 +1,164 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogTrigger,
Button,
DialogContent,
DialogHeader,
DialogTitle,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Input,
RadioGroup,
RadioGroupItem,
Checkbox,
} from "@common/components";
type IP = {
id: number;
ipAddress: string;
allowed: boolean;
};
function IPManagementDialog() {
const [rows, setRows] = useState<IP[]>([]);
const [rowSelection, setRowSelection] = useState<IP["id"][]>([]);
const handleSave = () => {};
const handleAddIP = () => {
const newIP: IP = {
id: Date.now(),
ipAddress: "",
allowed: true,
};
setRows([...rows, newIP]);
};
const handleDeleteIP = () => {
const filteredRows = rows.filter((row) => !rowSelection.includes(row.id));
setRows(filteredRows);
setRowSelection([]);
};
const handleIPAddressChange = (id: number, value: string) => {
setRows(
rows.map((row) => (row.id === id ? { ...row, ipAddress: value } : row)),
);
};
const handleAllowedChange = (id: number, value: boolean) => {
setRows(
rows.map((row) => (row.id === id ? { ...row, allowed: value } : row)),
);
};
const toggleRowSelection = (id: number) => {
setRowSelection((prev) => {
if (prev.includes(id)) {
return prev.filter((item) => item !== id);
} else {
return [...prev, id];
}
});
};
return (
<Dialog>
<DialogTrigger asChild>
<Button>IP 관리</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>IP 관리</DialogTitle>
</DialogHeader>
<div className="mb-4 flex justify-end gap-2">
<Button size="sm" color="green">
엑셀 업로드
</Button>
<Button size="sm" onClick={handleSave}>
저장
</Button>
<Button size="sm" color="white" onClick={handleAddIP}>
항목 추가
</Button>
<Button
size="sm"
color="red"
onClick={handleDeleteIP}
disabled={rowSelection.length === 0}
>
항목 삭제
</Button>
</div>
<div className="card card-border">
<Table>
<colgroup>
<col width="10%" />
<col width="10%" />
<col width="55%" />
<col width="25%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>NO</TableHead>
<TableHead>선택</TableHead>
<TableHead>IP 주소</TableHead>
<TableHead>허용여부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, idx) => (
<TableRow key={row.id}>
<TableCell align="center">{idx + 1}</TableCell>
<TableCell align="center">
<Checkbox
checked={rowSelection.includes(row.id)}
onCheckedChange={() => toggleRowSelection(row.id)}
className="cursor-pointer"
/>
</TableCell>
<TableCell align="center">
<Input
value={row.ipAddress}
onChange={(e) =>
handleIPAddressChange(row.id, e.target.value)
}
className="!text-[1.3rem]"
/>
</TableCell>
<TableCell align="center">
<RadioGroup
value={row.allowed ? "true" : "false"}
onValueChange={(value) =>
handleAllowedChange(row.id, value === "true")
}
className="flex justify-center"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="true" id={`allow-${row.id}`} />
<label htmlFor={`allow-${row.id}`}>예</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="false" id={`deny-${row.id}`} />
<label htmlFor={`deny-${row.id}`}>아니오</label>
</div>
</RadioGroup>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</DialogContent>
</Dialog>
);
}
export { IPManagementDialog };
+1 -1
View File
@@ -3,7 +3,7 @@ import { BaseLayout } from "@common/components";
function SitePageLayout({ children }: { children: React.ReactNode }) {
return (
<BaseLayout>
<div className="h-full bg-[#f1f6f9]">
<div className="h-auto min-h-full bg-[#f1f6f9]">
<h2 className="px-[1.8rem] pt-[2rem] text-[2.4rem] font-semibold text-[#4c5e6f]">
사이트 관리
</h2>
+22 -39
View File
@@ -1,3 +1,5 @@
"use client";
import {
Button,
DropdownMenu,
@@ -15,6 +17,7 @@ import {
TableRow,
} from "@common/components";
import { SitePageLayout } from "./layout";
import { IPManagementDialog } from "./ip-management-dialog";
function RegisterPage() {
return (
@@ -24,20 +27,20 @@ function RegisterPage() {
<h3 className="text-2xl font-medium text-[#666]">사이트 등록/수정</h3>
</div>
<div className="card">
<Table>
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead className="border bg-[#eaf3fb] text-[#647784]">
사이트 코드
</TableHead>
<TableHead>사이트 코드</TableHead>
<TableCell className="border">
<Input className="w-[12rem] !text-[1.3rem]" />
</TableCell>
</TableRow>
<TableRow>
<TableHead className="border bg-[#eaf3fb] text-[#647784]">
사이트 구분
</TableHead>
<TableHead>사이트 구분</TableHead>
<TableCell className="border">
<div className="flex gap-2">
<DropdownMenu>
@@ -57,14 +60,12 @@ function RegisterPage() {
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenu>
<Button>IP 관리</Button>
<IPManagementDialog />
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead className="border bg-[#eaf3fb] text-[#647784]">
사이트 디자인
</TableHead>
<TableHead>사이트 디자인</TableHead>
<TableCell className="border">
<DropdownMenu>
<DropdownMenu>
@@ -86,9 +87,7 @@ function RegisterPage() {
</TableCell>
</TableRow>
<TableRow>
<TableHead className="border bg-[#eaf3fb] text-[#647784]">
사이트명
</TableHead>
<TableHead>사이트명</TableHead>
<TableCell className="border">
<Input
className="!text-[1.3rem]"
@@ -97,9 +96,7 @@ function RegisterPage() {
</TableCell>
</TableRow>
<TableRow>
<TableHead className="border bg-[#eaf3fb] text-[#647784]">
사이트 설명
</TableHead>
<TableHead>사이트 설명</TableHead>
<TableCell className="border">
<Input
className="!text-[1.3rem]"
@@ -108,9 +105,7 @@ function RegisterPage() {
</TableCell>
</TableRow>
<TableRow>
<TableHead className="border bg-[#eaf3fb] text-[#647784]">
사이트 하단 표기 주소
</TableHead>
<TableHead>사이트 하단 표기 주소</TableHead>
<TableCell className="border">
<Input
className="!text-[1.3rem]"
@@ -119,9 +114,7 @@ function RegisterPage() {
</TableCell>
</TableRow>
<TableRow>
<TableHead className="border bg-[#eaf3fb] text-[#647784]">
사이트 하단 표기 전화번호
</TableHead>
<TableHead>사이트 하단 표기 전화번호</TableHead>
<TableCell className="border">
<Input
className="!text-[1.3rem]"
@@ -130,9 +123,7 @@ function RegisterPage() {
</TableCell>
</TableRow>
<TableRow>
<TableHead className="border bg-[#eaf3fb] text-[#647784]">
사이트 하단 표기 팩스번호
</TableHead>
<TableHead>사이트 하단 표기 팩스번호</TableHead>
<TableCell className="border">
<Input
className="!text-[1.3rem]"
@@ -141,9 +132,7 @@ function RegisterPage() {
</TableCell>
</TableRow>
<TableRow>
<TableHead className="border bg-[#eaf3fb] text-[#647784]">
사이트 하단 표기 내용
</TableHead>
<TableHead>사이트 하단 표기 내용</TableHead>
<TableCell className="border">
<Input
className="!text-[1.3rem]"
@@ -152,9 +141,7 @@ function RegisterPage() {
</TableCell>
</TableRow>
<TableRow>
<TableHead className="border bg-[#eaf3fb] text-[#647784]">
북마크 아이콘
</TableHead>
<TableHead>북마크 아이콘</TableHead>
<TableCell className="border">
<Input
className="!text-[1.3rem]"
@@ -163,9 +150,7 @@ function RegisterPage() {
</TableCell>
</TableRow>
<TableRow>
<TableHead className="border bg-[#eaf3fb] text-[#647784]">
기본 사이트 여부
</TableHead>
<TableHead>기본 사이트 여부</TableHead>
<TableCell className="border">
<RadioGroup defaultValue="comfortable">
<div className="flex items-center space-x-2">
@@ -180,9 +165,7 @@ function RegisterPage() {
</TableCell>
</TableRow>
<TableRow>
<TableHead className="border bg-[#eaf3fb] text-[#647784]">
사용 여부
</TableHead>
<TableHead>사용 여부</TableHead>
<TableCell className="border">
<RadioGroup defaultValue="comfortable">
<div className="flex items-center space-x-2">
@@ -200,7 +183,7 @@ function RegisterPage() {
</Table>
</div>
</div>
<div className="mt-10 flex justify-center gap-4">
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button size="lg">저장</Button>
<Button size="lg" color="white">
취소
+49 -6
View File
@@ -3,24 +3,67 @@
import "@common/assets/styles/grid.css";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { Button, Input, DataTable } from "@common/components";
import { data, columns } from "../model/__mocks__";
import { SiteApi } from "@/entities/site/api/site-service";
import { ENDPOINT } from "@/shared/config/api";
import { columns } from "../model/__mocks__";
import { SitePageLayout } from "./layout";
import type { ISiteQuery } from "@/entities/site/model/site-interface";
function SitePage() {
const [siteQuery, setSiteQuery] = useState<ISiteQuery>({
siteNm: "",
siteKndCd: "",
useYn: "",
page: 0,
size: 0,
sort: [],
});
const { data: siteInfo, refetch } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.SITES],
queryFn: () => SiteApi.siteInfo(siteQuery),
enabled: false,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
refetch();
};
const router = useRouter();
const table = useReactTable({
data: siteInfo?.content ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
});
useEffect(() => {
refetch();
}, []);
return (
<SitePageLayout>
<div className="card card-border !mt-6">
<div className="card card-border !border-[#dbe2e6] !bg-[#eef7ff]">
<div className="flex items-center gap-2 p-2">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<label htmlFor="" className="mr-2 text-[#657481]">
사이트명
</label>
<Input className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]" />
<Button className="h-[3.2rem]">조회</Button>
</div>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
value={siteQuery.siteNm}
onChange={(e) =>
setSiteQuery({ ...siteQuery, siteNm: e.target.value })
}
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border">
<div className="card-header">
@@ -28,7 +71,7 @@ function SitePage() {
<Button onClick={() => router.push("/site/register")}>등록</Button>
</div>
<div className="card !m-[1.2rem] h-[49rem] overflow-auto !p-0">
<DataTable data={data} columns={columns} />
<DataTable table={table} />
</div>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
import { Client } from "@common/network";
import { BASE_URL } from "../config/api";
import { SITE_ID } from "../config/env";
export const client = Client.getInstance({
baseURL: BASE_URL,
siteId: SITE_ID,
});
+16
View File
@@ -0,0 +1,16 @@
import { PROXY_HOST } from "./env";
export const BASE_URL = `${PROXY_HOST}/server`;
export const ENDPOINT = {
USER_SERVICE: {
LOGIN: "/user-service/login",
USERS: "/user-service/api/v1/users",
SILENT_REFRESH: "/user-service/api/v1/users/token/refresh",
},
CMS_SERVICE: {
SITES: "/cms-service/api/v1/sites",
},
};
export const JWT_EXPIRATION_TIME = 1800000;
+13
View File
@@ -0,0 +1,13 @@
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 || "CS";
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
export const ACCESS_TOKEN =
process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
export const REFRESH_TOKEN =
process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
+31
View File
@@ -0,0 +1,31 @@
"use client";
import Cookies from "js-cookie";
export function getCookie(name: string): string | undefined {
if (typeof window === "undefined") {
return undefined;
}
return Cookies.get(name);
}
export function setCookie(
name: string,
value: string,
options?: Cookies.CookieAttributes,
): void {
if (typeof window === "undefined") {
return;
}
Cookies.set(name, value, options);
}
export function removeCookie(
name: string,
options?: Cookies.CookieAttributes,
): void {
if (typeof window === "undefined") {
return;
}
Cookies.remove(name, options);
}
@@ -0,0 +1,97 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { createContext, useContext, useState } from "react";
import { client } from "../api/client";
import { ENDPOINT } from "../config/api";
import { AUTH_USER_ID } from "../config/env";
import { getCookie } from "../lib/cookie";
import { AxiosError } from "axios";
import { LoginApi } from "@/entities/auth/login";
interface IUserInfo {
regDt: string;
mdfcnDt: string;
rgtr: string;
mdfr: string;
userUnqId: string;
userId: string;
userName: string;
email: string;
encryptedPassword: string;
refreshToken: string;
userStateCode: string;
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;
}
export interface AuthContextType {
userInfo: IUserInfo | null;
}
export const AuthContext = createContext<AuthContextType | null>(null);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [userInfo, setUserInfo] = useState<IUserInfo | null>(null);
useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS],
queryFn: async () => {
try {
const response = await client.get<IUserInfo>(
`${ENDPOINT.USER_SERVICE.USERS}/${getCookie(AUTH_USER_ID)}`,
);
setUserInfo(response.data);
return response;
} catch (error) {
console.error(error);
if (error instanceof AxiosError) {
if (error.response?.status === 401) {
try {
await LoginApi.silentRefresh();
} catch (refreshError) {
console.error("Token refresh failed:", refreshError);
setUserInfo(null);
}
}
}
}
},
});
const authContextValue: AuthContextType = {
userInfo,
};
return (
<AuthContext.Provider value={authContextValue}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
@@ -0,0 +1,38 @@
"use client";
import { createContext, useContext } from "react";
import { getCookie, setCookie, removeCookie } from "../lib/cookie";
export interface CookieContextType {
getCookie: (name: string) => string | undefined;
setCookie: (
name: string,
value: string,
options: Cookies.CookieAttributes,
) => void;
removeCookie: (name: string, options: Cookies.CookieAttributes) => void;
}
export const CookieContext = createContext<CookieContextType | null>(null);
export function CookieProvider({ children }: { children: React.ReactNode }) {
const cookieContextValue: CookieContextType = {
getCookie,
setCookie,
removeCookie,
};
return (
<CookieContext.Provider value={cookieContextValue}>
{children}
</CookieContext.Provider>
);
}
export function useCookie(): CookieContextType {
const context = useContext(CookieContext);
if (!context) {
throw new Error("useCookie must be used within a CookieProvider");
}
return context;
}
+3
View File
@@ -0,0 +1,3 @@
export { AuthProvider, useAuth } from "./auth-provider";
export { CookieProvider, useCookie } from "./cookie-provider";
export { QueryProvider } from "./query-provider";
@@ -0,0 +1,11 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
export const QueryProvider = ({ children }: { children: React.ReactNode }) => {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
+2
View File
@@ -1,4 +1,5 @@
import type { Config } from "tailwindcss";
import tailwindcssAnimate from "tailwindcss-animate";
import presetConfig from "../../tailwind.config.preset";
export default {
@@ -15,4 +16,5 @@ export default {
},
},
},
plugins: [tailwindcssAnimate],
} satisfies Config;
+4 -1
View File
@@ -25,7 +25,10 @@
"baseUrl": "./src",
"paths": {
"@/*": ["./*"],
"@common/components": ["../../../packages/common/components/src"]
"@common/components/*": ["../../../packages/common/components/src/*"],
"@common/components": ["../../../packages/common/components/src"],
"@common/network/*": ["../../../packages/common/network/*"],
"@common/network": ["../../../packages/common/network/"]
}
},
"include": [