From c17d0fa21c0ff734c3844749baa41cc71a348a37 Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Fri, 31 Oct 2025 11:36:53 -0400 Subject: [PATCH 1/6] auth for ui --- web-frontend/package-lock.json | 42 ++- web-frontend/package.json | 1 + web-frontend/src/App.tsx | 49 ++-- web-frontend/src/auth/auth-api.ts | 105 +++++++ web-frontend/src/auth/auth-context.tsx | 24 ++ web-frontend/src/auth/auth-provider.tsx | 263 ++++++++++++++++++ web-frontend/src/auth/token-manager.ts | 82 ++++++ web-frontend/src/auth/types.ts | 48 ++++ web-frontend/src/client.ts | 104 +++++-- .../components/login-page/login-page.test.tsx | 97 +++++++ .../src/components/login-page/login-page.tsx | 130 +++++++++ .../src/components/protected-route.tsx | 39 +++ .../tiled-app-bar/tiled-app-bar.test.tsx | 34 ++- .../tiled-app-bar/tiled-app-bar.tsx | 100 +++++-- web-frontend/src/utils/apiclient.ts | 78 ++++++ web-frontend/vite.config.js | 18 +- 16 files changed, 1124 insertions(+), 90 deletions(-) create mode 100644 web-frontend/src/auth/auth-api.ts create mode 100644 web-frontend/src/auth/auth-context.tsx create mode 100644 web-frontend/src/auth/auth-provider.tsx create mode 100644 web-frontend/src/auth/token-manager.ts create mode 100644 web-frontend/src/auth/types.ts create mode 100644 web-frontend/src/components/login-page/login-page.test.tsx create mode 100644 web-frontend/src/components/login-page/login-page.tsx create mode 100644 web-frontend/src/components/protected-route.tsx create mode 100644 web-frontend/src/utils/apiclient.ts diff --git a/web-frontend/package-lock.json b/web-frontend/package-lock.json index 2337f226f..18792b54c 100644 --- a/web-frontend/package-lock.json +++ b/web-frontend/package-lock.json @@ -46,6 +46,7 @@ "@types/recharts": "^1.8.23", "@vitejs/plugin-react": "^5.0.1", "@vitest/coverage-v8": "^3.2.4", + "dotenv": "^17.2.3", "jsdom": "^26.1.0", "playwright": "^1.52.0", "prettier": "^3.6.2", @@ -2054,13 +2055,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", - "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.55.0" + "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" @@ -3005,9 +3006,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -3556,6 +3557,19 @@ "csstype": "^3.0.2" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4904,13 +4918,13 @@ } }, "node_modules/playwright": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", - "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", + "version": "1.53.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.1.tgz", + "integrity": "sha512-LJ13YLr/ocweuwxyGf1XNFWIU4M2zUSo149Qbp+A4cpwDjsxRPj7k6H25LBrEHiEwxvRbD8HdwvQmRMSvquhYw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.55.0" + "playwright-core": "1.53.1" }, "bin": { "playwright": "cli.js" @@ -4923,9 +4937,9 @@ } }, "node_modules/playwright-core": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", - "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", + "version": "1.53.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.1.tgz", + "integrity": "sha512-Z46Oq7tLAyT0lGoFx4DOuB1IA9D1TPj0QkYxpPVUnGDqHHvDpCftu1J2hM2PiWsNMoZh8+LQaarAWcDfPBc6zg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/web-frontend/package.json b/web-frontend/package.json index 3d1d0b658..83715fdc6 100644 --- a/web-frontend/package.json +++ b/web-frontend/package.json @@ -53,6 +53,7 @@ "@types/recharts": "^1.8.23", "@vitejs/plugin-react": "^5.0.1", "@vitest/coverage-v8": "^3.2.4", + "dotenv": "^17.2.3", "jsdom": "^26.1.0", "playwright": "^1.52.0", "prettier": "^3.6.2", diff --git a/web-frontend/src/App.tsx b/web-frontend/src/App.tsx index 0fdef0e74..5f2c26971 100644 --- a/web-frontend/src/App.tsx +++ b/web-frontend/src/App.tsx @@ -1,13 +1,17 @@ import Container from "@mui/material/Container"; import ErrorBoundary from "./components/error-boundary/error-boundary"; import { Outlet } from "react-router-dom"; -import TiledAppBar from "./components/tiled-app-bar/tiled-app-bar"; +import { TiledAppBar } from "./components/tiled-app-bar/tiled-app-bar"; import { useEffect, useState } from "react"; import { fetchSettings } from "./settings"; import { SettingsContext, emptySettings } from "./context/settings"; import { BrowserRouter, Route, Routes } from "react-router-dom"; import { Suspense, lazy } from "react"; import Skeleton from "@mui/material/Skeleton"; +import { LoginPage } from "./components/login-page/login-page"; +import { ProtectedRoute } from "./components/protected-route"; +import { Navigate } from "react-router-dom"; +import { AuthProvider } from "./auth/auth-provider"; const Browse = lazy(() => import("./routes/browse")); @@ -39,21 +43,34 @@ function App() { - }> - - }> - } /> - - -

There's nothing here!

- - } - /> -
-
+ + {" "} + {/* <-- Add this wrapper */} + }> + + } /> + + + + } + > + } /> + } /> + + +

There's nothing here!

