feat(auth): Google OAuth 로그인 구현 #7
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
<div className="sidebar">
|
||||
|
||||
44
apps/web/src/pages/denied/DeniedPage.tsx
Normal file
44
apps/web/src/pages/denied/DeniedPage.tsx
Normal file
@ -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">🚫</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>
|
||||
);
|
||||
}
|
||||
70
apps/web/src/pages/login/LoginPage.tsx
Normal file
70
apps/web/src/pages/login/LoginPage.tsx
Normal file
@ -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 · Wing Fleet Dashboard
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
apps/web/src/pages/pending/PendingPage.tsx
Normal file
39
apps/web/src/pages/pending/PendingPage.tsx
Normal file
@ -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">⏳</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>
|
||||
);
|
||||
}
|
||||
19
apps/web/src/shared/auth/AuthContext.ts
Normal file
19
apps/web/src/shared/auth/AuthContext.ts
Normal file
@ -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: () => {},
|
||||
});
|
||||
99
apps/web/src/shared/auth/AuthProvider.tsx
Normal file
99
apps/web/src/shared/auth/AuthProvider.tsx
Normal file
@ -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>;
|
||||
}
|
||||
35
apps/web/src/shared/auth/ProtectedRoute.tsx
Normal file
35
apps/web/src/shared/auth/ProtectedRoute.tsx
Normal file
@ -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 />;
|
||||
}
|
||||
34
apps/web/src/shared/auth/authApi.ts
Normal file
34
apps/web/src/shared/auth/authApi.ts
Normal file
@ -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) }),
|
||||
};
|
||||
4
apps/web/src/shared/auth/index.ts
Normal file
4
apps/web/src/shared/auth/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { AuthProvider } from './AuthProvider';
|
||||
export { useAuth } from './useAuth';
|
||||
export { ProtectedRoute } from './ProtectedRoute';
|
||||
export type { User, Role, AuthResponse, UserStatus } from './types';
|
||||
25
apps/web/src/shared/auth/types.ts
Normal file
25
apps/web/src/shared/auth/types.ts
Normal file
@ -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;
|
||||
}
|
||||
6
apps/web/src/shared/auth/useAuth.ts
Normal file
6
apps/web/src/shared/auth/useAuth.ts
Normal file
@ -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
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",
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user