diff --git a/docker-compose-server.yml b/docker-compose-server.yml index 313563a1..65714cd7 100644 --- a/docker-compose-server.yml +++ b/docker-compose-server.yml @@ -65,6 +65,16 @@ services: POSTGRES_PORT: ${POSTGRES_PORT:-5432} POSTGRES_DB: ${POSTGRES_DB:-mlmd} REACT_APP_CMF_API_URL: ${REACT_APP_CMF_API_URL} + # OAuth Configuration + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} + GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI} + SECRET_KEY: ${SECRET_KEY} + ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} + FRONTEND_URL: ${FRONTEND_URL} + AUTH_ENABLED: ${AUTH_ENABLED:-true} + COOKIE_SECURE: ${COOKIE_SECURE:-false} + ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-} healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8080 || exit 1"] interval: 15s diff --git a/env-example b/env-example index 592985ab..3dc91e6d 100644 --- a/env-example +++ b/env-example @@ -18,6 +18,39 @@ POSTGRES_DB=mlmd # Directory for persistent data storage CMF_DATA_DIR=/home/kulkashr/sk/ +# ======================================== +# OAuth2 Authentication Configuration +# ======================================== + +# Google OAuth2 Credentials (REQUIRED for Google Login) +# Get these from: https://console.cloud.google.com/apis/credentials +GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your-google-client-secret +GOOGLE_REDIRECT_URI=http://localhost:8080/api/auth/google/callback + +# JWT Secret Key (REQUIRED - Generate a secure random string) +# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))" +SECRET_KEY=your-secret-key-change-this-in-production + +# Session Configuration (OPTIONAL - defaults provided) +ACCESS_TOKEN_EXPIRE_MINUTES=60 +REFRESH_TOKEN_EXPIRE_DAYS=30 + +# Frontend URL (REQUIRED for OAuth callback) +#FRONTEND_URL=http://localhost:3000 + +# Authentication Toggle (OPTIONAL - set to false to disable auth) +AUTH_ENABLED=true + +# Email Domain Whitelist (OPTIONAL - comma separated, leave empty to allow all) +# Example: ALLOWED_EMAIL_DOMAINS=company.com,partner.com +ALLOWED_EMAIL_DOMAINS= + +# Cookie Security (OPTIONAL - set to true in production with HTTPS) +COOKIE_SECURE=false + +# ======================================== + #USER=frist #UID=123456789 diff --git a/server/app/auth_config.py b/server/app/auth_config.py new file mode 100644 index 00000000..25aff68d --- /dev/null +++ b/server/app/auth_config.py @@ -0,0 +1,47 @@ +""" +OAuth2 Authentication Configuration for CMF Server +""" +import os +from datetime import timedelta +from dotenv import load_dotenv + +load_dotenv() + +# Google OAuth2 Settings (REQUIRED) +GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "") +GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "") +GOOGLE_REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI", "http://localhost:8080/api/auth/google/callback") + +# JWT Settings for Session Management (REQUIRED) +SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-this-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60")) # 1 hour +REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "30")) # 30 days + +# Session Cookie Settings +COOKIE_NAME = "cmf_session" +COOKIE_MAX_AGE = ACCESS_TOKEN_EXPIRE_MINUTES * 60 # in seconds +COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true" # True for HTTPS only +COOKIE_HTTPONLY = True # Prevents JavaScript access +COOKIE_SAMESITE = "lax" # CSRF protection + +# Frontend URL (REQUIRED) +FRONTEND_URL = os.getenv("REACT_APP_CMF_API_URL", "http://localhost:3000") + +# Optional: Enable/Disable Authentication +AUTH_ENABLED = os.getenv("AUTH_ENABLED", "true").lower() == "true" + +# Optional: Whitelist of allowed email domains (e.g., only allow company emails) +ALLOWED_EMAIL_DOMAINS = os.getenv("ALLOWED_EMAIL_DOMAINS", "").split(",") if os.getenv("ALLOWED_EMAIL_DOMAINS") else [] + +# OAuth2 Configuration +GOOGLE_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_ACCESS_TOKEN_URL = "https://oauth2.googleapis.com/token" +GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo" + +# Scopes +GOOGLE_SCOPES = [ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile" +] diff --git a/server/app/auth_middleware.py b/server/app/auth_middleware.py new file mode 100644 index 00000000..5b4e219a --- /dev/null +++ b/server/app/auth_middleware.py @@ -0,0 +1,77 @@ +# """ +# Authentication middleware for protecting API routes +# """ +# from fastapi import Request, HTTPException, status +# from starlette.middleware.base import BaseHTTPMiddleware +# from server.app.auth_utils import verify_token +# from server.app.auth_config import COOKIE_NAME, AUTH_ENABLED + +# # Routes that don't require authentication +# PUBLIC_ROUTES = [ +# "/api/", +# "/api/auth/login/google", +# "/api/auth/google/callback", +# "/api/auth/status", +# "/api/auth/me", +# "/cmf-server/data/static" +# ] + + +# class AuthenticationMiddleware(BaseHTTPMiddleware): +# """ +# Middleware to enforce authentication on protected routes +# """ + +# async def dispatch(self, request: Request, call_next): +# # Skip authentication if disabled +# if not AUTH_ENABLED: +# return await call_next(request) + +# # Check if route is public +# path = request.url.path +# if any(path.startswith(route) for route in PUBLIC_ROUTES): +# return await call_next(request) + +# # Check for authentication cookie +# token = request.cookies.get(COOKIE_NAME) + +# if not token: +# raise HTTPException( +# status_code=status.HTTP_401_UNAUTHORIZED, +# detail="Authentication required" +# ) + +# # Verify token +# try: +# payload = verify_token(token) +# # Add user info to request state +# request.state.user = payload +# except HTTPException: +# raise HTTPException( +# status_code=status.HTTP_401_UNAUTHORIZED, +# detail="Invalid or expired session" +# ) + +# response = await call_next(request) +# return response + + +# def get_authenticated_user(request: Request) -> dict: +# """ +# Dependency to get authenticated user from request + +# Args: +# request: FastAPI Request object + +# Returns: +# User information dictionary + +# Raises: +# HTTPException: If user is not authenticated +# """ +# if not hasattr(request.state, "user"): +# raise HTTPException( +# status_code=status.HTTP_401_UNAUTHORIZED, +# detail="Authentication required" +# ) +# return request.state.user diff --git a/server/app/auth_routes.py b/server/app/auth_routes.py new file mode 100644 index 00000000..8486abe1 --- /dev/null +++ b/server/app/auth_routes.py @@ -0,0 +1,168 @@ +""" +OAuth2 Authentication Routes for Google Login +""" +from fastapi import APIRouter, Request, HTTPException +from fastapi.responses import RedirectResponse, JSONResponse +from authlib.integrations.starlette_client import OAuth +from starlette.config import Config +from server.app.auth_config import ( + GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET, + GOOGLE_REDIRECT_URI, + GOOGLE_SCOPES, + FRONTEND_URL, + AUTH_ENABLED +) +from server.app.auth_utils import ( + create_access_token, + set_auth_cookie, + clear_auth_cookie, + get_current_user_from_cookie, + validate_email_domain, + create_user_session_data +) +from typing import Dict, Any + +# Create router +router = APIRouter(prefix="/auth", tags=["authentication"]) + +# Configure OAuth +config = Config(environ={ + "GOOGLE_CLIENT_ID": GOOGLE_CLIENT_ID, + "GOOGLE_CLIENT_SECRET": GOOGLE_CLIENT_SECRET +}) + +oauth = OAuth(config) + +# Register Google OAuth provider +oauth.register( + name='google', + server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', + client_kwargs={ + 'scope': ' '.join(GOOGLE_SCOPES) + } +) + + +@router.get("/login/google") +async def login_google(request: Request): + """ + Initiate Google OAuth login flow + + Returns: + Redirect to Google's authorization page + """ + if not AUTH_ENABLED: + raise HTTPException(status_code=403, detail="Authentication is disabled") + + if not GOOGLE_CLIENT_ID or not GOOGLE_CLIENT_SECRET: + raise HTTPException( + status_code=500, + detail="Google OAuth not configured properly" + ) + + redirect_uri = GOOGLE_REDIRECT_URI + return await oauth.google.authorize_redirect(request, redirect_uri) + + +@router.get("/google/callback") +async def google_callback(request: Request): + """ + Handle Google OAuth callback + + Returns: + Redirect to frontend with authentication cookie set + """ + try: + # Get access token from Google + token = await oauth.google.authorize_access_token(request) + + # Get user info from Google + user_info = token.get('userinfo') + if not user_info: + # If userinfo not in token, fetch it + resp = await oauth.google.get('https://www.googleapis.com/oauth2/v3/userinfo', token=token) + user_info = resp.json() + + # Validate email domain if configured + email = user_info.get('email') + if not validate_email_domain(email): + return RedirectResponse( + url=f"{FRONTEND_URL}/?error=unauthorized_domain", + status_code=302 + ) + + # Create user session data + session_data = create_user_session_data(user_info) + + # Create JWT token + access_token = create_access_token(data=session_data) + + # Redirect to frontend with cookie + response = RedirectResponse(url=f"{FRONTEND_URL}/?auth=success", status_code=302) + set_auth_cookie(response, access_token) + + return response + + except Exception as e: + print(f"OAuth callback error: {str(e)}") + return RedirectResponse( + url=f"{FRONTEND_URL}/?error=auth_failed", + status_code=302 + ) + + +@router.post("/logout") +async def logout(request: Request): + """ + Logout user and clear session cookie + + Returns: + Success response with cleared cookie + """ + response = JSONResponse(content={"message": "Logged out successfully"}) + clear_auth_cookie(response) + return response + + +@router.get("/me") +async def get_current_user(request: Request): + """ + Get current authenticated user information + + Returns: + User information from session + + Raises: + HTTPException: If user is not authenticated + """ + try: + user = get_current_user_from_cookie(request) + return { + "authenticated": True, + "user": { + "email": user.get("email"), + "name": user.get("name"), + "picture": user.get("picture"), + "email_verified": user.get("email_verified", False) + } + } + except HTTPException: + return { + "authenticated": False, + "user": None + } + + +@router.get("/status") +async def auth_status(): + """ + Check if authentication is enabled + + Returns: + Authentication configuration status + """ + return { + "auth_enabled": AUTH_ENABLED, + "google_oauth_configured": bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET) + } diff --git a/server/app/auth_utils.py b/server/app/auth_utils.py new file mode 100644 index 00000000..4e21fccf --- /dev/null +++ b/server/app/auth_utils.py @@ -0,0 +1,164 @@ +""" +Authentication utilities for JWT token management and user sessions +""" +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +from jose import JWTError, jwt +from fastapi import HTTPException, status, Request, Response +from server.app.auth_config import ( + SECRET_KEY, + ALGORITHM, + ACCESS_TOKEN_EXPIRE_MINUTES, + COOKIE_NAME, + COOKIE_MAX_AGE, + COOKIE_SECURE, + COOKIE_HTTPONLY, + COOKIE_SAMESITE, + ALLOWED_EMAIL_DOMAINS +) + + +def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: + """ + Create JWT access token with user information + + Args: + data: Dictionary containing user information (email, name, picture, etc.) + expires_delta: Optional expiration time delta + + Returns: + Encoded JWT token string + """ + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({ + "exp": expire, + "iat": datetime.utcnow() + }) + + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def verify_token(token: str) -> Dict[str, Any]: + """ + Verify and decode JWT token + + Args: + token: JWT token string + + Returns: + Decoded token payload + + Raises: + HTTPException: If token is invalid or expired + """ + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + raise credentials_exception + + +def get_current_user_from_cookie(request: Request) -> Dict[str, Any]: + """ + Extract and verify user from session cookie + + Args: + request: FastAPI Request object + + Returns: + User information dictionary + + Raises: + HTTPException: If no valid session found + """ + token = request.cookies.get(COOKIE_NAME) + + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated" + ) + + return verify_token(token) + + +def set_auth_cookie(response: Response, token: str) -> None: + """ + Set authentication cookie in response + + Args: + response: FastAPI Response object + token: JWT token to store in cookie + """ + response.set_cookie( + key=COOKIE_NAME, + value=token, + max_age=COOKIE_MAX_AGE, + httponly=COOKIE_HTTPONLY, + secure=COOKIE_SECURE, + samesite=COOKIE_SAMESITE + ) + + +def clear_auth_cookie(response: Response) -> None: + """ + Clear authentication cookie (for logout) + + Args: + response: FastAPI Response object + """ + response.delete_cookie( + key=COOKIE_NAME, + httponly=COOKIE_HTTPONLY, + secure=COOKIE_SECURE, + samesite=COOKIE_SAMESITE + ) + + +def validate_email_domain(email: str) -> bool: + """ + Validate if email domain is in allowed list (if configured) + + Args: + email: User email address + + Returns: + True if email is allowed, False otherwise + """ + if not ALLOWED_EMAIL_DOMAINS or not ALLOWED_EMAIL_DOMAINS[0]: + # If no domain restrictions, allow all + return True + + domain = email.split("@")[-1] + return domain in ALLOWED_EMAIL_DOMAINS + + +def create_user_session_data(google_user_info: Dict[str, Any]) -> Dict[str, Any]: + """ + Extract and format user session data from Google user info + + Args: + google_user_info: User information from Google OAuth + + Returns: + Formatted user session dictionary + """ + return { + "email": google_user_info.get("email"), + "name": google_user_info.get("name"), + "picture": google_user_info.get("picture"), + "email_verified": google_user_info.get("email_verified", False), + "sub": google_user_info.get("sub") # Google user ID + } diff --git a/server/app/main.py b/server/app/main.py index e646325b..cb30d4c8 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -6,6 +6,7 @@ from fastapi.responses import HTMLResponse, PlainTextResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.sessions import SessionMiddleware from contextlib import asynccontextmanager import pandas as pd from typing import List, Dict, Any, Optional @@ -53,6 +54,10 @@ import dotenv from jsonpath_ng.ext import parse from cmflib.cmf_federation import update_mlmd +# Import authentication routes +from server.app.auth_routes import router as auth_router +# Optional: Import authentication middleware (uncomment to enable) +#from server.app.auth_middleware import AuthenticationMiddleware dotenv.load_dotenv() @@ -84,6 +89,16 @@ async def lifespan(app: FastAPI): app = FastAPI(title="cmf-server", lifespan=lifespan, root_path="/api") +# Add Session Middleware (REQUIRED for OAuth) +# This must be added BEFORE CORS middleware +app.add_middleware( + SessionMiddleware, + secret_key=os.getenv("SECRET_KEY", "your-secret-key-change-this-in-production"), + max_age=3600, # 1 hour + same_site="lax", + https_only=False # Set to True in production with HTTPS +) + # Add CORS middleware app.add_middleware( CORSMiddleware, @@ -93,6 +108,12 @@ async def lifespan(app: FastAPI): allow_headers=["*"], # Allows all headers ) +# Optional: Add authentication middleware (uncomment to enable route protection) +# app.add_middleware(AuthenticationMiddleware) + +# Include authentication routes +app.include_router(auth_router) + BASE_PATH = Path(__file__).resolve().parent app.mount("/cmf-server/data/static", StaticFiles(directory="/cmf-server/data/static"), name="static") diff --git a/server/requirements.txt b/server/requirements.txt index c671165a..eb644a28 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -10,3 +10,6 @@ python-dotenv psycopg[binary] httpx jsonpath_ng +authlib>=1.3.0 +python-jose[cryptography]>=3.3.0 +itsdangerous>=2.1.0 diff --git a/ui/package.json b/ui/package.json index 2a965fe2..1b13230d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -21,7 +21,8 @@ "react-table": "^7.8.0", "web-vitals": "^2.1.4", "react-data-table-component": "^7.7.0", - "papaparse": "^5.5.2" + "papaparse": "^5.5.2", + "js-cookie": "^3.0.5" }, "scripts": { "start": "react-scripts start", diff --git a/ui/src/App.js b/ui/src/App.js index 8b6ef6d4..a3c0a9da 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -16,7 +16,10 @@ import React from "react"; import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { AuthProvider } from "./contexts/AuthContext"; +import ProtectedRoute from "./components/ProtectedRoute"; import Home from "./pages/home"; +import Login from "./pages/login"; import Lineage from "./pages/lineage"; import TensorBoard from "./pages/tensorboard"; import Metahub from "./pages/metahub"; @@ -28,14 +31,64 @@ function App() { return (
- - } /> - } /> - } /> - } /> - } /> - } /> - + + + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + +
); diff --git a/ui/src/client.js b/ui/src/client.js index ae744a6d..b045d04f 100644 --- a/ui/src/client.js +++ b/ui/src/client.js @@ -33,6 +33,7 @@ class FastAPIClient { getApiClient(config) { const initialConfig = { baseURL: `${config.apiBasePath}/`, + withCredentials: true, // Important: include cookies in all requests }; const client = axios.create(initialConfig); @@ -41,8 +42,12 @@ class FastAPIClient { (response) => response, (error) => { console.error('API Error:', error); - // Show simple error message to user - if (error.response?.status >= 500) { + + // Handle authentication errors + if (error.response?.status === 401) { + // Redirect to login page + window.location.href = '/login'; + } else if (error.response?.status >= 500) { alert('Server error. Please try again later.'); } else if (error.request && !error.response) { alert('Unable to connect to server. Please check your connection.'); diff --git a/ui/src/components/ExamplePageWithUserProfile.js b/ui/src/components/ExamplePageWithUserProfile.js new file mode 100644 index 00000000..869d5eb5 --- /dev/null +++ b/ui/src/components/ExamplePageWithUserProfile.js @@ -0,0 +1,37 @@ +// /*** +// * Example of how to add UserProfile component to your page headers +// * +// * You can integrate this into any of your existing page components +// * by adding the UserProfile component to your navigation or header area +// ***/ + +// import React from "react"; +// import UserProfile from "../components/UserProfile"; + +// const ExamplePageWithUserProfile = () => { +// return ( +//
+// {/* Your page header/navigation */} +//
+//

CMF Server

+ +// {/* Add UserProfile component here */} +// +//
+ +// {/* Your page content */} +//
+//

Your page content goes here

+//
+//
+// ); +// }; + +// export default ExamplePageWithUserProfile; diff --git a/ui/src/components/ProtectedRoute.js b/ui/src/components/ProtectedRoute.js new file mode 100644 index 00000000..1401f91e --- /dev/null +++ b/ui/src/components/ProtectedRoute.js @@ -0,0 +1,44 @@ +/*** + * Copyright (2023) Hewlett Packard Enterprise Development LP + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + ***/ + +import React from "react"; +import { Navigate } from "react-router-dom"; +import { useAuth } from "../contexts/AuthContext"; + +const ProtectedRoute = ({ children }) => { + const { authenticated, loading } = useAuth(); + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + if (!authenticated) { + return ; + } + + return children; +}; + +export default ProtectedRoute; diff --git a/ui/src/components/UserProfile.css b/ui/src/components/UserProfile.css new file mode 100644 index 00000000..3d660412 --- /dev/null +++ b/ui/src/components/UserProfile.css @@ -0,0 +1,131 @@ +/* .user-profile { + position: relative; +} + +.user-profile-trigger { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + cursor: pointer; + border-radius: 8px; + transition: background-color 0.2s; +} + +.user-profile-trigger:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +.user-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; + border: 2px solid #e0e0e0; +} + +.user-name { + font-size: 14px; + font-weight: 500; + color: #333; +} + +.dropdown-arrow { + transition: transform 0.2s; +} + +.dropdown-arrow.open { + transform: rotate(180deg); +} + +.user-profile-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 998; +} + +.user-profile-menu { + position: absolute; + top: calc(100% + 8px); + right: 0; + background: white; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + min-width: 280px; + z-index: 999; + overflow: hidden; +} + +.user-info { + padding: 20px; + display: flex; + align-items: center; + gap: 12px; +} + +.user-avatar-large { + width: 48px; + height: 48px; + border-radius: 50%; + object-fit: cover; + border: 2px solid #e0e0e0; +} + +.user-details { + flex: 1; +} + +.user-name-large { + font-size: 16px; + font-weight: 600; + color: #333; + margin-bottom: 4px; +} + +.user-email { + font-size: 13px; + color: #666; +} + +.menu-divider { + height: 1px; + background: #e0e0e0; + margin: 0 12px; +} + +.logout-button { + width: 100%; + padding: 14px 20px; + display: flex; + align-items: center; + gap: 10px; + border: none; + background: white; + color: #d32f2f; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; +} + +.logout-button:hover { + background-color: #f5f5f5; +} + +.logout-button svg { + flex-shrink: 0; +} */ + +/* Responsive */ +/* @media (max-width: 768px) { + .user-name { + display: none; + } + + .user-profile-menu { + right: -12px; + } +} */ diff --git a/ui/src/components/UserProfile.js b/ui/src/components/UserProfile.js new file mode 100644 index 00000000..a376fb62 --- /dev/null +++ b/ui/src/components/UserProfile.js @@ -0,0 +1,79 @@ +// /*** +// * Copyright (2023) Hewlett Packard Enterprise Development LP +// * +// * Licensed under the Apache License, Version 2.0 (the "License"); +// * You may not use this file except in compliance with the License. +// * You may obtain a copy of the License at +// * +// * http://www.apache.org/licenses/LICENSE-2.0 +// * +// * Unless required by applicable law or agreed to in writing, software +// * distributed under the License is distributed on an "AS IS" BASIS, +// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// * See the License for the specific language governing permissions and +// * limitations under the License. +// ***/ + +// import React, { useState } from "react"; +// import { useAuth } from "../contexts/AuthContext"; +// import "./UserProfile.css"; + +// const UserProfile = () => { +// const { user, logout } = useAuth(); +// const [showMenu, setShowMenu] = useState(false); + +// if (!user) return null; + +// const toggleMenu = () => setShowMenu(!showMenu); + +// return ( +//
+//
+// {user.name} +// {user.name} +// +// +// +//
+ +// {showMenu && ( +// <> +//
+//
+//
+// {user.name} +//
+//
{user.name}
+//
{user.email}
+//
+//
+ +//
+ +// +//
+// +// )} +//
+// ); +// }; + +// export default UserProfile; diff --git a/ui/src/contexts/AuthContext.js b/ui/src/contexts/AuthContext.js new file mode 100644 index 00000000..35769cd9 --- /dev/null +++ b/ui/src/contexts/AuthContext.js @@ -0,0 +1,91 @@ +/*** + * Copyright (2023) Hewlett Packard Enterprise Development LP + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + ***/ + +import React, { createContext, useContext, useState, useEffect } from "react"; +import axios from "axios"; +import config from "../config"; + +const AuthContext = createContext(null); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within AuthProvider"); + } + return context; +}; + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [authenticated, setAuthenticated] = useState(false); + + // Check authentication status on mount + useEffect(() => { + checkAuthStatus(); + }, []); + + const checkAuthStatus = async () => { + try { + const response = await axios.get(`${config.apiBasePath}/auth/me`, { + withCredentials: true, // Important: include cookies + }); + + if (response.data.authenticated) { + setUser(response.data.user); + setAuthenticated(true); + } else { + setUser(null); + setAuthenticated(false); + } + } catch (error) { + console.error("Error checking auth status:", error); + setUser(null); + setAuthenticated(false); + } finally { + setLoading(false); + } + }; + + const login = () => { + // Redirect to Google OAuth login + window.location.href = `${config.apiBasePath}/auth/login/google`; + }; + + const logout = async () => { + try { + await axios.post(`${config.apiBasePath}/auth/logout`, {}, { + withCredentials: true, + }); + setUser(null); + setAuthenticated(false); + window.location.href = "/"; // Redirect to home + } catch (error) { + console.error("Error logging out:", error); + } + }; + + const value = { + user, + loading, + authenticated, + login, + logout, + checkAuthStatus, + }; + + return {children}; +}; diff --git a/ui/src/pages/login/index.js b/ui/src/pages/login/index.js new file mode 100644 index 00000000..f9066632 --- /dev/null +++ b/ui/src/pages/login/index.js @@ -0,0 +1,103 @@ +/*** + * Copyright (2023) Hewlett Packard Enterprise Development LP + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + ***/ + +import React from "react"; +import { useAuth } from "../../contexts/AuthContext"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import "./login.css"; + +const Login = () => { + const { login, authenticated } = useAuth(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + // Handle successful OAuth callback + React.useEffect(() => { + if (searchParams.get("auth") === "success") { + // Redirect to home after successful login + navigate("/"); + } + + // Handle authentication errors + const error = searchParams.get("error"); + if (error === "unauthorized_domain") { + alert("Your email domain is not authorized to access this application."); + } else if (error === "auth_failed") { + alert("Authentication failed. Please try again."); + } + }, [searchParams, navigate]); + + // Redirect if already authenticated + React.useEffect(() => { + if (authenticated) { + navigate("/"); + } + }, [authenticated, navigate]); + + const handleGoogleLogin = () => { + login(); + }; + + return ( +
+
+
+

CMF Server

+

Common Metadata Framework

+
+ +
+

Sign in to continue

+ + + +

+ Sign in with your Google account to access the CMF Server dashboard. +

+
+ +
+

© 2023 Hewlett Packard Enterprise Development LP

+
+
+
+ ); +}; + +export default Login; diff --git a/ui/src/pages/login/login.css b/ui/src/pages/login/login.css new file mode 100644 index 00000000..6c777892 --- /dev/null +++ b/ui/src/pages/login/login.css @@ -0,0 +1,129 @@ +.login-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: 20px; +} + +.login-card { + background: white; + border-radius: 12px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); + max-width: 450px; + width: 100%; + overflow: hidden; +} + +.login-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 40px 30px; + text-align: center; +} + +.login-header h1 { + margin: 0; + font-size: 32px; + font-weight: 700; + letter-spacing: -0.5px; +} + +.login-header p { + margin: 10px 0 0 0; + font-size: 16px; + opacity: 0.9; +} + +.login-content { + padding: 40px 30px; +} + +.login-content h2 { + margin: 0 0 30px 0; + font-size: 24px; + font-weight: 600; + color: #333; + text-align: center; +} + +.google-login-button { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 14px 24px; + font-size: 16px; + font-weight: 500; + color: #333; + background: white; + border: 2px solid #ddd; + border-radius: 8px; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.google-login-button:hover { + background: #f8f9fa; + border-color: #bbb; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); + transform: translateY(-1px); +} + +.google-login-button:active { + transform: translateY(0); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.google-icon { + width: 24px; + height: 24px; +} + +.login-note { + margin: 25px 0 0 0; + font-size: 14px; + color: #666; + text-align: center; + line-height: 1.5; +} + +.login-footer { + background: #f8f9fa; + padding: 20px 30px; + text-align: center; + border-top: 1px solid #eee; +} + +.login-footer p { + margin: 0; + font-size: 13px; + color: #888; +} + +/* Responsive design */ +@media (max-width: 480px) { + .login-card { + border-radius: 0; + } + + .login-header { + padding: 30px 20px; + } + + .login-header h1 { + font-size: 28px; + } + + .login-content { + padding: 30px 20px; + } + + .google-login-button { + padding: 12px 20px; + font-size: 15px; + } +}