+ + } + /> +
+
+
diff --git a/web-frontend/src/auth/auth-api.ts b/web-frontend/src/auth/auth-api.ts new file mode 100644 index 000000000..ca6af31bb --- /dev/null +++ b/web-frontend/src/auth/auth-api.ts @@ -0,0 +1,105 @@ +import { AuthConfig, AuthTokens, User } from "./types"; + +const API_BASE_URL = ""; +const API_PREFIX = "/api/v1"; +console.log(" Auth Service initialized with backend:", API_BASE_URL); +export const authService = { + async getAuthConfig(): Promise { + const response = await fetch(`${API_BASE_URL}${API_PREFIX}/`); + + if (!response.ok) { + throw new Error("Failed to fetch auth configuration"); + } + const data = await response.json(); + return data.authentication; + }, + + async loginWithPassword( + provider: string, + username: string, + password: string, + ): Promise { + + const url = `${API_BASE_URL}${API_PREFIX}/auth/provider/${provider}/token`; + + const formData = new URLSearchParams(); + formData.append("username", username); + formData.append("password", password); + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: formData.toString(), + }); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: "Login failed" })); + throw new Error(error.detail || "Invalid credentials"); + } + + return response.json(); + }, + + + + async refreshSession(refreshToken: string): Promise { + const response = await fetch( + `${API_BASE_URL}${API_PREFIX}/auth/session/refresh`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ refresh_token: refreshToken }), + }, + ); + + if (!response.ok) { + throw new Error("Failed to refresh session"); + } + + return response.json(); + }, + + async getCurrentUser(accessToken: string): Promise { + const response = await fetch(`${API_BASE_URL}${API_PREFIX}/auth/whoami`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error("Failed to get user info"); + } + + const data = await response.json(); + return data.data; // Tiled returns user in data.data + }, + + async logout(accessToken: string): Promise { + await fetch(`${API_BASE_URL}${API_PREFIX}/auth/logout`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + }, + + async authenticatedFetch( + url: string, + accessToken: string, + options: RequestInit = {}, + ): Promise { + return fetch(url, { + ...options, + headers: { + ...options.headers, + Authorization: `Bearer ${accessToken}`, + }, + }); + }, +}; diff --git a/web-frontend/src/auth/auth-context.tsx b/web-frontend/src/auth/auth-context.tsx new file mode 100644 index 000000000..1c56ec4ee --- /dev/null +++ b/web-frontend/src/auth/auth-context.tsx @@ -0,0 +1,24 @@ +import { createContext, useContext } from "react"; +import { AuthState } from "./types"; + +interface AuthContextType extends AuthState { + login: ( + provider: string, + username: string, + password: string, + ) => Promise; + logout: () => Promise; + refreshTokens: () => Promise; +} + +export const AuthContext = createContext( + undefined, +); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within AuthProvider"); + } + return context; +}; diff --git a/web-frontend/src/auth/auth-provider.tsx b/web-frontend/src/auth/auth-provider.tsx new file mode 100644 index 000000000..e6babe350 --- /dev/null +++ b/web-frontend/src/auth/auth-provider.tsx @@ -0,0 +1,263 @@ +import React, { useEffect, useState, useCallback, useRef } from "react"; +import { AuthContext } from "./auth-context"; +import { authService } from "./auth-api"; +import { tokenManager } from "./token-manager"; +import { AuthState, AuthConfig, AuthTokens, User } from "./types"; +import { setupAuthInterceptor, setupRefreshInterceptor } from "../client"; + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [state, setState] = useState({ + isAuthenticated: false, + isLoading: true, + user: null, + tokens: null, + error: null, + }); + + const [authConfig, setAuthConfig] = useState(null); + const refreshTimeoutRef = useRef(null); + + useEffect(() => { + // Setup auth token interceptor + setupAuthInterceptor(() => { + const tokens = tokenManager.getTokens(); + if (tokens?.access_token) { + return tokens.access_token; + } + + return null; + }); + + // Setup refresh token interceptor + setupRefreshInterceptor( + () => { + const tokens = tokenManager.getTokens(); + return tokens?.refresh_token || null; + }, + async (refreshToken: string) => { + const newTokens = await authService.refreshSession(refreshToken); + return newTokens; + }, + (tokens: AuthTokens) => { + tokenManager.saveTokens(tokens); + setState((prev) => ({ + ...prev, + tokens, + })); + }, + () => { + tokenManager.clearTokens(); + setState({ + isAuthenticated: false, + isLoading: false, + user: null, + tokens: null, + error: null, + }); + }, + ); + }, [state.isAuthenticated]); + + const scheduleTokenRefresh = useCallback((tokens: AuthTokens) => { + if (refreshTimeoutRef.current) { + clearTimeout(refreshTimeoutRef.current); + } + + if (!tokens.expires_in) { + return; + } + + // Calculate when to refresh (5 minutes before expiry, or half the lifetime) + const bufferTime = Math.min(5 * 60 * 1000, (tokens.expires_in * 1000) / 2); + const refreshIn = tokens.expires_in * 1000 - bufferTime; + + if (refreshIn <= 0) { + return; + } + + refreshTimeoutRef.current = setTimeout(async () => { + try { + await refreshTokens(); + } catch (error) { + // If auto-refresh fails, user will need to log in again + logout(); + } + }, refreshIn); + }, []); + + useEffect(() => { + const initAuth = async () => { + try { + // Fetch auth configuration from server + const config = await authService.getAuthConfig(); + setAuthConfig(config); + + //Check if we have stored tokens + const tokens = tokenManager.getTokens(); + + if (!tokens) { + setState((prev) => ({ + ...prev, + isLoading: false, + isAuthenticated: false, + })); + return; + } + + // Check if access token is expired + if (tokenManager.isAccessTokenExpired(tokens)) { + try { + const newTokens = await authService.refreshSession( + tokens.refresh_token, + ); + tokenManager.saveTokens(newTokens); + + // Get user info with new token + const user = await authService.getCurrentUser( + newTokens.access_token, + ); + + setState({ + isAuthenticated: true, + isLoading: false, + user, + tokens: newTokens, + error: null, + }); + + scheduleTokenRefresh(newTokens); + } catch (error) { + tokenManager.clearTokens(); + setState((prev) => ({ + ...prev, + isLoading: false, + isAuthenticated: false, + })); + } + } else { + const user = await authService.getCurrentUser(tokens.access_token); + setState({ + isAuthenticated: true, + isLoading: false, + user, + tokens, + error: null, + }); + + scheduleTokenRefresh(tokens); + } + } catch (error) { + setState((prev) => ({ + ...prev, + isLoading: false, + error: "Failed to initialize authentication", + })); + } + }; + + initAuth(); + + // Cleanup timeout on unmount + return () => { + if (refreshTimeoutRef.current) { + clearTimeout(refreshTimeoutRef.current); + } + }; + }, [scheduleTokenRefresh]); + + const login = async ( + provider: string, + username: string, + password: string, + ) => { + try { + setState((prev) => ({ ...prev, isLoading: true, error: null })); + + const tokens = await authService.loginWithPassword( + provider, + username, + password, + ); + + tokenManager.saveTokens(tokens); + + const user = await authService.getCurrentUser(tokens.access_token); + + setState({ + isAuthenticated: true, + isLoading: false, + user, + tokens, + error: null, + }); + + scheduleTokenRefresh(tokens); + } catch (error: any) { + setState((prev) => ({ + ...prev, + isLoading: false, + error: error.message || "Login failed", + })); + throw error; + } + }; + + const refreshTokens = async () => { + if (!state.tokens?.refresh_token) { + throw new Error("No refresh token available"); + } + + try { + const newTokens = await authService.refreshSession( + state.tokens.refresh_token, + ); + tokenManager.saveTokens(newTokens); + + setState((prev) => ({ + ...prev, + tokens: newTokens, + })); + + scheduleTokenRefresh(newTokens); + } catch (error) { + throw error; + } + }; + + const logout = async () => { + try { + if (state.tokens?.access_token) { + await authService.logout(state.tokens.access_token); + } + } catch (error) { + console.error(error); + } finally { + tokenManager.clearTokens(); + if (refreshTimeoutRef.current) { + clearTimeout(refreshTimeoutRef.current); + } + setState({ + isAuthenticated: false, + isLoading: false, + user: null, + tokens: null, + error: null, + }); + } + }; + + return ( + + {children} + + ); +}; diff --git a/web-frontend/src/auth/token-manager.ts b/web-frontend/src/auth/token-manager.ts new file mode 100644 index 000000000..d6cf2c3ae --- /dev/null +++ b/web-frontend/src/auth/token-manager.ts @@ -0,0 +1,82 @@ +// auth/tokenManager.ts +import { AuthTokens } from "./types"; + +const TOKEN_KEY = "tiled_tokens"; + +export const tokenManager = { + /** + * Save tokens to sessionStorage (more secure than localStorage) + * Tokens are cleared when browser closes + */ + saveTokens(tokens: AuthTokens): void { + try { + sessionStorage.setItem(TOKEN_KEY, JSON.stringify(tokens)); + } catch (error) { + console.error("Failed to save tokens:", error); + } + }, + + /** + * Retrieve tokens from storage + */ + getTokens(): AuthTokens | null { + try { + const tokensJson = sessionStorage.getItem(TOKEN_KEY); + if (!tokensJson) return null; + return JSON.parse(tokensJson) as AuthTokens; + } catch (error) { + console.error("Failed to retrieve tokens:", error); + return null; + } + }, + + /** + * Remove tokens from storage for logout + */ + clearTokens(): void { + sessionStorage.removeItem(TOKEN_KEY); + }, + + /** + * Check if access token is expired or about to expire + * Returns true if token will expire in less than 60 seconds + */ + isAccessTokenExpired(tokens: AuthTokens): boolean { + if (!tokens.access_token) return true; + + try { + // Decode JWT to get expiration time + const payload = this.decodeToken(tokens.access_token); + if (!payload.exp) return true; + + const expirationTime = payload.exp * 1000; // Convert to milliseconds + const currentTime = Date.now(); + const bufferTime = 60 * 1000; // 60 second buffer + + return expirationTime - currentTime < bufferTime; + } catch (error) { + console.error("Failed to check token expiration:", error); + return true; + } + }, + + /** + * Decode JWT token (without verification - server verifies) + */ + decodeToken(token: string): any { + try { + const base64Url = token.split(".")[1]; + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + const jsonPayload = decodeURIComponent( + atob(base64) + .split("") + .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) + .join(""), + ); + return JSON.parse(jsonPayload); + } catch (error) { + console.error("Failed to decode token:", error); + return null; + } + }, +}; diff --git a/web-frontend/src/auth/types.ts b/web-frontend/src/auth/types.ts new file mode 100644 index 000000000..ffbca964c --- /dev/null +++ b/web-frontend/src/auth/types.ts @@ -0,0 +1,48 @@ +export interface AuthTokens { + access_token: string; + refresh_token: string; + expires_in: number; + refresh_token_expires_in: number; + token_type: "bearer"; +} + +export interface AuthProvider { + provider: string; + mode: "internal" | "external"; + links: { + auth_endpoint: string; + }; + confirmation_message?: string; +} + +export interface AuthConfig { + required: boolean; + providers: AuthProvider[]; + links: { + whoami: string; + apikey: string; + refresh_session: string; + revoke_session: string; + logout: string; + }; +} + +export interface User { + uuid: string; + id: string; + type: string; + identities?: Array<{ + id: string; + provider: string; + latest_login?: string; + }>; + roles?: string[]; +} + +export interface AuthState { + isAuthenticated: boolean; + isLoading: boolean; + user: User | null; + tokens: AuthTokens | null; + error: string | null; +} diff --git a/web-frontend/src/client.ts b/web-frontend/src/client.ts index 5bdb17829..1f825e373 100644 --- a/web-frontend/src/client.ts +++ b/web-frontend/src/client.ts @@ -1,28 +1,89 @@ import axios from "axios"; import { components } from "./openapi_schemas"; -const axiosInstance = axios.create(); +const axiosInstance = axios.create({ + headers: { + "Content-Type": "application/json", + }, +}); + +export function setupAuthInterceptor(getAccessToken: () => string | null) { + axiosInstance.interceptors.request.use( + (config) => { + const token = getAccessToken(); + if (token) { + config.headers = config.headers || {}; + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error), + ); +} + +export function setupRefreshInterceptor( + getRefreshToken: () => string | null, + refreshTokenFn: (refreshToken: string) => Promise, + saveTokens: (tokens: any) => void, + clearTokens: () => void, +) { + axiosInstance.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + const refreshToken = getRefreshToken(); + + if (!refreshToken) { + throw new Error("No refresh token available"); + } + + const newTokens = await refreshTokenFn(refreshToken); + saveTokens(newTokens); + + originalRequest.headers = originalRequest.headers || {}; + originalRequest.headers.Authorization = `Bearer ${newTokens.access_token}`; + return axiosInstance(originalRequest); + } catch (refreshError) { + console.error(refreshError); + clearTokens(); + + // Redirect to login + if (typeof window !== "undefined") { + window.location.href = "/ui/login"; + } + + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + }, + ); +} export const search = async ( apiURL: string, segments: string[], signal: AbortSignal, fields: string[] = [], - selectMetadata: any = null, + selectMetadata: string | null = null, pageOffset: number = 0, pageLimit: number = 100, -): Promise< - components["schemas"]["Response_List_tiled.server.router.Resource_NodeAttributes__dict__dict____PaginationLinks__dict_"] -> => { - let url = `${apiURL}/search/${segments.join( - "/", - )}?page[offset]=${pageOffset}&page[limit]=${pageLimit}&fields=${fields.join( - "&fields=", - )}`; +): Promise => { + const fieldsParam = + fields.length > 0 ? `&fields=${fields.join("&fields=")}` : ""; + let url = `${apiURL}/search/${segments.join("/")}?page[offset]=${pageOffset}&page[limit]=${pageLimit}${fieldsParam}`; + if (selectMetadata !== null) { - url = url.concat(`&select_metadata=${selectMetadata}`); + url += `&select_metadata=${selectMetadata}`; } - const response = await axiosInstance.get(url, { signal: signal }); + + const response = await axiosInstance.get(url, { signal }); return response.data; }; @@ -31,20 +92,17 @@ export const metadata = async ( segments: string[], signal: AbortSignal, fields: string[] = [], -): Promise< - components["schemas"]["Response_Resource_NodeAttributes__dict__dict___dict__dict_"] -> => { - const response = await axiosInstance.get( - `${apiURL}/metadata/${segments.join("/")}?fields=${fields.join( - "&fields=", - )}`, - { signal: signal }, - ); +): Promise => { + const fieldsParam = + fields.length > 0 ? `?fields=${fields.join("&fields=")}` : ""; + const url = `${apiURL}/metadata/${segments.join("/")}${fieldsParam}`; + + const response = await axiosInstance.get(url, { signal }); return response.data; }; -export const about = async (): Promise => { - const response = await axiosInstance.get("/"); +export const about = async (apiURL: string = "/api/v1"): Promise => { + const response = await axiosInstance.get(`${apiURL}/`); return response.data; }; diff --git a/web-frontend/src/components/login-page/login-page.test.tsx b/web-frontend/src/components/login-page/login-page.test.tsx new file mode 100644 index 000000000..477f5a403 --- /dev/null +++ b/web-frontend/src/components/login-page/login-page.test.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import * as useAuthModule from "../../auth/auth-context"; +import { LoginPage } from "./login-page"; +import * as authApiModule from "../../auth/auth-api"; + +describe("LoginPage", () => { + const mockLogin = vi.fn(); + const mockUseAuth = { + login: mockLogin, + logout: vi.fn(), + refreshTokens: vi.fn(), + user: null, + tokens: null, + isAuthenticated: false, + isLoading: false, + error: null, + }; + + + function beforeEach(fn: () => void) { + + for (const key in describe) { + + } + fn(); + } + + + +beforeEach(() => { + vi.spyOn(useAuthModule, "useAuth").mockReturnValue(mockUseAuth); + vi.spyOn(authApiModule.authService, "getAuthConfig").mockResolvedValue({ + providers: [{ + provider: "pam", + mode: "internal", + links: { + auth_endpoint: "" + } + }], + required: false, + links: { + whoami: "", + apikey: "", + refresh_session: "", + revoke_session: "", + logout: "" + } + }); + mockLogin.mockClear(); +}); + + it("renders login form fields", () => { + render( + + + + ); + expect(screen.getByLabelText(/username/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /login/i })).toBeInTheDocument(); + }); + + it("calls login when form is submitted", async () => { + render( + + + + ); + fireEvent.change(screen.getByLabelText(/username/i), { + target: { value: "testuser" }, + }); + fireEvent.change(screen.getByLabelText(/password/i), { + target: { value: "testpass" }, + }); + fireEvent.click(screen.getByRole("button", { name: /login/i })); + expect(mockLogin).toHaveBeenCalled(); + }); + + it("shows error message if error is present", () => { + vi.spyOn(useAuthModule, "useAuth").mockReturnValue({ + ...mockUseAuth, + error: "Invalid credentials", + }); + render( + + + + ); + expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument(); + }); +}); + +function beforeEach(arg0: () => void) { + throw new Error("Function not implemented."); +} diff --git a/web-frontend/src/components/login-page/login-page.tsx b/web-frontend/src/components/login-page/login-page.tsx new file mode 100644 index 000000000..8575c6c2e --- /dev/null +++ b/web-frontend/src/components/login-page/login-page.tsx @@ -0,0 +1,130 @@ +// components/LoginPage.tsx +import React, { useState, useEffect } from "react"; +import { useAuth } from "../../auth/auth-context"; +import { useNavigate, useLocation } from "react-router-dom"; +import { authService } from "../../auth/auth-api"; +import { + Container, + Box, + TextField, + Button, + Typography, + Paper, + Alert, + CircularProgress, + Divider, +} from "@mui/material"; +import LoginIcon from "@mui/icons-material/Login"; + +export const LoginPage: React.FC = () => { + const { login, isAuthenticated, isLoading, error } = useAuth(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [selectedProvider, setSelectedProvider] = useState("pam"); // Default provider + const [providers, setProviders] = useState([]); + const navigate = useNavigate(); + const location = useLocation(); + + useEffect(() => { + async function fetchProviders() { + const config = await authService.getAuthConfig(); + setProviders(config.providers.map((p) => p.provider)); + setSelectedProvider(config.providers[0]?.provider || "pam"); + } + fetchProviders(); + }, []); + + // Redirect to original destination after login + useEffect(() => { + if (isAuthenticated) { + const from = (location.state as any)?.from?.pathname || "/browse"; + navigate(from, { replace: true }); + } + }, [isAuthenticated, navigate, location]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await login(selectedProvider, username, password); + } catch (err) { + console.error("Login error:", err); + } + }; + + return ( + + + + + + Login to Tiled + + + Enter your credentials to access the data server + + + + {error && ( + + {error} + + )} + + + setUsername(e.target.value)} + disabled={isLoading} + /> + setPassword(e.target.value)} + disabled={isLoading} + /> + + + + + + ); +}; diff --git a/web-frontend/src/components/protected-route.tsx b/web-frontend/src/components/protected-route.tsx new file mode 100644 index 000000000..7a4315eb5 --- /dev/null +++ b/web-frontend/src/components/protected-route.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { Navigate, useLocation } from "react-router-dom"; +import { useAuth } from "../auth/auth-context"; +import { Box, CircularProgress, Typography } from "@mui/material"; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +export const ProtectedRoute: React.FC = ({ children }) => { + const { isAuthenticated, isLoading } = useAuth(); + const location = useLocation(); + + if (isLoading) { + return ( + + + + Loading... + + + ); + } + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +}; diff --git a/web-frontend/src/components/tiled-app-bar/tiled-app-bar.test.tsx b/web-frontend/src/components/tiled-app-bar/tiled-app-bar.test.tsx index 780cc3082..664c64730 100644 --- a/web-frontend/src/components/tiled-app-bar/tiled-app-bar.test.tsx +++ b/web-frontend/src/components/tiled-app-bar/tiled-app-bar.test.tsx @@ -1,14 +1,32 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; -import TiledAppBar from "./tiled-app-bar"; +import { TiledAppBar } from "./tiled-app-bar"; +import { AuthProvider } from "../../auth/auth-provider"; + +import * as AuthContext from "../../auth/auth-context"; describe("TiledAppBar", () => { - const renderAppBar = (currentRoute = "/") => { + const renderAppBar = (currentRoute = "/", isAuthenticated = false) => { + vi.spyOn(AuthContext, "useAuth").mockReturnValue({ + isAuthenticated, + user: isAuthenticated + ? { id: "testuser", uuid: "test-uuid", type: "test-type" } + : null, + login: vi.fn(), + logout: vi.fn(), + isLoading: false, + refreshTokens: vi.fn(), + tokens: null, + error: null, + }); + return render( - - , + + + + ); }; @@ -17,11 +35,11 @@ describe("TiledAppBar", () => { expect(screen.getByText("TILED")).toBeInTheDocument(); }); + it("has a working Browse button that links to the browse page", () => { - renderAppBar(); - const browseButton = screen.getByRole("link", { name: "Browse" }); + renderAppBar("/login", true); + const browseButton = screen.getByRole("button", { name: "Browse" }); expect(browseButton).toBeInTheDocument(); - expect(browseButton).toHaveAttribute("href", "/browse/"); }); it("looks like a proper navigation bar", () => { diff --git a/web-frontend/src/components/tiled-app-bar/tiled-app-bar.tsx b/web-frontend/src/components/tiled-app-bar/tiled-app-bar.tsx index e9fcb456c..832e8f33d 100644 --- a/web-frontend/src/components/tiled-app-bar/tiled-app-bar.tsx +++ b/web-frontend/src/components/tiled-app-bar/tiled-app-bar.tsx @@ -1,36 +1,82 @@ -import AppBar from "@mui/material/AppBar"; -import Button from "@mui/material/Button"; -import Container from "@mui/material/Container"; -import { Link } from "react-router-dom"; -import Toolbar from "@mui/material/Toolbar"; -import Typography from "@mui/material/Typography"; - -const TiledAppBar = () => { +// src/components/TiledAppBar.tsx (or whatever your nav component is called) + +import React from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import { useAuth } from "../../auth/auth-context"; +import { + AppBar, + Box, + Toolbar, + Typography, + Button, + IconButton, +} from "@mui/material"; +import MenuIcon from "@mui/icons-material/Menu"; +import LogoutIcon from "@mui/icons-material/Logout"; +import LoginIcon from "@mui/icons-material/Login"; + +export const TiledAppBar: React.FC = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { isAuthenticated, user, logout } = useAuth(); + + const handleLogout = async () => { + await logout(); + navigate("/login"); + }; + + const handleLogin = () => { + navigate("/login"); + }; + + const handleBrowse = () => { + navigate("/browse"); + }; + return ( - - - - + + + + + + + TILED - - - + )} + + {isAuthenticated ? ( + + ) : ( + + )} - - + + ); }; -export default TiledAppBar; diff --git a/web-frontend/src/utils/apiclient.ts b/web-frontend/src/utils/apiclient.ts new file mode 100644 index 000000000..c4301c5a2 --- /dev/null +++ b/web-frontend/src/utils/apiclient.ts @@ -0,0 +1,78 @@ +// src/utils/apiClient.ts + +const API_BASE = import.meta.env.VITE_API_URL || "/api/v1"; + +/** + * Make an HTTP request with common options + */ +async function request( + endpoint: string, + options: RequestInit = {}, +): Promise { + const url = `${API_BASE}${endpoint}`; + + const response = await fetch(url, { + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + ...options, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || `Request failed: ${response.status}`); + } + + return response.json(); +} + +/** + * Make GET request + */ +export async function get(endpoint: string, token?: string): Promise { + return request(endpoint, { + method: "GET", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); +} + +/** + * Make POST request + */ +export async function post( + endpoint: string, + body: unknown, + token?: string, +): Promise { + return request(endpoint, { + method: "POST", + body: JSON.stringify(body), + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); +} + +/** + * Make POST request with form data + */ +export async function postForm( + endpoint: string, + formData: Record, +): Promise { + const body = new URLSearchParams(formData); + + const response = await fetch(`${API_BASE}${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || "Request failed"); + } + + return response.json(); +} diff --git a/web-frontend/vite.config.js b/web-frontend/vite.config.js index e640ac258..abc855225 100644 --- a/web-frontend/vite.config.js +++ b/web-frontend/vite.config.js @@ -2,6 +2,8 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { viteRequire } from "vite-require"; import { webcrypto as crypto } from "crypto"; +import dotenv from "dotenv"; +dotenv.config(); // vite.config.js if (!global.crypto) { @@ -13,12 +15,24 @@ if (!global.crypto) { export default defineConfig({ base: "/ui/", server: { + //https: true, proxy: { "/api": { - target: "http://127.0.0.1:8000", + target: "https://tiled-staging.nsls2.bnl.gov", + secure: false, + changeOrigin: true, + configure: (proxy, options) => { + proxy.on("proxyReq", (proxyReq, req, res) => { + }); + proxy.on("error", (err, req, res) => { + console.error("Proxy error:", err.message); + }); + }, }, "/tiled-ui-settings": { - target: "http://127.0.0.1:8000", + target: "https://tiled-staging.nsls2.bnl.gov", + secure: false, + changeOrigin: true, }, }, }, From 3a2d375d0640e1ee6644063b6c3b90b13b3aa558 Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Sun, 2 Nov 2025 23:34:56 -0500 Subject: [PATCH 2/6] auth test changes --- web-frontend/src/App.tsx | 2 + web-frontend/src/auth/auth-api.ts | 98 +++-------- web-frontend/src/auth/auth-context.tsx | 13 +- web-frontend/src/auth/auth-provider.tsx | 8 +- web-frontend/src/auth/token-manager.ts | 96 ++++------ web-frontend/src/auth/types.ts | 12 +- web-frontend/src/client.ts | 55 ++++++ .../download-core/download-core.tsx | 1 + .../components/login-page/login-page.test.tsx | 165 +++++++++--------- .../src/components/login-page/login-page.tsx | 28 +-- .../tiled-app-bar/tiled-app-bar.test.tsx | 14 +- .../tiled-app-bar/tiled-app-bar.tsx | 19 +- web-frontend/src/utils/apiclient.ts | 78 --------- web-frontend/vite.config.js | 9 +- 14 files changed, 243 insertions(+), 355 deletions(-) delete mode 100644 web-frontend/src/utils/apiclient.ts diff --git a/web-frontend/src/App.tsx b/web-frontend/src/App.tsx index 5f2c26971..3b53a7b93 100644 --- a/web-frontend/src/App.tsx +++ b/web-frontend/src/App.tsx @@ -35,6 +35,8 @@ function App() { const controller = new AbortController(); async function initSettingsContext() { const data = await fetchSettings(controller.signal); + data.api_url = "/api/v1"; + console.log("Modified api_url:", data.api_url); setSettings(data); } initSettingsContext(); diff --git a/web-frontend/src/auth/auth-api.ts b/web-frontend/src/auth/auth-api.ts index ca6af31bb..d784062da 100644 --- a/web-frontend/src/auth/auth-api.ts +++ b/web-frontend/src/auth/auth-api.ts @@ -1,16 +1,13 @@ import { AuthConfig, AuthTokens, User } from "./types"; +//const API_BASE_URL = import.meta.env.VITE_API_URL || ""; +const API_BASE = "/api/v1"; -const API_BASE_URL = ""; -const API_PREFIX = "/api/v1"; -console.log(" Auth Service initialized with backend:", API_BASE_URL); export const authService = { async getAuthConfig(): Promise { - const response = await fetch(`${API_BASE_URL}${API_PREFIX}/`); - - if (!response.ok) { - throw new Error("Failed to fetch auth configuration"); - } - const data = await response.json(); + const res = await fetch(`${API_BASE}/`); + if (!res.ok) throw new Error("Failed to fetch auth config"); + + const data = await res.json(); return data.authentication; }, @@ -19,87 +16,48 @@ export const authService = { username: string, password: string, ): Promise { - - const url = `${API_BASE_URL}${API_PREFIX}/auth/provider/${provider}/token`; - - const formData = new URLSearchParams(); - formData.append("username", username); - formData.append("password", password); + const body = new URLSearchParams({ username, password }); - const response = await fetch(url, { + const res = await fetch(`${API_BASE}/auth/provider/${provider}/token`, { method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: formData.toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, }); - if (!response.ok) { - const error = await response - .json() - .catch(() => ({ detail: "Login failed" })); - throw new Error(error.detail || "Invalid credentials"); + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: "Login failed" })); + throw new Error(err.detail || "Invalid credentials"); } - return response.json(); + return res.json(); }, - - async refreshSession(refreshToken: string): Promise { - const response = await fetch( - `${API_BASE_URL}${API_PREFIX}/auth/session/refresh`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ refresh_token: refreshToken }), - }, - ); - - if (!response.ok) { - throw new Error("Failed to refresh session"); - } + const res = await fetch(`${API_BASE}/auth/session/refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); - return response.json(); + if (!res.ok) throw new Error("Failed to refresh session"); + return res.json(); }, async getCurrentUser(accessToken: string): Promise { - const response = await fetch(`${API_BASE_URL}${API_PREFIX}/auth/whoami`, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, + const res = await fetch(`${API_BASE}/auth/whoami`, { + headers: { Authorization: `Bearer ${accessToken}` }, }); - if (!response.ok) { - throw new Error("Failed to get user info"); - } + if (!res.ok) throw new Error("Failed to get user info"); - const data = await response.json(); - return data.data; // Tiled returns user in data.data + const data = await res.json(); + return data.data; }, async logout(accessToken: string): Promise { - await fetch(`${API_BASE_URL}${API_PREFIX}/auth/logout`, { + await fetch(`${API_BASE}/auth/logout`, { method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - }, - - async authenticatedFetch( - url: string, - accessToken: string, - options: RequestInit = {}, - ): Promise { - return fetch(url, { - ...options, - headers: { - ...options.headers, - Authorization: `Bearer ${accessToken}`, - }, + headers: { Authorization: `Bearer ${accessToken}` }, }); }, }; diff --git a/web-frontend/src/auth/auth-context.tsx b/web-frontend/src/auth/auth-context.tsx index 1c56ec4ee..a3a52eb05 100644 --- a/web-frontend/src/auth/auth-context.tsx +++ b/web-frontend/src/auth/auth-context.tsx @@ -1,5 +1,5 @@ import { createContext, useContext } from "react"; -import { AuthState } from "./types"; +import { AuthState, AuthConfig } from "./types"; interface AuthContextType extends AuthState { login: ( @@ -9,16 +9,17 @@ interface AuthContextType extends AuthState { ) => Promise; logout: () => Promise; refreshTokens: () => Promise; + authConfig: AuthConfig | null; } -export const AuthContext = createContext( - undefined, -); +const AuthContext = createContext(undefined); -export const useAuth = () => { +export function useAuth() { const context = useContext(AuthContext); if (!context) { throw new Error("useAuth must be used within AuthProvider"); } return context; -}; +} + +export { AuthContext }; diff --git a/web-frontend/src/auth/auth-provider.tsx b/web-frontend/src/auth/auth-provider.tsx index e6babe350..83ab182ff 100644 --- a/web-frontend/src/auth/auth-provider.tsx +++ b/web-frontend/src/auth/auth-provider.tsx @@ -20,7 +20,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ const refreshTimeoutRef = useRef(null); useEffect(() => { - // Setup auth token interceptor setupAuthInterceptor(() => { const tokens = tokenManager.getTokens(); if (tokens?.access_token) { @@ -30,7 +29,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ return null; }); - // Setup refresh token interceptor setupRefreshInterceptor( () => { const tokens = tokenManager.getTokens(); @@ -90,11 +88,9 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ useEffect(() => { const initAuth = async () => { try { - // Fetch auth configuration from server const config = await authService.getAuthConfig(); setAuthConfig(config); - //Check if we have stored tokens const tokens = tokenManager.getTokens(); if (!tokens) { @@ -106,7 +102,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ return; } - // Check if access token is expired if (tokenManager.isAccessTokenExpired(tokens)) { try { const newTokens = await authService.refreshSession( @@ -114,7 +109,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ ); tokenManager.saveTokens(newTokens); - // Get user info with new token const user = await authService.getCurrentUser( newTokens.access_token, ); @@ -159,7 +153,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ initAuth(); - // Cleanup timeout on unmount return () => { if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); @@ -255,6 +248,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ login, logout, refreshTokens, + authConfig, }} > {children} diff --git a/web-frontend/src/auth/token-manager.ts b/web-frontend/src/auth/token-manager.ts index d6cf2c3ae..6596a24c8 100644 --- a/web-frontend/src/auth/token-manager.ts +++ b/web-frontend/src/auth/token-manager.ts @@ -1,82 +1,50 @@ -// auth/tokenManager.ts import { AuthTokens } from "./types"; const TOKEN_KEY = "tiled_tokens"; +const TOKEN_EXPIRY_BUFFER_MS = 60 * 1000; // 60 seconds -export const tokenManager = { - /** - * Save tokens to sessionStorage (more secure than localStorage) - * Tokens are cleared when browser closes - */ +interface JWTPayload { + exp?: number; + iat?: number; + sub?: string; + [key: string]: unknown; +} + +class TokenManager { saveTokens(tokens: AuthTokens): void { - try { - sessionStorage.setItem(TOKEN_KEY, JSON.stringify(tokens)); - } catch (error) { - console.error("Failed to save tokens:", error); - } - }, + sessionStorage.setItem(TOKEN_KEY, JSON.stringify(tokens)); + } - /** - * Retrieve tokens from storage - */ getTokens(): AuthTokens | null { - try { - const tokensJson = sessionStorage.getItem(TOKEN_KEY); - if (!tokensJson) return null; - return JSON.parse(tokensJson) as AuthTokens; - } catch (error) { - console.error("Failed to retrieve tokens:", error); - return null; - } - }, + const tokensJson = sessionStorage.getItem(TOKEN_KEY); + return tokensJson ? JSON.parse(tokensJson) : null; + } - /** - * Remove tokens from storage for logout - */ clearTokens(): void { sessionStorage.removeItem(TOKEN_KEY); - }, + } - /** - * Check if access token is expired or about to expire - * Returns true if token will expire in less than 60 seconds - */ isAccessTokenExpired(tokens: AuthTokens): boolean { - if (!tokens.access_token) return true; + const payload = this.decodeToken(tokens.access_token); + if (!payload?.exp) return true; - try { - // Decode JWT to get expiration time - const payload = this.decodeToken(tokens.access_token); - if (!payload.exp) return true; - - const expirationTime = payload.exp * 1000; // Convert to milliseconds - const currentTime = Date.now(); - const bufferTime = 60 * 1000; // 60 second buffer + const expirationTime = payload.exp * 1000; + const timeUntilExpiry = expirationTime - Date.now(); - return expirationTime - currentTime < bufferTime; - } catch (error) { - console.error("Failed to check token expiration:", error); - return true; - } - }, + return timeUntilExpiry < TOKEN_EXPIRY_BUFFER_MS; + } - /** - * Decode JWT token (without verification - server verifies) - */ - decodeToken(token: string): any { + private decodeToken(token: string): JWTPayload | null { try { - const base64Url = token.split(".")[1]; - const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); - const jsonPayload = decodeURIComponent( - atob(base64) - .split("") - .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) - .join(""), - ); - return JSON.parse(jsonPayload); - } catch (error) { - console.error("Failed to decode token:", error); + const [, payload] = token.split("."); + if (!payload) return null; + + const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(decoded); + } catch { return null; } - }, -}; + } +} + +export const tokenManager = new TokenManager(); diff --git a/web-frontend/src/auth/types.ts b/web-frontend/src/auth/types.ts index ffbca964c..f0c790fcc 100644 --- a/web-frontend/src/auth/types.ts +++ b/web-frontend/src/auth/types.ts @@ -27,15 +27,17 @@ export interface AuthConfig { }; } +export interface UserIdentity { + id: string; + provider: string; + latest_login?: string; +} + export interface User { uuid: string; id: string; type: string; - identities?: Array<{ - id: string; - provider: string; - latest_login?: string; - }>; + identities?: UserIdentity[]; roles?: string[]; } diff --git a/web-frontend/src/client.ts b/web-frontend/src/client.ts index 1f825e373..d80f0d4a9 100644 --- a/web-frontend/src/client.ts +++ b/web-frontend/src/client.ts @@ -2,11 +2,66 @@ import axios from "axios"; import { components } from "./openapi_schemas"; const axiosInstance = axios.create({ + //baseURL: "/api/v1", headers: { "Content-Type": "application/json", }, }); +// Helper to convert absolute URLs to relative paths +function toRelativePath(urlString: string): string { + try { + const url = new URL(urlString); + return url.pathname + url.search + url.hash; + } catch { + return urlString; + } +} + +// Transform all links in the response to relative paths +function transformLinks(data: any): any { + if (!data) return data; + + if (typeof data === "string" && data.startsWith("http")) { + return toRelativePath(data); + } + + if (Array.isArray(data)) { + return data.map(transformLinks); + } + + if (typeof data === "object") { + const transformed: any = {}; + for (const key in data) { + if (key === "links" && typeof data[key] === "object") { + // Transform all link values + transformed[key] = {}; + for (const linkKey in data[key]) { + const linkValue = data[key][linkKey]; + transformed[key][linkKey] = + typeof linkValue === "string" + ? toRelativePath(linkValue) + : linkValue; + } + } else { + transformed[key] = transformLinks(data[key]); + } + } + return transformed; + } + + return data; +} + +// Add response interceptor to transform all links +axiosInstance.interceptors.response.use( + (response) => { + response.data = transformLinks(response.data); + return response; + }, + (error) => Promise.reject(error), +); + export function setupAuthInterceptor(getAccessToken: () => string | null) { axiosInstance.interceptors.request.use( (config) => { diff --git a/web-frontend/src/components/download-core/download-core.tsx b/web-frontend/src/components/download-core/download-core.tsx index d234aee35..7bb01c7ca 100644 --- a/web-frontend/src/components/download-core/download-core.tsx +++ b/web-frontend/src/components/download-core/download-core.tsx @@ -19,6 +19,7 @@ import { about } from "../../client"; import { components } from "../../openapi_schemas"; import copy from "clipboard-copy"; import { SettingsContext } from "../../context/settings"; +//import { toRelativePath } from "../../utils/url-helper"; interface Format { mimetype: string; diff --git a/web-frontend/src/components/login-page/login-page.test.tsx b/web-frontend/src/components/login-page/login-page.test.tsx index 477f5a403..98eec91f1 100644 --- a/web-frontend/src/components/login-page/login-page.test.tsx +++ b/web-frontend/src/components/login-page/login-page.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import * as useAuthModule from "../../auth/auth-context"; @@ -6,92 +6,101 @@ import { LoginPage } from "./login-page"; import * as authApiModule from "../../auth/auth-api"; describe("LoginPage", () => { - const mockLogin = vi.fn(); - const mockUseAuth = { - login: mockLogin, - logout: vi.fn(), - refreshTokens: vi.fn(), - user: null, - tokens: null, - isAuthenticated: false, - isLoading: false, - error: null, - }; - - - function beforeEach(fn: () => void) { - - for (const key in describe) { - - } - fn(); - } - - + const mockLogin = vi.fn(); + const mockUseAuth = { + login: mockLogin, + logout: vi.fn(), + refreshTokens: vi.fn(), + user: null, + tokens: null, + isAuthenticated: false, + isLoading: false, + error: null, + authConfig: { + required: false, + providers: [], + links: { + whoami: "", + apikey: "", + refresh_session: "", + revoke_session: "", + logout: "", + }, + }, + }; -beforeEach(() => { - vi.spyOn(useAuthModule, "useAuth").mockReturnValue(mockUseAuth); - vi.spyOn(authApiModule.authService, "getAuthConfig").mockResolvedValue({ - providers: [{ - provider: "pam", - mode: "internal", - links: { - auth_endpoint: "" - } - }], - required: false, - links: { + beforeEach(() => { + vi.spyOn(useAuthModule, "useAuth").mockReturnValue(mockUseAuth); + vi.spyOn(authApiModule.authService, "getAuthConfig").mockResolvedValue({ + providers: [ + { + provider: "pam", + mode: "internal", + links: { + auth_endpoint: "", + }, + }, + ], + required: false, + links: { whoami: "", apikey: "", refresh_session: "", revoke_session: "", - logout: "" - } + logout: "", + }, + }); + mockLogin.mockClear(); }); - mockLogin.mockClear(); -}); - it("renders login form fields", () => { - render( - - - - ); - expect(screen.getByLabelText(/username/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /login/i })).toBeInTheDocument(); - }); + it("renders login form fields", () => { + render( + + + , + ); + expect(screen.getByLabelText(/username/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /login/i })).toBeInTheDocument(); + }); - it("calls login when form is submitted", async () => { - render( - - - - ); - fireEvent.change(screen.getByLabelText(/username/i), { - target: { value: "testuser" }, - }); - fireEvent.change(screen.getByLabelText(/password/i), { - target: { value: "testpass" }, - }); - fireEvent.click(screen.getByRole("button", { name: /login/i })); - expect(mockLogin).toHaveBeenCalled(); + it("calls login when form is submitted", async () => { + render( + + + , + ); + fireEvent.change(screen.getByLabelText(/username/i), { + target: { value: "testuser" }, + }); + fireEvent.change(screen.getByLabelText(/password/i), { + target: { value: "testpass" }, }); + fireEvent.click(screen.getByRole("button", { name: /login/i })); + expect(mockLogin).toHaveBeenCalled(); + }); - it("shows error message if error is present", () => { - vi.spyOn(useAuthModule, "useAuth").mockReturnValue({ - ...mockUseAuth, - error: "Invalid credentials", - }); - render( - - - - ); - expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument(); + it("shows error message if error is present", () => { + vi.spyOn(useAuthModule, "useAuth").mockReturnValue({ + ...mockUseAuth, + error: "Invalid credentials", + authConfig: { + required: false, + providers: [], + links: { + whoami: "", + apikey: "", + refresh_session: "", + revoke_session: "", + logout: "", + }, + }, }); + render( + + + , + ); + expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument(); + }); }); - -function beforeEach(arg0: () => void) { - throw new Error("Function not implemented."); -} diff --git a/web-frontend/src/components/login-page/login-page.tsx b/web-frontend/src/components/login-page/login-page.tsx index 8575c6c2e..53e81d519 100644 --- a/web-frontend/src/components/login-page/login-page.tsx +++ b/web-frontend/src/components/login-page/login-page.tsx @@ -1,8 +1,6 @@ -// components/LoginPage.tsx import React, { useState, useEffect } from "react"; import { useAuth } from "../../auth/auth-context"; import { useNavigate, useLocation } from "react-router-dom"; -import { authService } from "../../auth/auth-api"; import { Container, Box, @@ -12,29 +10,16 @@ import { Paper, Alert, CircularProgress, - Divider, } from "@mui/material"; import LoginIcon from "@mui/icons-material/Login"; export const LoginPage: React.FC = () => { - const { login, isAuthenticated, isLoading, error } = useAuth(); + const { login, isAuthenticated, isLoading, error, authConfig } = useAuth(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); - const [selectedProvider, setSelectedProvider] = useState("pam"); // Default provider - const [providers, setProviders] = useState([]); const navigate = useNavigate(); const location = useLocation(); - useEffect(() => { - async function fetchProviders() { - const config = await authService.getAuthConfig(); - setProviders(config.providers.map((p) => p.provider)); - setSelectedProvider(config.providers[0]?.provider || "pam"); - } - fetchProviders(); - }, []); - - // Redirect to original destination after login useEffect(() => { if (isAuthenticated) { const from = (location.state as any)?.from?.pathname || "/browse"; @@ -44,8 +29,11 @@ export const LoginPage: React.FC = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + + const provider = authConfig?.providers[0]?.provider || "pam"; + try { - await login(selectedProvider, username, password); + await login(provider, username, password); } catch (err) { console.error("Login error:", err); } @@ -73,7 +61,7 @@ export const LoginPage: React.FC = () => { Login to Tiled - Enter your credentials to access the data server + Enter your credentials @@ -88,9 +76,7 @@ export const LoginPage: React.FC = () => { margin="normal" required fullWidth - id="username" label="Username" - name="username" autoComplete="username" autoFocus value={username} @@ -101,10 +87,8 @@ export const LoginPage: React.FC = () => { margin="normal" required fullWidth - name="password" label="Password" type="password" - id="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} diff --git a/web-frontend/src/components/tiled-app-bar/tiled-app-bar.test.tsx b/web-frontend/src/components/tiled-app-bar/tiled-app-bar.test.tsx index 664c64730..e75347967 100644 --- a/web-frontend/src/components/tiled-app-bar/tiled-app-bar.test.tsx +++ b/web-frontend/src/components/tiled-app-bar/tiled-app-bar.test.tsx @@ -19,6 +19,17 @@ describe("TiledAppBar", () => { refreshTokens: vi.fn(), tokens: null, error: null, + authConfig: { + required: false, + providers: [], + links: { + whoami: "", + apikey: "", + refresh_session: "", + revoke_session: "", + logout: "", + }, + }, }); return render( @@ -26,7 +37,7 @@ describe("TiledAppBar", () => { - + , ); }; @@ -35,7 +46,6 @@ describe("TiledAppBar", () => { expect(screen.getByText("TILED")).toBeInTheDocument(); }); - it("has a working Browse button that links to the browse page", () => { renderAppBar("/login", true); const browseButton = screen.getByRole("button", { name: "Browse" }); diff --git a/web-frontend/src/components/tiled-app-bar/tiled-app-bar.tsx b/web-frontend/src/components/tiled-app-bar/tiled-app-bar.tsx index 832e8f33d..c806399d6 100644 --- a/web-frontend/src/components/tiled-app-bar/tiled-app-bar.tsx +++ b/web-frontend/src/components/tiled-app-bar/tiled-app-bar.tsx @@ -1,5 +1,3 @@ -// src/components/TiledAppBar.tsx (or whatever your nav component is called) - import React from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { useAuth } from "../../auth/auth-context"; @@ -18,21 +16,13 @@ import LoginIcon from "@mui/icons-material/Login"; export const TiledAppBar: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); - const { isAuthenticated, user, logout } = useAuth(); + const { isAuthenticated, logout } = useAuth(); const handleLogout = async () => { await logout(); navigate("/login"); }; - const handleLogin = () => { - navigate("/login"); - }; - - const handleBrowse = () => { - navigate("/browse"); - }; - return ( @@ -51,9 +41,8 @@ export const TiledAppBar: React.FC = () => { TILED - - {isAuthenticated && location.pathname !== "/ui/browse" && ( - )} @@ -69,7 +58,7 @@ export const TiledAppBar: React.FC = () => { ) : ( + + = (props) => { + diff --git a/web-frontend/src/components/login-page/login-page.tsx b/web-frontend/src/components/login-page/login-page.tsx index 53e81d519..542fe3a0b 100644 --- a/web-frontend/src/components/login-page/login-page.tsx +++ b/web-frontend/src/components/login-page/login-page.tsx @@ -60,9 +60,6 @@ export const LoginPage: React.FC = () => { Login to Tiled - - Enter your credentials - {error && ( @@ -82,6 +79,10 @@ export const LoginPage: React.FC = () => { value={username} onChange={(e) => setUsername(e.target.value)} disabled={isLoading} + InputLabelProps={{ + shrink: true, + sx: { fontSize: "1.20rem", top: "-5px" }, + }} /> { value={password} onChange={(e) => setPassword(e.target.value)} disabled={isLoading} + InputLabelProps={{ + shrink: true, + sx: { fontSize: "1.20rem", top: "-5px" }, + }} /> )} + + {isAuthenticated ? ( diff --git a/web-frontend/src/settings.ts b/web-frontend/src/settings.ts index b66ad0945..3c0aae97f 100644 --- a/web-frontend/src/settings.ts +++ b/web-frontend/src/settings.ts @@ -2,8 +2,7 @@ const basename = import.meta.env.BASE_URL; const tiledUISettingsURL = basename.split("/").slice(0, -2).join("/") + "/tiled-ui-settings"; -// Alternate idea -// const tiledUISettingsURL = import.meta.env.TILED_UI_SETTINGS || "/tiled-ui-settings"; + interface Column { header: string; @@ -27,14 +26,13 @@ function getApiBaseUrl(): string { if (import.meta.env.VITE_TILED_URL) { return import.meta.env.VITE_TILED_URL; } - return import.meta.env.DEV ? '' : window.location.origin; + return import.meta.env.DEV ? "" : window.location.origin; } - const fetchSettings = async (signal: AbortSignal): Promise => { try { const response = await fetch(tiledUISettingsURL, { signal }); - const settings = await response.json() as Settings; + const settings = (await response.json()) as Settings; settings.api_url = `${getApiBaseUrl()}/api/v1`; return settings; } catch (error) {