1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-09 00:18:00 +01:00

feat: add CORS instance settings (#1239)

* feat: add CORS instance settings

* refactor: hide the CORS page when embedProxy is false
This commit is contained in:
olav 2022-08-23 14:04:09 +02:00 committed by GitHub
parent 337e7888d2
commit e6b72ff4a0
15 changed files with 297 additions and 27 deletions

View File

@ -5,6 +5,7 @@ import {
Radio, Radio,
RadioGroup, RadioGroup,
Typography, Typography,
Box,
} from '@mui/material'; } from '@mui/material';
import { KeyboardArrowDownOutlined } from '@mui/icons-material'; import { KeyboardArrowDownOutlined } from '@mui/icons-material';
import React from 'react'; import React from 'react';
@ -16,6 +17,9 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { SelectProjectInput } from './SelectProjectInput/SelectProjectInput'; import { SelectProjectInput } from './SelectProjectInput/SelectProjectInput';
import { ApiTokenFormErrorType } from './useApiTokenForm'; import { ApiTokenFormErrorType } from './useApiTokenForm';
import { useStyles } from './ApiTokenForm.styles'; 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 { interface IApiTokenFormProps {
username: string; username: string;
@ -48,7 +52,6 @@ const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
errors, errors,
clearErrors, clearErrors,
}) => { }) => {
const TYPE_ADMIN = 'ADMIN';
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
const { environments } = useEnvironments(); const { environments } = useEnvironments();
@ -56,21 +59,21 @@ const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
const selectableTypes = [ const selectableTypes = [
{ {
key: 'CLIENT', key: TokenType.CLIENT,
label: 'Server-side SDK (CLIENT)', label: `Server-side SDK (${TokenType.CLIENT})`,
title: 'Connect server-side SDK or Unleash Proxy', title: 'Connect server-side SDK or Unleash Proxy',
}, },
{ {
key: 'ADMIN', key: TokenType.ADMIN,
label: 'ADMIN', label: TokenType.ADMIN,
title: 'Full access for managing Unleash', title: 'Full access for managing Unleash',
}, },
]; ];
if (uiConfig.embedProxy) { if (uiConfig.embedProxy) {
selectableTypes.splice(1, 0, { selectableTypes.splice(1, 0, {
key: 'FRONTEND', key: TokenType.FRONTEND,
label: 'Client-side SDK (FRONTEND)', label: `Client-side SDK (${TokenType.FRONTEND})`,
title: 'Connect web and mobile SDK directly to Unleash', title: 'Connect web and mobile SDK directly to Unleash',
}); });
} }
@ -81,7 +84,7 @@ const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
})); }));
const selectableEnvs = const selectableEnvs =
type === TYPE_ADMIN type === TokenType.ADMIN
? [{ key: '*', label: 'ALL' }] ? [{ key: '*', label: 'ALL' }]
: environments.map(environment => ({ : environments.map(environment => ({
key: environment.name, key: environment.name,
@ -143,7 +146,7 @@ const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
Which project do you want to give access to? Which project do you want to give access to?
</p> </p>
<SelectProjectInput <SelectProjectInput
disabled={type === TYPE_ADMIN} disabled={type === TokenType.ADMIN}
options={selectableProjects} options={selectableProjects}
defaultValue={projects} defaultValue={projects}
onChange={setProjects} onChange={setProjects}
@ -154,7 +157,7 @@ const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
Which environment should the token have access to? Which environment should the token have access to?
</p> </p>
<GeneralSelect <GeneralSelect
disabled={type === TYPE_ADMIN} disabled={type === TokenType.ADMIN}
options={selectableEnvs} options={selectableEnvs}
value={environment} value={environment}
onChange={setEnvironment} onChange={setEnvironment}
@ -172,6 +175,14 @@ const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
Cancel Cancel
</Button> </Button>
</div> </div>
<ConditionallyRender
condition={type === TokenType.FRONTEND}
show={
<Box sx={{ mt: 4 }}>
<CorsTokenAlert />
</Box>
}
/>
</form> </form>
); );
}; };

View File

@ -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');
});

View File

