diff --git a/apps/fo/.env.local b/apps/fo/.env.local index 646d80c2c..d5e6751ed 100644 --- a/apps/fo/.env.local +++ b/apps/fo/.env.local @@ -17,7 +17,7 @@ NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y} # NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login -NEXT_PUBLIC_CM_LOGIN_PAGE_URL = / +NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login #NEXT_PUBLIC_SERVER_API_URL = http://localhost:8883/operation-service diff --git a/apps/fo/.env.mindone b/apps/fo/.env.mindone index 2fd7e91c2..5b5ad726e 100644 --- a/apps/fo/.env.mindone +++ b/apps/fo/.env.mindone @@ -16,4 +16,4 @@ NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com/dc-service NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y} -NEXT_PUBLIC_CM_LOGIN_PAGE_URL = / \ No newline at end of file +NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login \ No newline at end of file diff --git a/apps/fo/app/auth/login/page.tsx b/apps/fo/app/auth/login/page.tsx new file mode 100644 index 000000000..0dbc42284 --- /dev/null +++ b/apps/fo/app/auth/login/page.tsx @@ -0,0 +1,2 @@ +import { LoginPage } from "@/src/features/auth/login"; +export default LoginPage; diff --git a/apps/fo/app/auth/login/route.ts b/apps/fo/app/auth/login/route-bak.ts similarity index 83% rename from apps/fo/app/auth/login/route.ts rename to apps/fo/app/auth/login/route-bak.ts index 208d4bf85..65bbce15e 100644 --- a/apps/fo/app/auth/login/route.ts +++ b/apps/fo/app/auth/login/route-bak.ts @@ -1,4 +1,3 @@ -import { tokenManager } from "@/src/entities/auth/lib/token-manager"; import { NextRequest, NextResponse } from "next/server"; export async function GET(request: NextRequest) { @@ -6,7 +5,6 @@ export async function GET(request: NextRequest) { 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)); // 공단 환경 diff --git a/apps/fo/app/components/api/index.ts b/apps/fo/app/components/api/index.ts index ee04f69d9..542e72947 100644 --- a/apps/fo/app/components/api/index.ts +++ b/apps/fo/app/components/api/index.ts @@ -1,6 +1,29 @@ -import { SITE_ID } from "@/src/shared/config/env"; +import { CLAIM_NAME, SITE_ID } from "@/src/shared/config/env"; +import { tokenManager } from "@/src/entities/auth/lib/token-manager"; import { DEV_URL_FO, DEV_URL_WR, LOCAL_URL } from "./config/env"; +const LOGIN_PAGE_URL = + process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login"; + +const handleUnauthorizedResponse = () => { + if (typeof window === "undefined") return; + + const remainingMs = tokenManager.getRemainingMsFromToken(CLAIM_NAME); + const isExpired = remainingMs === null || remainingMs <= 0; + + if (!isExpired) return; + + tokenManager.clearAuthToken(); + + const isLoginPage = + window.location.pathname === "/auth/login" || + window.location.pathname.startsWith("/auth/login/"); + + if (!isLoginPage) { + window.location.assign(LOGIN_PAGE_URL); + } +}; + export const getFetch = async (part : string, url: string) => { let reqUrl = ""; reqUrl = part === "fo" ? DEV_URL_FO+url : part === 'local' ? LOCAL_URL+url : DEV_URL_WR+url; @@ -11,6 +34,9 @@ export const getFetch = async (part : string, url: string) => { "X-Site-Id": SITE_ID, } }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } if(!response.ok) { throw new Error(`오류 발생: ${response.status}`); } @@ -26,6 +52,9 @@ export const getFetchAreaList = async (url: string) => { "X-Site-Id": SITE_ID, } }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } if(!response.ok) { throw new Error(`오류 발생: ${response.status}`); } @@ -44,6 +73,9 @@ export const postFetch = async (part : string, url: string, params: unknown) => }, body: JSON.stringify(params), }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } if(!response.ok) { throw new Error(`오류 발생: ${response.status}`); } @@ -61,6 +93,9 @@ export const putFetch = async (part : string, url: string, params: unknown) => { }, body: JSON.stringify(params), }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } if(!response.ok) { throw new Error(`오류 발생: ${response.status}`); } @@ -78,6 +113,9 @@ export const deleteFetch = async (part : string, url: string, params: unknown) = }, body: JSON.stringify(params), }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } if(!response.ok) { throw new Error(`오류 발생: ${response.status}`); } diff --git a/apps/fo/public/images/bg_login.jpg b/apps/fo/public/images/bg_login.jpg new file mode 100644 index 000000000..b7793d16a Binary files /dev/null and b/apps/fo/public/images/bg_login.jpg differ diff --git a/apps/fo/src/entities/auth/lib/token-manager.ts b/apps/fo/src/entities/auth/lib/token-manager.ts index e86dba5f2..5908dd091 100644 --- a/apps/fo/src/entities/auth/lib/token-manager.ts +++ b/apps/fo/src/entities/auth/lib/token-manager.ts @@ -1,6 +1,13 @@ import { AUTH_USER_ID, CLAIM_NAME } from "@/src/shared/config"; import { getCookie, removeCookie, setCookie } from "@/src/shared/lib/cookie"; +type JwtPayload = { + sub?: string; + exp?: number; + iat?: number; + [key: string]: unknown; +}; + export class TokenManager { private static instance: TokenManager; private refreshTimer: NodeJS.Timeout | null = null; @@ -18,6 +25,49 @@ export class TokenManager { return getCookie(key); } + private parseJwt(token: string): JwtPayload { + const parts = token.split("."); + if (parts.length !== 3) { + throw new Error("유효한 JWT 형식이 아닙니다."); + } + + const base64Url = parts[1] ?? ""; + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + const jsonPayload = decodeURIComponent( + atob(padded) + .split("") + .map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`) + .join("") + ); + + return JSON.parse(jsonPayload) as JwtPayload; + } + + public getRemainingMsFromToken(key: string = CLAIM_NAME): number | null { + const token = this.getToken(key); + if (!token) { + return null; + } + + try { + const payload = this.parseJwt(token); + if (typeof payload.exp !== "number") { + return null; + } + + return payload.exp * 1000 - Date.now(); + } catch (error) { + console.error("JWT 파싱 오류:", error); + return null; + } + } + + public isExpiredToken(key: string = CLAIM_NAME): boolean { + const remainingMs = this.getRemainingMsFromToken(key); + return remainingMs === null || remainingMs <= 0; + } + public setToken(key: string, value: string): void { setCookie(key, value, { secure: location.protocol === "https:", diff --git a/apps/fo/src/features/auth/login/index.ts b/apps/fo/src/features/auth/login/index.ts new file mode 100644 index 000000000..2093c3d8d --- /dev/null +++ b/apps/fo/src/features/auth/login/index.ts @@ -0,0 +1 @@ +export { LoginPage } from "./ui/login-page"; diff --git a/apps/fo/src/features/auth/login/ui/login-page.tsx b/apps/fo/src/features/auth/login/ui/login-page.tsx new file mode 100644 index 000000000..fe14819f3 --- /dev/null +++ b/apps/fo/src/features/auth/login/ui/login-page.tsx @@ -0,0 +1,139 @@ +"use client"; + +import Image from "next/image"; +import { useEffect } from "react"; +import { images } from "@common/assets"; +import { LoginApi } from "@/src/entities/auth"; +import "./login.css"; + +function LoginPage() { + useEffect(() => { + const hostname = window.location.hostname; + if (hostname.includes("hasuon.or.kr")) { + window.location.href = + process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login"; + } + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const formData = new FormData(e.target as HTMLFormElement); + const userId = formData.get("id") as string; + const password = formData.get("password") as string; + + await LoginApi.login({ userId, password }); + window.location.href = "/pages/dashboard"; + }; + + return ( +
+
+
+

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

