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 ( +
+ + + setValue(event.target.value)} + multiline + rows={12} + variant="outlined" + fullWidth + InputProps={{ + style: { fontFamily: 'monospace', fontSize: '0.8em' }, + }} + /> + + +
+ ); +}; + +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; }