@ -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');

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -77,7 +77,6 @@ function AdminMenu() {
} }
/> />
)} )}
<Tab <Tab
value="/admin/api" value="/admin/api"
label={ label={
@ -86,6 +85,19 @@ function AdminMenu() {
</NavLink> </NavLink>
} }
/> />
{uiConfig.embedProxy && (
<Tab
value="/admin/cors"
label={
<NavLink
to="/admin/cors"
style={createNavLinkStyle}
>
CORS origins
</NavLink>
}
/>
)}
<Tab <Tab
value="/admin/auth" value="/admin/auth"
label={ label={

View File

@ -1,15 +1,22 @@
import { weightTypes } from '../feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/enums'; 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 { IRoute } from 'interfaces/route';
import { IFeatureVariant } from 'interfaces/featureToggle'; import { IFeatureVariant } from 'interfaces/featureToggle';
import { format, isValid } from 'date-fns'; import { format, isValid } from 'date-fns';
export const filterByFlags = (flags: IFlags) => (r: IRoute) => { export const filterByConfig = (config: IUiConfig) => (r: IRoute) => {
if (!r.flag) { if (r.flag) {
return true; // 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 = () => { export const scrollToTop = () => {

View File

@ -18,7 +18,7 @@ import { IPermission } from 'interfaces/user';
import { NavigationMenu } from './NavigationMenu/NavigationMenu'; import { NavigationMenu } from './NavigationMenu/NavigationMenu';
import { getRoutes } from 'component/menu/routes'; import { getRoutes } from 'component/menu/routes';
import { KeyboardArrowDown } from '@mui/icons-material'; 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 { useAuthPermissions } from 'hooks/api/getters/useAuth/useAuthPermissions';
import { useStyles } from './Header.styles'; import { useStyles } from './Header.styles';
import classNames from 'classnames'; import classNames from 'classnames';
@ -34,10 +34,7 @@ const Header: VFC = () => {
const [admin, setAdmin] = useState(false); const [admin, setAdmin] = useState(false);
const { permissions } = useAuthPermissions(); const { permissions } = useAuthPermissions();
const { const { uiConfig, isOss } = useUiConfig();
uiConfig: { links, name, flags },
isOss,
} = useUiConfig();
const smallScreen = useMediaQuery(theme.breakpoints.down('md')); const smallScreen = useMediaQuery(theme.breakpoints.down('md'));
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
const { classes: themeStyles } = useThemeStyles(); const { classes: themeStyles } = useThemeStyles();
@ -64,10 +61,10 @@ const Header: VFC = () => {
}; };
const filteredMainRoutes = { const filteredMainRoutes = {
mainNavRoutes: routes.mainNavRoutes.filter(filterByFlags(flags)), mainNavRoutes: routes.mainNavRoutes.filter(filterByConfig(uiConfig)),
mobileRoutes: routes.mobileRoutes.filter(filterByFlags(flags)), mobileRoutes: routes.mobileRoutes.filter(filterByConfig(uiConfig)),
adminRoutes: routes.adminRoutes adminRoutes: routes.adminRoutes
.filter(filterByFlags(flags)) .filter(filterByConfig(uiConfig))
.filter(filterByEnterprise), .filter(filterByEnterprise),
}; };
@ -87,9 +84,9 @@ const Header: VFC = () => {
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<DrawerMenu <DrawerMenu
title={name} title={uiConfig.name}
flags={flags} flags={uiConfig.flags}
links={links} links={uiConfig.links}
open={openDrawer} open={openDrawer}
toggleDrawer={toggleDrawer} toggleDrawer={toggleDrawer}
admin={admin} admin={admin}

View File

@ -447,6 +447,17 @@ exports[`returns all baseRoutes 1`] = `
"title": "Single sign-on", "title": "Single sign-on",
"type": "protected", "type": "protected",
}, },
{
"component": [Function],
"configFlag": "embedProxy",
"menu": {
"adminSettings": true,
},
"parent": "/admin",
"path": "/admin/cors",
"title": "CORS origins",
"type": "protected",
},
{ {
"component": [Function], "component": [Function],
"menu": {}, "menu": {},

View File

@ -56,6 +56,7 @@ import { Group } from 'component/admin/groups/Group/Group';
import { CreateGroup } from 'component/admin/groups/CreateGroup/CreateGroup'; import { CreateGroup } from 'component/admin/groups/CreateGroup/CreateGroup';
import { EditGroup } from 'component/admin/groups/EditGroup/EditGroup'; import { EditGroup } from 'component/admin/groups/EditGroup/EditGroup';
import { LazyPlayground } from 'component/playground/Playground/LazyPlayground'; import { LazyPlayground } from 'component/playground/Playground/LazyPlayground';
import { CorsAdmin } from 'component/admin/cors';
export const routes: IRoute[] = [ export const routes: IRoute[] = [
// Splash // Splash
@ -489,6 +490,15 @@ export const routes: IRoute[] = [
type: 'protected', type: 'protected',
menu: { adminSettings: true }, menu: { adminSettings: true },
}, },
{
path: '/admin/cors',
parent: '/admin',
title: 'CORS origins',
component: CorsAdmin,
type: 'protected',
configFlag: 'embedProxy',
menu: { adminSettings: true },
},
{ {
path: '/admin/billing', path: '/admin/billing',
parent: '/admin', parent: '/admin',

View File

@ -0,0 +1,28 @@
import useAPI from '../useApi/useApi';
import { formatApiPath } from 'utils/formatPath';
export const useUiConfigApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const setFrontendSettings = async (
frontendApiOrigins: string[]
): Promise<void> => {
const payload = {
frontendSettings: { frontendApiOrigins },
};
const req = createRequest(
formatApiPath('api/admin/ui-config'),
{ method: 'POST', body: JSON.stringify(payload) },
'setFrontendSettings'
);
await makeRequest(req.caller, req.id);
};
return {
setFrontendSettings,
loading,
errors,
};
};

View File

@ -1,4 +1,5 @@
import { VoidFunctionComponent } from 'react'; import { VoidFunctionComponent } from 'react';
import { IUiConfig } from 'interfaces/uiConfig';
export interface IRoute { export interface IRoute {
path: string; path: string;
@ -7,6 +8,7 @@ export interface IRoute {
layout?: string; layout?: string;
parent?: string; parent?: string;
flag?: string; flag?: string;
configFlag?: keyof IUiConfig;
hidden?: boolean; hidden?: boolean;
enterprise?: boolean; enterprise?: boolean;
component: VoidFunctionComponent; component: VoidFunctionComponent;

View File

@ -0,0 +1,5 @@
export enum TokenType {
ADMIN = 'ADMIN',
CLIENT = 'CLIENT',
FRONTEND = 'FRONTEND',
}

View File

@ -16,6 +16,7 @@ export interface IUiConfig {
toast?: IProclamationToast; toast?: IProclamationToast;
segmentValuesLimit?: number; segmentValuesLimit?: number;
strategySegmentsLimit?: number; strategySegmentsLimit?: number;
frontendApiOrigins?: string[];
embedProxy?: boolean; embedProxy?: boolean;
} }