+

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

+
+
+ logo +
+
+
+
+
    +
  • +

    ID

    +
    + + +
    +
  • +
  • +

    PASSWORD

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

copyright © 2023 KECO. All rights reserved.

+
+ +

+ + 주소 : (22689) 인천광역시 서구 환경로 42(경서동 + 종합환경연구단지) + + + 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723, + 3722 + + 팩스 :032-590-3729 +

+
+
+ +
+ ); +} + +export { LoginPage }; + diff --git a/apps/fo/src/features/auth/login/ui/login.css b/apps/fo/src/features/auth/login/ui/login.css new file mode 100644 index 000000000..af73c9c68 --- /dev/null +++ b/apps/fo/src/features/auth/login/ui/login.css @@ -0,0 +1,310 @@ +/** +* 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(/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 */ diff --git a/apps/sn/.env.local b/apps/sn/.env.local index 1d4c11d21..b8e75fffe 100644 --- a/apps/sn/.env.local +++ b/apps/sn/.env.local @@ -15,5 +15,5 @@ NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000 NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083 NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com # NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login -NEXT_PUBLIC_CM_LOGIN_PAGE_URL = / +NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login # NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883 diff --git a/apps/sn/.env.mindone b/apps/sn/.env.mindone index 847103c97..dc567fea8 100644 --- a/apps/sn/.env.mindone +++ b/apps/sn/.env.mindone @@ -13,4 +13,4 @@ NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y} NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083 NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com -NEXT_PUBLIC_CM_LOGIN_PAGE_URL = / \ No newline at end of file +NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login \ No newline at end of file diff --git a/apps/sn/app/auth/login/page.tsx b/apps/sn/app/auth/login/page.tsx new file mode 100644 index 000000000..7a35b0ddd --- /dev/null +++ b/apps/sn/app/auth/login/page.tsx @@ -0,0 +1,2 @@ +import { LoginPage } from "@/features/auth/login"; +export default LoginPage; diff --git a/apps/sn/app/auth/login/route.ts b/apps/sn/app/auth/login/route-bak.ts similarity index 100% rename from apps/sn/app/auth/login/route.ts rename to apps/sn/app/auth/login/route-bak.ts diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index 13fdef876..73025226b 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -11,12 +11,15 @@ */ import { useEffect, useState } from "react"; import axios from 'axios'; -import { SITE_ID } from "@/shared/config"; +import { CLAIM_NAME, SITE_ID } from "@/shared/config"; +import { tokenManager } from "@/entities/auth/lib/token-manager"; const BASE_URL = '/sewerage-service/api'; const DEFAULT_HEADERS = { "X-Site-Id": SITE_ID, }; +const LOGIN_PAGE_URL = + process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login"; const withSiteIdHeaders = (headers: HeadersInit = {}) => ({ ...headers, @@ -26,11 +29,33 @@ const withSiteIdHeaders = (headers: HeadersInit = {}) => ({ const resolveApiUrl = (url: string) => url.startsWith('/') ? `${url}` : `${BASE_URL}/${url}`; +const handleUnauthorizedResponse = () => { + if (typeof window === "undefined") return; + + const remainingMs = tokenManager.getRemainingMsFromToken(CLAIM_NAME); + const isExpired = remainingMs === null || remainingMs <= 0; + + if (!isExpired) return; + + tokenManager.clearAuthToken(); + + const isLoginPage = + window.location.pathname === "/auth/login" || + window.location.pathname.startsWith("/auth/login/"); + + if (!isLoginPage) { + window.location.assign(LOGIN_PAGE_URL); + } +}; + export const getData = async (url: string): Promise => { const res = await fetch(resolveApiUrl(url), { method: "GET", headers: withSiteIdHeaders(), }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } if (res.ok) { try { // 응답이 비어있는지 확인 @@ -66,6 +91,9 @@ export const postData = async (url: string, data: object): Promise => { body: JSON.stringify(data), }; const res = await fetch(`${BASE_URL}/${url}`, options); + if (res.status === 401) { + handleUnauthorizedResponse(); + } if (res.ok) { const resData = await res.json(); console.log('postData response:', resData); @@ -91,6 +119,9 @@ export const putData = async (url: string, data: object): Promise => { body: JSON.stringify(data), }; const res = await fetch(`${BASE_URL}/${url}`, options); + if (res.status === 401) { + handleUnauthorizedResponse(); + } const resData = await res.json(); console.log('putData response:', resData); return resData.data; @@ -105,6 +136,9 @@ export const deleteData = async (url: string, data?: object): Promise => { body: data ? JSON.stringify(data) : undefined, }; const res = await fetch(resolveApiUrl(url), options); + if (res.status === 401) { + handleUnauthorizedResponse(); + } if (res.status === 204) return true return res; }; @@ -114,6 +148,9 @@ export const getRawJson = async (url: string): Promise => { method: "GET", headers: withSiteIdHeaders(), }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } if (!res.ok) { throw new Error('Failed to fetch data'); @@ -142,6 +179,9 @@ export const getBlobData = async (url: string): Promise => { method: "GET", headers: withSiteIdHeaders(), }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } if (!res.ok) { throw new Error('Failed to fetch data'); @@ -156,6 +196,9 @@ export const getDcData = async (url: string, resType: string = "json"): Promise< method: "GET", headers: withSiteIdHeaders(), }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } if (res.ok) { let data; if (resType === "json") data = await res.json(); @@ -172,6 +215,9 @@ export const getCmsData = async (url: string, data?: object): Promise => { method: "GET", headers: withSiteIdHeaders(), }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } if (res.ok) { const data = await res.json(); return data; @@ -185,6 +231,9 @@ export const getGeoData = async (url: string, data?: object): Promise => { method: "GET", headers: withSiteIdHeaders(), }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } if (res.ok) { const data = await res.json(); return data; diff --git a/apps/sn/public/images/bg_login.jpg b/apps/sn/public/images/bg_login.jpg new file mode 100644 index 000000000..b7793d16a Binary files /dev/null and b/apps/sn/public/images/bg_login.jpg differ diff --git a/apps/sn/src/entities/auth/lib/token-manager.ts b/apps/sn/src/entities/auth/lib/token-manager.ts index cbc9c508d..bae8ff421 100644 --- a/apps/sn/src/entities/auth/lib/token-manager.ts +++ b/apps/sn/src/entities/auth/lib/token-manager.ts @@ -1,6 +1,13 @@ import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config"; import { getCookie, setCookie, removeCookie } from "@/shared/lib";; +type JwtPayload = { + sub?: string; + exp?: number; + iat?: number; + [key: string]: unknown; +}; + export class TokenManager { private static instance: TokenManager; private refreshTimer: NodeJS.Timeout | null = null; @@ -18,6 +25,49 @@ export class TokenManager { 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(); diff --git a/apps/sn/src/features/auth/login/index.ts b/apps/sn/src/features/auth/login/index.ts new file mode 100644 index 000000000..2093c3d8d --- /dev/null +++ b/apps/sn/src/features/auth/login/index.ts @@ -0,0 +1 @@ +export { LoginPage } from "./ui/login-page"; diff --git a/apps/sn/src/features/auth/login/ui/login-page.tsx b/apps/sn/src/features/auth/login/ui/login-page.tsx new file mode 100644 index 000000000..dea3c90f1 --- /dev/null +++ b/apps/sn/src/features/auth/login/ui/login-page.tsx @@ -0,0 +1,139 @@ +"use client"; + +import Image from "next/image"; +import { useEffect } from "react"; +import { images } from "@common/assets"; +import { LoginApi } from "@/entities/auth"; +import "./login.css"; + +function LoginPage() { + useEffect(() => { + const hostname = window.location.hostname; + if (hostname.includes("hasuon.or.kr")) { + window.location.href = + process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login"; + } + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const formData = new FormData(e.target as HTMLFormElement); + const userId = formData.get("id") as string; + const password = formData.get("password") as string; + + await LoginApi.login({ userId, password }); + window.location.href = "/map"; + }; + + return ( +
+
+
+

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

