feat: 로그인 세션 통합 (인증 가드, fetchWithAuth, 환경변수)
- authStore: 메인 프로젝트 세션 쿠키 기반 인증 상태 관리 - fetchWithAuth: 401 응답 시 메인 프로젝트 로그인 페이지 리다이렉트 - SessionGuard: 앱 진입 시 세션 유효성 검증 래퍼 컴포넌트 - 기존 API 모듈 fetch → fetchWithAuth 전환 - 환경변수에 VITE_MAIN_APP_URL, VITE_DEV_SKIP_AUTH 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
34d5f6ef9e
커밋
de2cd907f1
3
.env
3
.env
@ -28,3 +28,6 @@ VITE_TRACKING_WS=wss://mda.kcg.go.kr/ws-tracks/websocket
|
||||
|
||||
# 선박 데이터 쓰로틀링 (ms, 위성망 대역폭 절약)
|
||||
VITE_SHIP_THROTTLE=30
|
||||
|
||||
# 메인 프로젝트 URL (세션 만료 시 리다이렉트)
|
||||
VITE_MAIN_APP_URL=https://mda.kcg.go.kr
|
||||
|
||||
3
.env.dev
3
.env.dev
@ -27,3 +27,6 @@ VITE_TRACKING_WS=ws://10.26.252.51:8090/ws-tracks/websocket
|
||||
|
||||
# 선박 데이터 쓰로틀링 (ms)
|
||||
VITE_SHIP_THROTTLE=30
|
||||
|
||||
# 메인 프로젝트 URL (세션 만료 시 리다이렉트)
|
||||
VITE_MAIN_APP_URL=http://10.26.252.39:9090
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
# - 로컬 개발 전용
|
||||
# ============================================
|
||||
|
||||
# 배포 경로 (로컬 개발은 루트)
|
||||
VITE_BASE_URL=/
|
||||
# 배포 경로 (프록시 모드: localhost:9090/kcgnv/ → localhost:3000/kcgnv/)
|
||||
VITE_BASE_URL=/kcgnv/
|
||||
|
||||
# API 서버 (프록시 타겟)
|
||||
VITE_API_URL=http://10.26.252.39:9090
|
||||
@ -27,3 +27,10 @@ VITE_TRACKING_WS=ws://10.26.252.51:8090/ws-tracks/websocket
|
||||
|
||||
# 선박 데이터 쓰로틀링 (ms, 0=무제한)
|
||||
VITE_SHIP_THROTTLE=0
|
||||
|
||||
# 메인 프로젝트 URL (세션 만료 시 리다이렉트)
|
||||
VITE_MAIN_APP_URL=http://localhost:9090
|
||||
|
||||
# 로컬 개발 인증 우회 (포트가 달라 localStorage 공유 불가)
|
||||
# true면 세션 없을 때 모의 사용자로 자동 로그인
|
||||
VITE_DEV_SKIP_AUTH=false
|
||||
|
||||
3
.env.qa
3
.env.qa
@ -27,3 +27,6 @@ VITE_TRACKING_WS=ws://10.188.141.123:9090/ws-tracks/websocket
|
||||
|
||||
# 선박 데이터 쓰로틀링 (ms)
|
||||
VITE_SHIP_THROTTLE=30
|
||||
|
||||
# 메인 프로젝트 URL (세션 만료 시 리다이렉트)
|
||||
VITE_MAIN_APP_URL=http://10.188.141.123:9090
|
||||
|
||||
@ -3,6 +3,7 @@ import { lazy, Suspense } from 'react';
|
||||
|
||||
// 구현 영역 - 레이아웃
|
||||
import MainLayout from './components/layout/MainLayout';
|
||||
import SessionGuard from './components/auth/SessionGuard';
|
||||
import { ToastContainer } from './components/common/Toast';
|
||||
import { AlertModalContainer } from './components/common/AlertModal';
|
||||
|
||||
@ -22,7 +23,7 @@ const PublishRouter = import.meta.env.DEV
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<SessionGuard>
|
||||
<ToastContainer />
|
||||
<AlertModalContainer />
|
||||
<Routes>
|
||||
@ -48,6 +49,6 @@ export default function App() {
|
||||
{/* />*/}
|
||||
{/*)}*/}
|
||||
</Routes>
|
||||
</>
|
||||
</SessionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 공통코드 API
|
||||
*/
|
||||
import { fetchWithAuth } from './fetchWithAuth';
|
||||
|
||||
const COMMON_CODE_LIST_ENDPOINT = '/api/cmn/code/list/search';
|
||||
|
||||
@ -12,10 +13,9 @@ const COMMON_CODE_LIST_ENDPOINT = '/api/cmn/code/list/search';
|
||||
*/
|
||||
export async function fetchCommonCodeList(commonCodeTypeNumber) {
|
||||
try {
|
||||
const response = await fetch(COMMON_CODE_LIST_ENDPOINT, {
|
||||
const response = await fetchWithAuth(COMMON_CODE_LIST_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ commonCodeTypeNumber }),
|
||||
});
|
||||
|
||||
|
||||
41
src/api/fetchWithAuth.js
Normal file
41
src/api/fetchWithAuth.js
Normal file
@ -0,0 +1,41 @@
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { SESSION_TIMEOUT_MS } from '../types/constants';
|
||||
|
||||
/**
|
||||
* 인증 래퍼 fetch
|
||||
* - 사전 체크: loginDate 기반 타임아웃
|
||||
* - 사후 체크: 4011 응답 감지 (세션 만료)
|
||||
* - credentials: 'include' 자동 설정
|
||||
*/
|
||||
export async function fetchWithAuth(url, options = {}) {
|
||||
// 로컬 개발: 세션 타임아웃 체크 우회
|
||||
if (import.meta.env.VITE_DEV_SKIP_AUTH !== 'true') {
|
||||
const loginDate = localStorage.getItem('loginDate');
|
||||
if (!loginDate || Date.now() - Number(loginDate) > SESSION_TIMEOUT_MS) {
|
||||
useAuthStore.getState().handleSessionExpired();
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
localStorage.setItem('loginDate', String(Date.now()));
|
||||
}
|
||||
|
||||
const response = await fetch(url, { ...options, credentials: 'include' });
|
||||
|
||||
// JSON 응답에서 4011 체크
|
||||
if (response.ok) {
|
||||
const ct = response.headers.get('content-type');
|
||||
if (ct && ct.includes('application/json')) {
|
||||
const cloned = response.clone();
|
||||
try {
|
||||
const data = await cloned.json();
|
||||
if (data?.code === 4011) {
|
||||
useAuthStore.getState().handleSessionExpired();
|
||||
throw new Error('Session expired (4011)');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message.includes('Session expired')) throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 위성 API
|
||||
*/
|
||||
import { fetchWithAuth } from './fetchWithAuth';
|
||||
|
||||
const SATELLITE_VIDEO_SEARCH_ENDPOINT = '/api/gis/satlit/search';
|
||||
const SATELLITE_CSV_ENDPOINT = '/api/gis/satlit/excelToJson';
|
||||
@ -43,10 +44,10 @@ export async function fetchSatelliteVideoList({
|
||||
satelliteVideoOrigin,
|
||||
}) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_VIDEO_SEARCH_ENDPOINT, {
|
||||
const response = await fetchWithAuth(SATELLITE_VIDEO_SEARCH_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify({
|
||||
page,
|
||||
startDate,
|
||||
@ -83,10 +84,10 @@ export async function fetchSatelliteVideoList({
|
||||
*/
|
||||
export async function fetchSatelliteCsvFeatures(csvFileName) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_CSV_ENDPOINT, {
|
||||
const response = await fetchWithAuth(SATELLITE_CSV_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify({ csvFileName }),
|
||||
});
|
||||
|
||||
@ -116,10 +117,10 @@ export async function fetchSatelliteCsvFeatures(csvFileName) {
|
||||
*/
|
||||
export async function fetchSatelliteVideoDetail(satelliteId) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_DETAIL_ENDPOINT, {
|
||||
const response = await fetchWithAuth(SATELLITE_DETAIL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify({ satelliteId }),
|
||||
});
|
||||
|
||||
@ -154,10 +155,10 @@ export async function fetchSatelliteVideoDetail(satelliteId) {
|
||||
*/
|
||||
export async function updateSatelliteVideo(params) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_UPDATE_ENDPOINT, {
|
||||
const response = await fetchWithAuth(SATELLITE_UPDATE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
@ -177,10 +178,10 @@ export async function updateSatelliteVideo(params) {
|
||||
*/
|
||||
export async function fetchSatelliteCompanyList() {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_COMPANY_LIST_ENDPOINT, {
|
||||
const response = await fetchWithAuth(SATELLITE_COMPANY_LIST_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
@ -204,10 +205,10 @@ export async function fetchSatelliteCompanyList() {
|
||||
*/
|
||||
export async function fetchSatelliteManageList(companyNo) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_MANAGE_LIST_ENDPOINT, {
|
||||
const response = await fetchWithAuth(SATELLITE_MANAGE_LIST_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify({ companyNo }),
|
||||
});
|
||||
|
||||
@ -230,9 +231,9 @@ export async function fetchSatelliteManageList(companyNo) {
|
||||
*/
|
||||
export async function saveSatelliteVideo(formData) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_SAVE_ENDPOINT, {
|
||||
const response = await fetchWithAuth(SATELLITE_SAVE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
|
||||
body: formData,
|
||||
});
|
||||
|
||||
@ -255,10 +256,10 @@ export async function saveSatelliteVideo(formData) {
|
||||
*/
|
||||
export async function searchSatelliteCompany({ companyTypeCode, companyName, page, limit }) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_COMPANY_SEARCH_ENDPOINT, {
|
||||
const response = await fetchWithAuth(SATELLITE_COMPANY_SEARCH_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify({ companyTypeCode, companyName, page, limit }),
|
||||
});
|
||||
|
||||
@ -289,10 +290,10 @@ export async function searchSatelliteCompany({ companyTypeCode, companyName, pag
|
||||
*/
|
||||
export async function saveSatelliteCompany(params) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_COMPANY_SAVE_ENDPOINT, {
|
||||
const response = await fetchWithAuth(SATELLITE_COMPANY_SAVE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
@ -313,10 +314,10 @@ export async function saveSatelliteCompany(params) {
|
||||
*/
|
||||
export async function fetchSatelliteCompanyDetail(companyNo) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_COMPANY_DETAIL_ENDPOINT, {
|
||||
const response = await fetchWithAuth(SATELLITE_COMPANY_DETAIL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify({ companyNo }),
|
||||
});
|
||||
|
||||
@ -345,10 +346,10 @@ export async function fetchSatelliteCompanyDetail(companyNo) {
|
||||
*/
|
||||
export async function updateSatelliteCompany(params) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_COMPANY_UPDATE_ENDPOINT, {
|
||||
const response = await fetchWithAuth(SATELLITE_COMPANY_UPDATE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
@ -372,10 +373,10 @@ export async function updateSatelliteCompany(params) {
|
||||
*/
|
||||
export async function searchSatelliteManage({ companyNo, satelliteName, sensorType, page, limit }) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_MANAGE_SEARCH_ENDPOINT, {
|
||||
const response = await fetchWithAuth(SATELLITE_MANAGE_SEARCH_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify({ companyNo, satelliteName, sensorType, page, limit }),
|
||||
});
|
||||
|
||||
@ -407,10 +408,10 @@ export async function searchSatelliteManage({ companyNo, satelliteName, sensorTy
|
||||
*/
|
||||
export async function saveSatelliteManage(params) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_MANAGE_SAVE_ENDPOINT, {
|
||||
const response = await fetchWithAuth(SATELLITE_MANAGE_SAVE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
@ -431,10 +432,10 @@ export async function saveSatelliteManage(params) {
|
||||
*/
|
||||
export async function fetchSatelliteManageDetail(satelliteManageId) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_MANAGE_DETAIL_ENDPOINT, {
|
||||
const response = await fetchWithAuth(SATELLITE_MANAGE_DETAIL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify({ satelliteManageId }),
|
||||
});
|
||||
|
||||
@ -464,10 +465,10 @@ export async function fetchSatelliteManageDetail(satelliteManageId) {
|
||||
*/
|
||||
export async function updateSatelliteManage(params) {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_MANAGE_UPDATE_ENDPOINT, {
|
||||
const response = await fetchWithAuth(SATELLITE_MANAGE_UPDATE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
* - 응답 데이터 가공 (ProcessedTrack 형태로 변환)
|
||||
*/
|
||||
import useShipStore from '../stores/shipStore';
|
||||
import { fetchWithAuth } from './fetchWithAuth';
|
||||
|
||||
/** API 엔드포인트 (메인 프로젝트와 동일) */
|
||||
const API_ENDPOINT = '/api/v2/tracks/vessels';
|
||||
@ -31,10 +32,9 @@ export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegrati
|
||||
isIntegration: isIntegration ? '1' : '0',
|
||||
};
|
||||
|
||||
const response = await fetch(API_ENDPOINT, {
|
||||
const response = await fetchWithAuth(API_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 기상해양 API
|
||||
*/
|
||||
import { fetchWithAuth } from './fetchWithAuth';
|
||||
|
||||
const SPECIAL_NEWS_ENDPOINT = '/api/gis/weather/special-news/search';
|
||||
|
||||
@ -16,10 +17,10 @@ const SPECIAL_NEWS_ENDPOINT = '/api/gis/weather/special-news/search';
|
||||
*/
|
||||
export async function fetchWeatherAlerts({ startPresentationDate, endPresentationDate, page, limit }) {
|
||||
try {
|
||||
const response = await fetch(SPECIAL_NEWS_ENDPOINT, {
|
||||
const response = await fetchWithAuth(SPECIAL_NEWS_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify({
|
||||
startPresentationDate,
|
||||
endPresentationDate,
|
||||
@ -59,10 +60,10 @@ const TYPHOON_DETAIL_ENDPOINT = '/api/gis/weather/typhoon/search';
|
||||
*/
|
||||
export async function fetchTyphoonList({ typhoonBeginningYear, typhoonBeginningMonth, page, limit }) {
|
||||
try {
|
||||
const response = await fetch(TYPHOON_LIST_ENDPOINT, {
|
||||
const response = await fetchWithAuth(TYPHOON_LIST_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify({
|
||||
typhoonBeginningYear,
|
||||
typhoonBeginningMonth,
|
||||
@ -100,10 +101,10 @@ export async function fetchTyphoonList({ typhoonBeginningYear, typhoonBeginningM
|
||||
*/
|
||||
export async function fetchTyphoonDetail({ typhoonSequence, year }) {
|
||||
try {
|
||||
const response = await fetch(TYPHOON_DETAIL_ENDPOINT, {
|
||||
const response = await fetchWithAuth(TYPHOON_DETAIL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify({
|
||||
typhoonSequence,
|
||||
year,
|
||||
@ -135,10 +136,10 @@ const SUNRISE_SUNSET_DETAIL_ENDPOINT = '/api/gis/weather/tide-information/observ
|
||||
*/
|
||||
export async function fetchTideInformation() {
|
||||
try {
|
||||
const response = await fetch(TIDE_INFORMATION_ENDPOINT, {
|
||||
const response = await fetchWithAuth(TIDE_INFORMATION_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
@ -171,10 +172,10 @@ export async function fetchTideInformation() {
|
||||
*/
|
||||
export async function fetchSunriseSunsetDetail(params) {
|
||||
try {
|
||||
const response = await fetch(SUNRISE_SUNSET_DETAIL_ENDPOINT, {
|
||||
const response = await fetchWithAuth(SUNRISE_SUNSET_DETAIL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
@ -201,10 +202,10 @@ const OBSERVATORY_DETAIL_ENDPOINT = '/api/gis/weather/observatory/select/detail/
|
||||
*/
|
||||
export async function fetchObservatoryList() {
|
||||
try {
|
||||
const response = await fetch(OBSERVATORY_ENDPOINT, {
|
||||
const response = await fetchWithAuth(OBSERVATORY_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
@ -239,10 +240,10 @@ const AIRPORT_DETAIL_ENDPOINT = '/api/gis/weather/airport/select';
|
||||
*/
|
||||
export async function fetchAirportList() {
|
||||
try {
|
||||
const response = await fetch(AIRPORT_ENDPOINT, {
|
||||
const response = await fetchWithAuth(AIRPORT_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
@ -267,10 +268,10 @@ export async function fetchAirportList() {
|
||||
*/
|
||||
export async function fetchAirportDetail({ airportId }) {
|
||||
try {
|
||||
const response = await fetch(AIRPORT_DETAIL_ENDPOINT, {
|
||||
const response = await fetchWithAuth(AIRPORT_DETAIL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify({ airportId }),
|
||||
});
|
||||
|
||||
@ -288,10 +289,10 @@ export async function fetchAirportDetail({ airportId }) {
|
||||
|
||||
export async function fetchObservatoryDetail({ observatoryId, toDate }) {
|
||||
try {
|
||||
const response = await fetch(OBSERVATORY_DETAIL_ENDPOINT, {
|
||||
const response = await fetchWithAuth(OBSERVATORY_DETAIL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
|
||||
body: JSON.stringify({ observatoryId, toDate }),
|
||||
});
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
* 응답 변환: trackQueryApi.convertToProcessedTracks() 재사용
|
||||
*/
|
||||
import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi';
|
||||
import { fetchWithAuth } from '../../api/fetchWithAuth';
|
||||
|
||||
const API_ENDPOINT = '/api/v2/tracks/area-search';
|
||||
|
||||
@ -62,10 +63,9 @@ export async function fetchAreaSearch(params) {
|
||||
polygons: params.polygons,
|
||||
};
|
||||
|
||||
const response = await fetch(API_ENDPOINT, {
|
||||
const response = await fetchWithAuth(API_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
* 응답 변환: trackQueryApi.convertToProcessedTracks() 재사용
|
||||
*/
|
||||
import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi';
|
||||
import { fetchWithAuth } from '../../api/fetchWithAuth';
|
||||
|
||||
const API_ENDPOINT = '/api/v2/tracks/vessel-contacts';
|
||||
|
||||
@ -37,10 +38,9 @@ export async function fetchVesselContacts(params) {
|
||||
maxContactDistanceMeters: params.maxContactDistanceMeters,
|
||||
};
|
||||
|
||||
const response = await fetch(API_ENDPOINT, {
|
||||
const response = await fetchWithAuth(API_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
|
||||
36
src/components/auth/SessionGuard.jsx
Normal file
36
src/components/auth/SessionGuard.jsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import useShipStore from '../../stores/shipStore';
|
||||
import { fetchUserFilter } from '../../api/userSettingApi';
|
||||
import './SessionGuard.scss';
|
||||
|
||||
export default function SessionGuard({ children }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const isChecking = useAuthStore((s) => s.isChecking);
|
||||
|
||||
useEffect(() => {
|
||||
const result = useAuthStore.getState().checkSession();
|
||||
if (!result.valid) {
|
||||
window.location.href = import.meta.env.VITE_MAIN_APP_URL || '/';
|
||||
return;
|
||||
}
|
||||
// 세션 유효 → 서버에 저장된 필터 설정 로드
|
||||
fetchUserFilter()
|
||||
.then((filterArray) => {
|
||||
if (filterArray) {
|
||||
useShipStore.getState().applyFilterSettings(filterArray);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.warn('[SessionGuard] 필터 로드 실패, 기본값 사용:', err));
|
||||
}, []);
|
||||
|
||||
if (isChecking || !isAuthenticated) {
|
||||
return (
|
||||
<div className="session-guard-loading">
|
||||
{!isChecking && <p>로그인 페이지로 이동합니다...</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
9
src/components/auth/SessionGuard.scss
Normal file
9
src/components/auth/SessionGuard.scss
Normal file
@ -0,0 +1,9 @@
|
||||
.session-guard-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { showAlert } from '../common/AlertModal';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
const SAMPLE_ALERTS = [
|
||||
{ message: '서버 응답 시간이 초과되었습니다.\n잠시 후 다시 시도해 주세요.', errorCode: 'ERR_TIMEOUT_504' },
|
||||
@ -14,6 +15,7 @@ const SAMPLE_ALERTS = [
|
||||
* - 로고, 알람, 설정(드롭다운), 마이페이지
|
||||
*/
|
||||
export default function Header() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const alertIndexRef = useRef(0);
|
||||
|
||||
const handleAlarmClick = (e) => {
|
||||
@ -33,6 +35,11 @@ export default function Header() {
|
||||
</div>
|
||||
<aside>
|
||||
<ul>
|
||||
{user?.shipName && (
|
||||
<li className="ship-info">
|
||||
<span>{user.shipName}</span>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<a href="#" className="alram" title="알람" onClick={handleAlarmClick}>
|
||||
<i className="badge"></i>
|
||||
|
||||
85
src/stores/authStore.js
Normal file
85
src/stores/authStore.js
Normal file
@ -0,0 +1,85 @@
|
||||
import { create } from 'zustand';
|
||||
import { SESSION_TIMEOUT_MS, KCGV_GROUP_IDS } from '../types/constants';
|
||||
|
||||
/** 로컬 개발 모의 사용자 (포트가 달라 localStorage 공유 불가) */
|
||||
const DEV_MOCK_USER = {
|
||||
userName: 'DevUser',
|
||||
userId: 'dev',
|
||||
groupId: '2',
|
||||
loginDate: Date.now(),
|
||||
accountNo: '0',
|
||||
accountRoll: 'USER',
|
||||
lat: '34.75',
|
||||
lon: '128.03',
|
||||
shipName: '개발함정',
|
||||
targetId: 'DEV001',
|
||||
};
|
||||
|
||||
/**
|
||||
* localStorage에서 사용자 정보 읽기
|
||||
* 메인 프로젝트(mda-react-front) 로그인 시 저장된 값
|
||||
*/
|
||||
function readUserFromStorage() {
|
||||
const userName = localStorage.getItem('userName');
|
||||
const userId = localStorage.getItem('userId');
|
||||
const groupId = localStorage.getItem('groupId');
|
||||
const loginDate = localStorage.getItem('loginDate');
|
||||
|
||||
if (!userName || !userId || !groupId || !loginDate) {
|
||||
// 로컬 개발: 세션 없으면 모의 사용자 자동 주입
|
||||
if (import.meta.env.VITE_DEV_SKIP_AUTH === 'true') {
|
||||
console.warn('[AuthStore] 개발 모드: 모의 사용자로 자동 로그인');
|
||||
return DEV_MOCK_USER;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userName,
|
||||
userId,
|
||||
groupId,
|
||||
loginDate: Number(loginDate),
|
||||
accountNo: localStorage.getItem('accountNo'),
|
||||
accountRoll: localStorage.getItem('accountRoll'),
|
||||
lat: localStorage.getItem('lat'),
|
||||
lon: localStorage.getItem('lon'),
|
||||
shipName: localStorage.getItem('shipName'),
|
||||
targetId: localStorage.getItem('targetId'),
|
||||
};
|
||||
}
|
||||
|
||||
export const useAuthStore = create((set) => ({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isChecking: true,
|
||||
|
||||
checkSession: () => {
|
||||
const user = readUserFromStorage();
|
||||
|
||||
if (!user) {
|
||||
set({ isChecking: false });
|
||||
return { valid: false, reason: 'NO_USER' };
|
||||
}
|
||||
|
||||
if (Date.now() - user.loginDate > SESSION_TIMEOUT_MS) {
|
||||
set({ isChecking: false });
|
||||
return { valid: false, reason: 'TIMEOUT' };
|
||||
}
|
||||
|
||||
if (!KCGV_GROUP_IDS.includes(user.groupId)) {
|
||||
set({ isChecking: false });
|
||||
return { valid: false, reason: 'NOT_KCGV' };
|
||||
}
|
||||
|
||||
// 세션 갱신 (슬라이딩 윈도우)
|
||||
localStorage.setItem('loginDate', String(Date.now()));
|
||||
set({ user, isAuthenticated: true, isChecking: false });
|
||||
return { valid: true };
|
||||
},
|
||||
|
||||
handleSessionExpired: () => {
|
||||
localStorage.clear();
|
||||
set({ user: null, isAuthenticated: false });
|
||||
window.location.href = import.meta.env.VITE_MAIN_APP_URL || '/';
|
||||
},
|
||||
}));
|
||||
@ -16,10 +16,9 @@ export default ({ mode, command }) => {
|
||||
const isProd = !isLocalDev && !isDev && !isQA; // production 또는 기타 모드
|
||||
const isBuild = command === 'build';
|
||||
|
||||
// 배포 경로 설정 (예: '/kcgv/', '/' 등)
|
||||
// 로컬 개발 모드(development)에서는 항상 '/' 사용
|
||||
// 빌드 모드(dev, qa, prod)에서는 VITE_BASE_URL 사용
|
||||
const base = isLocalDev ? '/' : (env.VITE_BASE_URL || '/');
|
||||
// 배포 경로 설정 (예: '/kcgnv/', '/' 등)
|
||||
// 모든 모드에서 VITE_BASE_URL 사용 (로컬 개발 프록시 모드 지원)
|
||||
const base = env.VITE_BASE_URL || '/';
|
||||
|
||||
console.log(`[Vite] Mode: ${mode}, Command: ${command}, Base: ${base}, isLocalDev: ${isLocalDev}`);
|
||||
|
||||
@ -77,7 +76,19 @@ export default ({ mode, command }) => {
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
// API 서버
|
||||
// 공통 API (개인설정, 공통코드 등) — 메인 API 서버로 라우팅
|
||||
'/api/cmn': {
|
||||
target: env.VITE_API_URL || 'http://10.26.252.39:9090',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
// 기상/위성 등 GIS API — 메인 API 서버로 라우팅
|
||||
'/api/gis': {
|
||||
target: env.VITE_API_URL || 'http://10.26.252.39:9090',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
// API 서버 (기타)
|
||||
'/api': {
|
||||
target: env.VITE_TRACK_API || 'http://localhost:8090',
|
||||
changeOrigin: true,
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user