Merge remote-tracking branch 'origin/develop' into feature/dual-rendering

# Conflicts:
#	apps/web/src/pages/dashboard/DashboardPage.tsx
This commit is contained in:
htlee 2026-02-16 16:25:38 +09:00
커밋 c423b59244
17개의 변경된 파일613개의 추가작업 그리고 5개의 파일을 삭제

파일 보기

@ -29,6 +29,8 @@ jobs:
env:
VITE_MAPTILER_KEY: ${{ secrets.MAPTILER_KEY }}
VITE_MAPTILER_BASE_MAP_ID: dataviz-dark
VITE_AUTH_API_URL: https://guide.gc-si.dev
VITE_GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
run: npm -w @wing/web run build
- name: Deploy to server

파일 보기

@ -14,9 +14,11 @@
"@deck.gl/core": "^9.2.7",
"@deck.gl/layers": "^9.2.7",
"@deck.gl/mapbox": "^9.2.7",
"@react-oauth/google": "^0.13.4",
"maplibre-gl": "^5.18.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"react-router": "^7.13.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

파일 보기

@ -1,6 +1,23 @@
import { BrowserRouter, Route, Routes } from "react-router";
import { AuthProvider, ProtectedRoute } from "../shared/auth";
import { DashboardPage } from "../pages/dashboard/DashboardPage";
import { LoginPage } from "../pages/login/LoginPage";
import { PendingPage } from "../pages/pending/PendingPage";
import { DeniedPage } from "../pages/denied/DeniedPage";
export default function App() {
return <DashboardPage />;
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/pending" element={<PendingPage />} />
<Route path="/denied" element={<DeniedPage />} />
<Route element={<ProtectedRoute />}>
<Route index element={<DashboardPage />} />
</Route>
</Routes>
</BrowserRouter>
</AuthProvider>
);
}

파일 보기

