From e6b72ff4a0ed768f348a670a8058b4dc1cfce1c7 Mon Sep 17 00:00:00 2001
From: olav
Date: Tue, 23 Aug 2022 14:04:09 +0200
Subject: [PATCH] feat: add CORS instance settings (#1239)
* feat: add CORS instance settings
* refactor: hide the CORS page when embedProxy is false
---
.../apiToken/ApiTokenForm/ApiTokenForm.tsx | 31 +++++---
.../component/admin/cors/CorsForm.test.tsx | 23 ++++++
.../src/component/admin/cors/CorsForm.tsx | 74 +++++++++++++++++++
.../component/admin/cors/CorsHelpAlert.tsx | 22 ++++++
.../component/admin/cors/CorsTokenAlert.tsx | 17 +++++
frontend/src/component/admin/cors/index.tsx | 50 +++++++++++++
.../src/component/admin/menu/AdminMenu.tsx | 14 +++-
frontend/src/component/common/util.ts | 17 +++--
frontend/src/component/menu/Header/Header.tsx | 19 ++---
.../__snapshots__/routes.test.tsx.snap | 11 +++
frontend/src/component/menu/routes.ts | 10 +++
.../actions/useUiConfigApi/useUiConfigApi.ts | 28 +++++++
frontend/src/interfaces/route.ts | 2 +
frontend/src/interfaces/token.ts | 5 ++
frontend/src/interfaces/uiConfig.ts | 1 +
15 files changed, 297 insertions(+), 27 deletions(-)
create mode 100644 frontend/src/component/admin/cors/CorsForm.test.tsx
create mode 100644 frontend/src/component/admin/cors/CorsForm.tsx
create mode 100644 frontend/src/component/admin/cors/CorsHelpAlert.tsx
create mode 100644 frontend/src/component/admin/cors/CorsTokenAlert.tsx
create mode 100644 frontend/src/component/admin/cors/index.tsx
create mode 100644 frontend/src/hooks/api/actions/useUiConfigApi/useUiConfigApi.ts
create mode 100644 frontend/src/interfaces/token.ts
diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx
index 52b5c152ce..5cce172e92 100644
--- a/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx
+++ b/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx
@@ -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 = ({
errors,
clearErrors,
}) => {
- const TYPE_ADMIN = 'ADMIN';
const { uiConfig } = useUiConfig();
const { classes: styles } = useStyles();
const { environments } = useEnvironments();
@@ -56,21 +59,21 @@ const ApiTokenForm: React.FC = ({
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 = ({
}));
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 = ({
Which project do you want to give access to?
= ({
Which environment should the token have access to?
= ({
Cancel
+
+
+
+ }
+ />
);
};
diff --git a/frontend/src/component/admin/cors/CorsForm.test.tsx b/frontend/src/component/admin/cors/CorsForm.test.tsx
new file mode 100644
index 0000000000..93b48176e8
--- /dev/null
+++ b/frontend/src/component/admin/cors/CorsForm.test.tsx
@@ -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');
+});
diff --git a/frontend/src/component/admin/cors/CorsForm.tsx b/frontend/src/component/admin/cors/CorsForm.tsx
new file mode 100644
index 0000000000..10dfd80459
--- /dev/null
+++ b/frontend/src/component/admin/cors/CorsForm.tsx
@@ -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 (
+
+ );
+};
+
+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');
diff --git a/frontend/src/component/admin/cors/CorsHelpAlert.tsx b/frontend/src/component/admin/cors/CorsHelpAlert.tsx
new file mode 100644
index 0000000000..05c3623ba1
--- /dev/null
+++ b/frontend/src/component/admin/cors/CorsHelpAlert.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { Alert } from '@mui/material';
+
+export const CorsHelpAlert = () => {
+ return (
+
+
+ Use this page to configure allowed CORS origins for the Frontend
+ API (/api/frontend
).
+
+
+ This configuration will not affect the Admin API (
+ /api/admin
) nor the Client API (
+ /api/client
).
+
+
+ An asterisk (*
) may be used to allow API calls from
+ any origin.
+
+
+ );
+};
diff --git a/frontend/src/component/admin/cors/CorsTokenAlert.tsx b/frontend/src/component/admin/cors/CorsTokenAlert.tsx
new file mode 100644
index 0000000000..bac1ebfba8
--- /dev/null
+++ b/frontend/src/component/admin/cors/CorsTokenAlert.tsx
@@ -0,0 +1,17 @@
+import { TokenType } from 'interfaces/token';
+import { Link } from 'react-router-dom';
+import { Alert } from '@mui/material';
+
+export const CorsTokenAlert = () => {
+ return (
+
+ 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{' '}
+
+ CORS origins configuration page
+
+ .
+
+ );
+};
diff --git a/frontend/src/component/admin/cors/index.tsx b/frontend/src/component/admin/cors/index.tsx
new file mode 100644
index 0000000000..802696b383
--- /dev/null
+++ b/frontend/src/component/admin/cors/index.tsx
@@ -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 (
+
+
}
+ />
+
}
+ elseShow={
}
+ />
+
+ );
+};
+
+const CorsPage = () => {
+ const { uiConfig, loading } = useUiConfig();
+
+ if (loading) {
+ return null;
+ }
+
+ return (
+ }>
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/menu/AdminMenu.tsx b/frontend/src/component/admin/menu/AdminMenu.tsx
index abedaedb45..f85102eb9c 100644
--- a/frontend/src/component/admin/menu/AdminMenu.tsx
+++ b/frontend/src/component/admin/menu/AdminMenu.tsx
@@ -77,7 +77,6 @@ function AdminMenu() {
}
/>
)}
-
}
/>
+ {uiConfig.embedProxy && (
+
+ CORS origins
+
+ }
+ />
+ )}
(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;
+ return Boolean(flags[r.flag]);
}
- return (flags as unknown as Record)[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 = () => {
diff --git a/frontend/src/component/menu/Header/Header.tsx b/frontend/src/component/menu/Header/Header.tsx
index d79afd3e7a..dbe8726e91 100644
--- a/frontend/src/component/menu/Header/Header.tsx
+++ b/frontend/src/component/menu/Header/Header.tsx
@@ -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 = () => {
{
+ const { makeRequest, createRequest, errors, loading } = useAPI({
+ propagateErrors: true,
+ });
+
+ const setFrontendSettings = async (
+ frontendApiOrigins: string[]
+ ): Promise => {
+ 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,
+ };
+};
diff --git a/frontend/src/interfaces/route.ts b/frontend/src/interfaces/route.ts
index 6c94cb1a80..06ebc52315 100644
--- a/frontend/src/interfaces/route.ts
+++ b/frontend/src/interfaces/route.ts
@@ -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;
diff --git a/frontend/src/interfaces/token.ts b/frontend/src/interfaces/token.ts
new file mode 100644
index 0000000000..3507a80072
--- /dev/null
+++ b/frontend/src/interfaces/token.ts
@@ -0,0 +1,5 @@
+export enum TokenType {
+ ADMIN = 'ADMIN',
+ CLIENT = 'CLIENT',
+ FRONTEND = 'FRONTEND',
+}
diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts
index 1a0e65563b..0383ac9674 100644
--- a/frontend/src/interfaces/uiConfig.ts
+++ b/frontend/src/interfaces/uiConfig.ts
@@ -16,6 +16,7 @@ export interface IUiConfig {
toast?: IProclamationToast;
segmentValuesLimit?: number;
strategySegmentsLimit?: number;
+ frontendApiOrigins?: string[];
embedProxy?: boolean;
}