diff --git a/apps/auth-ui/eslint.config.js b/apps/auth-ui/eslint.config.js index a31e7316..106475ef 100644 --- a/apps/auth-ui/eslint.config.js +++ b/apps/auth-ui/eslint.config.js @@ -24,5 +24,12 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'off', 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], }, + }, + { + // Test doubles and render helpers are not hot-reloaded. + files: ['tests/**/*.{ts,tsx}'], + rules: { + 'react-refresh/only-export-components': 'off', + }, } ); diff --git a/apps/auth-ui/package.json b/apps/auth-ui/package.json index 48541f10..34b3e108 100644 --- a/apps/auth-ui/package.json +++ b/apps/auth-ui/package.json @@ -10,6 +10,8 @@ "build:docker": "docker buildx build --build-arg APP_NAME=$npm_package_name -f ../../docker/frontend.Dockerfile -t ${DOCKER_REGISTRY:-}${npm_package_name}:${DOCKER_TAG:-latest} ${DOCKER_FLAGS:-} ../..", "build:docker-no-cache": "cross-env DOCKER_FLAGS=--no-cache pnpm run build:docker", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "test": "vitest run", + "test:watch": "vitest", "preview": "vite preview", "knip": "knip --directory ../.. --workspace apps/auth-ui" }, @@ -18,6 +20,7 @@ }, "dependencies": { "@hookform/resolvers": "^5.0.1", + "@monaco-editor/loader": "^1.7.0", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-checkbox": "^1.1.5", "@radix-ui/react-dialog": "^1.1.11", @@ -34,8 +37,8 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^3.6.0", + "lodash": "catalog:", "lucide-react": "^0.488.0", - "@monaco-editor/loader": "^1.7.0", "monaco-editor": "^0.54.0", "next-themes": "^0.4.6", "openapi-fetch": "^0.13.5", @@ -49,24 +52,29 @@ "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.3", + "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.5", - "zod": "^3.24.4", - "lodash": "catalog:", - "tailwindcss-animate": "^1.0.7" + "zod": "^3.24.4" }, "devDependencies": { "@eslint/js": "^9.21.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/node": "^22.14.1", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", + "auth-openapi": "workspace:*", "eslint": "^9.21.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.15.0", + "jsdom": "^29.1.1", "typescript": "^5.8.3", "typescript-eslint": "^8.24.1", "vite": "^6.2.6", - "auth-openapi": "workspace:*" + "vitest": "catalog:" } } diff --git a/apps/auth-ui/src/App.tsx b/apps/auth-ui/src/App.tsx index ccf5970b..52f3f0f8 100644 --- a/apps/auth-ui/src/App.tsx +++ b/apps/auth-ui/src/App.tsx @@ -1,40 +1,24 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; -import { Layout } from './components/layout'; -import { ClientsPage } from './pages/clients'; -import { ConnectionsPage } from './pages/connections'; -import { DomainsPage } from './pages/domains'; -import { JWTInspectorPage } from './pages/jwt-inspector'; -import { NotFoundPage } from './pages/not-found'; -import { ErrorPage } from './pages/error'; -import { OPAValidatorPage } from './pages/opa-validator'; +import { RouterProvider, createBrowserRouter } from 'react-router-dom'; import { Toaster } from './components/ui/sonner'; import { ConfigProvider } from './contexts/ConfigProvider'; import { ErrorBoundary } from './hooks/useErrorBoundary'; import { ThemeProvider } from './components/theme-provider'; +import { appRoutes } from './routes'; const queryClient = new QueryClient(); +// A data router rather than : the asset editor's unsaved-work guard +// needs useBlocker, which only a data router provides. +const router = createBrowserRouter(appRoutes); + function App() { return ( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - - + diff --git a/apps/auth-ui/src/components/asset-editor.tsx b/apps/auth-ui/src/components/asset-editor.tsx new file mode 100644 index 00000000..cb31316e --- /dev/null +++ b/apps/auth-ui/src/components/asset-editor.tsx @@ -0,0 +1,96 @@ +import { DiffEditor, Editor } from '@monaco-editor/react'; +import { useTheme } from 'next-themes'; +import { useRef } from 'react'; +import { REGO_LANGUAGE_ID, REGO_TEMPLATE_LANGUAGE_ID } from '../lib/monaco/language-ids'; + +/** + * The asset editor. Unlike the json editor the opa validator uses, this one is a fixed + * height filling its container with its own scrollbar, so a page's save action stays on + * screen however long the policy is. + * + * The existing json editor is deliberately left alone: the validator page depends on it. + */ +interface AssetEditorProps { + value: string; + language: string; + /** A template asset's interpolation points are highlighted apart from the rego around them. */ + isTemplate?: boolean; + readOnly?: boolean; + onChange?: (value: string) => void; + /** Fires on the editor's save shortcut, which also swallows the browser default. */ + onSave?: () => void; +} + +interface AssetDiffEditorProps { + original: string; + modified: string; + language: string; + isTemplate?: boolean; +} + +const EDITOR_OPTIONS = { + minimap: { enabled: false }, + scrollBeyondLastLine: false, + automaticLayout: true, + tabSize: 2, + wordWrap: 'on', + fixedOverflowWidgets: true, +} as const; + +/** The editor follows the application theme by prop, so a theme change does not remount it. */ +const useEditorTheme = (): string => (useTheme().resolvedTheme === 'dark' ? 'vs-dark' : 'light'); + +const templateVariant = (language: string, isTemplate: boolean): string => + isTemplate && language === REGO_LANGUAGE_ID ? REGO_TEMPLATE_LANGUAGE_ID : language; + +export const AssetEditor = ({ value, language, isTemplate = false, readOnly = false, onChange, onSave }: AssetEditorProps) => { + const theme = useEditorTheme(); + + // The command is registered once, on mount, so it has to read the current handler. + const onSaveRef = useRef(onSave); + onSaveRef.current = onSave; + + return ( +
{ + if (!onSave || event.key !== 's' || !(event.ctrlKey || event.metaKey)) return; + event.preventDefault(); + onSave(); + }} + > + onChange?.(next ?? '')} + options={{ ...EDITOR_OPTIONS, readOnly }} + onMount={(editor, monaco) => { + if (monaco.KeyMod === undefined || monaco.KeyCode === undefined) return; + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => onSaveRef.current?.()); + }} + /> +
+ ); +}; + +export const AssetDiffEditor = ({ original, modified, language, isTemplate = false }: AssetDiffEditorProps) => { + const theme = useEditorTheme(); + + return ( +
+ +
+ ); +}; diff --git a/apps/auth-ui/src/components/layout/Sidebar.tsx b/apps/auth-ui/src/components/layout/Sidebar.tsx index d28cf39f..b348ab4c 100644 --- a/apps/auth-ui/src/components/layout/Sidebar.tsx +++ b/apps/auth-ui/src/components/layout/Sidebar.tsx @@ -1,6 +1,6 @@ import { Link, useLocation } from 'react-router-dom'; import { cn } from '../../lib/utils'; -import { Users, Link as LinkIcon, Globe, Menu, X, Key, Shield } from 'lucide-react'; +import { Users, Link as LinkIcon, Globe, Menu, X, Key, Shield, FileCode } from 'lucide-react'; import { Button } from '../ui/button'; import { SiteSwitcher } from './SiteSwitcher'; import { ThemeToggle } from '../theme-toggle'; @@ -30,6 +30,11 @@ export const Sidebar = ({ className, isCollapsed, onCollapse }: SidebarProps) => href: '/domains', icon: Globe, }, + { + title: 'Assets', + href: '/assets', + icon: FileCode, + }, { title: 'JWT Inspector', href: '/jwt-inspector', @@ -57,7 +62,7 @@ export const Sidebar = ({ className, isCollapsed, onCollapse }: SidebarProps) => )}