1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-23 01:16:27 +02:00

Merge branch 'main' into refactor/applications

This commit is contained in:
Youssef Khedher 2022-02-08 13:44:42 +01:00 committed by GitHub
commit f58c284f70
85 changed files with 1104 additions and 1317 deletions

View File

@ -76,6 +76,7 @@
"react-dnd": "14.0.5",
"react-dnd-html5-backend": "14.1.0",
"react-dom": "17.0.2",
"react-hooks-global-state": "^1.0.2",
"react-outside-click-handler": "1.3.0",
"react-redux": "7.2.6",
"react-router-dom": "5.3.0",

View File

@ -31,6 +31,7 @@ import Dialogue from '../../../common/Dialogue';
import { CREATE_API_TOKEN_BUTTON } from '../../../../testIds';
import { Alert } from '@material-ui/lab';
import copy from 'copy-to-clipboard';
import { useLocationSettings } from "../../../../hooks/useLocationSettings";
interface IApiToken {
createdAt: Date;
@ -41,16 +42,13 @@ interface IApiToken {
environment: string;
}
interface IApiTokenList {
location: any;
}
const ApiTokenList = ({ location }: IApiTokenList) => {
const ApiTokenList = () => {
const styles = useStyles();
const { hasAccess } = useContext(AccessContext);
const { uiConfig } = useUiConfig();
const [showDelete, setShowDelete] = useState(false);
const [delToken, setDeleteToken] = useState<IApiToken>();
const { locationSettings } = useLocationSettings()
const { setToastData } = useToast();
const { tokens, loading, refetch, error } = useApiTokens();
const { deleteToken } = useApiTokensApi();
@ -150,7 +148,7 @@ const ApiTokenList = ({ location }: IApiTokenList) => {
>
{formatDateWithLocale(
item.createdAt,
location.locale
locationSettings.locale
)}
</TableCell>
<TableCell

View File

@ -5,7 +5,7 @@ import AdminMenu from '../menu/AdminMenu';
import usePermissions from '../../../hooks/usePermissions';
import ConditionallyRender from '../../common/ConditionallyRender';
const ApiPage = ({ history, location }) => {
const ApiPage = ({ history }) => {
const { isAdmin } = usePermissions();
return (
@ -14,7 +14,7 @@ const ApiPage = ({ history, location }) => {
condition={isAdmin()}
show={<AdminMenu history={history} />}
/>
<ApiTokenList location={location} />
<ApiTokenList />
</div>
);
};
@ -22,7 +22,6 @@ const ApiPage = ({ history, location }) => {
ApiPage.propTypes = {
match: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
location: PropTypes.object.isRequired,
};
export default ApiPage;

View File

@ -1,16 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
import AdminMenu from '../menu/AdminMenu';
import { Alert } from '@material-ui/lab';
import GoogleAuth from './google-auth-container';
import SamlAuth from './saml-auth-container';
import OidcAuth from './oidc-auth-container';
import PasswordAuthSettings from './PasswordAuthSettings';
import TabNav from '../../common/TabNav/TabNav';
import PageContent from '../../common/PageContent/PageContent';
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
import { OidcAuth } from './OidcAuth/OidcAuth';
import { SamlAuth } from './SamlAuth/SamlAuth';
import { PasswordAuth } from './PasswordAuth/PasswordAuth';
import { GoogleAuth } from './GoogleAuth/GoogleAuth';
export const AuthSettings = () => {
const { authenticationType } = useUiConfig().uiConfig;
function AdminAuthPage({ authenticationType, history }) {
const tabs = [
{
label: 'OpenID Connect',
@ -22,7 +24,7 @@ function AdminAuthPage({ authenticationType, history }) {
},
{
label: 'Password',
component: <PasswordAuthSettings />,
component: <PasswordAuth />,
},
{
label: 'Google',
@ -32,7 +34,7 @@ function AdminAuthPage({ authenticationType, history }) {
return (
<div>
<AdminMenu history={history} />
<AdminMenu />
<PageContent headerContent="Single Sign-On">
<ConditionallyRender
condition={authenticationType === 'enterprise'}
@ -80,12 +82,4 @@ function AdminAuthPage({ authenticationType, history }) {
</PageContent>
</div>
);
}
AdminAuthPage.propTypes = {
match: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
authenticationType: PropTypes.string,
};
export default AdminAuthPage;

View File

@ -1,102 +1,119 @@
import React, { ChangeEvent, Fragment } from 'react';
import { FormControl, Grid, MenuItem, Switch, TextField, Select, InputLabel, FormControlLabel } from '@material-ui/core';
import {
FormControl,
FormControlLabel,
Grid,
InputLabel,
MenuItem,
Select,
Switch,
TextField,
} from '@material-ui/core';
interface Props {
interface Props {
data?: {
enabled: boolean;
autoCreate: boolean;
defaultRootRole?: string;
emailDomains?: string;
};
setValue: (name: string, value: string | boolean) => void;
}
function AutoCreateForm({ data = { enabled: false, autoCreate: false }, setValue }: Props) {
export const AutoCreateForm = ({
data = { enabled: false, autoCreate: false },
setValue,
}: Props) => {
const updateAutoCreate = () => {
setValue('autoCreate', !data.autoCreate);
}
};
const updateDefaultRootRole = (evt: ChangeEvent<{ name?: string; value: unknown }>) => {
const updateDefaultRootRole = (
evt: ChangeEvent<{ name?: string; value: unknown }>
) => {
setValue('defaultRootRole', evt.target.value as string);
}
};
const updateField = (e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.name, e.target.value);
}
};
return (
<Fragment>
<Grid container spacing={3}>
<Grid item md={5}>
<strong>Auto-create users</strong>
<p>
Enable automatic creation of new users when signing in.
</p>
return (
<Fragment>
<Grid container spacing={3}>
<Grid item md={5}>
<strong>Auto-create users</strong>
<p>
Enable automatic creation of new users when signing in.
</p>
</Grid>
<Grid item md={6} style={{ padding: '20px' }}>
<FormControlLabel
control={
<Switch
onChange={updateAutoCreate}
name="enabled"
checked={data.autoCreate}
disabled={!data.enabled}
/>
}
label="Auto-create users"
/>
</Grid>
</Grid>
<Grid item md={6} style={{ padding: '20px' }}>
<FormControlLabel
control={ <Switch
onChange={updateAutoCreate}
name="enabled"
checked={data.autoCreate}
disabled={!data.enabled}
/>}
label="Auto-create users"
/>
<Grid container spacing={3}>
<Grid item md={5}>
<strong>Default Root Role</strong>
<p>
Choose which root role the user should get when no
explicit role mapping exists.
</p>
</Grid>
<Grid item md={6}>
<FormControl style={{ minWidth: '200px' }}>
<InputLabel id="defaultRootRole-label">
Default Role
</InputLabel>
<Select
labelId="defaultRootRole-label"
id="defaultRootRole"
name="defaultRootRole"
disabled={!data.autoCreate || !data.enabled}
value={data.defaultRootRole || 'Editor'}
onChange={updateDefaultRootRole}
>
{/*consider these from API or constants. */}
<MenuItem value="Viewer">Viewer</MenuItem>
<MenuItem value="Editor">Editor</MenuItem>
<MenuItem value="Admin">Admin</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
</Grid>
<Grid container spacing={3}>
<Grid item md={5}>
<strong>Default Root Role</strong>
<p>
Choose which root role the user should get when no explicit role mapping exists.
</p>
<Grid container spacing={3}>
<Grid item md={5}>
<strong>Email domains</strong>
<p>
Comma separated list of email domains that should be
allowed to sign in.
</p>
</Grid>
<Grid item md={6}>
<TextField
onChange={updateField}
label="Email domains"
name="emailDomains"
disabled={!data.autoCreate || !data.enabled}
required={!!data.autoCreate}
value={data.emailDomains || ''}
placeholder="@company.com, @anotherCompany.com"
style={{ width: '400px' }}
rows={2}
variant="outlined"
size="small"
/>
</Grid>
</Grid>
<Grid item md={6}>
<FormControl style={{minWidth: '200px'}}>
<InputLabel id="defaultRootRole-label">Default Role</InputLabel>
<Select
labelId="defaultRootRole-label"
id="defaultRootRole"
name="defaultRootRole"
disabled={!data.autoCreate || !data.enabled}
value={data.defaultRootRole || 'Editor'}
onChange={updateDefaultRootRole}
>
{/*consider these from API or constants. */}
<MenuItem value='Viewer'>Viewer</MenuItem>
<MenuItem value='Editor'>Editor</MenuItem>
<MenuItem value='Admin'>Admin</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
<Grid container spacing={3}>
<Grid item md={5}>
<strong>Email domains</strong>
<p>
Comma separated list of email domains
that should be allowed to sign in.
</p>
</Grid>
<Grid item md={6}>
<TextField
onChange={updateField}
label="Email domains"
name="emailDomains"
disabled={!data.autoCreate || !data.enabled}
required={!!data.autoCreate}
value={data.emailDomains || ''}
placeholder="@company.com, @anotherCompany.com"
style={{ width: '400px' }}
rows={2}
variant="outlined"
size="small"
/>
</Grid>
</Grid>
</Fragment>);
}
export default AutoCreateForm;
</Fragment>
);
};

View File

@ -1,5 +1,4 @@
import React, { useState, useEffect, useContext } from 'react';
import PropTypes from 'prop-types';
import React, { useContext, useEffect, useState } from 'react';
import {
Button,
FormControlLabel,
@ -8,30 +7,32 @@ import {
TextField,
} from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import PageContent from '../../common/PageContent/PageContent';
import AccessContext from '../../../contexts/AccessContext';
import { ADMIN } from '../../providers/AccessProvider/permissions';
import PageContent from '../../../common/PageContent/PageContent';
import AccessContext from '../../../../contexts/AccessContext';
import { ADMIN } from '../../../providers/AccessProvider/permissions';
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
import useAuthSettings from '../../../../hooks/api/getters/useAuthSettings/useAuthSettings';
import useAuthSettingsApi from '../../../../hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi';
import useToast from '../../../../hooks/useToast';
import { formatUnknownError } from '../../../../utils/format-unknown-error';
import { removeEmptyStringFields } from '../../../../utils/remove-empty-string-fields';
const initialState = {
enabled: false,
autoCreate: false,
unleashHostname: location.hostname,
clientId: '',
clientSecret: '',
emailDomains: '',
};
function GoogleAuth({
config,
getGoogleConfig,
updateGoogleConfig,
unleashUrl,
}) {
export const GoogleAuth = () => {
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const [data, setData] = useState(initialState);
const [info, setInfo] = useState();
const { hasAccess } = useContext(AccessContext);
useEffect(() => {
getGoogleConfig();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { config } = useAuthSettings('google');
const { updateSettings, errors, loading } = useAuthSettingsApi('google');
useEffect(() => {
if (config.clientId) {
@ -43,10 +44,10 @@ function GoogleAuth({
return <span>You need admin privileges to access this section.</span>;
}
const updateField = e => {
const updateField = (event: React.ChangeEvent<HTMLInputElement>) => {
setData({
...data,
[e.target.name]: e.target.value,
[event.target.name]: event.target.value,
});
};
@ -58,19 +59,22 @@ function GoogleAuth({
setData({ ...data, autoCreate: !data.autoCreate });
};
const onSubmit = async e => {
e.preventDefault();
setInfo('...saving');
const onSubmit = async (event: React.SyntheticEvent) => {
event.preventDefault();
try {
await updateGoogleConfig(data);
setInfo('Settings stored');
setTimeout(() => setInfo(''), 2000);
} catch (e) {
setInfo(e.message);
await updateSettings(removeEmptyStringFields(data));
setToastData({
title: 'Settings stored',
type: 'success',
});
} catch (err) {
setToastApiError(formatUnknownError(err));
}
};
return (
<PageContent>
<PageContent headerContent="">
<Grid container style={{ marginBottom: '1rem' }}>
<Grid item xs={12}>
<Alert severity="info">
@ -84,7 +88,7 @@ function GoogleAuth({
</a>{' '}
to learn how to integrate with Google OAuth 2.0. <br />
Callback URL:{' '}
<code>{unleashUrl}/auth/google/callback</code>
<code>{uiConfig.unleashUrl}/auth/google/callback</code>
</Alert>
</Grid>
</Grid>
@ -125,7 +129,7 @@ function GoogleAuth({
label="Client ID"
name="clientId"
placeholder=""
value={data.clientId || ''}
value={data.clientId}
style={{ width: '400px' }}
variant="outlined"
size="small"
@ -146,7 +150,7 @@ function GoogleAuth({
onChange={updateField}
label="Client Secret"
name="clientSecret"
value={data.clientSecret || ''}
value={data.clientSecret}
placeholder=""
style={{ width: '400px' }}
variant="outlined"
@ -195,9 +199,7 @@ function GoogleAuth({
onChange={updateAutoCreate}
name="enabled"
checked={data.autoCreate}
>
Auto-create users
</Switch>
/>
</Grid>
</Grid>
<Grid container spacing={3}>
@ -229,22 +231,18 @@ function GoogleAuth({
variant="contained"
color="primary"
type="submit"
disabled={loading}
>
Save
</Button>{' '}
<small>{info}</small>
<p>
<small style={{ color: 'red' }}>
{errors?.message}
</small>
</p>
</Grid>
</Grid>
</form>
</PageContent>
);
}
GoogleAuth.propTypes = {
config: PropTypes.object,
unleashUrl: PropTypes.string,
getGoogleConfig: PropTypes.func.isRequired,
updateGoogleConfig: PropTypes.func.isRequired,
};
export default GoogleAuth;

View File

@ -1,5 +1,4 @@
import React, { useState, useEffect, useContext } from 'react';
import PropTypes from 'prop-types';
import React, { useContext, useEffect, useState } from 'react';
import {
Button,
FormControlLabel,
@ -8,34 +7,40 @@ import {
TextField,
} from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import PageContent from '../../common/PageContent/PageContent';
import AccessContext from '../../../contexts/AccessContext';
import { ADMIN } from '../../providers/AccessProvider/permissions';
import AutoCreateForm from './AutoCreateForm/AutoCreateForm';
import PageContent from '../../../common/PageContent/PageContent';
import AccessContext from '../../../../contexts/AccessContext';
import { ADMIN } from '../../../providers/AccessProvider/permissions';
import { AutoCreateForm } from '../AutoCreateForm/AutoCreateForm';
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
import useAuthSettingsApi from '../../../../hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi';
import useAuthSettings from '../../../../hooks/api/getters/useAuthSettings/useAuthSettings';
import useToast from '../../../../hooks/useToast';
import { formatUnknownError } from '../../../../utils/format-unknown-error';
import { removeEmptyStringFields } from '../../../../utils/remove-empty-string-fields';
const initialState = {
enabled: false,
enableSingleSignOut: false,
autoCreate: false,
unleashHostname: location.hostname,
clientId: '',
discoverUrl: '',
secret: '',
acrValues: '',
};
function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
export const OidcAuth = () => {
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const [data, setData] = useState(initialState);
const [info, setInfo] = useState();
const [error, setError] = useState();
const { hasAccess } = useContext(AccessContext);
useEffect(() => {
getOidcConfig();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { config } = useAuthSettings('oidc');
const { updateSettings, errors, loading } = useAuthSettingsApi('oidc');
useEffect(() => {
if (config.discoverUrl) {
setData(config);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
if (!hasAccess(ADMIN)) {
@ -46,8 +51,8 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
);
}
const updateField = e => {
setValue(e.target.name, e.target.value);
const updateField = (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.name, event.target.value);
};
const updateEnabled = () => {
@ -58,28 +63,29 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
setData({ ...data, enableSingleSignOut: !data.enableSingleSignOut });
};
const setValue = (field, value) => {
const setValue = (name: string, value: string | boolean) => {
setData({
...data,
[field]: value,
[name]: value,
});
};
const onSubmit = async e => {
e.preventDefault();
setInfo('...saving');
setError('');
const onSubmit = async (event: React.SyntheticEvent) => {
event.preventDefault();
try {
await updateOidcConfig(data);
setInfo('Settings stored');
setTimeout(() => setInfo(''), 2000);
} catch (e) {
setInfo('');
setError(e.message);
await updateSettings(removeEmptyStringFields(data));
setToastData({
title: 'Settings stored',
type: 'success',
});
} catch (err) {
setToastApiError(formatUnknownError(err));
}
};
return (
<PageContent>
<PageContent headerContent="">
<Grid container style={{ marginBottom: '1rem' }}>
<Grid item md={12}>
<Alert severity="info">
@ -94,7 +100,7 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
to learn how to integrate with specific Open Id Connect
providers (Okta, Keycloak, Google, etc). <br />
Callback URL:{' '}
<code>{unleashUrl}/auth/oidc/callback</code>
<code>{uiConfig.unleashUrl}/auth/oidc/callback</code>
</Alert>
</Grid>
</Grid>
@ -128,7 +134,7 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
onChange={updateField}
label="Discover URL"
name="discoverUrl"
value={data.discoverUrl || ''}
value={data.discoverUrl}
disabled={!data.enabled}
style={{ width: '400px' }}
variant="outlined"
@ -146,7 +152,7 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
onChange={updateField}
label="Client ID"
name="clientId"
value={data.clientId || ''}
value={data.clientId}
disabled={!data.enabled}
style={{ width: '400px' }}
variant="outlined"
@ -167,7 +173,7 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
onChange={updateField}
label="Client Secret"
name="secret"
value={data.secret || ''}
value={data.secret}
disabled={!data.enabled}
style={{ width: '400px' }}
variant="outlined"
@ -180,7 +186,10 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
<Grid container spacing={3}>
<Grid item md={5}>
<strong>Enable Single Sign-Out</strong>
<p>If you enable Single Sign-Out Unleash will redirect the user to the IDP as part of the Sign-out process.</p>
<p>
If you enable Single Sign-Out Unleash will redirect
the user to the IDP as part of the Sign-out process.
</p>
</Grid>
<Grid item md={6} style={{ padding: '20px' }}>
<FormControlLabel
@ -204,15 +213,21 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
<Grid container spacing={3}>
<Grid item md={5}>
<strong>ACR Values</strong>
<p>Requested Authentication Context Class Reference values. If multiple values are specified they should be "space" separated. Will be sent as "acr_values" as
part of the authentication request. Unleash will validate the acr value in the id token claims against the list of acr values.</p>
<p>
Requested Authentication Context Class Reference
values. If multiple values are specified they should
be "space" separated. Will be sent as "acr_values"
as part of the authentication request. Unleash will
validate the acr value in the id token claims
against the list of acr values.
</p>
</Grid>
<Grid item md={6}>
<TextField
onChange={updateField}
label="ACR Values"
name="acrValues"
value={data.acrValues || ''}
value={data.acrValues}
disabled={!data.enabled}
style={{ width: '400px' }}
variant="outlined"
@ -229,23 +244,18 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
variant="contained"
color="primary"
type="submit"
disabled={loading}
>
Save
</Button>{' '}
<small>{info}</small>
<small style={{ color: 'red' }}>{error}</small>
<p>
<small style={{ color: 'red' }}>
{errors?.message}
</small>
</p>
</Grid>
</Grid>
</form>
</PageContent>
);
}
OidcAuth.propTypes = {
config: PropTypes.object,
unleash: PropTypes.string,
getOidcConfig: PropTypes.func.isRequired,
updateOidcConfig: PropTypes.func.isRequired,
};
export default OidcAuth;

View File

@ -1,30 +1,28 @@
import React, { useState, useContext, useEffect } from 'react';
import {
Button,
FormControlLabel,
Grid,
Switch,
} from '@material-ui/core';
import React, { useContext, useEffect, useState } from 'react';
import { Button, FormControlLabel, Grid, Switch } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import PageContent from '../../common/PageContent/PageContent';
import AccessContext from '../../../contexts/AccessContext';
import { ADMIN } from '../../providers/AccessProvider/permissions';
import useAuthSettings from '../../../hooks/api/getters/useAuthSettings/useAuthSettings';
import useAuthSettingsApi, {ISimpleAuthSettings } from '../../../hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi';
import useToast from '../../../hooks/useToast';
import PageContent from '../../../common/PageContent/PageContent';
import AccessContext from '../../../../contexts/AccessContext';
import { ADMIN } from '../../../providers/AccessProvider/permissions';
import useAuthSettings from '../../../../hooks/api/getters/useAuthSettings/useAuthSettings';
import useAuthSettingsApi, {
ISimpleAuthSettings,
} from '../../../../hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi';
import useToast from '../../../../hooks/useToast';
import { formatUnknownError } from '../../../../utils/format-unknown-error';
const PasswordAuthSettings = () => {
const { setToastData } = useToast();
export const PasswordAuth = () => {
const { setToastData, setToastApiError } = useToast();
const { config } = useAuthSettings('simple');
const [disablePasswordAuth, setDisablePasswordAuth] = useState<boolean>(false);
const { updateSettings, errors, loading } = useAuthSettingsApi<ISimpleAuthSettings>('simple')
const [disablePasswordAuth, setDisablePasswordAuth] =
useState<boolean>(false);
const { updateSettings, errors, loading } =
useAuthSettingsApi<ISimpleAuthSettings>('simple');
const { hasAccess } = useContext(AccessContext);
useEffect(() => {
setDisablePasswordAuth(!!config.disabled);
}, [ config.disabled ]);
}, [config.disabled]);
if (!hasAccess(ADMIN)) {
return (
@ -38,12 +36,13 @@ const PasswordAuthSettings = () => {
setDisablePasswordAuth(!disablePasswordAuth);
};
const onSubmit = async evt => {
evt.preventDefault();
const onSubmit = async (event: React.SyntheticEvent) => {
event.preventDefault();
try {
const settings: ISimpleAuthSettings = { disabled: disablePasswordAuth };
const settings: ISimpleAuthSettings = {
disabled: disablePasswordAuth,
};
await updateSettings(settings);
setToastData({
title: 'Successfully saved',
@ -52,20 +51,13 @@ const PasswordAuthSettings = () => {
type: 'success',
show: true,
});
} catch (err: any) {
setToastData({
title: 'Could not store settings',
text: err?.message,
autoHideDuration: 4000,
type: 'error',
show: true,
});
setDisablePasswordAuth(config.disabled)
} catch (err) {
setToastApiError(formatUnknownError(err));
setDisablePasswordAuth(config.disabled);
}
};
return (
<PageContent headerContent=''>
<PageContent headerContent="">
<form onSubmit={onSubmit}>
<Grid container spacing={3}>
<Grid item md={5}>
@ -82,7 +74,9 @@ const PasswordAuthSettings = () => {
checked={!disablePasswordAuth}
/>
}
label={!disablePasswordAuth ? 'Enabled' : 'Disabled'}
label={
!disablePasswordAuth ? 'Enabled' : 'Disabled'
}
/>
</Grid>
</Grid>
@ -96,12 +90,14 @@ const PasswordAuthSettings = () => {
>
Save
</Button>{' '}
<p><small style={{ color: 'red' }}>{errors?.message}</small></p>
<p>
<small style={{ color: 'red' }}>
{errors?.message}
</small>
</p>
</Grid>
</Grid>
</form>
</PageContent>
);
}
export default PasswordAuthSettings;
};

View File

@ -1,5 +1,4 @@
import React, { useState, useEffect, useContext } from 'react';
import PropTypes from 'prop-types';
import React, { useContext, useEffect, useState } from 'react';
import {
Button,
FormControlLabel,
@ -8,32 +7,40 @@ import {
TextField,
} from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import PageContent from '../../common/PageContent/PageContent';
import AccessContext from '../../../contexts/AccessContext';
import { ADMIN } from '../../providers/AccessProvider/permissions';
import AutoCreateForm from './AutoCreateForm/AutoCreateForm';
import PageContent from '../../../common/PageContent/PageContent';
import AccessContext from '../../../../contexts/AccessContext';
import { ADMIN } from '../../../providers/AccessProvider/permissions';
import { AutoCreateForm } from '../AutoCreateForm/AutoCreateForm';
import useToast from '../../../../hooks/useToast';
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
import useAuthSettings from '../../../../hooks/api/getters/useAuthSettings/useAuthSettings';
import useAuthSettingsApi from '../../../../hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi';
import { formatUnknownError } from '../../../../utils/format-unknown-error';
import { removeEmptyStringFields } from '../../../../utils/remove-empty-string-fields';
const initialState = {
enabled: false,
autoCreate: false,
unleashHostname: location.hostname,
entityId: '',
signOnUrl: '',
certificate: '',
signOutUrl: '',
spCertificate: '',
};
function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
export const SamlAuth = () => {
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const [data, setData] = useState(initialState);
const [info, setInfo] = useState();
const { hasAccess } = useContext(AccessContext);
useEffect(() => {
getSamlConfig();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { config } = useAuthSettings('saml');
const { updateSettings, errors, loading } = useAuthSettingsApi('saml');
useEffect(() => {
if (config.entityId) {
setData(config);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
if (!hasAccess(ADMIN)) {
@ -44,34 +51,37 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
);
}
const updateField = e => {
setValue(e.target.name, e.target.value);
const updateField = (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.name, event.target.value);
};
const updateEnabled = () => {
setData({ ...data, enabled: !data.enabled });
};
const setValue = (field, value) => {
const setValue = (name: string, value: string | boolean) => {
setData({
...data,
[field]: value,
[name]: value,
});
};
const onSubmit = async e => {
e.preventDefault();
setInfo('...saving');
const onSubmit = async (event: React.SyntheticEvent) => {
event.preventDefault();
try {
await updateSamlConfig(data);
setInfo('Settings stored');
setTimeout(() => setInfo(''), 2000);
} catch (e) {
setInfo(e.message);
await updateSettings(removeEmptyStringFields(data));
setToastData({
title: 'Settings stored',
type: 'success',
});
} catch (err) {
setToastApiError(formatUnknownError(err));
}
};
return (
<PageContent>
<PageContent headerContent="">
<Grid container style={{ marginBottom: '1rem' }}>
<Grid item md={12}>
<Alert severity="info">
@ -86,7 +96,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
to learn how to integrate with specific SAML 2.0
providers (Okta, Keycloak, etc). <br />
Callback URL:{' '}
<code>{unleashUrl}/auth/saml/callback</code>
<code>{uiConfig.unleashUrl}/auth/saml/callback</code>
</Alert>
</Grid>
</Grid>
@ -120,7 +130,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
onChange={updateField}
label="Entity ID"
name="entityId"
value={data.entityId || ''}
value={data.entityId}
disabled={!data.enabled}
style={{ width: '400px' }}
variant="outlined"
@ -142,7 +152,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
onChange={updateField}
label="Single Sign-On URL"
name="signOnUrl"
value={data.signOnUrl || ''}
value={data.signOnUrl}
disabled={!data.enabled}
style={{ width: '400px' }}
variant="outlined"
@ -164,7 +174,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
onChange={updateField}
label="X.509 Certificate"
name="certificate"
value={data.certificate || ''}
value={data.certificate}
disabled={!data.enabled}
style={{ width: '100%' }}
InputProps={{
@ -196,7 +206,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
onChange={updateField}
label="Single Sign-out URL"
name="signOutUrl"
value={data.signOutUrl || ''}
value={data.signOutUrl}
disabled={!data.enabled}
style={{ width: '400px' }}
variant="outlined"
@ -219,7 +229,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
onChange={updateField}
label="X.509 Certificate"
name="spCertificate"
value={data.spCertificate || ''}
value={data.spCertificate}
disabled={!data.enabled}
style={{ width: '100%' }}
InputProps={{
@ -243,22 +253,18 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
variant="contained"
color="primary"
type="submit"
disabled={loading}
>
Save
</Button>{' '}
<small>{info}</small>
<p>
<small style={{ color: 'red' }}>
{errors?.message}
</small>
</p>
</Grid>
</Grid>
</form>
</PageContent>
);
}
SamlAuth.propTypes = {
config: PropTypes.object,
unleash: PropTypes.string,
getSamlConfig: PropTypes.func.isRequired,
updateSamlConfig: PropTypes.func.isRequired,
};
export default SamlAuth;

View File

@ -1,12 +0,0 @@
import { connect } from 'react-redux';
import GoogleAuth from './google-auth';
import { getGoogleConfig, updateGoogleConfig } from '../../../store/e-admin-auth/actions';
const mapStateToProps = state => ({
config: state.authAdmin.get('google'),
unleashUrl: state.uiConfig.toJS().unleashUrl,
});
const Container = connect(mapStateToProps, { getGoogleConfig, updateGoogleConfig })(GoogleAuth);
export default Container;

View File

@ -1,10 +0,0 @@
import { connect } from 'react-redux';
import component from './authentication';
const mapStateToProps = state => ({
authenticationType: state.uiConfig.toJS().authenticationType,
});
const Container = connect(mapStateToProps, { })(component);
export default Container;

View File

@ -1,12 +0,0 @@
import { connect } from 'react-redux';
import OidcAuth from './oidc-auth';
import { getOidcConfig, updateOidcConfig } from '../../../store/e-admin-auth/actions';
const mapStateToProps = state => ({
config: state.authAdmin.get('oidc'),
unleashUrl: state.uiConfig.toJS().unleashUrl,
});
const OidcContainer = connect(mapStateToProps, { getOidcConfig, updateOidcConfig })(OidcAuth);
export default OidcContainer;

View File

@ -1,12 +0,0 @@
import { connect } from 'react-redux';
import SamlAuth from './saml-auth';
import { getSamlConfig, updateSamlConfig } from '../../../store/e-admin-auth/actions';
const mapStateToProps = state => ({
config: state.authAdmin.get('saml'),
unleashUrl: state.uiConfig.toJS().unleashUrl,
});
const Container = connect(mapStateToProps, { getSamlConfig, updateSamlConfig })(SamlAuth);
export default Container;

View File

@ -14,15 +14,15 @@ import HeaderTitle from '../../common/HeaderTitle';
import ConditionallyRender from '../../common/ConditionallyRender';
import { formatApiPath } from '../../../utils/format-path';
import useInvoices from '../../../hooks/api/getters/useInvoices/useInvoices';
import { useLocation } from 'react-router-dom';
import { IInvoice } from '../../../interfaces/invoice';
import { useLocationSettings } from '../../../hooks/useLocationSettings';
const PORTAL_URL = formatApiPath('api/admin/invoices/portal');
const InvoiceList = () => {
const { refetchInvoices, invoices } = useInvoices();
const [isLoaded, setLoaded] = useState(false);
const location = useLocation();
const { locationSettings } = useLocationSettings();
useEffect(() => {
refetchInvoices();
@ -89,7 +89,7 @@ const InvoiceList = () => {
{item.dueDate &&
formatDateWithLocale(
item.dueDate,
location.locale
locationSettings.locale
)}
</TableCell>
<TableCell

View File

@ -1,6 +1,6 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { Paper, Tabs, Tab } from '@material-ui/core';
import { NavLink, useLocation } from 'react-router-dom';
import { Paper, Tab, Tabs } from '@material-ui/core';
import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
const navLinkStyle = {
@ -13,18 +13,17 @@ const navLinkStyle = {
padding: '0.8rem 1.5rem',
};
const activeNavLinkStyle = {
const activeNavLinkStyle: React.CSSProperties = {
fontWeight: 'bold',
borderRadius: '3px',
padding: '0.8rem 1.5rem',
};
function AdminMenu({ history }) {
function AdminMenu() {
const { uiConfig } = useUiConfig();
const { pathname } = useLocation();
const { flags } = uiConfig;
const { location } = history;
const { pathname } = location;
return (
<Paper
style={{
@ -45,7 +44,7 @@ function AdminMenu({ history }) {
<span>Users</span>
</NavLink>
}
></Tab>
/>
{flags.RE && (
<Tab
value="/admin/roles"
@ -58,7 +57,7 @@ function AdminMenu({ history }) {
<span>PROJECT ROLES</span>
</NavLink>
}
></Tab>
/>
)}
<Tab
@ -72,7 +71,7 @@ function AdminMenu({ history }) {
API Access
</NavLink>
}
></Tab>
/>
<Tab
value="/admin/auth"
label={
@ -84,7 +83,7 @@ function AdminMenu({ history }) {
Single Sign-On
</NavLink>
}
></Tab>
/>
</Tabs>
</Paper>
);

View File

@ -14,6 +14,7 @@ import AccessContext from '../../../../../contexts/AccessContext';
import { IUser } from '../../../../../interfaces/user';
import { useStyles } from './UserListItem.styles';
import { useHistory } from 'react-router-dom';
import { ILocationSettings } from "../../../../../hooks/useLocationSettings";
interface IUserListItemProps {
user: IUser;
@ -21,11 +22,7 @@ interface IUserListItemProps {
openUpdateDialog: (user: IUser) => (e: SyntheticEvent) => void;
openPwDialog: (user: IUser) => (e: SyntheticEvent) => void;
openDelDialog: (user: IUser) => (e: SyntheticEvent) => void;
location: ILocation;
}
interface ILocation {
locale: string;
locationSettings: ILocationSettings;
}
const UserListItem = ({
@ -34,7 +31,7 @@ const UserListItem = ({
openDelDialog,
openPwDialog,
openUpdateDialog,
location,
locationSettings,
}: IUserListItemProps) => {
const { hasAccess } = useContext(AccessContext);
const history = useHistory()
@ -54,7 +51,7 @@ const UserListItem = ({
</TableCell>
<TableCell>
<span data-loading>
{formatDateWithLocale(user.createdAt, location.locale)}
{formatDateWithLocale(user.createdAt, locationSettings.locale)}
</span>
</TableCell>
<TableCell className={styles.leftTableCell}>

View File

@ -20,10 +20,10 @@ import loadingData from './loadingData';
import useLoading from '../../../../hooks/useLoading';
import usePagination from '../../../../hooks/usePagination';
import PaginateUI from '../../../common/PaginateUI/PaginateUI';
import { useHistory } from 'react-router-dom';
import { IUser } from '../../../../interfaces/user';
import IRole from '../../../../interfaces/role';
import useToast from '../../../../hooks/useToast';
import { useLocationSettings } from "../../../../hooks/useLocationSettings";
const UsersList = () => {
const { users, roles, refetch, loading } = useUsers();
@ -35,9 +35,8 @@ const UsersList = () => {
userLoading,
userApiErrors,
} = useAdminUsersApi();
const history = useHistory();
const { location } = history;
const { hasAccess } = useContext(AccessContext);
const { locationSettings } = useLocationSettings()
const [pwDialog, setPwDialog] = useState<{ open: boolean; user?: IUser }>({
open: false,
});
@ -104,7 +103,7 @@ const UsersList = () => {
user={user}
openPwDialog={openPwDialog}
openDelDialog={openDelDialog}
location={location}
locationSettings={locationSettings}
renderRole={renderRole}
/>
));
@ -117,7 +116,7 @@ const UsersList = () => {
user={user}
openPwDialog={openPwDialog}
openDelDialog={openDelDialog}
location={location}
locationSettings={locationSettings}
renderRole={renderRole}
/>
);

View File

@ -19,6 +19,7 @@ test('renders correctly if no application', () => {
storeApplicationMetaData={jest.fn()}
deleteApplication={jest.fn()}
history={{}}
locationSettings={{ locale: 'en-GB' }}
/>
</AccessProvider>
)
@ -77,7 +78,7 @@ test('renders correctly without permission', () => {
url: 'http://example.org',
description: 'app description',
}}
location={{ locale: 'en-GB' }}
locationSettings={{ locale: 'en-GB' }}
/>
</AccessProvider>
</ThemeProvider>
@ -140,7 +141,7 @@ test('renders correctly with permissions', () => {
url: 'http://example.org',
description: 'app description',
}}
location={{ locale: 'en-GB' }}
locationSettings={{ locale: 'en-GB' }}
/>
</AccessProvider>
</ThemeProvider>

View File

@ -33,7 +33,7 @@ class ClientApplications extends PureComponent {
fetchApplication: PropTypes.func.isRequired,
appName: PropTypes.string,
application: PropTypes.object,
location: PropTypes.object,
locationSettings: PropTypes.object.isRequired,
storeApplicationMetaData: PropTypes.func.isRequired,
deleteApplication: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
@ -54,8 +54,8 @@ class ClientApplications extends PureComponent {
.finally(() => this.setState({ loading: false }));
}
formatFullDateTime = v =>
formatFullDateTimeWithLocale(v, this.props.location.locale);
formatDate = v => formatDateWithLocale(v, this.props.location.locale);
formatFullDateTimeWithLocale(v, this.props.locationSettings.locale);
formatDate = v => formatDateWithLocale(v, this.props.locationSettings.locale);
deleteApplication = async evt => {
evt.preventDefault();

View File

@ -1,23 +0,0 @@
import { connect } from 'react-redux';
import ApplicationEdit from './application-edit-component';
import { fetchApplication, storeApplicationMetaData, deleteApplication } from './../../store/application/actions';
const mapStateToProps = (state, props) => {
let application = state.applications.getIn(['apps', props.appName]);
const location = state.settings.toJS().location || {};
if (application) {
application = application.toJS();
}
return {
application,
location,
};
};
const Container = connect(mapStateToProps, {
fetchApplication,
storeApplicationMetaData,
deleteApplication,
})(ApplicationEdit);
export default Container;

View File

@ -0,0 +1,30 @@
import { connect } from 'react-redux';
import ApplicationEdit from './application-edit-component';
import {
deleteApplication,
fetchApplication,
storeApplicationMetaData,
} from '../../store/application/actions';
import { useLocationSettings } from '../../hooks/useLocationSettings';
const ApplicationEditContainer = props => {
const { locationSettings } = useLocationSettings();
return <ApplicationEdit {...props} locationSettings={locationSettings} />;
};
const mapStateToProps = (state, props) => {
let application = state.applications.getIn(['apps', props.appName]);
if (application) {
application = application.toJS();
}
return {
application,
};
};
export default connect(mapStateToProps, {
fetchApplication,
storeApplicationMetaData,
deleteApplication,
})(ApplicationEditContainer);

View File

@ -0,0 +1,36 @@
import { useFeaturesArchive } from '../../hooks/api/getters/useFeaturesArchive/useFeaturesArchive';
import FeatureToggleList from '../feature/FeatureToggleList/FeatureToggleList';
import useUiConfig from '../../hooks/api/getters/useUiConfig/useUiConfig';
import { useFeaturesFilter } from '../../hooks/useFeaturesFilter';
import { useFeatureArchiveApi } from '../../hooks/api/actions/useFeatureArchiveApi/useReviveFeatureApi';
import useToast from '../../hooks/useToast';
import { useFeaturesSort } from '../../hooks/useFeaturesSort';
export const ArchiveListContainer = () => {
const { setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const { reviveFeature } = useFeatureArchiveApi();
const { archivedFeatures, loading, refetchArchived } = useFeaturesArchive();
const { filtered, filter, setFilter } = useFeaturesFilter(archivedFeatures);
const { sorted, sort, setSort } = useFeaturesSort(filtered);
const revive = (feature: string) => {
reviveFeature(feature)
.then(refetchArchived)
.catch(e => setToastApiError(e.toString()));
};
return (
<FeatureToggleList
features={sorted}
loading={loading}
revive={revive}
flags={uiConfig.flags}
filter={filter}
setFilter={setFilter}
sort={sort}
setSort={setSort}
archive
/>
);
};

View File

@ -1,19 +0,0 @@
import { connect } from 'react-redux';
import FeatureListComponent from '../feature/FeatureToggleList/FeatureToggleList';
import { fetchArchive, revive } from './../../store/archive/actions';
import { updateSettingForGroup } from './../../store/settings/actions';
import { mapStateToPropsConfigurable } from '../feature/FeatureToggleList';
const mapStateToProps = mapStateToPropsConfigurable(false);
const mapDispatchToProps = {
fetcher: () => fetchArchive(),
revive,
updateSetting: updateSettingForGroup('feature'),
};
const ArchiveListContainer = connect(
mapStateToProps,
mapDispatchToProps
)(FeatureListComponent);
export default ArchiveListContainer;

View File

@ -1,37 +0,0 @@
.archiveList {
background-color: #fff;
color: rgba(0, 0, 0, 0.54);
align-items: center;
padding: 0 16px 0 18px;
}
.listItemToggle {
width: 40%;
flex-shrink: 0;
margin-right: 20%;
}
.listItemCreated {
width: 10%;
flex-shrink: 0;
margin-right: 2px;
}
.listItemRevive {
width: 5%;
flex-shrink: 0;
margin-right: 10%;
}
.toggleDetails {
font-size: 14px;
font-weight: 400;
line-height: 24px;
letter-spacing: 0;
line-height: 18px;
color: rgba(0, 0, 0, 0.54);
display: block;
padding: 0;
}
.strategiesList {
flex-shrink: 0;
float: right;
margin-left: 8px !important;
}

View File

@ -13,16 +13,21 @@ interface IPermissionSwitchProps extends OverridableComponent<any> {
checked: boolean;
}
const PermissionSwitch: React.FC<IPermissionSwitchProps> = ({
permission,
tooltip = '',
disabled,
projectId,
environmentId,
checked,
onChange,
...rest
}) => {
const PermissionSwitch = React.forwardRef<
HTMLButtonElement,
IPermissionSwitchProps
>((props, ref) => {
const {
permission,
tooltip = '',
disabled,
projectId,
environmentId,
checked,
onChange,
...rest
} = props;
const { hasAccess } = useContext(AccessContext);
let access;
@ -45,11 +50,12 @@ const PermissionSwitch: React.FC<IPermissionSwitchProps> = ({
onChange={onChange}
disabled={disabled || !access}
checked={checked}
ref={ref}
{...rest}
/>
</span>
</Tooltip>
);
};
});
export default PermissionSwitch;

View File

@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React from 'react';
import { MenuItem } from '@material-ui/core';
import PropTypes from 'prop-types';
import DropdownMenu from '../DropdownMenu/DropdownMenu';
@ -9,20 +9,8 @@ const ALL_PROJECTS = { id: '*', name: '> All projects' };
const ProjectSelect = ({ currentProjectId, updateCurrentProject, ...rest }) => {
const { projects } = useProjects();
useEffect(() => {
let currentProject = projects.find(i => i.id === currentProjectId);
if (currentProject) {
setProject(currentProject.id);
return;
}
setProject('*');
/* eslint-disable-next-line */
}, []);
const setProject = v => {
const id = typeof v === 'string' ? v.trim() : '';
const id = v && typeof v === 'string' ? v.trim() : '*';
updateCurrentProject(id);
};

View File

@ -3,9 +3,8 @@ import ProjectSelect from './ProjectSelect';
import { fetchProjects } from '../../../store/project/actions';
const mapStateToProps = (state, ownProps) => ({
...ownProps,
projects: state.projects.toJS(),
currentProjectId: ownProps.settings.currentProjectId || '*',
updateCurrentProject: id => ownProps.updateSetting('currentProjectId', id),
});
export default connect(mapStateToProps, { fetchProjects })(ProjectSelect);

View File

@ -66,6 +66,7 @@ const TabNav = ({
TabNav.propTypes = {
tabData: PropTypes.array.isRequired,
navClass: PropTypes.string,
className: PropTypes.string,
startingTab: PropTypes.number,
};

View File

@ -79,7 +79,7 @@ const CreateFeature = () => {
title="Create Feature toggle"
description="Feature toggles support different use cases, each with their own specific needs such as simple static routing or more complex routing.
The feature toggle is disabled when created and you decide when to enable"
documentationLink="https://docs.getunleash.io/"
documentationLink="https://docs.getunleash.io/advanced/feature_toggle_types"
formatApiCode={formatApiCode}
>
<FeatureForm

View File

@ -83,7 +83,7 @@ const EditFeature = () => {
title="Edit Feature toggle"
description="Feature toggles support different use cases, each with their own specific needs such as simple static routing or more complex routing.
The feature toggle is disabled when created and you decide when to enable"
documentationLink="https://docs.getunleash.io/"
documentationLink="https://docs.getunleash.io/advanced/feature_toggle_types"
formatApiCode={formatApiCode}
>
<FeatureForm

View File

@ -11,6 +11,7 @@ import ConditionallyRender from '../../common/ConditionallyRender';
import { trim } from '../../common/util';
import Input from '../../common/Input/Input';
import { CREATE_FEATURE } from '../../providers/AccessProvider/permissions';
import { useHistory } from 'react-router-dom';
interface IFeatureToggleForm {
type: string;
@ -22,8 +23,8 @@ interface IFeatureToggleForm {
setName: React.Dispatch<React.SetStateAction<string>>;
setDescription: React.Dispatch<React.SetStateAction<string>>;
setProject: React.Dispatch<React.SetStateAction<string>>;
validateToggleName: () => void;
setImpressionData: React.Dispatch<React.SetStateAction<boolean>>;
validateToggleName?: () => void;
handleSubmit: (e: any) => void;
handleCancel: () => void;
errors: { [key: string]: string };
@ -52,6 +53,7 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
}) => {
const styles = useStyles();
const { featureTypes } = useFeatureTypes();
const history = useHistory();
const { permissions } = useUser();
const editable = mode !== 'Edit';
@ -75,9 +77,7 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
onFocus={() => clearErrors()}
value={name}
onChange={e => setName(trim(e.target.value))}
inputProps={{
'data-test': CF_NAME_ID,
}}
data-test={CF_NAME_ID}
onBlur={validateToggleName}
/>
<p className={styles.inputDescription}>
@ -89,9 +89,7 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
label={'Toggle type'}
id="feature-type-select"
editable
inputProps={{
'data-test': CF_TYPE_ID,
}}
data-test={CF_TYPE_ID}
IconComponent={KeyboardArrowDownOutlined}
className={styles.selectInput}
/>
@ -108,7 +106,12 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
/>
<FeatureProjectSelect
value={project}
onChange={e => setProject(e.target.value)}
onChange={e => {
setProject(e.target.value);
history.replace(
`/projects/${e.target.value}/create-toggle`
);
}}
enabled={editable}
filter={projectFilterGenerator(
{ permissions },

View File

@ -1,8 +1,8 @@
import { useContext, useLayoutEffect, useEffect } from 'react';
import { useContext } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { Link } from 'react-router-dom';
import { Button, List, Tooltip, IconButton, ListItem } from '@material-ui/core';
import { Button, IconButton, List, ListItem, Tooltip } from '@material-ui/core';
import useMediaQuery from '@material-ui/core/useMediaQuery';
import { Add } from '@material-ui/icons';
@ -23,43 +23,31 @@ import { useStyles } from './styles';
import ListPlaceholder from '../../common/ListPlaceholder/ListPlaceholder';
import { getCreateTogglePath } from '../../../utils/route-path-helpers';
import { NAVIGATE_TO_CREATE_FEATURE } from '../../../testIds';
import { resolveFilteredProjectId } from '../../../hooks/useFeaturesFilter';
const FeatureToggleList = ({
fetcher,
features,
settings,
revive,
currentProjectId,
updateSetting,
featureMetrics,
toggleFeature,
archive,
loading,
flags,
filter,
setFilter,
sort,
setSort,
}) => {
const { hasAccess } = useContext(AccessContext);
const styles = useStyles();
const smallScreen = useMediaQuery('(max-width:800px)');
const mobileView = useMediaQuery('(max-width:600px)');
useLayoutEffect(() => {
fetcher();
}, [fetcher]);
useEffect(() => {
updateSetting('filter', '');
/* eslint-disable-next-line */
}, []);
const toggleMetrics = () => {
updateSetting('showLastHour', !settings.showLastHour);
const setFilterQuery = v => {
const query = v && typeof v === 'string' ? v.trim() : '';
setFilter(prev => ({ ...prev, query }));
};
const setSort = v => {
updateSetting('sort', typeof v === 'string' ? v.trim() : '');
};
const createURL = getCreateTogglePath(currentProjectId, flags.E);
const resolvedProjectId = resolveFilteredProjectId(filter);
const createURL = getCreateTogglePath(resolvedProjectId, flags.E);
const renderFeatures = () => {
features.forEach(e => {
@ -70,11 +58,7 @@ const FeatureToggleList = ({
return loadingFeatures.map(feature => (
<FeatureToggleListItem
key={feature.name}
settings={settings}
metricsLastHour={featureMetrics.lastHour[feature.name]}
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
feature={feature}
toggleFeature={toggleFeature}
revive={revive}
hasAccess={hasAccess}
className={'skeleton'}
@ -89,13 +73,7 @@ const FeatureToggleList = ({
show={features.map(feature => (
<FeatureToggleListItem
key={feature.name}
settings={settings}
metricsLastHour={featureMetrics.lastHour[feature.name]}
metricsLastMinute={
featureMetrics.lastMinute[feature.name]
}
feature={feature}
toggleFeature={toggleFeature}
revive={revive}
hasAccess={hasAccess}
flags={flags}
@ -129,7 +107,7 @@ const FeatureToggleList = ({
<div className={styles.featureContainer}>
<div className={styles.searchBarContainer}>
<SearchField
updateValue={updateSetting.bind(this, 'filter')}
updateValue={setFilterQuery}
className={classnames(styles.searchBar, {
skeleton: loading,
})}
@ -151,10 +129,10 @@ const FeatureToggleList = ({
condition={!smallScreen}
show={
<FeatureToggleListActions
settings={settings}
toggleMetrics={toggleMetrics}
filter={filter}
setFilter={setFilter}
sort={sort}
setSort={setSort}
updateSetting={updateSetting}
loading={loading}
/>
}
@ -175,7 +153,7 @@ const FeatureToggleList = ({
disabled={
!hasAccess(
CREATE_FEATURE,
currentProjectId
resolvedProjectId
)
}
>
@ -195,7 +173,7 @@ const FeatureToggleList = ({
disabled={
!hasAccess(
CREATE_FEATURE,
currentProjectId
resolvedProjectId
)
}
className={classnames({
@ -221,16 +199,14 @@ const FeatureToggleList = ({
FeatureToggleList.propTypes = {
features: PropTypes.array.isRequired,
featureMetrics: PropTypes.object.isRequired,
fetcher: PropTypes.func,
revive: PropTypes.func,
updateSetting: PropTypes.func.isRequired,
toggleFeature: PropTypes.func,
settings: PropTypes.object,
history: PropTypes.object.isRequired,
loading: PropTypes.bool,
currentProjectId: PropTypes.string.isRequired,
archive: PropTypes.bool,
flags: PropTypes.object,
filter: PropTypes.object.isRequired,
setFilter: PropTypes.func.isRequired,
sort: PropTypes.object.isRequired,
setSort: PropTypes.func.isRequired,
};
export default FeatureToggleList;

View File

@ -2,31 +2,21 @@ import React from 'react';
import PropTypes from 'prop-types';
import { MenuItem, Typography } from '@material-ui/core';
// import { HourglassEmpty, HourglassFull } from '@material-ui/icons';
// import { MenuItemWithIcon } from '../../../common';
import DropdownMenu from '../../../common/DropdownMenu/DropdownMenu';
import ProjectSelect from '../../../common/ProjectSelect';
import { useStyles } from './styles';
import useLoading from '../../../../hooks/useLoading';
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
import ConditionallyRender from '../../../common/ConditionallyRender';
import { createFeaturesFilterSortOptions } from '../../../../hooks/useFeaturesSort';
const sortingOptions = [
{ type: 'name', displayName: 'Name' },
{ type: 'type', displayName: 'Type' },
{ type: 'enabled', displayName: 'Enabled' },
{ type: 'stale', displayName: 'Stale' },
{ type: 'created', displayName: 'Created' },
{ type: 'Last seen', displayName: 'Last seen' },
{ type: 'project', displayName: 'Project' },
{ type: 'metrics', displayName: 'Metrics' },
];
const sortOptions = createFeaturesFilterSortOptions();
const FeatureToggleListActions = ({
settings,
filter,
setFilter,
sort,
setSort,
toggleMetrics,
updateSetting,
loading,
}) => {
const styles = useStyles();
@ -34,65 +24,33 @@ const FeatureToggleListActions = ({
const ref = useLoading(loading);
const handleSort = e => {
const target = e.target.getAttribute('data-target');
setSort(target);
const type = e.target.getAttribute('data-target')?.trim();
type && setSort(prev => ({ ...prev, type }));
};
const isDisabled = type => settings.sort === type;
const isDisabled = s => s === sort.type;
const selectedOption = sortOptions.find(o => o.type === sort.type) || sortOptions[0];
const renderSortingOptions = () =>
sortingOptions.map(option => (
sortOptions.map(option => (
<MenuItem
style={{ fontSize: '14px' }}
key={option.type}
disabled={isDisabled(option.type)}
data-target={option.type}
>
{option.displayName}
{option.name}
</MenuItem>
));
/*
const renderMetricsOptions = () => [
<MenuItemWithIcon
style={{ fontSize: '14px' }}
icon={HourglassEmpty}
disabled={!settings.showLastHour}
data-target="minute"
label="Last minute"
key={1}
/>,
<MenuItemWithIcon
style={{ fontSize: '14px' }}
icon={HourglassFull}
disabled={settings.showLastHour}
data-target="hour"
label="Last hour"
key={2}
/>,
];
*/
return (
<div className={styles.actions} ref={ref}>
<Typography variant="body2" data-loading>
Sorted by:
</Typography>
{/* }
<DropdownMenu
id={'metric'}
label={`Last ${settings.showLastHour ? 'hour' : 'minute'}`}
title="Metric interval"
callback={toggleMetrics}
renderOptions={renderMetricsOptions}
className=""
style={{ textTransform: 'lowercase', fontWeight: 'normal' }}
data-loading
/>
{*/}
<DropdownMenu
id={'sorting'}
label={`By ${settings.sort}`}
label={`By ${selectedOption.name}`}
callback={handleSort}
renderOptions={renderSortingOptions}
title="Sort by"
@ -104,8 +62,8 @@ const FeatureToggleListActions = ({
condition={uiConfig.flags.P}
show={
<ProjectSelect
settings={settings}
updateSetting={updateSetting}
currentProjectId={filter.project}
updateCurrentProject={project => setFilter(prev => ({ ...prev, project }))}
style={{
textTransform: 'lowercase',
fontWeight: 'normal',
@ -119,10 +77,11 @@ const FeatureToggleListActions = ({
};
FeatureToggleListActions.propTypes = {
settings: PropTypes.object,
filter: PropTypes.object,
setFilter: PropTypes.func,
sort: PropTypes.object,
setSort: PropTypes.func,
toggleMetrics: PropTypes.func,
updateSetting: PropTypes.func,
loading: PropTypes.bool,
};

View File

@ -0,0 +1,24 @@
import { useFeatures } from '../../../hooks/api/getters/useFeatures/useFeatures';
import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
import { useFeaturesFilter } from '../../../hooks/useFeaturesFilter';
import FeatureToggleList from './FeatureToggleList';
import { useFeaturesSort } from '../../../hooks/useFeaturesSort';
export const FeatureToggleListContainer = () => {
const { uiConfig } = useUiConfig();
const { features, loading } = useFeatures();
const { filtered, filter, setFilter } = useFeaturesFilter(features);
const { sorted, sort, setSort } = useFeaturesSort(filtered);
return (
<FeatureToggleList
features={sorted}
loading={loading}
flags={uiConfig.flags}
filter={filter}
setFilter={setFilter}
sort={sort}
setSort={setSort}
/>
);
};

View File

@ -22,10 +22,6 @@ import PermissionIconButton from '../../../common/PermissionIconButton/Permissio
const FeatureToggleListItem = ({
feature,
toggleFeature,
settings,
metricsLastHour = { yes: 0, no: 0, isFallback: true },
metricsLastMinute = { yes: 0, no: 0, isFallback: true },
revive,
hasAccess,
flags = {},
@ -164,10 +160,6 @@ const FeatureToggleListItem = ({
FeatureToggleListItem.propTypes = {
feature: PropTypes.object,
toggleFeature: PropTypes.func,
settings: PropTypes.object,
metricsLastHour: PropTypes.object,
metricsLastMinute: PropTypes.object,
revive: PropTypes.func,
hasAccess: PropTypes.func.isRequired,
flags: PropTypes.object,

View File

@ -1,26 +0,0 @@
import React, { memo } from 'react';
import { Chip } from '@material-ui/core';
import PropTypes from 'prop-types';
import { useStyles } from './styles';
const FeatureToggleListItemChip = ({ type, types, onClick }) => {
const styles = useStyles();
const typeObject = types.find(o => o.id === type) || {
id: type,
name: type,
};
return (
<Chip className={styles.typeChip} title={typeObject.description} label={typeObject.name} onClick={onClick} />
);
};
FeatureToggleListItemChip.propTypes = {
type: PropTypes.string.isRequired,
types: PropTypes.array,
onClick: PropTypes.func,
};
export default memo(FeatureToggleListItemChip);

View File

@ -1,10 +0,0 @@
import { connect } from 'react-redux';
import Component from './FeatureToggleListItemChip';
const mapStateToProps = state => ({
types: state.featureTypes.toJS(),
});
const FeatureType = connect(mapStateToProps)(Component);
export default FeatureType;

View File

@ -1,10 +0,0 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles(theme => ({
typeChip: {
margin: '0 8px',
background: 'transparent',
border: `1px solid ${theme.palette.primary.main}`,
color: theme.palette.primary.main,
},
}));

View File

@ -117,7 +117,7 @@ exports[`renders correctly with one feature 1`] = `
<span
className="MuiButton-label"
>
By name
By Name
<span
className="MuiButton-endIcon MuiButton-iconSizeMedium"
>
@ -185,12 +185,6 @@ exports[`renders correctly with one feature 1`] = `
}
flags={Object {}}
hasAccess={[Function]}
settings={
Object {
"sort": "name",
}
}
toggleFeature={[MockFunction]}
/>
</ul>
</div>
@ -315,7 +309,7 @@ exports[`renders correctly with one feature without permissions 1`] = `
<span
className="MuiButton-label"
>
By name
By Name
<span
className="MuiButton-endIcon MuiButton-iconSizeMedium"
>
@ -386,12 +380,6 @@ exports[`renders correctly with one feature without permissions 1`] = `
}
flags={Object {}}
hasAccess={[Function]}
settings={
Object {
"sort": "name",
}
}
toggleFeature={[MockFunction]}
/>
</ul>
</div>

View File

@ -7,8 +7,6 @@ import renderer from 'react-test-renderer';
import theme from '../../../../themes/main-theme';
jest.mock('../FeatureToggleListItem/FeatureToggleListItemChip');
test('renders correctly with one feature', () => {
const feature = {
name: 'Another',
@ -26,18 +24,12 @@ test('renders correctly with one feature', () => {
],
createdAt: '2018-02-04T20:27:52.127Z',
};
const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} };
const settings = { sort: 'name' };
const tree = renderer.create(
<MemoryRouter>
<ThemeProvider theme={theme}>
<FeatureToggleListItem
key={0}
settings={settings}
metricsLastHour={featureMetrics.lastHour[feature.name]}
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
feature={feature}
toggleFeature={jest.fn()}
hasAccess={() => true}
/>
</ThemeProvider>
@ -63,18 +55,12 @@ test('renders correctly with one feature without permission', () => {
],
createdAt: '2018-02-04T20:27:52.127Z',
};
const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} };
const settings = { sort: 'name' };
const tree = renderer.create(
<MemoryRouter>
<ThemeProvider theme={theme}>
<FeatureToggleListItem
key={0}
settings={settings}
metricsLastHour={featureMetrics.lastHour[feature.name]}
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
feature={feature}
toggleFeature={jest.fn()}
hasAccess={() => true}
/>
</ThemeProvider>

View File

@ -25,8 +25,7 @@ test('renders correctly with one feature', () => {
name: 'Another',
},
];
const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} };
const settings = { sort: 'name' };
const tree = renderer.create(
<MemoryRouter>
<ThemeProvider theme={theme}>
@ -35,13 +34,12 @@ test('renders correctly with one feature', () => {
>
<FeatureToggleList
updateSetting={jest.fn()}
settings={settings}
history={{}}
featureMetrics={featureMetrics}
filter={{}}
setFilter={jest.fn()}
sort={{}}
setSort={jest.fn()}
features={features}
toggleFeature={jest.fn()}
fetcher={jest.fn()}
currentProjectId="default"
flags={{}}
/>
</AccessProvider>
@ -58,8 +56,6 @@ test('renders correctly with one feature without permissions', () => {
name: 'Another',
},
];
const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} };
const settings = { sort: 'name' };
const tree = renderer.create(
<MemoryRouter>
<ThemeProvider theme={theme}>
@ -67,14 +63,12 @@ test('renders correctly with one feature without permissions', () => {
store={createFakeStore([{ permission: CREATE_FEATURE }])}
>
<FeatureToggleList
updateSetting={jest.fn()}
settings={settings}
history={{}}
featureMetrics={featureMetrics}
filter={{}}
setFilter={jest.fn()}
sort={{}}
setSort={jest.fn()}
features={features}
toggleFeature={jest.fn()}
fetcher={jest.fn()}
currentProjectId="default"
flags={{}}
/>
</AccessProvider>

View File

@ -1,151 +0,0 @@
import { connect } from 'react-redux';
import {
toggleFeature,
fetchFeatureToggles,
} from '../../../store/feature-toggle/actions';
import { updateSettingForGroup } from '../../../store/settings/actions';
import FeatureToggleList from './FeatureToggleList';
function checkConstraints(strategy, regex) {
if (!strategy.constraints) {
return;
}
return strategy.constraints.some(c => c.values.some(v => regex.test(v)));
}
function resolveCurrentProjectId(settings) {
if (!settings.currentProjectId || settings.currentProjectId === '*') {
return 'default';
}
return settings.currentProjectId;
}
export const mapStateToPropsConfigurable = isFeature => state => {
const featureMetrics = state.featureMetrics.toJS();
const flags = state.uiConfig.toJS().flags;
const settings = state.settings.toJS().feature || {};
let features = isFeature
? state.features.toJS()
: state.archive.get('list').toArray();
if (settings.currentProjectId && settings.currentProjectId !== '*') {
features = features.filter(
f => f.project === settings.currentProjectId
);
}
if (settings.filter) {
try {
const regex = new RegExp(settings.filter, 'i');
features = features.filter(feature => {
if (!isFeature) {
return (
regex.test(feature.name) ||
regex.test(feature.description) ||
(settings.filter.length > 1 &&
regex.test(JSON.stringify(feature)))
);
}
return (
feature.strategies.some(s => checkConstraints(s, regex)) ||
regex.test(feature.name) ||
regex.test(feature.description) ||
feature.strategies.some(
s => s && s.name && regex.test(s.name)
) ||
(settings.filter.length > 1 &&
regex.test(JSON.stringify(feature)))
);
});
} catch (e) {
// Invalid filter regex
}
}
if (!settings.sort) {
settings.sort = 'name';
}
if (settings.sort === 'enabled') {
features = features.sort((a, b) =>
// eslint-disable-next-line
a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1
);
} else if (settings.sort === 'stale') {
features = features.sort((a, b) =>
// eslint-disable-next-line
a.stale === b.stale ? 0 : a.stale ? -1 : 1
);
} else if (settings.sort === 'created') {
features = features.sort((a, b) =>
new Date(a.createdAt) > new Date(b.createdAt) ? -1 : 1
);
} else if (settings.sort === 'Last seen') {
features = features.sort((a, b) =>
new Date(a.lastSeenAt) > new Date(b.lastSeenAt) ? -1 : 1
);
} else if (settings.sort === 'name') {
features = features.sort((a, b) => {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
});
} else if (settings.sort === 'project') {
features = features.sort((a, b) =>
a.project.length > b.project.length ? -1 : 1
);
} else if (settings.sort === 'type') {
features = features.sort((a, b) => {
if (a.type < b.type) {
return -1;
}
if (a.type > b.type) {
return 1;
}
return 0;
});
} else if (settings.sort === 'metrics') {
const target = settings.showLastHour
? featureMetrics.lastHour
: featureMetrics.lastMinute;
features = features.sort((a, b) => {
if (!target[a.name]) {
return 1;
}
if (!target[b.name]) {
return -1;
}
if (target[a.name].yes > target[b.name].yes) {
return -1;
}
return 1;
});
}
return {
features,
currentProjectId: resolveCurrentProjectId(settings),
featureMetrics,
archive: !isFeature,
settings,
flags,
loading: state.apiCalls.fetchTogglesState.loading,
};
};
const mapStateToProps = mapStateToPropsConfigurable(true);
const mapDispatchToProps = {
toggleFeature,
fetcher: () => fetchFeatureToggles(),
updateSetting: updateSettingForGroup('feature'),
};
const FeatureToggleListContainer = connect(
mapStateToProps,
mapDispatchToProps
)(FeatureToggleList);
export default FeatureToggleListContainer;

View File

@ -1,25 +1,21 @@
import { Tooltip } from '@material-ui/core';
import { connect } from 'react-redux';
import { formatDateWithLocale, formatFullDateTimeWithLocale } from '../../../common/util';
import { useLocationSettings } from "../../../../hooks/useLocationSettings";
interface CreatedAtProps {
time: Date;
//@ts-ignore
location: any;
}
const CreatedAt = ({time, location}: CreatedAtProps) => {
const CreatedAt = ({time}: CreatedAtProps) => {
const { locationSettings } = useLocationSettings();
return (
<Tooltip title={`Created at ${formatFullDateTimeWithLocale(time, location.locale)}`}>
<Tooltip title={`Created at ${formatFullDateTimeWithLocale(time, locationSettings.locale)}`}>
<span>
{formatDateWithLocale(time, location.locale)}
{formatDateWithLocale(time, locationSettings.locale)}
</span>
</Tooltip>
);
}
const mapStateToProps = (state: any) => ({
location: state.settings.toJS().location,
});
export default connect(mapStateToProps)(CreatedAt);
export default CreatedAt;

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Redirect, useParams } from 'react-router-dom';
import useFeatures from '../../../hooks/api/getters/useFeatures/useFeatures';
import { useFeatures } from '../../../hooks/api/getters/useFeatures/useFeatures';
import { IFeatureToggle } from '../../../interfaces/featureToggle';
import { getTogglePath } from '../../../utils/route-path-helpers';

View File

@ -50,9 +50,8 @@ const useFeatureForm = (
return {
type,
name,
projectId: project,
description: description,
impressionData
description,
impressionData,
};
};

View File

@ -11,24 +11,21 @@ import EventCard from './EventCard/EventCard';
import { useStyles } from './EventLog.styles.js';
const EventLog = ({
updateSetting,
title,
history,
settings,
eventSettings,
setEventSettings,
locationSettings,
displayInline,
location,
hideName,
}) => {
const styles = useStyles();
const toggleShowDiff = () => {
updateSetting('showData', !settings.showData);
setEventSettings({ showData: !eventSettings.showData });
};
const formatFulldateTime = v => {
return formatFullDateTimeWithLocale(v, location.locale);
return formatFullDateTimeWithLocale(v, locationSettings.locale);
};
const showData = settings.showData;
if (!history || history.length < 0) {
return null;
}
@ -44,7 +41,7 @@ const EventLog = ({
</div>
);
if (showData) {
if (eventSettings.showData) {
entries = history.map(entry => (
<EventJson key={`log${entry.id}`} entry={entry} />
));
@ -63,7 +60,7 @@ const EventLog = ({
<FormControlLabel
control={
<Switch
checked={showData}
checked={eventSettings.showData}
onChange={toggleShowDiff}
color="primary"
/>
@ -82,12 +79,12 @@ const EventLog = ({
};
EventLog.propTypes = {
updateSettings: PropTypes.func,
history: PropTypes.array,
eventSettings: PropTypes.object.isRequired,
setEventSettings: PropTypes.func.isRequired,
locationSettings: PropTypes.object.isRequired,
title: PropTypes.string,
settings: PropTypes.object,
displayInline: PropTypes.bool,
location: PropTypes.object,
hideName: PropTypes.bool,
};
export default EventLog;

View File

@ -1,18 +0,0 @@
import { connect } from 'react-redux';
import EventLog from './EventLog';
import { updateSettingForGroup } from '../../../store/settings/actions';
const mapStateToProps = state => {
const settings = state.settings.toJS().history || {};
const location = state.settings.toJS().location || {};
return {
settings,
location,
};
};
const EventLogContainer = connect(mapStateToProps, {
updateSetting: updateSettingForGroup('history'),
})(EventLog);
export default EventLogContainer;

View File

@ -0,0 +1,27 @@
import EventLog from './EventLog';
import { useEventSettings } from "../../../hooks/useEventSettings";
import { useLocationSettings } from "../../../hooks/useLocationSettings";
interface IEventLogContainerProps {
title: string;
history: unknown[];
displayInline?: boolean;
}
const EventLogContainer = (props: IEventLogContainerProps) => {
const { locationSettings } = useLocationSettings();
const { eventSettings, setEventSettings } = useEventSettings();
return (
<EventLog
title={props.title}
history={props.history}
eventSettings={eventSettings}
setEventSettings={setEventSettings}
locationSettings={locationSettings}
displayInline={props.displayInline}
/>
);
};
export default EventLogContainer;

View File

@ -398,12 +398,7 @@ Array [
"type": "protected",
},
Object {
"component": Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
},
"component": [Function],
"layout": "main",
"menu": Object {
"adminSettings": true,

View File

@ -1,11 +1,13 @@
import CopyFeatureToggle from '../../page/features/copy';
import Features from '../../page/features';
import { FeatureToggleListContainer } from '../feature/FeatureToggleList/FeatureToggleListContainer';
import CreateStrategies from '../../page/strategies/create';
import StrategyView from '../../page/strategies/show';
import Strategies from '../../page/strategies';
import HistoryPage from '../../page/history';
import HistoryTogglePage from '../../page/history/toggle';
import Archive from '../../page/archive';
import { ArchiveListContainer } from '../archive/ArchiveListContainer';
import Applications from '../../page/applications';
import ApplicationView from '../../page/applications/view';
import ListTagTypes from '../../page/tag-types';
import Addons from '../../page/addons';
import AddonsCreate from '../../page/addons/create';
@ -14,7 +16,7 @@ import Admin from '../admin';
import AdminApi from '../admin/api';
import AdminInvoice from '../admin/invoice/InvoiceAdminPage';
import AdminUsers from '../admin/users/UsersAdmin';
import AdminAuth from '../admin/auth';
import { AuthSettings } from '../admin/auth/AuthSettings';
import Login from '../user/Login/Login';
import { P, C, E, EEA, RE } from '../common/flags';
import NewUser from '../user/NewUser';
@ -22,7 +24,7 @@ import ResetPassword from '../user/ResetPassword/ResetPassword';
import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword';
import ProjectListNew from '../project/ProjectList/ProjectList';
import Project from '../project/Project/Project';
import RedirectArchive from '../feature/RedirectArchive/RedirectArchive';
import RedirectArchive from '../archive/RedirectArchive';
import EnvironmentList from '../environments/EnvironmentList/EnvironmentList';
import FeatureView from '../feature/FeatureView/FeatureView';
import ProjectRoles from '../admin/project-roles/ProjectRoles/ProjectRoles';
@ -183,7 +185,7 @@ export const routes = [
{
path: '/features',
title: 'Feature Toggles',
component: Features,
component: FeatureToggleListContainer,
type: 'protected',
layout: 'main',
menu: { mobile: true },
@ -373,7 +375,7 @@ export const routes = [
{
path: '/archive',
title: 'Archived Toggles',
component: Archive,
component: ArchiveListContainer,
type: 'protected',
layout: 'main',
menu: {},
@ -447,7 +449,7 @@ export const routes = [
path: '/admin/auth',
parent: '/admin',
title: 'Single Sign-On',
component: AdminAuth,
component: AuthSettings,
type: 'protected',
layout: 'main',
menu: { adminSettings: true },

View File

@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import OutsideClickHandler from 'react-outside-click-handler';
import { Avatar, Button } from '@material-ui/core';
@ -9,16 +8,18 @@ import { useStyles } from './UserProfile.styles';
import { useCommonStyles } from '../../../common.styles';
import UserProfileContent from './UserProfileContent/UserProfileContent';
import { IUser } from "../../../interfaces/user";
import { ILocationSettings } from "../../../hooks/useLocationSettings";
interface IUserProfileProps {
profile: IUser
updateSettingLocation: (field: 'locale', value: string) => void
locationSettings: ILocationSettings
setLocationSettings: React.Dispatch<React.SetStateAction<ILocationSettings>>
}
const UserProfile = ({
profile,
location,
updateSettingLocation,
locationSettings,
setLocationSettings,
}: IUserProfileProps) => {
const [showProfile, setShowProfile] = useState(false);
const [currentLocale, setCurrentLocale] = useState<string>();
@ -40,17 +41,15 @@ const UserProfile = ({
]);
useEffect(() => {
const locale = location.locale || navigator.language;
let found = possibleLocales.find(l =>
l.toLowerCase().includes(locale.toLowerCase())
l.toLowerCase().includes(locationSettings.locale.toLowerCase())
);
setCurrentLocale(found);
if (!found) {
setPossibleLocales(prev => [...prev, locale]);
setPossibleLocales(prev => [...prev, locationSettings.locale]);
}
/* eslint-disable-next-line*/
}, []);
}, [locationSettings]);
const email = profile ? profile.email : '';
const imageUrl = email ? profile.imageUrl : 'unknown-user.png';
@ -75,7 +74,7 @@ const UserProfile = ({
showProfile={showProfile}
imageUrl={imageUrl}
profile={profile}
updateSettingLocation={updateSettingLocation}
setLocationSettings={setLocationSettings}
possibleLocales={possibleLocales}
setCurrentLocale={setCurrentLocale}
currentLocale={currentLocale}
@ -85,10 +84,4 @@ const UserProfile = ({
);
};
UserProfile.propTypes = {
profile: PropTypes.object,
location: PropTypes.object,
updateSettingLocation: PropTypes.func.isRequired,
};
export default UserProfile;

View File

@ -1,13 +1,13 @@
import React, { useState } from 'react';
import ConditionallyRender from '../../../common/ConditionallyRender';
import {
Paper,
Avatar,
Typography,
Button,
FormControl,
Select,
InputLabel,
Paper,
Select,
Typography,
} from '@material-ui/core';
import classnames from 'classnames';
import { useStyles } from './UserProfileContent.styles';
@ -17,26 +17,29 @@ import EditProfile from '../EditProfile/EditProfile';
import legacyStyles from '../../user.module.scss';
import { getBasePath } from '../../../../utils/format-path';
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
import { IUser } from "../../../../interfaces/user";
import { IUser } from '../../../../interfaces/user';
import { ILocationSettings } from '../../../../hooks/useLocationSettings';
interface IUserProfileContentProps {
showProfile: boolean
profile: IUser
possibleLocales: string[]
updateSettingLocation: (field: 'locale', value: string) => void
imageUrl: string
currentLocale?: string
setCurrentLocale: (value: string) => void
showProfile: boolean;
profile: IUser;
possibleLocales: string[];
imageUrl: string;
currentLocale?: string;
setCurrentLocale: (value: string) => void;
setLocationSettings: React.Dispatch<
React.SetStateAction<ILocationSettings>
>;
}
const UserProfileContent = ({
showProfile,
profile,
possibleLocales,
updateSettingLocation,
imageUrl,
currentLocale,
setCurrentLocale,
setLocationSettings,
}: IUserProfileContentProps) => {
const commonStyles = useCommonStyles();
const { uiConfig } = useUiConfig();
@ -44,10 +47,6 @@ const UserProfileContent = ({
const [editingProfile, setEditingProfile] = useState(false);
const styles = useStyles();
const setLocale = (value: string) => {
updateSettingLocation('locale', value);
};
// @ts-expect-error
const profileAvatarClasses = classnames(styles.avatar, {
// @ts-expect-error
@ -61,9 +60,9 @@ const UserProfileContent = ({
});
const handleChange = (e: React.ChangeEvent<{ value: unknown }>) => {
const value = e.target.value as string;
setCurrentLocale(value);
setLocale(value);
const locale = e.target.value as string;
setCurrentLocale(locale);
setLocationSettings({ locale });
};
return (

View File

@ -1,27 +0,0 @@
import useUser from '../../../hooks/api/getters/useUser/useUser';
import { connect } from 'react-redux';
import UserProfile from './UserProfile';
import { updateSettingForGroup } from '../../../store/settings/actions';
const mapDispatchToProps = {
updateSettingLocation: updateSettingForGroup('location'),
};
const mapStateToProps = state => ({
location: state.settings ? state.settings.toJS().location : {},
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(props => {
const user = useUser();
return (
<UserProfile
location={props.location}
updateSettingLocation={props.updateSettingLocation}
profile={user.user}
/>
);
});

View File

@ -0,0 +1,18 @@
import useUser from '../../../hooks/api/getters/useUser/useUser';
import UserProfile from './UserProfile';
import { useLocationSettings } from '../../../hooks/useLocationSettings';
const UserProfileContainer = () => {
const user = useUser();
const { locationSettings, setLocationSettings } = useLocationSettings();
return (
<UserProfile
locationSettings={locationSettings}
setLocationSettings={setLocationSettings}
profile={user.user}
/>
);
};
export default UserProfileContainer;

View File

@ -46,7 +46,7 @@ const useAPI = ({
handleUnauthorized,
propagateErrors = false,
}: IUseAPI) => {
const [errors, setErrors] = useState({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const defaultOptions: RequestInit = {

View File

@ -12,7 +12,6 @@ export const handleBadRequest = async (
if (!setErrors) return;
if (res) {
const data = await res.json();
setErrors({message: data.message});
throw new Error(data.message);
}

View File

@ -0,0 +1,15 @@
import useAPI from '../useApi/useApi';
export const useFeatureArchiveApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const reviveFeature = async (feature: string) => {
const path = `api/admin/archive/revive/${feature}`;
const req = createRequest(path, { method: 'POST' });
return makeRequest(req.caller, req.id);
};
return { reviveFeature, errors, loading };
};

View File

@ -1,40 +1,39 @@
import useSWR, { mutate, SWRConfiguration } from 'swr';
import { useState, useEffect } from 'react';
import { useCallback } from 'react';
import { formatApiPath } from '../../../../utils/format-path';
import handleErrorResponses from '../httpErrorResponseHandler';
import { IFeatureToggle } from '../../../../interfaces/featureToggle';
const useFeatures = (options: SWRConfiguration = {}) => {
const fetcher = async () => {
const path = formatApiPath('api/admin/features/');
return fetch(path, {
method: 'GET',
})
.then(handleErrorResponses('Features'))
.then(res => res.json());
};
const PATH = formatApiPath('api/admin/features');
const FEATURES_CACHE_KEY = 'api/admin/features/';
export interface IUseFeaturesOutput {
features: IFeatureToggle[];
refetchFeatures: () => void;
loading: boolean;
error?: Error;
}
const { data, error } = useSWR(FEATURES_CACHE_KEY, fetcher, {
...options,
});
export const useFeatures = (options?: SWRConfiguration): IUseFeaturesOutput => {
const { data, error } = useSWR<{ features: IFeatureToggle[] }>(
PATH,
fetchFeatures,
options
);
const [loading, setLoading] = useState(!error && !data);
const refetchFeatures = () => {
mutate(FEATURES_CACHE_KEY);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
const refetchFeatures = useCallback(() => {
mutate(PATH).catch(console.warn);
}, []);
return {
features: data?.features || [],
error,
loading,
loading: !error && !data,
refetchFeatures,
error,
};
};
export default useFeatures;
const fetchFeatures = () => {
return fetch(PATH, { method: 'GET' })
.then(handleErrorResponses('Features'))
.then(res => res.json());
};

View File

@ -0,0 +1,41 @@
import useSWR, { mutate, SWRConfiguration } from 'swr';
import { useCallback } from 'react';
import { formatApiPath } from '../../../../utils/format-path';
import handleErrorResponses from '../httpErrorResponseHandler';
import { IFeatureToggle } from '../../../../interfaces/featureToggle';
const PATH = formatApiPath('api/admin/archive/features');
export interface UseFeaturesArchiveOutput {
archivedFeatures: IFeatureToggle[];
refetchArchived: () => void;
loading: boolean;
error?: Error;
}
export const useFeaturesArchive = (
options?: SWRConfiguration
): UseFeaturesArchiveOutput => {
const { data, error } = useSWR<{ features: IFeatureToggle[] }>(
PATH,
fetchArchivedFeatures,
options
);
const refetchArchived = useCallback(() => {
mutate(PATH).catch(console.warn);
}, []);
return {
archivedFeatures: data?.features || [],
refetchArchived,
loading: !error && !data,
error,
};
};
const fetchArchivedFeatures = () => {
return fetch(PATH, { method: 'GET' })
.then(handleErrorResponses('Archive'))
.then(res => res.json());
};

View File

@ -5,7 +5,7 @@ export const defaultValue = {
version: '3.x',
environment: '',
slogan: 'The enterprise ready feature toggle service.',
flags: { P: false, C: false, E: false },
flags: { P: false, C: false, E: false, RE: false },
links: [
{
value: 'Documentation',

View File

@ -0,0 +1,27 @@
import { getBasePath } from '../utils/format-path';
import { createPersistentGlobalState } from './usePersistentGlobalState';
import React from 'react';
export interface IEventSettings {
showData: boolean;
}
interface IUseEventSettingsOutput {
eventSettings: IEventSettings;
setEventSettings: React.Dispatch<React.SetStateAction<IEventSettings>>;
}
export const useEventSettings = (): IUseEventSettingsOutput => {
const [eventSettings, setEventSettings] = useGlobalState();
return { eventSettings, setEventSettings };
};
const createInitialValue = (): IEventSettings => {
return { showData: false };
};
const useGlobalState = createPersistentGlobalState<IEventSettings>(
`${getBasePath()}:useEventSettings:v1`,
createInitialValue()
);

View File

@ -0,0 +1,117 @@
import { IFeatureToggle } from '../interfaces/featureToggle';
import React, { useMemo } from 'react';
import { getBasePath } from '../utils/format-path';
import { createPersistentGlobalState } from './usePersistentGlobalState';
export interface IFeaturesFilter {
query?: string;
project: string;
}
export interface IFeaturesSortOutput {
filtered: IFeatureToggle[];
filter: IFeaturesFilter;
setFilter: React.Dispatch<React.SetStateAction<IFeaturesFilter>>
}
// Store the features filter state globally, and in localStorage.
// When changing the format of IFeaturesFilter, change the version as well.
const useFeaturesFilterState = createPersistentGlobalState<IFeaturesFilter>(
`${getBasePath()}:useFeaturesFilter:v1`,
{ project: '*' }
);
export const useFeaturesFilter = (
features: IFeatureToggle[]
): IFeaturesSortOutput => {
const [filter, setFilter] = useFeaturesFilterState();
const filtered = useMemo(() => {
return filterFeatures(features, filter);
}, [features, filter]);
return {
setFilter,
filter,
filtered,
};
};
// Return the current project ID a project has been selected,
// or the 'default' project if showing all projects.
export const resolveFilteredProjectId = (filter: IFeaturesFilter): string => {
if (!filter.project || filter.project === '*') {
return 'default';
}
return filter.project;
};
const filterFeatures = (
features: IFeatureToggle[],
filter: IFeaturesFilter
): IFeatureToggle[] => {
return filterFeaturesByQuery(
filterFeaturesByProject(features, filter),
filter
);
};
const filterFeaturesByProject = (
features: IFeatureToggle[],
filter: IFeaturesFilter
): IFeatureToggle[] => {
return filter.project === '*'
? features
: features.filter(f => f.project === filter.project);
};
const filterFeaturesByQuery = (
features: IFeatureToggle[],
filter: IFeaturesFilter
): IFeatureToggle[] => {
if (!filter.query) {
return features;
}
// Try to parse the search query as a RegExp.
// Return all features if it can't be parsed.
try {
const regExp = new RegExp(filter.query, 'i');
return features.filter(f => filterFeatureByRegExp(f, filter, regExp));
} catch (err) {
if (err instanceof SyntaxError) {
return features;
} else {
throw err;
}
}
};
const filterFeatureByRegExp = (
feature: IFeatureToggle,
filter: IFeaturesFilter,
regExp: RegExp
): boolean => {
if (regExp.test(feature.name) || regExp.test(feature.description)) {
return true;
}
if (
filter.query &&
filter.query.length > 1 &&
regExp.test(JSON.stringify(feature))
) {
return true;
}
if (!feature.strategies) {
return false;
}
return feature.strategies.some(
s =>
regExp.test(s.name) ||
s.constraints.some(c => c.values.some(v => regExp.test(v)))
);
};

View File

@ -0,0 +1,137 @@
import { IFeatureToggle } from '../interfaces/featureToggle';
import React, { useMemo } from 'react';
import { getBasePath } from '../utils/format-path';
import { createPersistentGlobalState } from './usePersistentGlobalState';
type FeaturesSortType =
| 'name'
| 'type'
| 'enabled'
| 'stale'
| 'created'
| 'last-seen'
| 'project';
interface IFeaturesSort {
type: FeaturesSortType;
}
export interface IFeaturesSortOutput {
sort: IFeaturesSort;
sorted: IFeatureToggle[];
setSort: React.Dispatch<React.SetStateAction<IFeaturesSort>>
}
export interface IFeaturesFilterSortOption {
type: FeaturesSortType;
name: string;
}
// Store the features sort state globally, and in localStorage.
// When changing the format of IFeaturesSort, change the version as well.
const useFeaturesSortState = createPersistentGlobalState<IFeaturesSort>(
`${getBasePath()}:useFeaturesSort:v1`,
{ type: 'name' }
);
export const useFeaturesSort = (
features: IFeatureToggle[]
): IFeaturesSortOutput => {
const [sort, setSort] = useFeaturesSortState();
const sorted = useMemo(() => {
return sortFeatures(features, sort);
}, [features, sort]);
return {
setSort,
sort,
sorted,
};
};
export const createFeaturesFilterSortOptions =
(): IFeaturesFilterSortOption[] => {
return [
{ type: 'name', name: 'Name' },
{ type: 'type', name: 'Type' },
{ type: 'enabled', name: 'Enabled' },
{ type: 'stale', name: 'Stale' },
{ type: 'created', name: 'Created' },
{ type: 'last-seen', name: 'Last seen' },
{ type: 'project', name: 'Project' },
];
};
const sortFeatures = (
features: IFeatureToggle[],
sort: IFeaturesSort
): IFeatureToggle[] => {
switch (sort.type) {
case 'enabled':
return sortByEnabled(features);
case 'stale':
return sortByStale(features);
case 'created':
return sortByCreated(features);
case 'last-seen':
return sortByLastSeen(features);
case 'name':
return sortByName(features);
case 'project':
return sortByProject(features);
case 'type':
return sortByType(features);
default:
console.error(`Unknown feature sort type: ${sort.type}`);
return features;
}
};
const sortByEnabled = (
features: Readonly<IFeatureToggle[]>
): IFeatureToggle[] => {
return [...features].sort((a, b) =>
a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1
);
};
const sortByStale = (
features: Readonly<IFeatureToggle[]>
): IFeatureToggle[] => {
return [...features].sort((a, b) =>
a.stale === b.stale ? 0 : a.stale ? -1 : 1
);
};
const sortByLastSeen = (
features: Readonly<IFeatureToggle[]>
): IFeatureToggle[] => {
return [...features].sort((a, b) =>
a.lastSeenAt && b.lastSeenAt
? a.lastSeenAt.localeCompare(b.lastSeenAt)
: 0
);
};
const sortByCreated = (
features: Readonly<IFeatureToggle[]>
): IFeatureToggle[] => {
return [...features].sort((a, b) =>
new Date(a.createdAt) > new Date(b.createdAt) ? -1 : 1
);
};
const sortByName = (features: Readonly<IFeatureToggle[]>): IFeatureToggle[] => {
return [...features].sort((a, b) => a.name.localeCompare(b.name));
};
const sortByProject = (
features: Readonly<IFeatureToggle[]>
): IFeatureToggle[] => {
return [...features].sort((a, b) => a.project.localeCompare(b.project));
};
const sortByType = (features: Readonly<IFeatureToggle[]>): IFeatureToggle[] => {
return [...features].sort((a, b) => a.type.localeCompare(b.type));
};

View File

@ -0,0 +1,29 @@
import { getBasePath } from '../utils/format-path';
import { createPersistentGlobalState } from './usePersistentGlobalState';
import React from 'react';
export interface ILocationSettings {
locale: string;
}
interface IUseLocationSettingsOutput {
locationSettings: ILocationSettings;
setLocationSettings: React.Dispatch<
React.SetStateAction<ILocationSettings>
>;
}
export const useLocationSettings = (): IUseLocationSettingsOutput => {
const [locationSettings, setLocationSettings] = useGlobalState();
return { locationSettings, setLocationSettings };
};
const createInitialValue = (): ILocationSettings => {
return { locale: navigator.language };
};
const useGlobalState = createPersistentGlobalState<ILocationSettings>(
`${getBasePath()}:useLocationSettings:v1`,
createInitialValue()
);

View File

@ -0,0 +1,29 @@
import React from 'react';
import { createGlobalState } from 'react-hooks-global-state';
import { getLocalStorageItem, setLocalStorageItem } from '../utils/storage';
type UsePersistentGlobalState<T> = () => [
value: T,
setValue: React.Dispatch<React.SetStateAction<T>>
];
// Create a hook that stores global state (shared across all hook instances).
// The state is also persisted to localStorage and restored on page load.
// The localStorage state is not synced between tabs.
export const createPersistentGlobalState = <T extends object>(
key: string,
initialValue: T
): UsePersistentGlobalState<T> => {
const container = createGlobalState<{ [key: string]: T }>({
[key]: getLocalStorageItem(key) ?? initialValue,
});
const setGlobalState = (value: React.SetStateAction<T>) => {
const prev = container.getGlobalState(key);
const next = typeof value === 'function' ? value(prev) : value;
container.setGlobalState(key, next);
setLocalStorageItem(key, next);
};
return () => [container.useGlobalState(key)[0], setGlobalState];
};

View File

@ -32,8 +32,9 @@ export interface IFeatureTogglePayload {
export interface IFeatureToggle {
stale: boolean;
archived: boolean;
createdAt: Date;
lastSeenAt?: Date;
enabled?: boolean;
createdAt: string;
lastSeenAt?: string;
description: string;
environments: IFeatureEnvironment[];
name: string;
@ -41,6 +42,7 @@ export interface IFeatureToggle {
type: string;
variants: IFeatureVariant[];
impressionData: boolean;
strategies?: IFeatureStrategy[];
}
export interface IFeatureEnvironment {

View File

@ -15,6 +15,7 @@ export interface IFlags {
C: boolean;
P: boolean;
E: boolean;
RE: boolean;
}
export interface IVersionInfo {

View File

@ -1,11 +0,0 @@
import React from 'react';
import Archive from '../../component/archive/archive-list-container';
import PropTypes from 'prop-types';
const render = ({ match: { params }, history }) => <Archive name={params.name} history={history} />;
render.propTypes = {
match: PropTypes.object,
history: PropTypes.object,
};
export default render;

View File

@ -1,11 +0,0 @@
import React from 'react';
import FeatureListContainer from '../../component/feature/FeatureToggleList';
import PropTypes from 'prop-types';
const render = ({ history }) => <FeatureListContainer history={history} />;
render.propTypes = {
history: PropTypes.object.isRequired,
};
export default render;

View File

@ -1,32 +0,0 @@
import api from './api';
import { dispatchError } from '../util';
export const REVIVE_TOGGLE = 'REVIVE_TOGGLE';
export const RECEIVE_ARCHIVE = 'RECEIVE_ARCHIVE';
export const ERROR_RECEIVE_ARCHIVE = 'ERROR_RECEIVE_ARCHIVE';
const receiveArchive = json => ({
type: RECEIVE_ARCHIVE,
value: json.features,
});
const reviveToggle = archiveFeatureToggle => ({
type: REVIVE_TOGGLE,
value: archiveFeatureToggle,
});
export function revive(featureToggle) {
return dispatch =>
api
.revive(featureToggle)
.then(() => dispatch(reviveToggle(featureToggle)))
.catch(dispatchError(dispatch, ERROR_RECEIVE_ARCHIVE));
}
export function fetchArchive() {
return dispatch =>
api
.fetchAll()
.then(json => dispatch(receiveArchive(json)))
.catch(dispatchError(dispatch, ERROR_RECEIVE_ARCHIVE));
}

View File

@ -1,23 +0,0 @@
import { formatApiPath } from '../../utils/format-path';
import { throwIfNotSuccess, headers } from '../api-helper';
const URI = formatApiPath('api/admin/archive');
function fetchAll() {
return fetch(`${URI}/features`, { credentials: 'include' })
.then(throwIfNotSuccess)
.then(response => response.json());
}
function revive(featureName) {
return fetch(`${URI}/revive/${featureName}`, {
method: 'POST',
headers,
credentials: 'include',
}).then(throwIfNotSuccess);
}
export default {
fetchAll,
revive,
};

View File

@ -1,23 +0,0 @@
import { List, Map as $Map } from 'immutable';
import { RECEIVE_ARCHIVE, REVIVE_TOGGLE } from './actions';
import { USER_LOGOUT, USER_LOGIN } from '../user/actions';
function getInitState() {
return new $Map({ list: new List() });
}
const archiveStore = (state = getInitState(), action) => {
switch (action.type) {
case REVIVE_TOGGLE:
return state.update('list', list => list.filter(item => item.name !== action.value));
case RECEIVE_ARCHIVE:
return state.set('list', new List(action.value));
case USER_LOGOUT:
case USER_LOGIN:
return getInitState();
default:
return state;
}
};
export default archiveStore;

View File

@ -1,91 +0,0 @@
import api from './api';
import { dispatchError } from '../util';
export const RECIEVE_GOOGLE_CONFIG = 'RECIEVE_GOOGLE_CONFIG';
export const RECIEVE_GOOGLE_CONFIG_ERROR = 'RECIEVE_GOOGLE_CONFIG_ERROR';
export const UPDATE_GOOGLE_AUTH = 'UPDATE_GOOGLE_AUTH';
export const UPDATE_GOOGLE_AUTH_ERROR = 'UPDATE_GOOGLE_AUTH_ERROR';
export const RECIEVE_SAML_CONFIG = 'RECIEVE_SAML_CONFIG';
export const RECIEVE_SAML_CONFIG_ERROR = 'RECIEVE_SAML_CONFIG_ERROR';
export const UPDATE_SAML_AUTH = 'UPDATE_SAML_AUTH';
export const UPDATE_SAML_AUTH_ERROR = 'UPDATE_SAML_AUTH_ERROR';
export const RECIEVE_OIDC_CONFIG = 'RECIEVE_OIDC_CONFIG';
export const RECIEVE_OIDC_CONFIG_ERROR = 'RECIEVE_OIDC_CONFIG_ERROR';
export const UPDATE_OIDC_AUTH = 'UPDATE_OIDC_AUTH';
export const UPDATE_OIDC_AUTH_ERROR = 'UPDATE_OIDC_AUTH_ERROR';
const debug = require('debug')('unleash:e-admin-auth-actions');
export function getGoogleConfig() {
debug('Start fetching google-auth config');
return dispatch =>
api
.getGoogleConfig()
.then(config =>
dispatch({
type: RECIEVE_GOOGLE_CONFIG,
config,
})
)
.catch(dispatchError(dispatch, RECIEVE_GOOGLE_CONFIG_ERROR));
}
export function updateGoogleConfig(data) {
return dispatch =>
api
.updateGoogleConfig(data)
.then(config => dispatch({ type: UPDATE_GOOGLE_AUTH, config }))
.catch(e => {
dispatchError(dispatch, UPDATE_GOOGLE_AUTH_ERROR)(e);
throw e;
});
}
export function getSamlConfig() {
debug('Start fetching Saml-auth config');
return dispatch =>
api
.getSamlConfig()
.then(config =>
dispatch({
type: RECIEVE_SAML_CONFIG,
config,
})
)
.catch(dispatchError(dispatch, RECIEVE_SAML_CONFIG_ERROR));
}
export function updateSamlConfig(data) {
return dispatch =>
api
.updateSamlConfig(data)
.then(config => dispatch({ type: UPDATE_SAML_AUTH, config }))
.catch(e => {
dispatchError(dispatch, UPDATE_SAML_AUTH_ERROR)(e);
throw e;
});
}
export function getOidcConfig() {
debug('Start fetching OIDC-auth config');
return dispatch =>
api
.getOidcConfig()
.then(config =>
dispatch({
type: RECIEVE_OIDC_CONFIG,
config,
})
)
.catch(dispatchError(dispatch, RECIEVE_OIDC_CONFIG_ERROR));
}
export function updateOidcConfig(data) {
return dispatch =>
api
.updateOidcConfig(data)
.then(config => dispatch({ type: UPDATE_OIDC_AUTH, config }))
.catch(e => {
dispatchError(dispatch, UPDATE_OIDC_AUTH_ERROR)(e);
throw e;
});
}

View File

@ -1,66 +0,0 @@
import { throwIfNotSuccess, headers } from '../api-helper';
import { formatApiPath } from '../../utils/format-path';
const GOOGLE_URI = formatApiPath('api/admin/auth/google/settings');
const SAML_URI = formatApiPath('api/admin/auth/saml/settings');
const OIDC_URI = formatApiPath('api/admin/auth/oidc/settings');
function getGoogleConfig() {
return fetch(GOOGLE_URI, { headers, credentials: 'include' })
.then(throwIfNotSuccess)
.then(response => response.json());
}
function updateGoogleConfig(data) {
return fetch(GOOGLE_URI, {
method: 'POST',
headers,
body: JSON.stringify(data),
credentials: 'include',
})
.then(throwIfNotSuccess)
.then(response => response.json());
}
function getSamlConfig() {
return fetch(SAML_URI, { headers, credentials: 'include' })
.then(throwIfNotSuccess)
.then(response => response.json());
}
function updateSamlConfig(data) {
return fetch(SAML_URI, {
method: 'POST',
headers,
body: JSON.stringify(data),
credentials: 'include',
})
.then(throwIfNotSuccess)
.then(response => response.json());
}
function getOidcConfig() {
return fetch(OIDC_URI, { headers, credentials: 'include' })
.then(throwIfNotSuccess)
.then(response => response.json());
}
function updateOidcConfig(data) {
return fetch(OIDC_URI, {
method: 'POST',
headers,
body: JSON.stringify(data),
credentials: 'include',
})
.then(throwIfNotSuccess)
.then(response => response.json());
}
export default {
getGoogleConfig,
updateGoogleConfig,
getSamlConfig,
updateSamlConfig,
getOidcConfig,
updateOidcConfig,
};

View File

@ -1,20 +0,0 @@
import { Map as $Map } from 'immutable';
import { RECIEVE_GOOGLE_CONFIG, UPDATE_GOOGLE_AUTH, RECIEVE_SAML_CONFIG, UPDATE_SAML_AUTH, UPDATE_OIDC_AUTH, RECIEVE_OIDC_CONFIG } from './actions';
const store = (state = new $Map({ google: {}, saml: {}, oidc: {} }), action) => {
switch (action.type) {
case UPDATE_GOOGLE_AUTH:
case RECIEVE_GOOGLE_CONFIG:
return state.set('google', action.config);
case UPDATE_SAML_AUTH:
case RECIEVE_SAML_CONFIG:
return state.set('saml', action.config);
case UPDATE_OIDC_AUTH:
case RECIEVE_OIDC_CONFIG:
return state.set('oidc', action.config);
default:
return state;
}
};
export default store;

View File

@ -6,16 +6,13 @@ import featureTags from './feature-tags';
import tagTypes from './tag-type';
import tags from './tag';
import strategies from './strategy';
import archive from './archive';
import error from './error';
import settings from './settings';
import user from './user';
import applications from './application';
import uiConfig from './ui-config';
import context from './context';
import projects from './project';
import addons from './addons';
import authAdmin from './e-admin-auth';
import apiCalls from './api-calls';
import invoiceAdmin from './e-admin-invoice';
import feedback from './feedback';
@ -28,16 +25,13 @@ const unleashStore = combineReducers({
tagTypes,
tags,
featureTags,
archive,
error,
settings,
user,
applications,
uiConfig,
context,
projects,
addons,
authAdmin,
apiCalls,
invoiceAdmin,
feedback,

View File

@ -1,10 +0,0 @@
export const UPDATE_SETTING = 'UPDATE_SETTING';
export const updateSetting = (group, field, value) => ({
type: UPDATE_SETTING,
group,
field,
value,
});
export const updateSettingForGroup = group => (field, value) => updateSetting(group, field, value);

View File

@ -1,44 +0,0 @@
import { fromJS } from 'immutable';
import { UPDATE_SETTING } from './actions';
import { USER_LOGOUT, USER_LOGIN } from '../user/actions';
import { getBasePath } from '../../utils/format-path';
const localStorage = window.localStorage || {
setItem: () => {},
getItem: () => {},
};
const basePath = getBasePath();
const SETTINGS = `${basePath}:settings`;
const DEFAULT = fromJS({ location: {} });
function getInitState() {
try {
const state = JSON.parse(localStorage.getItem(SETTINGS));
return state ? DEFAULT.merge(state) : DEFAULT;
} catch (e) {
return DEFAULT;
}
}
function updateSetting(state, action) {
const newState = state.updateIn([action.group, action.field], () => action.value);
localStorage.setItem(SETTINGS, JSON.stringify(newState.toJSON()));
return newState;
}
const settingStore = (state = getInitState(), action) => {
switch (action.type) {
case UPDATE_SETTING:
return updateSetting(state, action);
case USER_LOGOUT:
case USER_LOGIN:
return getInitState();
default:
return state;
}
};
export default settingStore;

View File

@ -0,0 +1,8 @@
import { formatUnknownError } from './format-unknown-error';
test('formatUnknownError', () => {
expect(formatUnknownError(1)).toEqual('Unknown error');
expect(formatUnknownError('1')).toEqual('1');
expect(formatUnknownError(new Error('1'))).toEqual('1');
expect(formatUnknownError(new Error())).toEqual('Error');
});

View File

@ -0,0 +1,10 @@
// Get a human-readable error message string from a caught value.
export const formatUnknownError = (error: unknown): string => {
if (error instanceof Error) {
return error.message || error.toString();
} else if (typeof error === 'string') {
return error;
} else {
return 'Unknown error';
}
};

View File

@ -0,0 +1,11 @@
import { removeEmptyStringFields } from './remove-empty-string-fields';
test('removeEmptyStringFields', () => {
expect(removeEmptyStringFields({})).toEqual({});
expect(removeEmptyStringFields({ a: undefined })).toEqual({ a: undefined });
expect(removeEmptyStringFields({ a: 0 })).toEqual({ a: 0 });
expect(removeEmptyStringFields({ a: 1 })).toEqual({ a: 1 });
expect(removeEmptyStringFields({ a: '1' })).toEqual({ a: '1' });
expect(removeEmptyStringFields({ a: '' })).toEqual({});
expect(removeEmptyStringFields({ a: '', b: '2' })).toEqual({ b: '2' });
});

View File

@ -0,0 +1,9 @@
// Remove fields from an object if their value is the empty string.
export const removeEmptyStringFields = (object: {
[key: string]: unknown;
}): { [key: string]: unknown } => {
const entries = Object.entries(object);
const filtered = entries.filter(([, v]) => v !== '');
return Object.fromEntries(filtered);
};

View File

@ -0,0 +1,29 @@
// Get an item from localStorage.
// Returns undefined if the browser denies access.
export function getLocalStorageItem<T>(key: string): T | undefined {
try {
return parseStoredItem<T>(window.localStorage.getItem(key));
} catch (err: unknown) {
console.warn(err);
}
}
// Store an item in localStorage.
// Does nothing if the browser denies access.
export function setLocalStorageItem(key: string, value: unknown) {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch (err: unknown) {
console.warn(err);
}
}
// Parse an item from localStorage.
// Returns undefined if the item could not be parsed.
function parseStoredItem<T>(data: string | null): T | undefined {
try {
return data ? JSON.parse(data) : undefined;
} catch (err: unknown) {
console.warn(err);
}
}

View File

@ -10310,6 +10310,11 @@ react-error-overlay@^6.0.9:
resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.9.tgz"
integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==
react-hooks-global-state@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/react-hooks-global-state/-/react-hooks-global-state-1.0.2.tgz#37bbc3203a0be9f3ac0658abfd28dd7ce7ee166c"
integrity sha512-UcWz+VjcUUCQ7bXGmOhanGII3j22zyPSjwJnQWeycxFYj/etBxIbz9xziEm4sv5+OqGuS7bzvpx24XkCxgJ7Bg==
react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.6:
version "16.13.1"
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"