Skip to content
This repository has been archived by the owner on Oct 27, 2022. It is now read-only.

Commit

Permalink
feat: add CORS instance settings (#1239)
Browse files Browse the repository at this point in the history
* feat: add CORS instance settings

* refactor: hide the CORS page when embedProxy is false
  • Loading branch information
olav authored Aug 23, 2022
1 parent 2677670 commit d37479d
Show file tree
Hide file tree
Showing 15 changed files with 297 additions and 27 deletions.
31 changes: 21 additions & 10 deletions src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Radio,
RadioGroup,
Typography,
Box,
} from '@mui/material';
import { KeyboardArrowDownOutlined } from '@mui/icons-material';
import React from 'react';
Expand All @@ -16,6 +17,9 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { SelectProjectInput } from './SelectProjectInput/SelectProjectInput';
import { ApiTokenFormErrorType } from './useApiTokenForm';
import { useStyles } from './ApiTokenForm.styles';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { TokenType } from 'interfaces/token';
import { CorsTokenAlert } from 'component/admin/cors/CorsTokenAlert';

interface IApiTokenFormProps {
username: string;
Expand Down Expand Up @@ -48,29 +52,28 @@ const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
errors,
clearErrors,
}) => {
const TYPE_ADMIN = 'ADMIN';
const { uiConfig } = useUiConfig();
const { classes: styles } = useStyles();
const { environments } = useEnvironments();
const { projects: availableProjects } = useProjects();

const selectableTypes = [
{
key: 'CLIENT',
label: 'Server-side SDK (CLIENT)',
key: TokenType.CLIENT,
label: `Server-side SDK (${TokenType.CLIENT})`,
title: 'Connect server-side SDK or Unleash Proxy',
},
{
key: 'ADMIN',
label: 'ADMIN',
key: TokenType.ADMIN,
label: TokenType.ADMIN,
title: 'Full access for managing Unleash',
},
];

if (uiConfig.embedProxy) {
selectableTypes.splice(1, 0, {
key: 'FRONTEND',
label: 'Client-side SDK (FRONTEND)',
key: TokenType.FRONTEND,
label: `Client-side SDK (${TokenType.FRONTEND})`,
title: 'Connect web and mobile SDK directly to Unleash',
});
}
Expand All @@ -81,7 +84,7 @@ const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
}));

const selectableEnvs =
type === TYPE_ADMIN
type === TokenType.ADMIN
? [{ key: '*', label: 'ALL' }]
: environments.map(environment => ({
key: environment.name,
Expand Down Expand Up @@ -143,7 +146,7 @@ const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
Which project do you want to give access to?
</p>
<SelectProjectInput
disabled={type === TYPE_ADMIN}
disabled={type === TokenType.ADMIN}
options={selectableProjects}
defaultValue={projects}
onChange={setProjects}
Expand All @@ -154,7 +157,7 @@ const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
Which environment should the token have access to?
</p>
<GeneralSelect
disabled={type === TYPE_ADMIN}
disabled={type === TokenType.ADMIN}
options={selectableEnvs}
value={environment}
onChange={setEnvironment}
Expand All @@ -172,6 +175,14 @@ const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
Cancel
</Button>
</div>
<ConditionallyRender
condition={type === TokenType.FRONTEND}
show={
<Box sx={{ mt: 4 }}>
<CorsTokenAlert />
</Box>
}
/>
</form>
);
};
Expand Down
23 changes: 23 additions & 0 deletions src/component/admin/cors/CorsForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {
parseInputValue,
formatInputValue,
} from 'component/admin/cors/CorsForm';

test('parseInputValue', () => {
const fn = parseInputValue;
expect(fn('')).toEqual([]);
expect(fn('a')).toEqual(['a']);
expect(fn('a\nb,,c,d,')).toEqual(['a', 'b', 'c', 'd']);
expect(fn('http://localhost:8080')).toEqual(['http://localhost:8080']);
expect(fn('https://example.com')).toEqual(['https://example.com']);
expect(fn('https://example.com/')).toEqual(['https://example.com']);
expect(fn('https://example.com/')).toEqual(['https://example.com']);
});

test('formatInputValue', () => {
const fn = formatInputValue;
expect(fn(undefined)).toEqual('');
expect(fn([])).toEqual('');
expect(fn(['a'])).toEqual('a');
expect(fn(['a', 'b', 'c', 'd'])).toEqual('a\nb\nc\nd');
});
74 changes: 74 additions & 0 deletions src/component/admin/cors/CorsForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import React, { useState } from 'react';
import { TextField, Box } from '@mui/material';
import { UpdateButton } from 'component/common/UpdateButton/UpdateButton';
import { useUiConfigApi } from 'hooks/api/actions/useUiConfigApi/useUiConfigApi';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useId } from 'hooks/useId';

