diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 7cd5ba7..78e64ab 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -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
diff --git a/apps/web/package.json b/apps/web/package.json
index 9141864..39bbdea 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -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",
diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx
index afffeaa..74e53a5 100644
--- a/apps/web/src/app/App.tsx
+++ b/apps/web/src/app/App.tsx
@@ -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 ;
+ return (
+
+
+
+ } />
+ } />
+ } />
+ }>
+ } />
+
+
+
+
+ );
}
-
diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css
index 462325f..a57ba8d 100644
--- a/apps/web/src/app/styles.css
+++ b/apps/web/src/app/styles.css
@@ -1097,6 +1097,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;
diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx
index 87e5bdb..eabce78 100644
--- a/apps/web/src/pages/dashboard/DashboardPage.tsx
+++ b/apps/web/src/pages/dashboard/DashboardPage.tsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
+import { useAuth } from "../../shared/auth";
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
@@ -74,6 +75,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();
@@ -310,6 +312,8 @@ export function DashboardPage() {
clock={clock}
adminMode={adminMode}
onLogoClick={onLogoClick}
+ userName={user?.name}
+ onLogout={logout}
/>
diff --git a/apps/web/src/pages/denied/DeniedPage.tsx b/apps/web/src/pages/denied/DeniedPage.tsx
new file mode 100644
index 0000000..e69f2e7
--- /dev/null
+++ b/apps/web/src/pages/denied/DeniedPage.tsx
@@ -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 (
+
+ );
+ }
+
+ if (!user) {
+ return
;
+ }
+
+ const isRejectedOrDisabled =
+ user.status === 'REJECTED' || user.status === 'DISABLED';
+ const hasWingPermit = user.roles.some((r) => r.name === 'WING_PERMIT');
+
+ return (
+
+
+
🚫
+
접근 불가
+
+ {isRejectedOrDisabled
+ ? `계정이 ${user.status === 'REJECTED' ? '거절' : '비활성화'}되었습니다.`
+ : !hasWingPermit
+ ? 'WING 대시보드 접근 권한이 없습니다. 관리자에게 WING_PERMIT 역할을 요청하세요.'
+ : '접근이 거부되었습니다.'}
+
+
+ {user.email}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/login/LoginPage.tsx b/apps/web/src/pages/login/LoginPage.tsx
new file mode 100644
index 0000000..b035240
--- /dev/null
+++ b/apps/web/src/pages/login/LoginPage.tsx
@@ -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
(null);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (user) {
+ return ;
+ }
+
+ 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 (
+
+
+
GC WINGdemo
+
조업감시 대시보드
+
@gcsc.co.kr 계정으로 로그인하세요
+
+ {error &&
{error}
}
+
+
+
+ setError('Google 로그인에 실패했습니다.')}
+ theme="filled_black"
+ size="large"
+ width="280"
+ />
+
+
+
+ {devLogin && (
+
+ )}
+
+
+ GC SI Team · Wing Fleet Dashboard
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/pending/PendingPage.tsx b/apps/web/src/pages/pending/PendingPage.tsx
new file mode 100644
index 0000000..291e635
--- /dev/null
+++ b/apps/web/src/pages/pending/PendingPage.tsx
@@ -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 (
+
+ );
+ }
+
+ if (!user) {
+ return ;
+ }
+
+ if (user.status !== 'PENDING') {
+ return ;
+ }
+
+ return (
+
+
+
⏳
+
승인 대기 중
+
+ {user.email} 계정이 등록되었습니다.
+
+ 관리자가 승인하면 대시보드에 접근할 수 있습니다.
+
+
+
+
+ );
+}
diff --git a/apps/web/src/shared/auth/AuthContext.ts b/apps/web/src/shared/auth/AuthContext.ts
new file mode 100644
index 0000000..f2750d0
--- /dev/null
+++ b/apps/web/src/shared/auth/AuthContext.ts
@@ -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;
+ devLogin?: () => void;
+ logout: () => void;
+}
+
+export const AuthContext = createContext({
+ user: null,
+ token: null,
+ loading: true,
+ login: async () => {},
+ logout: () => {},
+});
diff --git a/apps/web/src/shared/auth/AuthProvider.tsx b/apps/web/src/shared/auth/AuthProvider.tsx
new file mode 100644
index 0000000..acd9b81
--- /dev/null
+++ b/apps/web/src/shared/auth/AuthProvider.tsx
@@ -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(() =>
+ isDevMockSession() ? DEV_MOCK_USER : null,
+ );
+ const [token, setToken] = useState(
+ () => 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('/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('/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 {children};
+}
diff --git a/apps/web/src/shared/auth/ProtectedRoute.tsx b/apps/web/src/shared/auth/ProtectedRoute.tsx
new file mode 100644
index 0000000..36c1e34
--- /dev/null
+++ b/apps/web/src/shared/auth/ProtectedRoute.tsx
@@ -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 (
+
+ );
+ }
+
+ if (!user) {
+ return ;
+ }
+
+ if (user.status === 'PENDING') {
+ return ;
+ }
+
+ if (user.status === 'REJECTED' || user.status === 'DISABLED') {
+ return ;
+ }
+
+ const hasPermit = user.roles.some((r) => r.name === REQUIRED_ROLE);
+ if (!hasPermit) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/apps/web/src/shared/auth/authApi.ts b/apps/web/src/shared/auth/authApi.ts
new file mode 100644
index 0000000..39cd9ea
--- /dev/null
+++ b/apps/web/src/shared/auth/authApi.ts
@@ -0,0 +1,34 @@
+const API_BASE = (import.meta.env.VITE_AUTH_API_URL || '').replace(/\/$/, '') + '/api';
+
+async function request(path: string, options?: RequestInit): Promise {
+ const token = localStorage.getItem('token');
+ const headers: Record = {
+ '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: (path: string) => request(path),
+ post: (path: string, body?: unknown) =>
+ request(path, { method: 'POST', body: JSON.stringify(body) }),
+};
diff --git a/apps/web/src/shared/auth/index.ts b/apps/web/src/shared/auth/index.ts
new file mode 100644
index 0000000..90a7808
--- /dev/null
+++ b/apps/web/src/shared/auth/index.ts
@@ -0,0 +1,4 @@
+export { AuthProvider } from './AuthProvider';
+export { useAuth } from './useAuth';
+export { ProtectedRoute } from './ProtectedRoute';
+export type { User, Role, AuthResponse, UserStatus } from './types';
diff --git a/apps/web/src/shared/auth/types.ts b/apps/web/src/shared/auth/types.ts
new file mode 100644
index 0000000..900a8d8
--- /dev/null
+++ b/apps/web/src/shared/auth/types.ts
@@ -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;
+}
diff --git a/apps/web/src/shared/auth/useAuth.ts b/apps/web/src/shared/auth/useAuth.ts
new file mode 100644
index 0000000..318a166
--- /dev/null
+++ b/apps/web/src/shared/auth/useAuth.ts
@@ -0,0 +1,6 @@
+import { useContext } from 'react';
+import { AuthContext } from './AuthContext';
+
+export function useAuth() {
+ return useContext(AuthContext);
+}
diff --git a/apps/web/src/widgets/topbar/Topbar.tsx b/apps/web/src/widgets/topbar/Topbar.tsx
index 042243f..da1230a 100644
--- a/apps/web/src/widgets/topbar/Topbar.tsx
+++ b/apps/web/src/widgets/topbar/Topbar.tsx
@@ -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
{clock}
+ {userName && (
+
+ {userName}
+ {onLogout && (
+
+ )}
+
+ )}
);
}
diff --git a/package-lock.json b/package-lock.json
index 5a8ef66..0aa3a3d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",