@ -1098,6 +1098,167 @@ body {
padding: 1px 0;
}
/* ── Auth pages ──────────────────────────────────────────────────── */
.auth-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #020617 0%, #0f172a 50%, #020617 100%);
}
.auth-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 40px 36px;
width: 360px;
text-align: center;
}
.auth-logo {
font-size: 36px;
font-weight: 900;
color: var(--accent);
letter-spacing: 4px;
margin-bottom: 4px;
}
.auth-title {
font-size: 16px;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
}
.auth-subtitle {
font-size: 12px;
color: var(--muted);
margin-bottom: 24px;
}
.auth-error {
font-size: 11px;
color: var(--crit);
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 8px;
padding: 8px 12px;
margin-bottom: 16px;
}
.auth-google-btn {
display: flex;
justify-content: center;
margin-bottom: 12px;
}
.auth-dev-btn {
width: 100%;
padding: 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--card);
color: var(--muted);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
margin-bottom: 12px;
}
.auth-dev-btn:hover {
color: var(--text);
border-color: var(--accent);
}
.auth-footer {
font-size: 10px;
color: var(--muted);
margin-top: 16px;
}
.auth-status-icon {
font-size: 48px;
margin-bottom: 12px;
}
.auth-message {
font-size: 13px;
color: var(--muted);
line-height: 1.6;
margin-bottom: 16px;
}
.auth-message b {
color: var(--text);
}
.auth-link-btn {
background: none;
border: none;
color: var(--accent);
font-size: 12px;
cursor: pointer;
text-decoration: underline;
padding: 4px 8px;
}
.auth-link-btn:hover {
color: var(--text);
}
.auth-loading {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
}
.auth-loading__spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(148, 163, 184, 0.28);
border-top-color: var(--accent);
border-radius: 50%;
animation: map-loader-spin 0.7s linear infinite;
}
/* ── Topbar user ─────────────────────────────────────────────────── */
.topbar-user {
display: flex;
align-items: center;
gap: 8px;
margin-left: 10px;
flex-shrink: 0;
}
.topbar-user__name {
font-size: 10px;
color: var(--text);
font-weight: 500;
white-space: nowrap;
}
.topbar-user__logout {
font-size: 9px;
color: var(--muted);
background: none;
border: 1px solid var(--border);
border-radius: 3px;
padding: 2px 6px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.topbar-user__logout:hover {
color: var(--text);
border-color: var(--accent);
}
@media (max-width: 920px) {
.app {
grid-template-columns: 1fr;

파일 보기

@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "../../shared/auth";
import { usePersistedState } from "../../shared/hooks";
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
@ -70,6 +71,7 @@ function useLegacyIndex(data: LegacyVesselDataset | null): LegacyVesselIndex | n
}
export function DashboardPage() {
const { user, logout } = useAuth();
const { data: zones, error: zonesError } = useZones();
const { data: legacyData, error: legacyError } = useLegacyVessels();
const { data: subcableData } = useSubcables();
@ -304,6 +306,8 @@ export function DashboardPage() {
clock={clock}
adminMode={adminMode}
onLogoClick={onLogoClick}
userName={user?.name}
onLogout={logout}
/>
<div className="sidebar">

파일 보기

@ -0,0 +1,44 @@
import { Navigate } from 'react-router';
import { useAuth } from '../../shared/auth';
export function DeniedPage() {
const { user, loading, logout } = useAuth();
if (loading) {
return (
<div className="auth-loading">
<div className="auth-loading__spinner" />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
const isRejectedOrDisabled =
user.status === 'REJECTED' || user.status === 'DISABLED';
const hasWingPermit = user.roles.some((r) => r.name === 'WING_PERMIT');
return (
<div className="auth-page">
<div className="auth-card">
<div className="auth-status-icon">&#128683;</div>
<div className="auth-title"> </div>
<div className="auth-message">
{isRejectedOrDisabled
? `계정이 ${user.status === 'REJECTED' ? '거절' : '비활성화'}되었습니다.`
: !hasWingPermit
? 'WING 대시보드 접근 권한이 없습니다. 관리자에게 WING_PERMIT 역할을 요청하세요.'
: '접근이 거부되었습니다.'}
</div>
<div className="auth-message" style={{ fontSize: 11, marginTop: 8 }}>
{user.email}
</div>
<button className="auth-link-btn" onClick={logout}>
</button>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,70 @@
import { useState } from 'react';
import { Navigate } from 'react-router';
import { GoogleLogin, GoogleOAuthProvider } from '@react-oauth/google';
import { useAuth } from '../../shared/auth';
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || '';
export function LoginPage() {
const { user, login, devLogin, loading } = useAuth();
const [error, setError] = useState<string | null>(null);
if (loading) {
return (
<div className="auth-loading">
<div className="auth-loading__spinner" />
</div>
);
}
if (user) {
return <Navigate to="/" replace />;
}
const handleSuccess = async (credentialResponse: { credential?: string }) => {
if (!credentialResponse.credential) {
setError('Google 인증 토큰을 받지 못했습니다.');
return;
}
try {
setError(null);
await login(credentialResponse.credential);
} catch (e) {
setError(e instanceof Error ? e.message : '로그인에 실패했습니다.');
}
};
return (
<div className="auth-page">
<div className="auth-card">
<div className="auth-logo"><span style={{ position: 'relative' }}>GC WING<span style={{ position: 'absolute', right: -48, bottom: 0, color: '#F59E0B', fontSize: 12, fontWeight: 600 }}>demo</span></span></div>
<div className="auth-title"> </div>
<div className="auth-subtitle">@gcsc.co.kr </div>
{error && <div className="auth-error">{error}</div>}
<div className="auth-google-btn">
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
<GoogleLogin
onSuccess={handleSuccess}
onError={() => setError('Google 로그인에 실패했습니다.')}
theme="filled_black"
size="large"
width="280"
/>
</GoogleOAuthProvider>
</div>
{devLogin && (
<button className="auth-dev-btn" onClick={devLogin}>
DEV Mock
</button>
)}
<div className="auth-footer">
GC SI Team &middot; Wing Fleet Dashboard
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,39 @@
import { Navigate } from 'react-router';
import { useAuth } from '../../shared/auth';
export function PendingPage() {
const { user, loading, logout } = useAuth();
if (loading) {
return (
<div className="auth-loading">
<div className="auth-loading__spinner" />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
if (user.status !== 'PENDING') {
return <Navigate to="/" replace />;
}
return (
<div className="auth-page">
<div className="auth-card">
<div className="auth-status-icon">&#9203;</div>
<div className="auth-title"> </div>
<div className="auth-message">
<b>{user.email}</b> .
<br />
.
</div>
<button className="auth-link-btn" onClick={logout}>
</button>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,19 @@
import { createContext } from 'react';
import type { User } from './types';
export interface AuthContextValue {
user: User | null;
token: string | null;
loading: boolean;
login: (googleToken: string) => Promise<void>;
devLogin?: () => void;
logout: () => void;
}
export const AuthContext = createContext<AuthContextValue>({
user: null,
token: null,
loading: true,
login: async () => {},
logout: () => {},
});

파일 보기

@ -0,0 +1,99 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import type { AuthResponse, User } from './types';
import { authApi } from './authApi';
import { AuthContext } from './AuthContext';
const DEV_MOCK_USER: User = {
id: 1,
email: 'htlee@gcsc.co.kr',
name: '김개발 (DEV)',
avatarUrl: null,
status: 'ACTIVE',
isAdmin: true,
roles: [
{ id: 1, name: 'ADMIN', description: '관리자', urlPatterns: ['/**'] },
{ id: 99, name: 'WING_PERMIT', description: 'Wing 접근 권한', urlPatterns: [] },
],
createdAt: new Date().toISOString(),
lastLoginAt: new Date().toISOString(),
};
function isDevMockSession(): boolean {
return import.meta.env.DEV && localStorage.getItem('dev-user') === 'true';
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(() =>
isDevMockSession() ? DEV_MOCK_USER : null,
);
const [token, setToken] = useState<string | null>(
() => localStorage.getItem('token'),
);
const [initialized, setInitialized] = useState(
() => isDevMockSession() || !localStorage.getItem('token'),
);
const logout = useCallback(() => {
const hadToken = !!localStorage.getItem('token') && !isDevMockSession();
localStorage.removeItem('token');
localStorage.removeItem('dev-user');
setToken(null);
setUser(null);
if (hadToken) {
authApi.post('/auth/logout').catch(() => {});
}
}, []);
const devLogin = useCallback(() => {
localStorage.setItem('dev-user', 'true');
localStorage.setItem('token', 'dev-mock-token');
setToken('dev-mock-token');
setUser(DEV_MOCK_USER);
setInitialized(true);
}, []);
const login = useCallback(async (googleToken: string) => {
const res = await authApi.post<AuthResponse>('/auth/google', {
idToken: googleToken,
});
localStorage.setItem('token', res.token);
setToken(res.token);
setUser(res.user);
}, []);
useEffect(() => {
if (!token || isDevMockSession()) return;
let cancelled = false;
authApi
.get<User>('/auth/me')
.then((data) => {
if (!cancelled) setUser(data);
})
.catch(() => {
if (!cancelled) logout();
})
.finally(() => {
if (!cancelled) setInitialized(true);
});
return () => {
cancelled = true;
};
}, [token, logout]);
const loading = !initialized;
const value = useMemo(
() => ({
user,
token,
loading,
login,
devLogin: import.meta.env.DEV ? devLogin : undefined,
logout,
}),
[user, token, loading, login, devLogin, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

파일 보기

@ -0,0 +1,35 @@
import { Navigate, Outlet } from 'react-router';
import { useAuth } from './useAuth';
const REQUIRED_ROLE = 'WING_PERMIT';
export function ProtectedRoute() {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="auth-loading">
<div className="auth-loading__spinner" />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
if (user.status === 'PENDING') {
return <Navigate to="/pending" replace />;
}
if (user.status === 'REJECTED' || user.status === 'DISABLED') {
return <Navigate to="/denied" replace />;
}
const hasPermit = user.roles.some((r) => r.name === REQUIRED_ROLE);
if (!hasPermit) {
return <Navigate to="/denied" replace />;
}
return <Outlet />;
}

파일 보기

@ -0,0 +1,34 @@
const API_BASE = (import.meta.env.VITE_AUTH_API_URL || '').replace(/\/$/, '') + '/api';
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (res.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!res.ok) {
const body = await res.text();
throw new Error(body || `HTTP ${res.status}`);
}
if (res.status === 204 || res.headers.get('content-length') === '0') {
return undefined as T;
}
return res.json();
}
export const authApi = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
};

파일 보기

@ -0,0 +1,4 @@
export { AuthProvider } from './AuthProvider';
export { useAuth } from './useAuth';
export { ProtectedRoute } from './ProtectedRoute';
export type { User, Role, AuthResponse, UserStatus } from './types';

파일 보기

@ -0,0 +1,25 @@
export type UserStatus = 'PENDING' | 'ACTIVE' | 'REJECTED' | 'DISABLED';
export interface Role {
id: number;
name: string;
description: string;
urlPatterns: string[];
}
export interface User {
id: number;
email: string;
name: string;
avatarUrl: string | null;
status: UserStatus;
isAdmin: boolean;
roles: Role[];
createdAt: string;
lastLoginAt: string | null;
}
export interface AuthResponse {
token: string;
user: User;
}

파일 보기

@ -0,0 +1,6 @@
import { useContext } from 'react';
import { AuthContext } from './AuthContext';
export function useAuth() {
return useContext(AuthContext);
}

파일 보기

@ -9,9 +9,11 @@ type Props = {
clock: string;
adminMode?: boolean;
onLogoClick?: () => void;
userName?: string;
onLogout?: () => void;
};
export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStatus, lastFetchMinutes, clock, adminMode, onLogoClick }: Props) {
export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStatus, lastFetchMinutes, clock, adminMode, onLogoClick, userName, onLogout }: Props) {
const statusColor =
pollingStatus === "ready" ? "#22C55E" : pollingStatus === "loading" ? "#F59E0B" : pollingStatus === "error" ? "#EF4444" : "var(--muted)";
return (
@ -47,6 +49,16 @@ export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStat
</div>
</div>
<div className="time">{clock}</div>
{userName && (
<div className="topbar-user">
<span className="topbar-user__name">{userName}</span>
{onLogout && (
<button className="topbar-user__logout" onClick={onLogout}>
</button>
)}
</div>
)}
</div>
);
}

37
package-lock.json generated
파일 보기

@ -33,9 +33,11 @@
"@deck.gl/core": "^9.2.7",
"@deck.gl/layers": "^9.2.7",
"@deck.gl/mapbox": "^9.2.7",
"@react-oauth/google": "^0.13.4",
"maplibre-gl": "^5.18.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"react-router": "^7.13.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@ -1575,6 +1577,16 @@
"integrity": "sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==",
"license": "MIT"
},
"node_modules/@react-oauth/google": {
"version": "0.13.4",
"resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.13.4.tgz",
"integrity": "sha512-hGKyNEH+/PK8M0sFEuo3MAEk0txtHpgs94tDQit+s2LXg7b6z53NtzHfqDvoB2X8O6lGB+FRg80hY//X6hfD+w==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@ -4030,6 +4042,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -4047,6 +4060,28 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
"integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",