+

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

+
+
+ logo +
+
+
+
+
    +
  • +

    ID

    +
    + + +
    +
  • +
  • +

    PASSWORD

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

copyright © 2023 KECO. All rights reserved.

+
+ +

+ + 주소 : (22689) 인천광역시 서구 환경로 42(경서동 + 종합환경연구단지) + + + 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723, + 3722 + + 팩스 :032-590-3729 +

+
+
+ +
+ ); +} + +export { LoginPage }; + diff --git a/apps/sn/src/features/auth/login/ui/login.css b/apps/sn/src/features/auth/login/ui/login.css new file mode 100644 index 000000000..af73c9c68 --- /dev/null +++ b/apps/sn/src/features/auth/login/ui/login.css @@ -0,0 +1,310 @@ +/** +* 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(/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 */ diff --git a/apps/wr/src/features/auth/login/ui/login-page.tsx b/apps/wr/src/features/auth/login/ui/login-page.tsx index 955ddd24d..6af8018af 100644 --- a/apps/wr/src/features/auth/login/ui/login-page.tsx +++ b/apps/wr/src/features/auth/login/ui/login-page.tsx @@ -1,13 +1,20 @@ "use client"; import Image from "next/image"; +import { useEffect } from "react"; import { images } from "@common/assets"; import { LoginApi } from "@/src/entities/auth"; import "./login.css"; function LoginPage() { - const hostname = window.location.hostname; - if (hostname.includes("hasuon.or.kr")) window.location.href = process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL||'/auth/login'; + useEffect(() => { + const hostname = window.location.hostname; + if (hostname.includes("hasuon.or.kr")) { + window.location.href = + process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login"; + } + }, []); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault();