Skip to content
Draft
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
11 changes: 11 additions & 0 deletions src/app/api/auth/me/route.ts
Original file line number Diff line number Diff line change
@@ -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));
}
40 changes: 40 additions & 0 deletions src/app/api/auth/token/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
132 changes: 116 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>
);
}
10 changes: 10 additions & 0 deletions src/config/dynamic/dynamic.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import workflowDiagnosticsEnabled from './resolvers/workflow-diagnostics-enabled
const dynamicConfigs: {
CADENCE_WEB_PORT: ConfigEnvDefinition;
ADMIN_SECURITY_TOKEN: ConfigEnvDefinition;
CADENCE_WEB_RBAC_ENABLED: ConfigEnvDefinition;
CADENCE_WEB_JWT_TOKEN: ConfigEnvDefinition;
CLUSTERS: ConfigSyncResolverDefinition<
undefined,
ClustersConfigs,
Expand Down Expand Up @@ -89,6 +91,14 @@ const dynamicConfigs: {
env: 'CADENCE_ADMIN_SECURITY_TOKEN',
default: '',
},
CADENCE_WEB_RBAC_ENABLED: {
env: 'CADENCE_WEB_RBAC_ENABLED',
default: 'false',
},
CADENCE_WEB_JWT_TOKEN: {
env: 'CADENCE_WEB_JWT_TOKEN',
default: process.env.CADENCE_WEB_RBAC_TOKEN || '',
},
CLUSTERS: {
resolver: clusters,
evaluateOn: 'serverStart',
Expand Down
55 changes: 55 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,55 @@
'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 shouldLoadDomain = Boolean(userInfoQuery.data);

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

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

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

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 || (shouldLoadDomain && 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
Loading