interface ICorsFormProps {
frontendApiOrigins: string[] | undefined;
}

export const CorsForm = ({ frontendApiOrigins }: ICorsFormProps) => {
const { setFrontendSettings } = useUiConfigApi();
const { setToastData, setToastApiError } = useToast();
const [value, setValue] = useState(formatInputValue(frontendApiOrigins));
const inputFieldId = useId();
const helpTextId = useId();

const onSubmit = async (event: React.FormEvent) => {
try {
const split = parseInputValue(value);
event.preventDefault();
await setFrontendSettings(split);
setValue(formatInputValue(split));
setToastData({ title: 'Settings saved', type: 'success' });
} catch (error) {
setToastApiError(formatUnknownError(error));
}
};

return (
<form onSubmit={onSubmit}>
<Box sx={{ display: 'grid', gap: 1 }}>
<label htmlFor={inputFieldId}>
Which origins should be allowed to call the Frontend API
(one per line)?
</label>
<TextField
id={inputFieldId}
aria-describedby={helpTextId}
placeholder={textareaDomainsPlaceholder}
value={value}
onChange={event => setValue(event.target.value)}
multiline
rows={12}
variant="outlined"
fullWidth
InputProps={{
style: { fontFamily: 'monospace', fontSize: '0.8em' },
}}
/>
<UpdateButton permission={ADMIN} />
</Box>
</form>
);
};

export const parseInputValue = (value: string): string[] => {
return value
.split(/[,\n\s]+/) // Split by commas/newlines/spaces.
.map(value => value.replace(/\/$/, '')) // Remove trailing slashes.
.filter(Boolean); // Remove empty values from (e.g.) double newlines.
};

export const formatInputValue = (values: string[] | undefined): string => {
return values?.join('\n') ?? '';
};

const textareaDomainsPlaceholder = [
'https://example.com',
'https://example.org',
].join('\n');
22 changes: 22 additions & 0 deletions src/component/admin/cors/CorsHelpAlert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';
import { Alert } from '@mui/material';

export const CorsHelpAlert = () => {
return (
<Alert severity="info">
<p>
Use this page to configure allowed CORS origins for the Frontend
API (<code>/api/frontend</code>).
</p>
<p>
This configuration will not affect the Admin API (
<code>/api/admin</code>) nor the Client API (
<code>/api/client</code>).
</p>
<p>
An asterisk (<code>*</code>) may be used to allow API calls from
any origin.
</p>
</Alert>
);
};
17 changes: 17 additions & 0 deletions src/component/admin/cors/CorsTokenAlert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { TokenType } from 'interfaces/token';
import { Link } from 'react-router-dom';
import { Alert } from '@mui/material';

export const CorsTokenAlert = () => {
return (
<Alert sx={{ mt: 4 }} severity="info">
By default, all {TokenType.FRONTEND} tokens may be used from any
CORS origin. If you'd like to configure a strict set of origins,
please use the{' '}
<Link to="/admin/cors" target="_blank">
CORS origins configuration page
</Link>
.
</Alert>
);
};
50 changes: 50 additions & 0 deletions src/component/admin/cors/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useLocation } from 'react-router-dom';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import AdminMenu from '../menu/AdminMenu';
import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import AccessContext from 'contexts/AccessContext';
import React, { useContext } from 'react';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { Box } from '@mui/material';
import { CorsHelpAlert } from 'component/admin/cors/CorsHelpAlert';
import { CorsForm } from 'component/admin/cors/CorsForm';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';

export const CorsAdmin = () => {
const { pathname } = useLocation();
const showAdminMenu = pathname.includes('/admin/');
const { hasAccess } = useContext(AccessContext);

return (
<div>
<ConditionallyRender
condition={showAdminMenu}
show={<AdminMenu />}
/>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={<CorsPage />}
elseShow={<AdminAlert />}
/>
</div>
);
};

