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:
parent
337e7888d2
commit
e6b72ff4a0
@ -5,6 +5,7 @@ import {
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Typography,
|
||||
Box,
|
||||
} from '@mui/material';
|
||||
import { KeyboardArrowDownOutlined } from '@mui/icons-material';
|
||||
import React from 'react';
|
||||
@ -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;
|
||||
@ -48,7 +52,6 @@ const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
|
||||
errors,
|
||||
clearErrors,
|
||||
}) => {
|
||||
const TYPE_ADMIN = 'ADMIN';
|
||||
const { uiConfig } = useUiConfig();
|
||||
const { classes: styles } = useStyles();
|
||||
const { environments } = useEnvironments();
|
||||
@ -56,21 +59,21 @@ const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
@ -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}
|
||||
@ -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}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
23
frontend/src/component/admin/cors/CorsForm.test.tsx
Normal file
23
frontend/src/component/admin/cors/CorsForm.test.tsx
Normal 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');
|
||||
});
|
74
frontend/src/component/admin/cors/CorsForm.tsx
Normal file
74
frontend/src/component/admin/cors/CorsForm.tsx
Normal 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');
|
22
frontend/src/component/admin/cors/CorsHelpAlert.tsx
Normal file
22
frontend/src/component/admin/cors/CorsHelpAlert.tsx
Normal 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>
|
||||
);
|
||||
};
|
17
frontend/src/component/admin/cors/CorsTokenAlert.tsx
Normal file
17
frontend/src/component/admin/cors/CorsTokenAlert.tsx
Normal 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>
|
||||
);
|
||||
};
|
50
frontend/src/component/admin/cors/index.tsx
Normal file
50
frontend/src/component/admin/cors/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -77,7 +77,6 @@ function AdminMenu() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tab
|
||||
value="/admin/api"
|
||||
label={
|
||||
@ -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={
|
||||
|
@ -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 = () => {
|
||||
|
@ -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';
|
||||
@ -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();
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
@ -447,6 +447,17 @@ exports[`returns all baseRoutes 1`] = `
|
||||
"title": "Single sign-on",
|
||||
"type": "protected",
|
||||
},
|
||||
{
|
||||
"component": [Function],
|
||||
"configFlag": "embedProxy",
|
||||
"menu": {
|
||||
"adminSettings": true,
|
||||
},
|
||||
"parent": "/admin",
|
||||
"path": "/admin/cors",
|
||||
"title": "CORS origins",
|
||||
"type": "protected",
|
||||
},
|
||||
{
|
||||
"component": [Function],
|
||||
"menu": {},
|
||||
|
@ -56,6 +56,7 @@ import { Group } from 'component/admin/groups/Group/Group';
|
||||
import { CreateGroup } from 'component/admin/groups/CreateGroup/CreateGroup';
|
||||
import { EditGroup } from 'component/admin/groups/EditGroup/EditGroup';
|
||||
import { LazyPlayground } from 'component/playground/Playground/LazyPlayground';
|
||||
import { CorsAdmin } from 'component/admin/cors';
|
||||
|
||||
export const routes: IRoute[] = [
|
||||
// Splash
|
||||
@ -489,6 +490,15 @@ export const routes: IRoute[] = [
|
||||
type: 'protected',
|
||||
menu: { adminSettings: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/cors',
|
||||
parent: '/admin',
|
||||
title: 'CORS origins',
|
||||
component: CorsAdmin,
|
||||
type: 'protected',
|
||||
configFlag: 'embedProxy',
|
||||
menu: { adminSettings: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/billing',
|
||||
parent: '/admin',
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
@ -1,4 +1,5 @@
|
||||
import { VoidFunctionComponent } from 'react';
|
||||
import { IUiConfig } from 'interfaces/uiConfig';
|
||||
|
||||
export interface IRoute {
|
||||
path: string;
|
||||
@ -7,6 +8,7 @@ export interface IRoute {
|
||||
layout?: string;
|
||||
parent?: string;
|
||||
flag?: string;
|
||||
configFlag?: keyof IUiConfig;
|
||||
hidden?: boolean;
|
||||
enterprise?: boolean;
|
||||
component: VoidFunctionComponent;
|
||||
|
5
frontend/src/interfaces/token.ts
Normal file
5
frontend/src/interfaces/token.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export enum TokenType {
|
||||
ADMIN = 'ADMIN',
|
||||
CLIENT = 'CLIENT',
|
||||
FRONTEND = 'FRONTEND',
|
||||
}
|
@ -16,6 +16,7 @@ export interface IUiConfig {
|
||||
toast?: IProclamationToast;
|
||||
segmentValuesLimit?: number;
|
||||
strategySegmentsLimit?: number;
|
||||
frontendApiOrigins?: string[];
|
||||
embedProxy?: boolean;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user