diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..8c7d65e --- /dev/null +++ b/.env.local.example @@ -0,0 +1 @@ +API_KEY=your_secret_key_here diff --git a/docs/getting-started.md b/docs/getting-started.md index a568413..7a17bea 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -27,6 +27,7 @@ The left sidebar provides access to all main features: - **Snapshots** - View and manage saved snapshots - **PV Browser** - Browse and search for PVs - **Tags** - Manage tag groups for organizing PVs +- **API Keys** _(Admin only)_ - Create and manage API keys for programmatic access ### Main Content Area @@ -38,10 +39,11 @@ The main area displays the content for the selected feature. Most views include: ### Admin Mode -Some features require admin privileges. If you have admin access, you can toggle admin mode using the switch in the sidebar. +Some features require admin privileges. If you have admin access, you can toggle admin mode using the switch in the sidebar. With admin mode enabled, additional items appear in the sidebar — including **API Keys**. ## Next Steps - Learn about [Snapshots](user-guide/snapshots.md) - the core feature for saving PV states - Explore the [PV Browser](user-guide/pv-browser.md) to find and manage PVs - Set up [Tags](user-guide/tags.md) to organize your PVs +- Manage [API Keys](user-guide/api-keys.md) for programmatic access (admin only) diff --git a/docs/images/api_key_creation.png b/docs/images/api_key_creation.png new file mode 100644 index 0000000..fac04d0 Binary files /dev/null and b/docs/images/api_key_creation.png differ diff --git a/docs/images/api_key_token.png b/docs/images/api_key_token.png new file mode 100644 index 0000000..86e4dbc Binary files /dev/null and b/docs/images/api_key_token.png differ diff --git a/docs/images/api_keys_page.png b/docs/images/api_keys_page.png new file mode 100644 index 0000000..078b04b Binary files /dev/null and b/docs/images/api_keys_page.png differ diff --git a/docs/user-guide/api-keys.md b/docs/user-guide/api-keys.md new file mode 100644 index 0000000..ae5b15e --- /dev/null +++ b/docs/user-guide/api-keys.md @@ -0,0 +1,55 @@ +# API Keys + +API keys allow programmatic access to the Squirrel backend. They are used to authenticate requests made outside the browser UI (e.g., scripts, integrations, or other tools). Each key carries independent read and/or write permissions. + +> **Admin only** — the API Keys page is only visible when admin mode is enabled. + +## Accessing the API Keys Page + +1. Enable admin mode using the toggle in the sidebar. +2. Click **Manage API Keys** in the sidebar (under the admin section). + +![API Keys page](../images/api_keys_page.png) + +The page shows a table of all existing keys with their status, permissions, and available actions. + +## Creating an API Key + +1. Click the **Create Key** button in the top-right corner of the page. +2. Enter an **Application Name** to identify what the key will be used for. +3. Check at least one permission: + - **Read Access** — allows read operations + - **Write Access** — allows write operations +4. Click **CREATE**. + +![Create key dialog](../images/api_key_creation.png) + +## Copying Your Token + +After the key is created, the generated token is displayed **once**. You must copy it immediately — it will not be shown again. + +![Token display dialog](../images/api_key_token.png) + +- Click the copy icon to copy the token to your clipboard. +- Store it somewhere secure, such as a password manager. +- Click **I'VE COPIED MY KEY** to dismiss the dialog. + +If you lose the token, you will need to deactivate the key and create a new one. + +## Deactivating a Key + +In the API Keys table, click the **Deactivate** button in the Actions column for the key you want to revoke. The key's status will change to **Inactive** and it will no longer authenticate requests. + +Deactivation is permanent — a deactivated key cannot be re-activated. + +## Bootstrap Key + +If no API keys exist yet, a **Bootstrap Key** option is available. This creates an initial key so you can get started without needing an existing key to authenticate. + +## API Key Error Banner + +If Squirrel detects that the configured API key is missing or invalid, a banner appears at the top of the page: + +> **API KEY INVALID OR EXPIRED** + +Navigate to the API Keys page to create a new key or verify that a valid key is in use. diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 25f36f9..625513e 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -19,6 +19,10 @@ This guide covers all the features of Squirrel in detail. 5. **[PV Details](pv-details.md)** - View detailed information about individual PVs 6. **[Tags](tags.md)** - Organize PVs into groups for easier filtering +### Admin + +7. **[API Keys](api-keys.md)** - Create and manage API keys for programmatic access (admin only) + ## Quick Reference | Task | Where to Go | @@ -30,3 +34,4 @@ This guide covers all the features of Squirrel in detail. | Add new PVs | PV Browser → Add PV or Import CSV | | Restore saved values | Snapshot Details → Select PVs → Restore | | Filter PVs by tag | Any PV table → Tag filter dropdown | +| Create/manage API keys | API Keys (admin mode required) | diff --git a/mkdocs.yml b/mkdocs.yml index 69e5303..58db93c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -93,3 +93,4 @@ nav: - PV Details: user-guide/pv-details.md - Tags: user-guide/tags.md - Restore: user-guide/restore.md + - API Keys: user-guide/api-keys.md diff --git a/package.json b/package.json index 9c80bf0..383b669 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "devDependencies": { "@tanstack/router-devtools": "^1.166.11", "@tanstack/router-vite-plugin": "^1.166.19", + "@types/node": "^25.3.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^7.18.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eec1269..5d03e3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,7 +47,10 @@ importers: version: 1.166.11(@tanstack/react-router@1.168.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.168.3)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-vite-plugin': specifier: ^1.166.19 - version: 1.166.19(@tanstack/react-router@1.168.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.2(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3)) + version: 1.166.19(@tanstack/react-router@1.168.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3)) + '@types/node': + specifier: ^25.3.3 + version: 25.5.0 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -62,7 +65,7 @@ importers: version: 7.18.0(eslint@8.57.1)(typescript@6.0.2) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.2(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3)) + version: 6.0.1(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3)) eslint: specifier: ^8.57.1 version: 8.57.1 @@ -101,7 +104,7 @@ importers: version: 6.0.2 vite: specifier: ^8.0.2 - version: 8.0.2(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3) + version: 8.0.2(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3) packages: @@ -803,6 +806,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/node@25.5.0': + resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -2347,6 +2353,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unplugin@2.3.11: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} @@ -3088,7 +3097,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.167.4(@tanstack/react-router@1.168.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.2(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3))': + '@tanstack/router-plugin@1.167.4(@tanstack/react-router@1.168.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -3105,7 +3114,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.168.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 8.0.2(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.2(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -3123,9 +3132,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-vite-plugin@1.166.19(@tanstack/react-router@1.168.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.2(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3))': + '@tanstack/router-vite-plugin@1.166.19(@tanstack/react-router@1.168.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - '@tanstack/router-plugin': 1.167.4(@tanstack/react-router@1.168.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.2(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3)) + '@tanstack/router-plugin': 1.167.4(@tanstack/react-router@1.168.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - '@rsbuild/core' - '@tanstack/react-router' @@ -3149,6 +3158,10 @@ snapshots: '@types/json5@0.0.29': {} + '@types/node@25.5.0': + dependencies: + undici-types: 7.18.2 + '@types/parse-json@4.0.2': {} '@types/prop-types@15.7.15': {} @@ -3248,10 +3261,10 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@6.0.1(vite@8.0.2(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3))': + '@vitejs/plugin-react@6.0.1(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.2(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.2(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3) acorn-jsx@5.3.2(acorn@8.16.0): dependencies: @@ -4976,6 +4989,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@7.18.2: {} + unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 @@ -4997,7 +5012,7 @@ snapshots: dependencies: react: 19.2.4 - vite@8.0.2(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3): + vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -5005,6 +5020,7 @@ snapshots: rolldown: 1.0.0-rc.11 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 25.5.0 esbuild: 0.27.4 fsevents: 2.3.3 tsx: 4.21.0 diff --git a/src/components/ApiKeyErrorBanner.tsx b/src/components/ApiKeyErrorBanner.tsx new file mode 100644 index 0000000..2112e88 --- /dev/null +++ b/src/components/ApiKeyErrorBanner.tsx @@ -0,0 +1,21 @@ +// src/components/ApiKeyErrorBanner.tsx +import { Alert, Collapse, Typography } from '@mui/material'; +import { VpnKey as KeyIcon } from '@mui/icons-material'; +import { useApiKeyError } from '../contexts/ApiKeyErrorContext'; + +export function ApiKeyErrorBanner() { + const { hasApiKeyError } = useApiKeyError(); + + return ( + + } sx={{ borderRadius: 0 }}> + + API KEY INVALID OR EXPIRED + + + Requests are being rejected. Check your API key configuration. + + + + ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index d6e07c8..7e18348 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -10,6 +10,7 @@ import { Sidebar } from './Sidebar'; import { CreateSnapshotDialog } from './CreateSnapshotDialog'; import { LiveDataWarningBanner } from './LiveDataWarningBanner'; import { useAdminMode } from '../contexts/AdminModeContext'; +import { ApiKeyErrorBanner } from './ApiKeyErrorBanner'; interface LayoutProps { children: ReactNode; @@ -100,6 +101,9 @@ export function Layout({ children }: LayoutProps) { + {/* Warning banner - shows when API Key is missing or invalid */} + + {/* Warning banner - shows when PV monitor is dead */} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 3ddfecb..aed001b 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -18,12 +18,14 @@ import { PhotoCamera as CameraIcon, Search as SearchIcon, Label as LabelIcon, + Key as KeyIcon, ChevronLeft as ChevronLeftIcon, CheckCircle as CheckCircleIcon, Error as ErrorIcon, } from '@mui/icons-material'; import { Link, useLocation, useNavigate } from '@tanstack/react-router'; import { useSnapshot } from '../contexts'; +import { useAdminMode } from '../contexts/AdminModeContext'; const SIDEBAR_WIDTH_EXPANDED = 240; const SIDEBAR_WIDTH_COLLAPSED = 60; @@ -65,6 +67,7 @@ export function Sidebar({ open, onToggle, onSaveSnapshot }: SidebarProps) { const location = useLocation(); const navigate = useNavigate(); const { snapshotProgress, clearSnapshot } = useSnapshot(); + const { isAdminMode } = useAdminMode(); const menuItems = [ { title: 'View Snapshots', icon: , path: '/snapshots' }, @@ -72,6 +75,8 @@ export function Sidebar({ open, onToggle, onSaveSnapshot }: SidebarProps) { { title: 'Configure Tags', icon: , path: '/tags' }, ]; + const adminMenuItems = [{ title: 'Manage API Keys', icon: , path: '/api-keys' }]; + const isActive = (path: string) => location.pathname === path || location.pathname.startsWith(`${path}/`); @@ -184,6 +189,42 @@ export function Sidebar({ open, onToggle, onSaveSnapshot }: SidebarProps) { ))} + + {/* Admin only Navigation Menu Items */} + {isAdminMode && } + {isAdminMode && + adminMenuItems.map((item) => ( + + + + {item.icon} + + {open && } + + + ))} {/* Snapshot Status and Save Button at Bottom */} diff --git a/src/config/api.ts b/src/config/api.ts index cc5a45e..22bedd6 100644 --- a/src/config/api.ts +++ b/src/config/api.ts @@ -12,6 +12,7 @@ export const API_CONFIG = { pvs: '/v1/pvs', tags: '/v1/tags', jobs: '/v1/jobs', + apiKeys: '/v1/api-keys', }, timeout: 30000, // 30 seconds }; @@ -33,3 +34,13 @@ export interface PagedResultDTO { continuationToken?: string; totalCount?: number; } + +/** + * Thrown when the backend rejects a request due to an invalid or missing API key. + */ +export class ApiKeyError extends Error { + constructor(public status: 401 | 403) { + super(status === 401 ? 'API key is missing or invalid' : 'API key does not have access'); + this.name = 'ApiKeyError'; + } +} diff --git a/src/contexts/ApiKeyErrorContext.tsx b/src/contexts/ApiKeyErrorContext.tsx new file mode 100644 index 0000000..03557fa --- /dev/null +++ b/src/contexts/ApiKeyErrorContext.tsx @@ -0,0 +1,59 @@ +/** + * API Key Error Context + * + * Tracks whether any request has been rejected due to an invalid or missing + * API key (401/403). Components can use useApiKeyError() to show a banner + * or otherwise respond to auth failures. + */ + +import { createContext, useContext, useState, useCallback, ReactNode, useMemo } from 'react'; + +interface ApiKeyErrorContextValue { + /** Whether a 401/403 has been received from the backend */ + hasApiKeyError: boolean; + /** Call this when an ApiKeyError is caught to set the error state */ + reportApiKeyError: () => void; + /** Call this to clear the error (e.g. after a key is updated) */ + clearApiKeyError: () => void; +} + +const ApiKeyErrorContext = createContext({ + hasApiKeyError: false, + reportApiKeyError: () => {}, + clearApiKeyError: () => {}, +}); + +interface ApiKeyErrorProviderProps { + children: ReactNode; +} + +export function ApiKeyErrorProvider({ children }: ApiKeyErrorProviderProps) { + const [hasApiKeyError, setHasApiKeyError] = useState(false); + + const reportApiKeyError = useCallback(() => setHasApiKeyError(true), []); + const clearApiKeyError = useCallback(() => setHasApiKeyError(false), []); + + const contextValue = useMemo( + () => ({ hasApiKeyError, reportApiKeyError, clearApiKeyError }), + [hasApiKeyError, reportApiKeyError, clearApiKeyError] + ); + + return {children}; +} + +/** + * Hook to access API key error state. + * + * @example + * const { hasApiKeyError } = useApiKeyError(); + * + * @example + * // In an error handler: + * const { reportApiKeyError } = useApiKeyError(); + * if (error instanceof ApiKeyError) reportApiKeyError(); + */ +export function useApiKeyError(): ApiKeyErrorContextValue { + return useContext(ApiKeyErrorContext); +} + +export default ApiKeyErrorContext; diff --git a/src/main.tsx b/src/main.tsx index b12e8ac..f379195 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,11 +1,13 @@ -import React from 'react'; +import React, { useState } from 'react'; import ReactDOM from 'react-dom/client'; import { RouterProvider, createRouter } from '@tanstack/react-router'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@tanstack/react-query'; +import { ApiKeyError } from './config/api'; import { HeartbeatProvider } from './contexts/HeartbeatContext'; import { LivePVProvider } from './contexts/LivePVContext'; import { SnapshotProvider } from './contexts/SnapshotContext'; import { AdminModeProvider } from './contexts/AdminModeContext'; +import { ApiKeyErrorProvider, useApiKeyError } from './contexts/ApiKeyErrorContext'; // Import the generated route tree import { routeTree } from './routeTree.gen'; @@ -13,18 +15,6 @@ import { routeTree } from './routeTree.gen'; // Create a new router instance const router = createRouter({ routeTree }); -// Create React Query client with optimized defaults -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30 * 1000, // 30 seconds - gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime) - retry: 2, - refetchOnWindowFocus: false, - }, - }, -}); - // Register the router instance for type safety declare module '@tanstack/react-router' { interface Register { @@ -32,8 +22,34 @@ declare module '@tanstack/react-router' { } } -ReactDOM.createRoot(document.getElementById('root')!).render( - +function App() { + const { reportApiKeyError } = useApiKeyError(); + + const [queryClient] = useState( + () => + new QueryClient({ + queryCache: new QueryCache({ + onError: (error) => { + if (error instanceof ApiKeyError) reportApiKeyError(); + }, + }), + mutationCache: new MutationCache({ + onError: (error) => { + if (error instanceof ApiKeyError) reportApiKeyError(); + }, + }), + defaultOptions: { + queries: { + staleTime: 30 * 1000, // 30 seconds + gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime) + retry: (failureCount, error) => !(error instanceof ApiKeyError) && failureCount < 2, + refetchOnWindowFocus: false, + }, + }, + }) + ); + + return ( @@ -45,5 +61,13 @@ ReactDOM.createRoot(document.getElementById('root')!).render( + ); +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + ); diff --git a/src/pages/ApiKeysPage.tsx b/src/pages/ApiKeysPage.tsx new file mode 100644 index 0000000..18c88ec --- /dev/null +++ b/src/pages/ApiKeysPage.tsx @@ -0,0 +1,276 @@ +import { + Alert, + Box, + Button, + Checkbox, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + FormGroup, + IconButton, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { Add, ContentCopy, RemoveCircleOutline } from '@mui/icons-material'; +import { useState } from 'react'; +import { ApiKeyDTO, ApiKeyCreateDTO, ApiKeyCreateResultDTO } from '../types'; + +interface ApiKeysPageProps { + apiKeys?: ApiKeyDTO[]; + onCreateKey?: (keyInfo: ApiKeyCreateDTO) => Promise; + onDeactivateKey?: (keyId: string) => Promise; +} + +export function ApiKeysPage({ apiKeys = [], onCreateKey, onDeactivateKey }: ApiKeysPageProps) { + const [dialog, setDialog] = useState<{ + appName: string; + readAccess: boolean; + writeAccess: boolean; + } | null>(null); + const [createdKey, setCreatedKey] = useState(null); + + const handleOpenDialog = () => { + setDialog({ appName: '', readAccess: true, writeAccess: false }); + setCreatedKey(null); + }; + + const handleCloseDialog = () => { + setDialog(null); + }; + + const handleDone = async () => { + handleCloseDialog(); + setCreatedKey(null); + }; + + const handleCreate = async () => { + if (dialog === null || !onCreateKey) return; + try { + const result = await onCreateKey({ ...dialog }); + setCreatedKey(result); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Failed to create key:', err); + // eslint-disable-next-line no-alert + alert(`Failed to create key: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + }; + + const handleDeactivate = async (keyId: string) => { + if (keyId === null || !onDeactivateKey) return; + try { + await onDeactivateKey(keyId); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Failed to deactivate key:', err); + // eslint-disable-next-line no-alert + alert(`Failed to deactivate key: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + }; + + return ( + + + + API Keys + + + + + + + + + + + + App Name + + + + + Status + + + + + Read Access + + + + + Write Access + + + + + + + {apiKeys.map((key) => ( + + + {key.appName} + + + + + + + + + + + + handleDeactivate(key.id)} + aria-label="Deactivate token" + > + + + + + + + ))} + +
+ {apiKeys.length === 0 && ( + + + No API keys available + + + )} +
+ + + {createdKey === null ? 'Create API Key' : 'Key Created'} + + {createdKey === null && ( + + setDialog({ ...dialog!, appName: e.target.value })} + /> + + setDialog({ ...dialog!, readAccess: e.target.checked })} + /> + } + label="Read Access" + /> + setDialog({ ...dialog!, writeAccess: e.target.checked })} + /> + } + label="Write Access" + /> + + + + + + + )} + {createdKey !== null && ( + + + Copy this API token now — it will not be shown again. Once you close this dialog + there is no way to retrieve it. Store it somewhere safe such as a password manager. + + + + {createdKey?.token} + + navigator.clipboard.writeText(createdKey?.token ?? '')} + aria-label="Copy token" + sx={{ alignSelf: 'flex-start' }} + > + + + + + + + + )} + + +
+ ); +} diff --git a/src/pages/index.ts b/src/pages/index.ts index 5e1b4ed..4fefcef 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -8,3 +8,4 @@ export * from './SnapshotComparisonPage'; export * from './PVBrowserPage'; export * from './PVDetailsPage'; export * from './TagPage'; +export * from './ApiKeysPage'; diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 2216516..3ab66cf 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as SnapshotsRouteImport } from './routes/snapshots' import { Route as SnapshotDetailsRouteImport } from './routes/snapshot-details' import { Route as PvBrowserRouteImport } from './routes/pv-browser' import { Route as ComparisonRouteImport } from './routes/comparison' +import { Route as ApiKeysRouteImport } from './routes/api-keys' import { Route as IndexRouteImport } from './routes/index' const TagsRoute = TagsRouteImport.update({ @@ -41,6 +42,11 @@ const ComparisonRoute = ComparisonRouteImport.update({ path: '/comparison', getParentRoute: () => rootRouteImport, } as any) +const ApiKeysRoute = ApiKeysRouteImport.update({ + id: '/api-keys', + path: '/api-keys', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -49,6 +55,7 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/api-keys': typeof ApiKeysRoute '/comparison': typeof ComparisonRoute '/pv-browser': typeof PvBrowserRoute '/snapshot-details': typeof SnapshotDetailsRoute @@ -57,6 +64,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/api-keys': typeof ApiKeysRoute '/comparison': typeof ComparisonRoute '/pv-browser': typeof PvBrowserRoute '/snapshot-details': typeof SnapshotDetailsRoute @@ -66,6 +74,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/api-keys': typeof ApiKeysRoute '/comparison': typeof ComparisonRoute '/pv-browser': typeof PvBrowserRoute '/snapshot-details': typeof SnapshotDetailsRoute @@ -76,6 +85,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/api-keys' | '/comparison' | '/pv-browser' | '/snapshot-details' @@ -84,6 +94,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/api-keys' | '/comparison' | '/pv-browser' | '/snapshot-details' @@ -92,6 +103,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/api-keys' | '/comparison' | '/pv-browser' | '/snapshot-details' @@ -101,6 +113,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + ApiKeysRoute: typeof ApiKeysRoute ComparisonRoute: typeof ComparisonRoute PvBrowserRoute: typeof PvBrowserRoute SnapshotDetailsRoute: typeof SnapshotDetailsRoute @@ -145,6 +158,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComparisonRouteImport parentRoute: typeof rootRouteImport } + '/api-keys': { + id: '/api-keys' + path: '/api-keys' + fullPath: '/api-keys' + preLoaderRoute: typeof ApiKeysRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -157,6 +177,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + ApiKeysRoute: ApiKeysRoute, ComparisonRoute: ComparisonRoute, PvBrowserRoute: PvBrowserRoute, SnapshotDetailsRoute: SnapshotDetailsRoute, diff --git a/src/routes/api-keys.tsx b/src/routes/api-keys.tsx new file mode 100644 index 0000000..806cf80 --- /dev/null +++ b/src/routes/api-keys.tsx @@ -0,0 +1,59 @@ +import { useState, useCallback, useEffect } from 'react'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { useAdminMode } from '../contexts/AdminModeContext'; +import { ApiKeysPage } from '../pages'; +import { apiKeyService } from '../services/apiKeyService'; +import { ApiKeyCreateDTO, ApiKeyDTO } from '../types'; + +function ApiKeys() { + const [apiKeys, setApiKeys] = useState([]); + const { isAdminMode } = useAdminMode(); + const navigate = useNavigate(); + + const fetchApiKeys = useCallback(async () => { + const keys = await apiKeyService.listAllKeys(); + setApiKeys(keys); + }, []); + + const handleCreateKey = useCallback( + async (data: ApiKeyCreateDTO) => { + const result = await apiKeyService.createApiKey(data); + await fetchApiKeys(); + return result; + }, + [fetchApiKeys] + ); + + const handleDeactivateKey = useCallback( + async (keyId: string) => { + const result = await apiKeyService.deactivateApiKey(keyId); + await fetchApiKeys(); + return result; + }, + [fetchApiKeys] + ); + + useEffect(() => { + if (!isAdminMode) { + navigate({ to: '/snapshots' }); + } + }, [isAdminMode, navigate]); + + useEffect(() => { + fetchApiKeys(); + }, [fetchApiKeys]); + + if (!isAdminMode) return null; + + return ( + + ); +} + +export const Route = createFileRoute('/api-keys')({ + component: ApiKeys, +}); diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 56bcc9c..31576ec 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -2,7 +2,7 @@ * Base API client for making HTTP requests to score-backend */ -import { API_CONFIG, ApiResultResponse } from '../config/api'; +import { API_CONFIG, ApiKeyError, ApiResultResponse } from '../config/api'; class APIClient { private baseURL: string; @@ -33,6 +33,9 @@ class APIClient { clearTimeout(timeoutId); if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new ApiKeyError(response.status); + } // Try to get error details from response body try { const errorData = await response.text(); diff --git a/src/services/apiKeyService.ts b/src/services/apiKeyService.ts new file mode 100644 index 0000000..dd37b78 --- /dev/null +++ b/src/services/apiKeyService.ts @@ -0,0 +1,35 @@ +import { API_CONFIG } from '../config/api'; +import { apiClient } from './apiClient'; +import { ApiKeyCreateDTO, ApiKeyCreateResultDTO, ApiKeyDTO } from '../types'; + +export const apiKeyService = { + /** + * List all API Keys, optionally filtered by active status + */ + async listAllKeys(activeOnly: boolean = false): Promise { + return apiClient.get(API_CONFIG.endpoints.apiKeys, { active_only: activeOnly }); + }, + + /** + * Create a new API Key + */ + async createApiKey(data: ApiKeyCreateDTO): Promise { + return apiClient.post(API_CONFIG.endpoints.apiKeys, data); + }, + + /** + * Deactivate an API Key by ID + */ + async deactivateApiKey(keyId: string): Promise { + return apiClient.delete(`${API_CONFIG.endpoints.apiKeys}/${keyId}`); + }, + + /** + * Get the current number of API Keys + */ + async getApiKeyCount(activeOnly: boolean = false): Promise { + return apiClient.get(`${API_CONFIG.endpoints.apiKeys}/count`, { + active_only: activeOnly, + }); + }, +}; diff --git a/src/types/api.ts b/src/types/api.ts index 7fa7a08..a748d64 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -154,6 +154,29 @@ export interface FindParameter { tags?: string[]; } +/** + * API Key DTOs - matches backend app/schemas/api_key.py + */ +export interface ApiKeyCreateDTO { + appName: string; + readAccess: boolean; + writeAccess: boolean; +} + +export interface ApiKeyDTO { + id: string; + appName: string; + isActive: boolean; + readAccess: boolean; + writeAccess: boolean; + createdAt: string; + updatedAt: string; +} + +export interface ApiKeyCreateResultDTO extends ApiKeyDTO { + token: string; +} + /** * Job DTOs for async task tracking */ diff --git a/vite.config.ts b/vite.config.ts index 727ecfd..8d86bed 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,26 +1,45 @@ -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; import { TanStackRouterVite } from '@tanstack/router-vite-plugin'; // https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react(), TanStackRouterVite()], - server: { - proxy: { - // All /v1 routes including WebSocket - '/v1': { - target: 'http://localhost:8080', - changeOrigin: true, - ws: true, - configure: (proxy) => { - proxy.on('error', (err) => { - console.log('proxy error', err); - }); - proxy.on('proxyReqWs', (proxyReq, req) => { - console.log('WebSocket proxy request:', req.url); - }); +export default defineConfig(({ mode }) => { + // Use '' prefix to load all env vars (not just VITE_-prefixed ones) + const env = loadEnv(mode, process.cwd(), ''); + console.log('API_KEY loaded:', env.API_KEY ? `[set, ${env.API_KEY.length} chars]` : 'MISSING'); + + if (!env.API_KEY) { + throw new Error('API_KEY is not set. Add it to your .env file (e.g. API_KEY=your-key).'); + } + + return { + plugins: [react(), TanStackRouterVite()], + server: { + proxy: { + // All /v1 routes including WebSocket + '/v1': { + target: 'http://localhost:8080', + changeOrigin: true, + ws: true, + configure: (proxy) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const p = proxy as any; + p.on('error', (err: Error) => { + console.log('proxy error', err); + }); + p.on('proxyReq', (proxyReq: { setHeader: (k: string, v: string) => void }) => { + proxyReq.setHeader('X-API-Key', env.API_KEY); + }); + p.on( + 'proxyReqWs', + (proxyReq: { setHeader: (k: string, v: string) => void }, req: { url: string }) => { + console.log('WebSocket proxy request:', req.url); + proxyReq.setHeader('X-API-Key', env.API_KEY); + } + ); + }, }, }, }, - }, + }; });