diff --git a/README.md b/README.md index 970716ff2..c5e2d17dd 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Set these environment variables if you need to change their defaults | CADENCE_WEB_PORT | HTTP port to serve on | 8088 | | CADENCE_WEB_HOSTNAME | Host name to serve on | 0.0.0.0 | | CADENCE_ADMIN_SECURITY_TOKEN | Admin token for accessing admin methods | '' | +| CADENCE_WEB_RBAC_ENABLED | Enables RBAC-aware UI (login/logout). | false | +| CADENCE_WEB_JWT_TOKEN | Static Cadence JWT forwarded when no request token exists | '' | | CADENCE_GRPC_TLS_CA_FILE | Path to root CA certificate file for enabling one-way TLS on gRPC connections | '' | | CADENCE_WEB_SERVICE_NAME | Name of the web service used as GRPC caller and OTEL resource name | cadence-web | diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 000000000..caba92529 --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,11 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { + getPublicAuthContext, + resolveAuthContext, +} from '@/utils/auth/auth-context'; + +export async function GET(request: NextRequest) { + const authContext = await resolveAuthContext(request.cookies); + return NextResponse.json(getPublicAuthContext(authContext)); +} diff --git a/src/app/api/auth/token/route.ts b/src/app/api/auth/token/route.ts new file mode 100644 index 000000000..d2a980bee --- /dev/null +++ b/src/app/api/auth/token/route.ts @@ -0,0 +1,40 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { CADENCE_AUTH_COOKIE_NAME } from '@/utils/auth/auth-context'; + +const COOKIE_OPTIONS = { + httpOnly: true, + secure: process.env.NODE_ENV !== 'development', + sameSite: 'lax' as const, + path: '/', +}; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + if (!body?.token || typeof body.token !== 'string') { + return NextResponse.json( + { message: 'A valid token is required' }, + { status: 400 } + ); + } + + const response = NextResponse.json({ ok: true }); + response.cookies.set(CADENCE_AUTH_COOKIE_NAME, body.token, COOKIE_OPTIONS); + return response; + } catch { + return NextResponse.json( + { message: 'Invalid request body' }, + { status: 400 } + ); + } +} + +export async function DELETE() { + const response = NextResponse.json({ ok: true }); + response.cookies.set(CADENCE_AUTH_COOKIE_NAME, '', { + ...COOKIE_OPTIONS, + maxAge: 0, + }); + return response; +} diff --git a/src/components/app-nav-bar/app-nav-bar.tsx b/src/components/app-nav-bar/app-nav-bar.tsx index c30c221e3..72f846a93 100644 --- a/src/components/app-nav-bar/app-nav-bar.tsx +++ b/src/components/app-nav-bar/app-nav-bar.tsx @@ -1,29 +1,129 @@ 'use client'; +import React, { useMemo, useState } from 'react'; + import { AppNavBar as BaseAppNavBar } from 'baseui/app-nav-bar'; +import { useSnackbar } from 'baseui/snackbar'; import NextLink from 'next/link'; +import { useRouter } from 'next/navigation'; import useStyletronClasses from '@/hooks/use-styletron-classes'; +import useUserInfo from '@/hooks/use-user-info/use-user-info'; +import request from '@/utils/request'; + +import AuthTokenModal from '../auth-token-modal/auth-token-modal'; import { cssStyles } from './app-nav-bar.styles'; +const LOGIN_ITEM = 'login'; +const LOGOUT_ITEM = 'logout'; + export default function AppNavBar() { const { cls } = useStyletronClasses(cssStyles); + const router = useRouter(); + const { enqueue } = useSnackbar(); + const [isModalOpen, setIsModalOpen] = useState(false); + + const { data: authInfo, isLoading: isAuthLoading, refetch } = useUserInfo(); + const userItems = useMemo(() => { + if (!authInfo?.rbacEnabled) return undefined; + if (!authInfo.isAuthenticated) { + return [{ label: 'Login with JWT', info: LOGIN_ITEM }]; + } + return [ + { label: 'Switch token', info: LOGIN_ITEM }, + { label: 'Logout', info: LOGOUT_ITEM }, + ]; + }, [authInfo]); + + const username = useMemo(() => { + if (!authInfo?.rbacEnabled) { + return undefined; + } + if (isAuthLoading || !authInfo) { + return 'Checking access...'; + } + return authInfo.isAuthenticated + ? authInfo.userName || 'Authenticated user' + : 'Authenticate'; + }, [authInfo, isAuthLoading]); + + const usernameSubtitle = + authInfo?.rbacEnabled && authInfo.isAuthenticated + ? authInfo.isAdmin + ? 'Admin' + : 'Authenticated' + : authInfo?.rbacEnabled + ? 'Paste a Cadence JWT' + : undefined; + + const saveToken = async (token: string) => { + try { + await request('/api/auth/token', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token }), + }); + await refetch(); + enqueue({ message: 'Token saved' }); + setIsModalOpen(false); + router.refresh(); + } catch (e) { + const message = + e instanceof Error ? e.message : 'Failed to save authentication token'; + enqueue({ message }); + throw e; + } + }; + + const logout = async () => { + try { + await request('/api/auth/token', { method: 'DELETE' }); + enqueue({ message: 'Signed out' }); + } catch (e) { + const message = e instanceof Error ? e.message : 'Failed to sign out'; + enqueue({ message }); + } finally { + setIsModalOpen(false); + await refetch(); + router.refresh(); + } + }; + return ( - - - - - - } - /> + <> + + + + + + } + username={userItems ? username : undefined} + usernameSubtitle={userItems ? usernameSubtitle : undefined} + userItems={userItems} + onUserItemSelect={(item) => { + if (item.info === LOGIN_ITEM) { + setIsModalOpen(true); + } else if (item.info === LOGOUT_ITEM) { + void logout(); + } + }} + /> + {authInfo?.rbacEnabled && ( + setIsModalOpen(false)} + onSubmit={saveToken} + /> + )} + ); } diff --git a/src/components/auth-token-modal/auth-token-modal.tsx b/src/components/auth-token-modal/auth-token-modal.tsx new file mode 100644 index 000000000..de3e65519 --- /dev/null +++ b/src/components/auth-token-modal/auth-token-modal.tsx @@ -0,0 +1,85 @@ +'use client'; +import React, { useState } from 'react'; + +import { FormControl } from 'baseui/form-control'; +import { + Modal, + ModalBody, + ModalButton, + ModalFooter, + ModalHeader, +} from 'baseui/modal'; +import { Textarea } from 'baseui/textarea'; + +type Props = { + isOpen: boolean; + onClose: () => void; + onSubmit: (token: string) => Promise | void; +}; + +export default function AuthTokenModal({ isOpen, onClose, onSubmit }: Props) { + const [token, setToken] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + if (!token.trim()) { + setError('Please paste a JWT token first'); + return; + } + + setIsSubmitting(true); + setError(null); + try { + await onSubmit(token.trim()); + setToken(''); + } catch (e) { + setError( + e instanceof Error ? e.message : 'Failed to save authentication token' + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + Authenticate with JWT + + +