const CorsPage = () => {
const { uiConfig, loading } = useUiConfig();

if (loading) {
return null;
}

return (
<PageContent header={<PageHeader title="CORS origins" />}>
<Box sx={{ display: 'grid', gap: 4 }}>
<CorsHelpAlert />
<CorsForm frontendApiOrigins={uiConfig.frontendApiOrigins} />
</Box>
</PageContent>
);
};
14 changes: 13 additions & 1 deletion src/component/admin/menu/AdminMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ function AdminMenu() {
}
/>
)}

<Tab
value="/admin/api"
label={
Expand All @@ -86,6 +85,19 @@ function AdminMenu() {
</NavLink>
}
/>
{uiConfig.embedProxy && (
<Tab
value="/admin/cors"
label={
<NavLink
to="/admin/cors"
style={createNavLinkStyle}
>
CORS origins
</NavLink>
}
/>
)}
<Tab
value="/admin/auth"
label={
Expand Down
17 changes: 12 additions & 5 deletions src/component/common/util.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import { weightTypes } from '../feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/enums';
import { IFlags } from 'interfaces/uiConfig';
import { IUiConfig } from 'interfaces/uiConfig';
import { IRoute } from 'interfaces/route';
import { IFeatureVariant } from 'interfaces/featureToggle';
import { format, isValid } from 'date-fns';

export const filterByFlags = (flags: IFlags) => (r: IRoute) => {
if (!r.flag) {
return true;
export const filterByConfig = (config: IUiConfig) => (r: IRoute) => {
if (r.flag) {
// Check if the route's `flag` is enabled in IUiConfig.flags.
const flags = config.flags as unknown as Record<string, boolean>;
return Boolean(flags[r.flag]);
}

return (flags as unknown as Record<string, boolean>)[r.flag];
if (r.configFlag) {
// Check if the route's `configFlag` is enabled in IUiConfig.
return Boolean(config[r.configFlag]);
}

return true;
};

export const scrollToTop = () => {
Expand Down
19 changes: 8 additions & 11 deletions src/component/menu/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { IPermission } from 'interfaces/user';
import { NavigationMenu } from './NavigationMenu/NavigationMenu';
import { getRoutes } from 'component/menu/routes';
import { KeyboardArrowDown } from '@mui/icons-material';
import { filterByFlags } from 'component/common/util';
import { filterByConfig } from 'component/common/util';
import { useAuthPermissions } from 'hooks/api/getters/useAuth/useAuthPermissions';
import { useStyles } from './Header.styles';
import classNames from 'classnames';
Expand All @@ -34,10 +34,7 @@ const Header: VFC = () => {

const [admin, setAdmin] = useState(false);
const { permissions } = useAuthPermissions();
const {
uiConfig: { links, name, flags },
isOss,
} = useUiConfig();
const { uiConfig, isOss } = useUiConfig();
const smallScreen = useMediaQuery(theme.breakpoints.down('md'));
const { classes: styles } = useStyles();
const { classes: themeStyles } = useThemeStyles();
Expand All @@ -64,10 +61,10 @@ const Header: VFC = () => {
};

const filteredMainRoutes = {
mainNavRoutes: routes.mainNavRoutes.filter(filterByFlags(flags)),
mobileRoutes: routes.mobileRoutes.filter(filterByFlags(flags)),
mainNavRoutes: routes.mainNavRoutes.filter(filterByConfig(uiConfig)),
mobileRoutes: routes.mobileRoutes.filter(filterByConfig(uiConfig)),
adminRoutes: routes.adminRoutes
.filter(filterByFlags(flags))
.filter(filterByConfig(uiConfig))
.filter(filterByEnterprise),
};

Expand All @@ -87,9 +84,9 @@ const Header: VFC = () => {
</IconButton>
</Tooltip>
<DrawerMenu
title={name}
flags={flags}
links={links}
title={uiConfig.name}
flags={uiConfig.flags}
links={uiConfig.links}
open={openDrawer}
toggleDrawer={toggleDrawer}
admin={admin}
Expand Down
Loading

0 comments on commit d37479d

Please sign in to comment.