From 79b21c7d44a09ea9cbad83a0af415022f05a335a Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 08:44:25 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(auth):=20Google=20OAuth=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared/auth 모듈: AuthProvider, ProtectedRoute, useAuth, authApi - 페이지: LoginPage(Google OAuth), PendingPage, DeniedPage - WING_PERMIT 역할 기반 접근 제어 - Topbar에 사용자 이름 + 로그아웃 버튼 추가 - App.tsx에 react-router 라우팅 + AuthProvider 래핑 - DEV 모드 Mock 로그인 지원 (김개발) Co-Authored-By: Claude Opus 4.6 --- .gitea/workflows/deploy.yml | 2 + apps/web/package.json | 4 +- apps/web/src/app/App.tsx | 21 ++- apps/web/src/app/styles.css | 161 ++++++++++++++++++ .../web/src/pages/dashboard/DashboardPage.tsx | 4 + apps/web/src/pages/denied/DeniedPage.tsx | 44 +++++ apps/web/src/pages/login/LoginPage.tsx | 70 ++++++++ apps/web/src/pages/pending/PendingPage.tsx | 39 +++++ apps/web/src/shared/auth/AuthContext.ts | 19 +++ apps/web/src/shared/auth/AuthProvider.tsx | 99 +++++++++++ apps/web/src/shared/auth/ProtectedRoute.tsx | 35 ++++ apps/web/src/shared/auth/authApi.ts | 34 ++++ apps/web/src/shared/auth/index.ts | 4 + apps/web/src/shared/auth/types.ts | 25 +++ apps/web/src/shared/auth/useAuth.ts | 6 + apps/web/src/widgets/topbar/Topbar.tsx | 14 +- package-lock.json | 37 +++- 17 files changed, 613 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/pages/denied/DeniedPage.tsx create mode 100644 apps/web/src/pages/login/LoginPage.tsx create mode 100644 apps/web/src/pages/pending/PendingPage.tsx create mode 100644 apps/web/src/shared/auth/AuthContext.ts create mode 100644 apps/web/src/shared/auth/AuthProvider.tsx create mode 100644 apps/web/src/shared/auth/ProtectedRoute.tsx create mode 100644 apps/web/src/shared/auth/authApi.ts create mode 100644 apps/web/src/shared/auth/index.ts create mode 100644 apps/web/src/shared/auth/types.ts create mode 100644 apps/web/src/shared/auth/useAuth.ts 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..8ff90ff --- /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 ( +
+
+
WING
+
조업감시 대시보드
+
@gcsc.co.kr 계정으로 로그인하세요
+ + {error &&
{error}
} + +
+ + setError('Google 로그인에 실패했습니다.')} + theme="filled_black" + size="large" + width="280" + /> + +
+ + {devLogin && ( + + )} + +
+ GC Surveillance · 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", -- 2.45.2 From 20db4cb0cf28efc74a96071463113e087f197a33 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 08:47:50 +0900 Subject: [PATCH 2/2] =?UTF-8?q?style(auth):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20UI=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로고: WING → GC WING + demo 라벨 - 하단: GC Surveillance → GC SI Team Co-Authored-By: Claude Opus 4.6 --- apps/web/src/pages/login/LoginPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/login/LoginPage.tsx b/apps/web/src/pages/login/LoginPage.tsx index 8ff90ff..b035240 100644 --- a/apps/web/src/pages/login/LoginPage.tsx +++ b/apps/web/src/pages/login/LoginPage.tsx @@ -37,7 +37,7 @@ export function LoginPage() { return (
-
WING
+
GC WINGdemo
조업감시 대시보드
@gcsc.co.kr 계정으로 로그인하세요
@@ -62,7 +62,7 @@ export function LoginPage() { )}
- GC Surveillance · Wing Fleet Dashboard + GC SI Team · Wing Fleet Dashboard
-- 2.45.2