Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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_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 |

Expand Down
13 changes: 13 additions & 0 deletions src/app/api/auth/me/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
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), {
headers: { 'Cache-Control': 'no-store' },
});
}
61 changes: 61 additions & 0 deletions src/app/api/auth/token/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { NextResponse, type NextRequest } from 'next/server';

import { CADENCE_AUTH_COOKIE_NAME } from '@/utils/auth/auth-context';

const COOKIE_OPTIONS = {
httpOnly: true,
sameSite: 'lax' as const,
path: '/',
};

function getCookieSecureAttribute(request: NextRequest) {
const xfProto = request.headers.get('x-forwarded-proto');
const proto = xfProto?.split(',')[0]?.trim().toLowerCase();
if (proto) return proto === 'https';
return request.nextUrl.protocol === 'https:';
}

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, headers: { 'Cache-Control': 'no-store' } }
);
}

const normalizedToken = body.token.trim().replace(/^bearer\s+/i, '');
if (!normalizedToken) {
return NextResponse.json(
{ message: 'A valid token is required' },
{ status: 400, headers: { 'Cache-Control': 'no-store' } }
);
}

const response = NextResponse.json({ ok: true });
response.headers.set('Cache-Control', 'no-store');
response.cookies.set(CADENCE_AUTH_COOKIE_NAME, normalizedToken, {
...COOKIE_OPTIONS,
secure: getCookieSecureAttribute(request),
});
return response;
} catch {
return NextResponse.json(
{ message: 'Invalid request body' },
{ status: 400, headers: { 'Cache-Control': 'no-store' } }
);
}
}

export async function DELETE(request: NextRequest) {
const response = NextResponse.json({ ok: true });
response.headers.set('Cache-Control', 'no-store');
response.cookies.set(CADENCE_AUTH_COOKIE_NAME, '', {
...COOKIE_OPTIONS,
secure: getCookieSecureAttribute(request),
expires: new Date(0),
maxAge: 0,
});
return response;
}
205 changes: 189 additions & 16 deletions src/components/app-nav-bar/app-nav-bar.tsx

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions src/components/auth-token-modal/auth-token-modal.tsx
Original file line number Diff line number Diff line change
@@ -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> | void;
};

export default function AuthTokenModal({ isOpen, onClose, onSubmit }: Props) {
const [token, setToken] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Modal
size="default"
onClose={onClose}
isOpen={isOpen}
closeable={!isSubmitting}
autoFocus
>
<ModalHeader>Authenticate with JWT</ModalHeader>
<ModalBody>
<FormControl
label="Cadence JWT"
caption="Paste a Cadence-compatible JWT issued by your identity provider."
error={error || null}
>
<Textarea
value={token}
onChange={(event) =>
setToken((event?.target as HTMLTextAreaElement)?.value || '')
}
clearOnEscape
disabled={isSubmitting}
rows={6}
/>
</FormControl>
</ModalBody>
<ModalFooter>
<ModalButton kind="tertiary" onClick={onClose} disabled={isSubmitting}>
Cancel
</ModalButton>
<ModalButton
onClick={handleSubmit}
isLoading={isSubmitting}
data-testid="auth-token-submit"
>
Save token
</ModalButton>
</ModalFooter>
</Modal>
);
}
2 changes: 1 addition & 1 deletion src/components/snackbar-provider/snackbar-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default function SnackbarProvider({ children }: Props) {
<BaseSnackbarProvider
placement={PLACEMENT.bottom}
overrides={overrides.snackbar}
defaultDuration={DURATION.infinite}
defaultDuration={DURATION.medium}
>
{children}
</BaseSnackbarProvider>
Expand Down
5 changes: 5 additions & 0 deletions src/config/dynamic/dynamic.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import workflowDiagnosticsEnabled from './resolvers/workflow-diagnostics-enabled
const dynamicConfigs: {
CADENCE_WEB_PORT: ConfigEnvDefinition;
ADMIN_SECURITY_TOKEN: ConfigEnvDefinition;
CADENCE_WEB_RBAC_ENABLED: ConfigEnvDefinition;
CLUSTERS: ConfigSyncResolverDefinition<
undefined,
ClustersConfigs,
Expand Down Expand Up @@ -90,6 +91,10 @@ const dynamicConfigs: {
env: 'CADENCE_ADMIN_SECURITY_TOKEN',
default: '',
},
CADENCE_WEB_RBAC_ENABLED: {
env: 'CADENCE_WEB_RBAC_ENABLED',
default: 'false',
},
CLUSTERS: {
resolver: clusters,
evaluateOn: 'serverStart',
Expand Down
59 changes: 59 additions & 0 deletions src/hooks/use-domain-access/use-domain-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client';
import { useMemo } from 'react';

import { useQuery } from '@tanstack/react-query';

import { getDomainAccessForUser } from '@/utils/auth/auth-shared';
import getDomainDescriptionQueryOptions from '@/views/shared/hooks/use-domain-description/get-domain-description-query-options';
import { type UseDomainDescriptionParams } from '@/views/shared/hooks/use-domain-description/use-domain-description.types';

import useUserInfo from '../use-user-info/use-user-info';

export default function useDomainAccess(params: UseDomainDescriptionParams) {
const userInfoQuery = useUserInfo();
const isRbacEnabled = userInfoQuery.data?.rbacEnabled === true;

const domainQuery = useQuery({
...getDomainDescriptionQueryOptions(params),
enabled: isRbacEnabled,
});

const access = useMemo(() => {
if (userInfoQuery.isError) {
return { canRead: false, canWrite: false };
}

if (!userInfoQuery.data) {
return undefined;
}

if (!userInfoQuery.data.rbacEnabled) {
return { canRead: true, canWrite: true };
}

if (domainQuery.data) {
return getDomainAccessForUser(domainQuery.data, userInfoQuery.data);
}

if (domainQuery.isError) {
return { canRead: false, canWrite: false };
}

return undefined;
}, [
domainQuery.data,
domainQuery.isError,
userInfoQuery.data,
userInfoQuery.isError,
]);

const isLoading =
userInfoQuery.isLoading || (isRbacEnabled && domainQuery.isLoading);

return {
access,
isLoading,
isError: userInfoQuery.isError || domainQuery.isError,
userInfoQuery,
};
}
17 changes: 17 additions & 0 deletions src/hooks/use-user-info/use-user-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use client';
import { useQuery } from '@tanstack/react-query';

import { type PublicAuthContext } from '@/utils/auth/auth-shared';
import request from '@/utils/request';
import { type RequestError } from '@/utils/request/request-error';

export default function useUserInfo() {
return useQuery<PublicAuthContext, RequestError>({
queryKey: ['auth', 'me'],
queryFn: async () => {
const res = await request('/api/auth/me', { method: 'GET' });
return res.json();
},
staleTime: 30_000,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,9 @@ async function setup({
},
userInfo: {
id: 'test-user-id',
rbacEnabled: false,
isAdmin: true,
groups: [],
},
};

Expand Down
Loading