mirror of
https://github.com/Unleash/unleash.git
synced 2025-03-04 00:18:40 +01:00
Merge branch 'main' into gitar_simplifyProjectOverview_true
This commit is contained in:
commit
6006544dcc
@ -1,4 +1,4 @@
|
|||||||
import { Alert, Tab, Tabs } from '@mui/material';
|
import { Tab, Tabs } from '@mui/material';
|
||||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
@ -15,7 +15,6 @@ import { TabPanel } from 'component/common/TabNav/TabPanel/TabPanel';
|
|||||||
import { usePageTitle } from 'hooks/usePageTitle';
|
import { usePageTitle } from 'hooks/usePageTitle';
|
||||||
|
|
||||||
export const AuthSettings = () => {
|
export const AuthSettings = () => {
|
||||||
const { authenticationType } = useUiConfig().uiConfig;
|
|
||||||
const { uiConfig, isEnterprise } = useUiConfig();
|
const { uiConfig, isEnterprise } = useUiConfig();
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
@ -35,17 +34,14 @@ export const AuthSettings = () => {
|
|||||||
label: 'Google',
|
label: 'Google',
|
||||||
component: <GoogleAuth />,
|
component: <GoogleAuth />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'SCIM',
|
||||||
|
component: <ScimSettings />,
|
||||||
|
},
|
||||||
].filter(
|
].filter(
|
||||||
(item) => uiConfig.flags?.googleAuthEnabled || item.label !== 'Google',
|
(item) => uiConfig.flags?.googleAuthEnabled || item.label !== 'Google',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isEnterprise()) {
|
|
||||||
tabs.push({
|
|
||||||
label: 'SCIM',
|
|
||||||
component: <ScimSettings />,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState(0);
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
usePageTitle(`Single sign-on: ${tabs[activeTab].label}`);
|
usePageTitle(`Single sign-on: ${tabs[activeTab].label}`);
|
||||||
|
|
||||||
@ -56,7 +52,7 @@ export const AuthSettings = () => {
|
|||||||
withTabs
|
withTabs
|
||||||
header={
|
header={
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={authenticationType === 'enterprise'}
|
condition={isEnterprise()}
|
||||||
show={
|
show={
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
@ -85,41 +81,7 @@ export const AuthSettings = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={authenticationType === 'open-source'}
|
condition={isEnterprise()}
|
||||||
show={<PremiumFeature feature='sso' />}
|
|
||||||
/>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={authenticationType === 'demo'}
|
|
||||||
show={
|
|
||||||
<Alert severity='warning'>
|
|
||||||
You are running Unleash in demo mode. You have
|
|
||||||
to use the Enterprise edition in order configure
|
|
||||||
Single Sign-on.
|
|
||||||
</Alert>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={authenticationType === 'custom'}
|
|
||||||
show={
|
|
||||||
<Alert severity='warning'>
|
|
||||||
You have decided to use custom authentication
|
|
||||||
type. You have to use the Enterprise edition in
|
|
||||||
order configure Single Sign-on from the user
|
|
||||||
interface.
|
|
||||||
</Alert>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={authenticationType === 'hosted'}
|
|
||||||
show={
|
|
||||||
<Alert severity='info'>
|
|
||||||
Your Unleash instance is managed by the Unleash
|
|
||||||
team.
|
|
||||||
</Alert>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={authenticationType === 'enterprise'}
|
|
||||||
show={
|
show={
|
||||||
<div>
|
<div>
|
||||||
{tabs.map((tab, index) => (
|
{tabs.map((tab, index) => (
|
||||||
@ -133,6 +95,7 @@ export const AuthSettings = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
elseShow={<PremiumFeature feature='sso' />}
|
||||||
/>
|
/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { TextField, Box } from '@mui/material';
|
import { TextField, Box } from '@mui/material';
|
||||||
@ -7,23 +6,30 @@ import { useUiConfigApi } from 'hooks/api/actions/useUiConfigApi/useUiConfigApi'
|
|||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
import { useId } from 'hooks/useId';
|
import { useId } from 'hooks/useId';
|
||||||
|
import { ADMIN, UPDATE_CORS } from '@server/types/permissions';
|
||||||
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
|
|
||||||
interface ICorsFormProps {
|
interface ICorsFormProps {
|
||||||
frontendApiOrigins: string[] | undefined;
|
frontendApiOrigins: string[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CorsForm = ({ frontendApiOrigins }: ICorsFormProps) => {
|
export const CorsForm = ({ frontendApiOrigins }: ICorsFormProps) => {
|
||||||
const { setFrontendSettings } = useUiConfigApi();
|
const { setFrontendSettings, setCors } = useUiConfigApi();
|
||||||
const { setToastData, setToastApiError } = useToast();
|
const { setToastData, setToastApiError } = useToast();
|
||||||
const [value, setValue] = useState(formatInputValue(frontendApiOrigins));
|
const [value, setValue] = useState(formatInputValue(frontendApiOrigins));
|
||||||
const inputFieldId = useId();
|
const inputFieldId = useId();
|
||||||
const helpTextId = useId();
|
const helpTextId = useId();
|
||||||
|
const isGranularPermissionsEnabled = useUiFlag('granularAdminPermissions');
|
||||||
|
|
||||||
const onSubmit = async (event: React.FormEvent) => {
|
const onSubmit = async (event: React.FormEvent) => {
|
||||||
try {
|
try {
|
||||||
const split = parseInputValue(value);
|
const split = parseInputValue(value);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (isGranularPermissionsEnabled) {
|
||||||
|
await setCors(split);
|
||||||
|
} else {
|
||||||
await setFrontendSettings(split);
|
await setFrontendSettings(split);
|
||||||
|
}
|
||||||
setValue(formatInputValue(split));
|
setValue(formatInputValue(split));
|
||||||
setToastData({ text: 'Settings saved', type: 'success' });
|
setToastData({ text: 'Settings saved', type: 'success' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -67,7 +73,7 @@ export const CorsForm = ({ frontendApiOrigins }: ICorsFormProps) => {
|
|||||||
style: { fontFamily: 'monospace', fontSize: '0.8em' },
|
style: { fontFamily: 'monospace', fontSize: '0.8em' },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<UpdateButton permission={ADMIN} />
|
<UpdateButton permission={[ADMIN, UPDATE_CORS]} />
|
||||||
</Box>
|
</Box>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import { PermissionGuard } from 'component/common/PermissionGuard/PermissionGuard';
|
import { PermissionGuard } from 'component/common/PermissionGuard/PermissionGuard';
|
||||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
|
||||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||||
import { Box } from '@mui/material';
|
import { Box } from '@mui/material';
|
||||||
import { CorsHelpAlert } from 'component/admin/cors/CorsHelpAlert';
|
import { CorsHelpAlert } from 'component/admin/cors/CorsHelpAlert';
|
||||||
import { CorsForm } from 'component/admin/cors/CorsForm';
|
import { CorsForm } from 'component/admin/cors/CorsForm';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import { ADMIN, UPDATE_CORS } from '@server/types/permissions';
|
||||||
|
|
||||||
export const CorsAdmin = () => (
|
export const CorsAdmin = () => (
|
||||||
<div>
|
<div>
|
||||||
<PermissionGuard permissions={ADMIN}>
|
<PermissionGuard permissions={[ADMIN, UPDATE_CORS]}>
|
||||||
<CorsPage />
|
<CorsPage />
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
</div>
|
</div>
|
||||||
|
@ -23,6 +23,7 @@ const StyledGridContainer = styled('div')(({ theme }) => ({
|
|||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
gap: theme.spacing(2),
|
gap: theme.spacing(2),
|
||||||
|
gridAutoRows: '1fr',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
type PageQueryType = Partial<Record<'search', string>>;
|
type PageQueryType = Partial<Record<'search', string>>;
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { Box, styled } from '@mui/material';
|
import { Box, styled } from '@mui/material';
|
||||||
import { InviteLinkBar } from '../InviteLinkBar/InviteLinkBar';
|
import { InviteLinkBar } from '../InviteLinkBar/InviteLinkBar';
|
||||||
import { useUiFlag } from 'hooks/useUiFlag';
|
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import { LicensedUsersBox } from './LicensedUsersBox';
|
import { LicensedUsersBox } from './LicensedUsersBox';
|
||||||
|
|
||||||
@ -24,9 +23,8 @@ const StyledElement = styled(Box)(({ theme }) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export const UsersHeader = () => {
|
export const UsersHeader = () => {
|
||||||
const licensedUsers = useUiFlag('licensedUsers');
|
|
||||||
const { isOss } = useUiConfig();
|
const { isOss } = useUiConfig();
|
||||||
const licensedUsersEnabled = licensedUsers && !isOss();
|
const licensedUsersEnabled = !isOss();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
|
||||||
import { PermissionGuard } from 'component/common/PermissionGuard/PermissionGuard';
|
import { PermissionGuard } from 'component/common/PermissionGuard/PermissionGuard';
|
||||||
import { EventLog } from 'component/events/EventLog/EventLog';
|
import { EventLog } from 'component/events/EventLog/EventLog';
|
||||||
|
import { READ_LOGS, ADMIN } from '@server/types/permissions';
|
||||||
|
|
||||||
export const EventPage = () => (
|
export const EventPage = () => (
|
||||||
<PermissionGuard permissions={ADMIN}>
|
<PermissionGuard permissions={[ADMIN, READ_LOGS]}>
|
||||||
<EventLog title='Event log' />
|
<EventLog title='Event log' />
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
);
|
);
|
||||||
|
@ -3,6 +3,7 @@ import { PermissionGuard } from 'component/common/PermissionGuard/PermissionGuar
|
|||||||
import { LoginHistoryTable } from './LoginHistoryTable/LoginHistoryTable';
|
import { LoginHistoryTable } from './LoginHistoryTable/LoginHistoryTable';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature';
|
import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature';
|
||||||
|
import { READ_LOGS } from '@server/types/permissions';
|
||||||
|
|
||||||
export const LoginHistory = () => {
|
export const LoginHistory = () => {
|
||||||
const { isEnterprise } = useUiConfig();
|
const { isEnterprise } = useUiConfig();
|
||||||
@ -13,7 +14,7 @@ export const LoginHistory = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PermissionGuard permissions={ADMIN}>
|
<PermissionGuard permissions={[ADMIN, READ_LOGS]}>
|
||||||
<LoginHistoryTable />
|
<LoginHistoryTable />
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
</div>
|
</div>
|
||||||
|
@ -71,9 +71,14 @@ const StyledAccordion = styled(Accordion)(({ theme }) => ({
|
|||||||
const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
|
const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
|
||||||
boxShadow: 'none',
|
boxShadow: 'none',
|
||||||
padding: theme.spacing(1.5, 2),
|
padding: theme.spacing(1.5, 2),
|
||||||
|
borderRadius: theme.shape.borderRadiusMedium,
|
||||||
[theme.breakpoints.down(400)]: {
|
[theme.breakpoints.down(400)]: {
|
||||||
padding: theme.spacing(1, 2),
|
padding: theme.spacing(1, 2),
|
||||||
},
|
},
|
||||||
|
'&.Mui-focusVisible': {
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
padding: theme.spacing(0.5, 2, 0.3, 2),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
|
const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
|
||||||
|
@ -57,6 +57,10 @@ export const MilestoneCardName = ({
|
|||||||
setEditMode(false);
|
setEditMode(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onClick={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!editMode && (
|
{!editMode && (
|
||||||
|
@ -5,6 +5,9 @@ export const useUiConfigApi = () => {
|
|||||||
propagateErrors: true,
|
propagateErrors: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated remove when `granularAdminPermissions` flag is removed
|
||||||
|
*/
|
||||||
const setFrontendSettings = async (
|
const setFrontendSettings = async (
|
||||||
frontendApiOrigins: string[],
|
frontendApiOrigins: string[],
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
@ -19,8 +22,18 @@ export const useUiConfigApi = () => {
|
|||||||
await makeRequest(req.caller, req.id);
|
await makeRequest(req.caller, req.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setCors = async (frontendApiOrigins: string[]): Promise<void> => {
|
||||||
|
const req = createRequest(
|
||||||
|
'api/admin/ui-config/cors',
|
||||||
|
{ method: 'POST', body: JSON.stringify({ frontendApiOrigins }) },
|
||||||
|
'setCors',
|
||||||
|
);
|
||||||
|
await makeRequest(req.caller, req.id);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setFrontendSettings,
|
setFrontendSettings,
|
||||||
|
setCors,
|
||||||
loading,
|
loading,
|
||||||
errors,
|
errors,
|
||||||
};
|
};
|
||||||
|
@ -89,7 +89,6 @@ export type UiFlags = {
|
|||||||
productivityReportEmail?: boolean;
|
productivityReportEmail?: boolean;
|
||||||
showUserDeviceCount?: boolean;
|
showUserDeviceCount?: boolean;
|
||||||
flagOverviewRedesign?: boolean;
|
flagOverviewRedesign?: boolean;
|
||||||
licensedUsers?: boolean;
|
|
||||||
granularAdminPermissions?: boolean;
|
granularAdminPermissions?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -167,7 +167,7 @@
|
|||||||
"stoppable": "^1.1.0",
|
"stoppable": "^1.1.0",
|
||||||
"ts-toolbelt": "^9.6.0",
|
"ts-toolbelt": "^9.6.0",
|
||||||
"type-is": "^1.6.18",
|
"type-is": "^1.6.18",
|
||||||
"unleash-client": "^6.3.3",
|
"unleash-client": "^6.4.0",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -9,10 +9,8 @@ import type {
|
|||||||
import type { Logger } from '../../logger';
|
import type { Logger } from '../../logger';
|
||||||
|
|
||||||
import type { FeatureConfigurationClient } from '../feature-toggle/types/feature-toggle-strategies-store-type';
|
import type { FeatureConfigurationClient } from '../feature-toggle/types/feature-toggle-strategies-store-type';
|
||||||
import type {
|
import type { ClientFeatureToggleDelta } from './delta/client-feature-toggle-delta';
|
||||||
RevisionDeltaEntry,
|
import type { ClientFeaturesDeltaSchema } from '../../openapi';
|
||||||
ClientFeatureToggleDelta,
|
|
||||||
} from './delta/client-feature-toggle-delta';
|
|
||||||
|
|
||||||
export class ClientFeatureToggleService {
|
export class ClientFeatureToggleService {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
@ -44,7 +42,7 @@ export class ClientFeatureToggleService {
|
|||||||
async getClientDelta(
|
async getClientDelta(
|
||||||
revisionId: number | undefined,
|
revisionId: number | undefined,
|
||||||
query: IFeatureToggleQuery,
|
query: IFeatureToggleQuery,
|
||||||
): Promise<RevisionDeltaEntry | undefined> {
|
): Promise<ClientFeaturesDeltaSchema | undefined> {
|
||||||
if (this.clientFeatureToggleDelta !== null) {
|
if (this.clientFeatureToggleDelta !== null) {
|
||||||
return this.clientFeatureToggleDelta.getDelta(revisionId, query);
|
return this.clientFeatureToggleDelta.getDelta(revisionId, query);
|
||||||
} else {
|
} else {
|
||||||
|
@ -140,3 +140,37 @@ const syncRevisions = async () => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await app.services.clientFeatureToggleService.clientFeatureToggleDelta.onUpdateRevisionEvent();
|
await app.services.clientFeatureToggleService.clientFeatureToggleDelta.onUpdateRevisionEvent();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
test('archived features should not be returned as updated', async () => {
|
||||||
|
await app.createFeature('base_feature');
|
||||||
|
await syncRevisions();
|
||||||
|
const { body } = await app.request.get('/api/client/delta').expect(200);
|
||||||
|
const currentRevisionId = body.revisionId;
|
||||||
|
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
updated: [
|
||||||
|
{
|
||||||
|
name: 'base_feature',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.archiveFeature('base_feature');
|
||||||
|
await app.createFeature('new_feature');
|
||||||
|
|
||||||
|
await syncRevisions();
|
||||||
|
|
||||||
|
const { body: deltaBody } = await app.request
|
||||||
|
.get('/api/client/delta')
|
||||||
|
.set('If-None-Match', currentRevisionId)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(deltaBody).toMatchObject({
|
||||||
|
updated: [
|
||||||
|
{
|
||||||
|
name: 'new_feature',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
removed: ['base_feature'],
|
||||||
|
});
|
||||||
|
});
|
@ -17,8 +17,10 @@ import type { OpenApiService } from '../../../services/openapi-service';
|
|||||||
import { NONE } from '../../../types/permissions';
|
import { NONE } from '../../../types/permissions';
|
||||||
import { createResponseSchema } from '../../../openapi/util/create-response-schema';
|
import { createResponseSchema } from '../../../openapi/util/create-response-schema';
|
||||||
import type { ClientFeatureToggleService } from '../client-feature-toggle-service';
|
import type { ClientFeatureToggleService } from '../client-feature-toggle-service';
|
||||||
import type { RevisionDeltaEntry } from './client-feature-toggle-delta';
|
import {
|
||||||
import { clientFeaturesDeltaSchema } from '../../../openapi';
|
type ClientFeaturesDeltaSchema,
|
||||||
|
clientFeaturesDeltaSchema,
|
||||||
|
} from '../../../openapi';
|
||||||
import type { QueryOverride } from '../client-feature-toggle.controller';
|
import type { QueryOverride } from '../client-feature-toggle.controller';
|
||||||
|
|
||||||
export default class ClientFeatureToggleDeltaController extends Controller {
|
export default class ClientFeatureToggleDeltaController extends Controller {
|
||||||
@ -75,7 +77,7 @@ export default class ClientFeatureToggleDeltaController extends Controller {
|
|||||||
|
|
||||||
async getDelta(
|
async getDelta(
|
||||||
req: IAuthRequest,
|
req: IAuthRequest,
|
||||||
res: Response<RevisionDeltaEntry>,
|
res: Response<ClientFeaturesDeltaSchema>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this.flagResolver.isEnabled('deltaApi')) {
|
if (!this.flagResolver.isEnabled('deltaApi')) {
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
|
@ -4,7 +4,7 @@ import type { FeatureConfigurationClient } from '../../feature-toggle/types/feat
|
|||||||
export interface FeatureConfigurationDeltaClient
|
export interface FeatureConfigurationDeltaClient
|
||||||
extends FeatureConfigurationClient {
|
extends FeatureConfigurationClient {
|
||||||
description: string;
|
description: string;
|
||||||
impressionData: false;
|
impressionData: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IClientFeatureToggleDeltaReadModel {
|
export interface IClientFeatureToggleDeltaReadModel {
|
||||||
|
@ -17,6 +17,7 @@ import type {
|
|||||||
import { CLIENT_DELTA_MEMORY } from '../../../metric-events';
|
import { CLIENT_DELTA_MEMORY } from '../../../metric-events';
|
||||||
import type EventEmitter from 'events';
|
import type EventEmitter from 'events';
|
||||||
import type { Logger } from '../../../logger';
|
import type { Logger } from '../../../logger';
|
||||||
|
import type { ClientFeaturesDeltaSchema } from '../../../openapi';
|
||||||
|
|
||||||
type DeletedFeature = {
|
type DeletedFeature = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -79,6 +80,7 @@ const filterRevisionByProject = (
|
|||||||
(feature) =>
|
(feature) =>
|
||||||
projects.includes('*') || projects.includes(feature.project),
|
projects.includes('*') || projects.includes(feature.project),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { ...revision, updated, removed };
|
return { ...revision, updated, removed };
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -153,7 +155,7 @@ export class ClientFeatureToggleDelta {
|
|||||||
async getDelta(
|
async getDelta(
|
||||||
sdkRevisionId: number | undefined,
|
sdkRevisionId: number | undefined,
|
||||||
query: IFeatureToggleQuery,
|
query: IFeatureToggleQuery,
|
||||||
): Promise<RevisionDeltaEntry | undefined> {
|
): Promise<ClientFeaturesDeltaSchema | undefined> {
|
||||||
const projects = query.project ? query.project : ['*'];
|
const projects = query.project ? query.project : ['*'];
|
||||||
const environment = query.environment ? query.environment : 'default';
|
const environment = query.environment ? query.environment : 'default';
|
||||||
// TODO: filter by tags, what is namePrefix? anything else?
|
// TODO: filter by tags, what is namePrefix? anything else?
|
||||||
@ -181,9 +183,10 @@ export class ClientFeatureToggleDelta {
|
|||||||
projects,
|
projects,
|
||||||
);
|
);
|
||||||
|
|
||||||
const revisionResponse = {
|
const revisionResponse: ClientFeaturesDeltaSchema = {
|
||||||
...compressedRevision,
|
...compressedRevision,
|
||||||
segments: this.segments,
|
segments: this.segments,
|
||||||
|
removed: compressedRevision.removed.map((feature) => feature.name),
|
||||||
};
|
};
|
||||||
|
|
||||||
return Promise.resolve(revisionResponse);
|
return Promise.resolve(revisionResponse);
|
||||||
@ -197,6 +200,9 @@ export class ClientFeatureToggleDelta {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is used in client-feature-delta-api.e2e.test.ts, do not remove
|
||||||
|
*/
|
||||||
public resetDelta() {
|
public resetDelta() {
|
||||||
this.delta = {};
|
this.delta = {};
|
||||||
}
|
}
|
||||||
@ -217,6 +223,7 @@ export class ClientFeatureToggleDelta {
|
|||||||
...new Set(
|
...new Set(
|
||||||
changeEvents
|
changeEvents
|
||||||
.filter((event) => event.featureName)
|
.filter((event) => event.featureName)
|
||||||
|
.filter((event) => event.type !== 'feature-archived')
|
||||||
.map((event) => event.featureName!),
|
.map((event) => event.featureName!),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
@ -40,6 +40,7 @@ import type { UpdateContextFieldSchema } from '../../openapi/spec/update-context
|
|||||||
import type { CreateContextFieldSchema } from '../../openapi/spec/create-context-field-schema';
|
import type { CreateContextFieldSchema } from '../../openapi/spec/create-context-field-schema';
|
||||||
import { extractUserIdFromUser } from '../../util';
|
import { extractUserIdFromUser } from '../../util';
|
||||||
import type { LegalValueSchema } from '../../openapi';
|
import type { LegalValueSchema } from '../../openapi';
|
||||||
|
import type { WithTransactional } from '../../db/transaction';
|
||||||
|
|
||||||
interface ContextParam {
|
interface ContextParam {
|
||||||
contextField: string;
|
contextField: string;
|
||||||
@ -50,7 +51,7 @@ interface DeleteLegalValueParam extends ContextParam {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ContextController extends Controller {
|
export class ContextController extends Controller {
|
||||||
private contextService: ContextService;
|
private transactionalContextService: WithTransactional<ContextService>;
|
||||||
|
|
||||||
private openApiService: OpenApiService;
|
private openApiService: OpenApiService;
|
||||||
|
|
||||||
@ -59,14 +60,17 @@ export class ContextController extends Controller {
|
|||||||
constructor(
|
constructor(
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
{
|
{
|
||||||
contextService,
|
transactionalContextService,
|
||||||
openApiService,
|
openApiService,
|
||||||
}: Pick<IUnleashServices, 'contextService' | 'openApiService'>,
|
}: Pick<
|
||||||
|
IUnleashServices,
|
||||||
|
'transactionalContextService' | 'openApiService'
|
||||||
|
>,
|
||||||
) {
|
) {
|
||||||
super(config);
|
super(config);
|
||||||
this.openApiService = openApiService;
|
this.openApiService = openApiService;
|
||||||
this.logger = config.getLogger('/admin-api/context.ts');
|
this.logger = config.getLogger('/admin-api/context.ts');
|
||||||
this.contextService = contextService;
|
this.transactionalContextService = transactionalContextService;
|
||||||
|
|
||||||
this.route({
|
this.route({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
@ -257,7 +261,9 @@ export class ContextController extends Controller {
|
|||||||
res: Response<ContextFieldsSchema>,
|
res: Response<ContextFieldsSchema>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
res.status(200)
|
res.status(200)
|
||||||
.json(serializeDates(await this.contextService.getAll()))
|
.json(
|
||||||
|
serializeDates(await this.transactionalContextService.getAll()),
|
||||||
|
)
|
||||||
.end();
|
.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,7 +274,7 @@ export class ContextController extends Controller {
|
|||||||
try {
|
try {
|
||||||
const name = req.params.contextField;
|
const name = req.params.contextField;
|
||||||
const contextField =
|
const contextField =
|
||||||
await this.contextService.getContextField(name);
|
await this.transactionalContextService.getContextField(name);
|
||||||
this.openApiService.respondWithValidation(
|
this.openApiService.respondWithValidation(
|
||||||
200,
|
200,
|
||||||
res,
|
res,
|
||||||
@ -286,9 +292,8 @@ export class ContextController extends Controller {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const value = req.body;
|
const value = req.body;
|
||||||
|
|
||||||
const result = await this.contextService.createContextField(
|
const result = await this.transactionalContextService.transactional(
|
||||||
value,
|
(service) => service.createContextField(value, req.audit),
|
||||||
req.audit,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.openApiService.respondWithValidation(
|
this.openApiService.respondWithValidation(
|
||||||
@ -307,9 +312,8 @@ export class ContextController extends Controller {
|
|||||||
const name = req.params.contextField;
|
const name = req.params.contextField;
|
||||||
const contextField = req.body;
|
const contextField = req.body;
|
||||||
|
|
||||||
await this.contextService.updateContextField(
|
await this.transactionalContextService.transactional((service) =>
|
||||||
{ ...contextField, name },
|
service.updateContextField({ ...contextField, name }, req.audit),
|
||||||
req.audit,
|
|
||||||
);
|
);
|
||||||
res.status(200).end();
|
res.status(200).end();
|
||||||
}
|
}
|
||||||
@ -321,9 +325,8 @@ export class ContextController extends Controller {
|
|||||||
const name = req.params.contextField;
|
const name = req.params.contextField;
|
||||||
const legalValue = req.body;
|
const legalValue = req.body;
|
||||||
|
|
||||||
await this.contextService.updateLegalValue(
|
await this.transactionalContextService.transactional((service) =>
|
||||||
{ name, legalValue },
|
service.updateLegalValue({ name, legalValue }, req.audit),
|
||||||
req.audit,
|
|
||||||
);
|
);
|
||||||
res.status(200).end();
|
res.status(200).end();
|
||||||
}
|
}
|
||||||
@ -335,9 +338,8 @@ export class ContextController extends Controller {
|
|||||||
const name = req.params.contextField;
|
const name = req.params.contextField;
|
||||||
const legalValue = req.params.legalValue;
|
const legalValue = req.params.legalValue;
|
||||||
|
|
||||||
await this.contextService.deleteLegalValue(
|
await this.transactionalContextService.transactional((service) =>
|
||||||
{ name, legalValue },
|
service.deleteLegalValue({ name, legalValue }, req.audit),
|
||||||
req.audit,
|
|
||||||
);
|
);
|
||||||
res.status(200).end();
|
res.status(200).end();
|
||||||
}
|
}
|
||||||
@ -348,7 +350,9 @@ export class ContextController extends Controller {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const name = req.params.contextField;
|
const name = req.params.contextField;
|
||||||
|
|
||||||
await this.contextService.deleteContextField(name, req.audit);
|
await this.transactionalContextService.transactional((service) =>
|
||||||
|
service.deleteContextField(name, req.audit),
|
||||||
|
);
|
||||||
res.status(200).end();
|
res.status(200).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -358,7 +362,7 @@ export class ContextController extends Controller {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { name } = req.body;
|
const { name } = req.body;
|
||||||
|
|
||||||
await this.contextService.validateName(name);
|
await this.transactionalContextService.validateName(name);
|
||||||
res.status(200).end();
|
res.status(200).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -369,7 +373,7 @@ export class ContextController extends Controller {
|
|||||||
const { contextField } = req.params;
|
const { contextField } = req.params;
|
||||||
const { user } = req;
|
const { user } = req;
|
||||||
const contextFields =
|
const contextFields =
|
||||||
await this.contextService.getStrategiesByContextField(
|
await this.transactionalContextService.getStrategiesByContextField(
|
||||||
contextField,
|
contextField,
|
||||||
extractUserIdFromUser(user),
|
extractUserIdFromUser(user),
|
||||||
);
|
);
|
||||||
|
@ -6,7 +6,12 @@ import {
|
|||||||
} from '../../../test/e2e/helpers/test-helper';
|
} from '../../../test/e2e/helpers/test-helper';
|
||||||
import getLogger from '../../../test/fixtures/no-logger';
|
import getLogger from '../../../test/fixtures/no-logger';
|
||||||
import type { FeatureSearchQueryParameters } from '../../openapi/spec/feature-search-query-parameters';
|
import type { FeatureSearchQueryParameters } from '../../openapi/spec/feature-search-query-parameters';
|
||||||
import { DEFAULT_PROJECT, type IUnleashStores } from '../../types';
|
import {
|
||||||
|
CREATE_FEATURE_STRATEGY,
|
||||||
|
DEFAULT_PROJECT,
|
||||||
|
type IUnleashStores,
|
||||||
|
UPDATE_FEATURE_ENVIRONMENT,
|
||||||
|
} from '../../types';
|
||||||
import { DEFAULT_ENV } from '../../util';
|
import { DEFAULT_ENV } from '../../util';
|
||||||
|
|
||||||
let app: IUnleashTest;
|
let app: IUnleashTest;
|
||||||
@ -29,7 +34,7 @@ beforeAll(async () => {
|
|||||||
);
|
);
|
||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
|
|
||||||
await app.request
|
const { body } = await app.request
|
||||||
.post(`/auth/demo/login`)
|
.post(`/auth/demo/login`)
|
||||||
.send({
|
.send({
|
||||||
email: 'user@getunleash.io',
|
email: 'user@getunleash.io',
|
||||||
@ -43,12 +48,30 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
await app.linkProjectToEnvironment('default', 'development');
|
await app.linkProjectToEnvironment('default', 'development');
|
||||||
|
|
||||||
|
await stores.accessStore.addPermissionsToRole(
|
||||||
|
body.rootRole,
|
||||||
|
[
|
||||||
|
{ name: UPDATE_FEATURE_ENVIRONMENT },
|
||||||
|
{ name: CREATE_FEATURE_STRATEGY },
|
||||||
|
],
|
||||||
|
'development',
|
||||||
|
);
|
||||||
|
|
||||||
await stores.environmentStore.create({
|
await stores.environmentStore.create({
|
||||||
name: 'production',
|
name: 'production',
|
||||||
type: 'production',
|
type: 'production',
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.linkProjectToEnvironment('default', 'production');
|
await app.linkProjectToEnvironment('default', 'production');
|
||||||
|
|
||||||
|
await stores.accessStore.addPermissionsToRole(
|
||||||
|
body.rootRole,
|
||||||
|
[
|
||||||
|
{ name: UPDATE_FEATURE_ENVIRONMENT },
|
||||||
|
{ name: CREATE_FEATURE_STRATEGY },
|
||||||
|
],
|
||||||
|
'production',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
@ -208,6 +208,23 @@ export class FrontendApiService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setFrontendCorsSettings(
|
||||||
|
value: FrontendSettings['frontendApiOrigins'],
|
||||||
|
auditUser: IAuditUser,
|
||||||
|
): Promise<void> {
|
||||||
|
const error = validateOrigins(value);
|
||||||
|
if (error) {
|
||||||
|
throw new BadDataError(error);
|
||||||
|
}
|
||||||
|
const settings = (await this.getFrontendSettings(false)) || {};
|
||||||
|
await this.services.settingService.insert(
|
||||||
|
frontendSettingsKey,
|
||||||
|
{ ...settings, frontendApiOrigins: value },
|
||||||
|
auditUser,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async fetchFrontendSettings(): Promise<FrontendSettings> {
|
async fetchFrontendSettings(): Promise<FrontendSettings> {
|
||||||
try {
|
try {
|
||||||
this.cachedFrontendSettings =
|
this.cachedFrontendSettings =
|
||||||
|
@ -805,6 +805,11 @@ describe('Managing Project access', () => {
|
|||||||
mode: 'open' as const,
|
mode: 'open' as const,
|
||||||
defaultStickiness: 'clientId',
|
defaultStickiness: 'clientId',
|
||||||
};
|
};
|
||||||
|
await db.stores.environmentStore.create({
|
||||||
|
name: 'production',
|
||||||
|
type: 'production',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
const auditUser = extractAuditInfoFromUser(user);
|
const auditUser = extractAuditInfoFromUser(user);
|
||||||
await projectService.createProject(project, user, auditUser);
|
await projectService.createProject(project, user, auditUser);
|
||||||
|
27
src/lib/openapi/spec/client-features-delta-schema.test.ts
Normal file
27
src/lib/openapi/spec/client-features-delta-schema.test.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { validateSchema } from '../validate';
|
||||||
|
import type { ClientFeaturesDeltaSchema } from './client-features-delta-schema';
|
||||||
|
|
||||||
|
test('clientFeaturesDeltaSchema all fields', () => {
|
||||||
|
const data: ClientFeaturesDeltaSchema = {
|
||||||
|
revisionId: 6,
|
||||||
|
updated: [
|
||||||
|
{
|
||||||
|
impressionData: false,
|
||||||
|
enabled: false,
|
||||||
|
name: 'base_feature',
|
||||||
|
description: null,
|
||||||
|
project: 'default',
|
||||||
|
stale: false,
|
||||||
|
type: 'release',
|
||||||
|
variants: [],
|
||||||
|
strategies: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
removed: [],
|
||||||
|
segments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateSchema('#/components/schemas/clientFeaturesDeltaSchema', data),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
@ -177,6 +177,7 @@ export * from './search-features-schema';
|
|||||||
export * from './segment-schema';
|
export * from './segment-schema';
|
||||||
export * from './segment-strategies-schema';
|
export * from './segment-strategies-schema';
|
||||||
export * from './segments-schema';
|
export * from './segments-schema';
|
||||||
|
export * from './set-cors-schema';
|
||||||
export * from './set-strategy-sort-order-schema';
|
export * from './set-strategy-sort-order-schema';
|
||||||
export * from './set-ui-config-schema';
|
export * from './set-ui-config-schema';
|
||||||
export * from './sort-order-schema';
|
export * from './sort-order-schema';
|
||||||
|
20
src/lib/openapi/spec/set-cors-schema.ts
Normal file
20
src/lib/openapi/spec/set-cors-schema.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import type { FromSchema } from 'json-schema-to-ts';
|
||||||
|
|
||||||
|
export const setCorsSchema = {
|
||||||
|
$id: '#/components/schemas/setCorsSchema',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
description: 'Unleash CORS configuration.',
|
||||||
|
properties: {
|
||||||
|
frontendApiOrigins: {
|
||||||
|
description:
|
||||||
|
'The list of origins that the front-end API should accept requests from.',
|
||||||
|
example: ['*'],
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SetCorsSchema = FromSchema<typeof setCorsSchema>;
|
@ -19,6 +19,11 @@ const uiConfig = {
|
|||||||
async function getSetup() {
|
async function getSetup() {
|
||||||
const base = `/random${Math.round(Math.random() * 1000)}`;
|
const base = `/random${Math.round(Math.random() * 1000)}`;
|
||||||
const config = createTestConfig({
|
const config = createTestConfig({
|
||||||
|
experimental: {
|
||||||
|
flags: {
|
||||||
|
granularAdminPermissions: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
server: { baseUriPath: base },
|
server: { baseUriPath: base },
|
||||||
ui: uiConfig,
|
ui: uiConfig,
|
||||||
});
|
});
|
||||||
@ -56,3 +61,26 @@ test('should get ui config', async () => {
|
|||||||
expect(body.segmentValuesLimit).toEqual(DEFAULT_SEGMENT_VALUES_LIMIT);
|
expect(body.segmentValuesLimit).toEqual(DEFAULT_SEGMENT_VALUES_LIMIT);
|
||||||
expect(body.strategySegmentsLimit).toEqual(DEFAULT_STRATEGY_SEGMENTS_LIMIT);
|
expect(body.strategySegmentsLimit).toEqual(DEFAULT_STRATEGY_SEGMENTS_LIMIT);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should update CORS settings', async () => {
|
||||||
|
const { body } = await request
|
||||||
|
.get(`${base}/api/admin/ui-config`)
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body.frontendApiOrigins).toEqual(['*']);
|
||||||
|
|
||||||
|
await request
|
||||||
|
.post(`${base}/api/admin/ui-config/cors`)
|
||||||
|
.send({
|
||||||
|
frontendApiOrigins: ['https://example.com'],
|
||||||
|
})
|
||||||
|
.expect(204);
|
||||||
|
|
||||||
|
const { body: updatedBody } = await request
|
||||||
|
.get(`${base}/api/admin/ui-config`)
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(updatedBody.frontendApiOrigins).toEqual(['https://example.com']);
|
||||||
|
});
|
||||||
|
@ -10,7 +10,7 @@ import {
|
|||||||
type SimpleAuthSettings,
|
type SimpleAuthSettings,
|
||||||
simpleAuthSettingsKey,
|
simpleAuthSettingsKey,
|
||||||
} from '../../types/settings/simple-auth-settings';
|
} from '../../types/settings/simple-auth-settings';
|
||||||
import { ADMIN, NONE } from '../../types/permissions';
|
import { ADMIN, NONE, UPDATE_CORS } from '../../types/permissions';
|
||||||
import { createResponseSchema } from '../../openapi/util/create-response-schema';
|
import { createResponseSchema } from '../../openapi/util/create-response-schema';
|
||||||
import {
|
import {
|
||||||
uiConfigSchema,
|
uiConfigSchema,
|
||||||
@ -22,6 +22,7 @@ import { emptyResponse } from '../../openapi/util/standard-responses';
|
|||||||
import type { IAuthRequest } from '../unleash-types';
|
import type { IAuthRequest } from '../unleash-types';
|
||||||
import NotFoundError from '../../error/notfound-error';
|
import NotFoundError from '../../error/notfound-error';
|
||||||
import type { SetUiConfigSchema } from '../../openapi/spec/set-ui-config-schema';
|
import type { SetUiConfigSchema } from '../../openapi/spec/set-ui-config-schema';
|
||||||
|
import type { SetCorsSchema } from '../../openapi/spec/set-cors-schema';
|
||||||
import { createRequestSchema } from '../../openapi/util/create-request-schema';
|
import { createRequestSchema } from '../../openapi/util/create-request-schema';
|
||||||
import type { FrontendApiService, SessionService } from '../../services';
|
import type { FrontendApiService, SessionService } from '../../services';
|
||||||
import type MaintenanceService from '../../features/maintenance/maintenance-service';
|
import type MaintenanceService from '../../features/maintenance/maintenance-service';
|
||||||
@ -99,6 +100,7 @@ class ConfigController extends Controller {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO: deprecate when removing `granularAdminPermissions` flag
|
||||||
this.route({
|
this.route({
|
||||||
method: 'post',
|
method: 'post',
|
||||||
path: '',
|
path: '',
|
||||||
@ -116,6 +118,24 @@ class ConfigController extends Controller {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.route({
|
||||||
|
method: 'post',
|
||||||
|
path: '/cors',
|
||||||
|
handler: this.setCors,
|
||||||
|
permission: [ADMIN, UPDATE_CORS],
|
||||||
|
middleware: [
|
||||||
|
openApiService.validPath({
|
||||||
|
tags: ['Admin UI'],
|
||||||
|
summary: 'Sets allowed CORS origins',
|
||||||
|
description:
|
||||||
|
'Sets Cross-Origin Resource Sharing headers for Frontend SDK API.',
|
||||||
|
operationId: 'setCors',
|
||||||
|
requestBody: createRequestSchema('setCorsSchema'),
|
||||||
|
responses: { 204: emptyResponse },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUiConfig(
|
async getUiConfig(
|
||||||
@ -197,6 +217,30 @@ class ConfigController extends Controller {
|
|||||||
|
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setCors(
|
||||||
|
req: IAuthRequest<void, void, SetCorsSchema>,
|
||||||
|
res: Response<string>,
|
||||||
|
): Promise<void> {
|
||||||
|
const granularAdminPermissions = this.flagResolver.isEnabled(
|
||||||
|
'granularAdminPermissions',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!granularAdminPermissions) {
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.frontendApiOrigins) {
|
||||||
|
await this.frontendApiService.setFrontendCorsSettings(
|
||||||
|
req.body.frontendApiOrigins,
|
||||||
|
req.audit,
|
||||||
|
);
|
||||||
|
res.sendStatus(204);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ConfigController;
|
export default ConfigController;
|
||||||
|
@ -200,9 +200,10 @@ export const createServices = (
|
|||||||
? new FeatureLifecycleReadModel(db, config.flagResolver)
|
? new FeatureLifecycleReadModel(db, config.flagResolver)
|
||||||
: new FakeFeatureLifecycleReadModel();
|
: new FakeFeatureLifecycleReadModel();
|
||||||
|
|
||||||
const contextService = db
|
const transactionalContextService = db
|
||||||
? withTransactional(createContextService(config), db)
|
? withTransactional(createContextService(config), db)
|
||||||
: withFakeTransactional(createFakeContextService(config));
|
: withFakeTransactional(createFakeContextService(config));
|
||||||
|
const contextService = transactionalContextService;
|
||||||
const emailService = new EmailService(config);
|
const emailService = new EmailService(config);
|
||||||
const featureTypeService = new FeatureTypeService(
|
const featureTypeService = new FeatureTypeService(
|
||||||
stores,
|
stores,
|
||||||
@ -434,6 +435,7 @@ export const createServices = (
|
|||||||
clientInstanceService,
|
clientInstanceService,
|
||||||
clientMetricsServiceV2,
|
clientMetricsServiceV2,
|
||||||
contextService,
|
contextService,
|
||||||
|
transactionalContextService,
|
||||||
versionService,
|
versionService,
|
||||||
apiTokenService,
|
apiTokenService,
|
||||||
emailService,
|
emailService,
|
||||||
|
@ -56,12 +56,12 @@ export type IFlagKey =
|
|||||||
| 'showUserDeviceCount'
|
| 'showUserDeviceCount'
|
||||||
| 'deleteStaleUserSessions'
|
| 'deleteStaleUserSessions'
|
||||||
| 'memorizeStats'
|
| 'memorizeStats'
|
||||||
| 'licensedUsers'
|
|
||||||
| 'granularAdminPermissions'
|
| 'granularAdminPermissions'
|
||||||
| 'streaming'
|
| 'streaming'
|
||||||
| 'etagVariant'
|
| 'etagVariant'
|
||||||
| 'oidcRedirect'
|
| 'oidcRedirect'
|
||||||
| 'deltaApi';
|
| 'deltaApi'
|
||||||
|
| 'newHostedAuthHandler';
|
||||||
|
|
||||||
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
||||||
|
|
||||||
@ -265,10 +265,6 @@ const flags: IFlags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_FLAG_OVERVIEW_REDESIGN,
|
process.env.UNLEASH_EXPERIMENTAL_FLAG_OVERVIEW_REDESIGN,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
licensedUsers: parseEnvVarBoolean(
|
|
||||||
process.env.UNLEASH_EXPERIMENTAL_FLAG_LICENSED_USERS,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
granularAdminPermissions: parseEnvVarBoolean(
|
granularAdminPermissions: parseEnvVarBoolean(
|
||||||
process.env.UNLEASH_EXPERIMENTAL_GRANULAR_ADMIN_PERMISSIONS,
|
process.env.UNLEASH_EXPERIMENTAL_GRANULAR_ADMIN_PERMISSIONS,
|
||||||
false,
|
false,
|
||||||
@ -290,6 +286,10 @@ const flags: IFlags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_DELTA_API,
|
process.env.UNLEASH_EXPERIMENTAL_DELTA_API,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
newHostedAuthHandler: parseEnvVarBoolean(
|
||||||
|
process.env.UNLEASH_EXPERIMENTAL_NEW_HOSTED_AUTH_HANDLER,
|
||||||
|
false,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultExperimentalOptions: IExperimentalOptions = {
|
export const defaultExperimentalOptions: IExperimentalOptions = {
|
||||||
|
@ -54,7 +54,13 @@ export interface IVersionOption {
|
|||||||
export enum IAuthType {
|
export enum IAuthType {
|
||||||
OPEN_SOURCE = 'open-source',
|
OPEN_SOURCE = 'open-source',
|
||||||
DEMO = 'demo',
|
DEMO = 'demo',
|
||||||
|
/**
|
||||||
|
* Self-hosted by the customer. Should eventually be renamed to better reflect this.
|
||||||
|
*/
|
||||||
ENTERPRISE = 'enterprise',
|
ENTERPRISE = 'enterprise',
|
||||||
|
/**
|
||||||
|
* Hosted by Unleash.
|
||||||
|
*/
|
||||||
HOSTED = 'hosted',
|
HOSTED = 'hosted',
|
||||||
CUSTOM = 'custom',
|
CUSTOM = 'custom',
|
||||||
NONE = 'none',
|
NONE = 'none',
|
||||||
|
@ -41,8 +41,10 @@ export const CREATE_TAG_TYPE = 'CREATE_TAG_TYPE';
|
|||||||
export const UPDATE_TAG_TYPE = 'UPDATE_TAG_TYPE';
|
export const UPDATE_TAG_TYPE = 'UPDATE_TAG_TYPE';
|
||||||
export const DELETE_TAG_TYPE = 'DELETE_TAG_TYPE';
|
export const DELETE_TAG_TYPE = 'DELETE_TAG_TYPE';
|
||||||
|
|
||||||
|
export const READ_LOGS = 'READ_LOGS';
|
||||||
export const UPDATE_MAINTENANCE_MODE = 'UPDATE_MAINTENANCE_MODE';
|
export const UPDATE_MAINTENANCE_MODE = 'UPDATE_MAINTENANCE_MODE';
|
||||||
export const UPDATE_INSTANCE_BANNERS = 'UPDATE_INSTANCE_BANNERS';
|
export const UPDATE_INSTANCE_BANNERS = 'UPDATE_INSTANCE_BANNERS';
|
||||||
|
export const UPDATE_CORS = 'UPDATE_CORS';
|
||||||
export const UPDATE_AUTH_CONFIGURATION = 'UPDATE_AUTH_CONFIGURATION';
|
export const UPDATE_AUTH_CONFIGURATION = 'UPDATE_AUTH_CONFIGURATION';
|
||||||
|
|
||||||
// Project
|
// Project
|
||||||
@ -147,7 +149,12 @@ export const ROOT_PERMISSION_CATEGORIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Instance maintenance',
|
label: 'Instance maintenance',
|
||||||
permissions: [UPDATE_MAINTENANCE_MODE, UPDATE_INSTANCE_BANNERS],
|
permissions: [
|
||||||
|
READ_LOGS,
|
||||||
|
UPDATE_MAINTENANCE_MODE,
|
||||||
|
UPDATE_INSTANCE_BANNERS,
|
||||||
|
UPDATE_CORS,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Authentication',
|
label: 'Authentication',
|
||||||
@ -162,4 +169,5 @@ export const MAINTENANCE_MODE_PERMISSIONS = [
|
|||||||
READ_CLIENT_API_TOKEN,
|
READ_CLIENT_API_TOKEN,
|
||||||
READ_FRONTEND_API_TOKEN,
|
READ_FRONTEND_API_TOKEN,
|
||||||
UPDATE_MAINTENANCE_MODE,
|
UPDATE_MAINTENANCE_MODE,
|
||||||
|
READ_LOGS,
|
||||||
];
|
];
|
||||||
|
@ -69,6 +69,7 @@ export interface IUnleashServices {
|
|||||||
clientInstanceService: ClientInstanceService;
|
clientInstanceService: ClientInstanceService;
|
||||||
clientMetricsServiceV2: ClientMetricsServiceV2;
|
clientMetricsServiceV2: ClientMetricsServiceV2;
|
||||||
contextService: ContextService;
|
contextService: ContextService;
|
||||||
|
transactionalContextService: WithTransactional<ContextService>;
|
||||||
emailService: EmailService;
|
emailService: EmailService;
|
||||||
environmentService: EnvironmentService;
|
environmentService: EnvironmentService;
|
||||||
transactionalEnvironmentService: WithTransactional<EnvironmentService>;
|
transactionalEnvironmentService: WithTransactional<EnvironmentService>;
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
exports.up = function (db, cb) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
UPDATE role_permission SET environment = null where environment = '';
|
||||||
|
DELETE FROM role_permission WHERE environment IS NOT NULL AND environment NOT IN (SELECT name FROM environments);
|
||||||
|
ALTER TABLE role_permission ADD CONSTRAINT fk_role_permission_environment FOREIGN KEY (environment) REFERENCES environments(name) ON DELETE CASCADE;
|
||||||
|
`,
|
||||||
|
cb,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (db, cb) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
ALTER TABLE role_permission
|
||||||
|
DROP CONSTRAINT IF EXISTS fk_role_permission_environment;
|
||||||
|
`,
|
||||||
|
cb,
|
||||||
|
);
|
||||||
|
};
|
13
src/migrations/20250102150900-add-permission-read-logs.js
Normal file
13
src/migrations/20250102150900-add-permission-read-logs.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
exports.up = function (db, cb) {
|
||||||
|
db.runSql(`
|
||||||
|
INSERT INTO permissions (permission, display_name, type) VALUES ('READ_LOGS', 'Read instance logs and login history', 'root');
|
||||||
|
`, cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.down = function (db, cb) {
|
||||||
|
db.runSql(`
|
||||||
|
DELETE FROM permissions WHERE permission IN ('READ_LOGS');
|
||||||
|
`, cb);
|
||||||
|
}
|
@ -53,7 +53,6 @@ process.nextTick(async () => {
|
|||||||
releasePlans: false,
|
releasePlans: false,
|
||||||
showUserDeviceCount: true,
|
showUserDeviceCount: true,
|
||||||
flagOverviewRedesign: false,
|
flagOverviewRedesign: false,
|
||||||
licensedUsers: true,
|
|
||||||
granularAdminPermissions: true,
|
granularAdminPermissions: true,
|
||||||
deltaApi: true,
|
deltaApi: true,
|
||||||
},
|
},
|
||||||
|
@ -21,9 +21,19 @@ delete process.env.DATABASE_URL;
|
|||||||
// because of db-migrate bug (https://github.com/Unleash/unleash/issues/171)
|
// because of db-migrate bug (https://github.com/Unleash/unleash/issues/171)
|
||||||
process.setMaxListeners(0);
|
process.setMaxListeners(0);
|
||||||
|
|
||||||
|
async function getDefaultEnvRolePermissions(knex) {
|
||||||
|
return knex.table('role_permission').whereIn('environment', ['default']);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreRolePermissions(knex, rolePermissions) {
|
||||||
|
await knex.table('role_permission').insert(rolePermissions);
|
||||||
|
}
|
||||||
|
|
||||||
async function resetDatabase(knex) {
|
async function resetDatabase(knex) {
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
knex.table('environments').del(),
|
knex
|
||||||
|
.table('environments')
|
||||||
|
.del(), // deletes role permissions transitively
|
||||||
knex.table('strategies').del(),
|
knex.table('strategies').del(),
|
||||||
knex.table('features').del(),
|
knex.table('features').del(),
|
||||||
knex.table('client_applications').del(),
|
knex.table('client_applications').del(),
|
||||||
@ -110,15 +120,20 @@ export default async function init(
|
|||||||
const testDb = createDb(config);
|
const testDb = createDb(config);
|
||||||
const stores = await createStores(config, testDb);
|
const stores = await createStores(config, testDb);
|
||||||
stores.eventStore.setMaxListeners(0);
|
stores.eventStore.setMaxListeners(0);
|
||||||
|
const defaultRolePermissions = await getDefaultEnvRolePermissions(testDb);
|
||||||
await resetDatabase(testDb);
|
await resetDatabase(testDb);
|
||||||
await setupDatabase(stores);
|
await setupDatabase(stores);
|
||||||
|
await restoreRolePermissions(testDb, defaultRolePermissions);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rawDatabase: testDb,
|
rawDatabase: testDb,
|
||||||
stores,
|
stores,
|
||||||
reset: async () => {
|
reset: async () => {
|
||||||
|
const defaultRolePermissions =
|
||||||
|
await getDefaultEnvRolePermissions(testDb);
|
||||||
await resetDatabase(testDb);
|
await resetDatabase(testDb);
|
||||||
await setupDatabase(stores);
|
await setupDatabase(stores);
|
||||||
|
await restoreRolePermissions(testDb, defaultRolePermissions);
|
||||||
},
|
},
|
||||||
destroy: async () => {
|
destroy: async () => {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
@ -94,8 +94,6 @@ const createRole = async (rolePermissions: PermissionRef[]) => {
|
|||||||
|
|
||||||
const hasCommonProjectAccess = async (user, projectName, condition) => {
|
const hasCommonProjectAccess = async (user, projectName, condition) => {
|
||||||
const defaultEnv = 'default';
|
const defaultEnv = 'default';
|
||||||
const developmentEnv = 'development';
|
|
||||||
const productionEnv = 'production';
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
CREATE_FEATURE,
|
CREATE_FEATURE,
|
||||||
@ -155,70 +153,6 @@ const hasCommonProjectAccess = async (user, projectName, condition) => {
|
|||||||
defaultEnv,
|
defaultEnv,
|
||||||
),
|
),
|
||||||
).toBe(condition);
|
).toBe(condition);
|
||||||
expect(
|
|
||||||
await accessService.hasPermission(
|
|
||||||
user,
|
|
||||||
CREATE_FEATURE_STRATEGY,
|
|
||||||
projectName,
|
|
||||||
developmentEnv,
|
|
||||||
),
|
|
||||||
).toBe(condition);
|
|
||||||
expect(
|
|
||||||
await accessService.hasPermission(
|
|
||||||
user,
|
|
||||||
UPDATE_FEATURE_STRATEGY,
|
|
||||||
projectName,
|
|
||||||
developmentEnv,
|
|
||||||
),
|
|
||||||
).toBe(condition);
|
|
||||||
expect(
|
|
||||||
await accessService.hasPermission(
|
|
||||||
user,
|
|
||||||
DELETE_FEATURE_STRATEGY,
|
|
||||||
projectName,
|
|
||||||
developmentEnv,
|
|
||||||
),
|
|
||||||
).toBe(condition);
|
|
||||||
expect(
|
|
||||||
await accessService.hasPermission(
|
|
||||||
user,
|
|
||||||
UPDATE_FEATURE_ENVIRONMENT,
|
|
||||||
projectName,
|
|
||||||
developmentEnv,
|
|
||||||
),
|
|
||||||
).toBe(condition);
|
|
||||||
expect(
|
|
||||||
await accessService.hasPermission(
|
|
||||||
user,
|
|
||||||
CREATE_FEATURE_STRATEGY,
|
|
||||||
projectName,
|
|
||||||
productionEnv,
|
|
||||||
),
|
|
||||||
).toBe(condition);
|
|
||||||
expect(
|
|
||||||
await accessService.hasPermission(
|
|
||||||
user,
|
|
||||||
UPDATE_FEATURE_STRATEGY,
|
|
||||||
projectName,
|
|
||||||
productionEnv,
|
|
||||||
),
|
|
||||||
).toBe(condition);
|
|
||||||
expect(
|
|
||||||
await accessService.hasPermission(
|
|
||||||
user,
|
|
||||||
DELETE_FEATURE_STRATEGY,
|
|
||||||
projectName,
|
|
||||||
productionEnv,
|
|
||||||
),
|
|
||||||
).toBe(condition);
|
|
||||||
expect(
|
|
||||||
await accessService.hasPermission(
|
|
||||||
user,
|
|
||||||
UPDATE_FEATURE_ENVIRONMENT,
|
|
||||||
projectName,
|
|
||||||
productionEnv,
|
|
||||||
),
|
|
||||||
).toBe(condition);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFullProjectAccess = async (user, projectName: string, condition) => {
|
const hasFullProjectAccess = async (user, projectName: string, condition) => {
|
||||||
@ -378,7 +312,7 @@ test('should remove CREATE_FEATURE on default environment', async () => {
|
|||||||
await accessService.addPermissionToRole(
|
await accessService.addPermissionToRole(
|
||||||
editRole.id,
|
editRole.id,
|
||||||
permissions.CREATE_FEATURE,
|
permissions.CREATE_FEATURE,
|
||||||
'*',
|
'default',
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: to validate the remove works, we should make sure that we had permission before removing it
|
// TODO: to validate the remove works, we should make sure that we had permission before removing it
|
||||||
@ -637,7 +571,7 @@ test('should support permission with "ALL" environment requirement', async () =>
|
|||||||
await accessStore.addPermissionsToRole(
|
await accessStore.addPermissionsToRole(
|
||||||
customRole.id,
|
customRole.id,
|
||||||
[{ name: CREATE_FEATURE_STRATEGY }],
|
[{ name: CREATE_FEATURE_STRATEGY }],
|
||||||
'production',
|
'default',
|
||||||
);
|
);
|
||||||
await accessStore.addUserToRole(user.id, customRole.id, ALL_PROJECTS);
|
await accessStore.addUserToRole(user.id, customRole.id, ALL_PROJECTS);
|
||||||
|
|
||||||
@ -645,7 +579,7 @@ test('should support permission with "ALL" environment requirement', async () =>
|
|||||||
user,
|
user,
|
||||||
CREATE_FEATURE_STRATEGY,
|
CREATE_FEATURE_STRATEGY,
|
||||||
'default',
|
'default',
|
||||||
'production',
|
'default',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(hasAccess).toBe(true);
|
expect(hasAccess).toBe(true);
|
||||||
@ -667,7 +601,7 @@ test('Should have access to create a strategy in an environment', async () => {
|
|||||||
user,
|
user,
|
||||||
CREATE_FEATURE_STRATEGY,
|
CREATE_FEATURE_STRATEGY,
|
||||||
'default',
|
'default',
|
||||||
'development',
|
'default',
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@ -693,7 +627,7 @@ test('Should have access to edit a strategy in an environment', async () => {
|
|||||||
user,
|
user,
|
||||||
UPDATE_FEATURE_STRATEGY,
|
UPDATE_FEATURE_STRATEGY,
|
||||||
'default',
|
'default',
|
||||||
'development',
|
'default',
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@ -706,7 +640,7 @@ test('Should have access to delete a strategy in an environment', async () => {
|
|||||||
user,
|
user,
|
||||||
DELETE_FEATURE_STRATEGY,
|
DELETE_FEATURE_STRATEGY,
|
||||||
'default',
|
'default',
|
||||||
'development',
|
'default',
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
10
yarn.lock
10
yarn.lock
@ -9229,9 +9229,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"unleash-client@npm:^6.3.3":
|
"unleash-client@npm:^6.4.0":
|
||||||
version: 6.3.3
|
version: 6.4.0
|
||||||
resolution: "unleash-client@npm:6.3.3"
|
resolution: "unleash-client@npm:6.4.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
http-proxy-agent: "npm:^7.0.2"
|
http-proxy-agent: "npm:^7.0.2"
|
||||||
https-proxy-agent: "npm:^7.0.5"
|
https-proxy-agent: "npm:^7.0.5"
|
||||||
@ -9241,7 +9241,7 @@ __metadata:
|
|||||||
murmurhash3js: "npm:^3.0.1"
|
murmurhash3js: "npm:^3.0.1"
|
||||||
proxy-from-env: "npm:^1.1.0"
|
proxy-from-env: "npm:^1.1.0"
|
||||||
semver: "npm:^7.6.2"
|
semver: "npm:^7.6.2"
|
||||||
checksum: 10c0/047f3b63aa1cadde15abc39a4f627f77c297aaa15d11928d93d86815f88b7d72811c931be4a7fcd44887cc12bdb5ad32f674dbe19b62665ece4a86dac77224bb
|
checksum: 10c0/df9647b903d21537f1d4d1fbebc4bd1451e8e1f6090f17bdab1eba67158bd98794e535c7850be1472f839cd3f2b06398add097fc7a1a8c5c1ec936c6c0274427
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -9356,7 +9356,7 @@ __metadata:
|
|||||||
tsc-watch: "npm:6.2.1"
|
tsc-watch: "npm:6.2.1"
|
||||||
type-is: "npm:^1.6.18"
|
type-is: "npm:^1.6.18"
|
||||||
typescript: "npm:5.4.5"
|
typescript: "npm:5.4.5"
|
||||||
unleash-client: "npm:^6.3.3"
|
unleash-client: "npm:^6.4.0"
|
||||||
uuid: "npm:^9.0.0"
|
uuid: "npm:^9.0.0"
|
||||||
wait-on: "npm:^7.2.0"
|
wait-on: "npm:^7.2.0"
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
|
Loading…
Reference in New Issue
Block a user