1
0
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:
sjaanus 2025-01-08 13:14:14 +02:00
commit 6006544dcc
No known key found for this signature in database
GPG Key ID: 20E007C0248BA7FF
37 changed files with 379 additions and 181 deletions

View File

@ -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>

View File

@ -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>
); );

View File

@ -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>

View File

@ -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>>;

View File

@ -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>

View File

@ -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>
); );

View File

@ -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>

View File

@ -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 }) => ({

View File

@ -57,6 +57,10 @@ export const MilestoneCardName = ({
setEditMode(false); setEditMode(false);
} }
}} }}
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
}}
/> />
)} )}
{!editMode && ( {!editMode && (

View File

@ -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,
}; };

View File

@ -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;
}; };

View File

@ -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": {

View File

@ -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 {

View File

@ -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'],
});
});

View File

@ -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();

View File

@ -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 {

View File

@ -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!),
), ),
]; ];

View File

@ -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),
); );

View File

@ -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 () => {

View File

@ -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 =

View File

@ -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);

View 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();
});

View File

@ -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';

View 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>;

View File

@ -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']);
});

View File

@ -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;

View File

@ -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,

View File

@ -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 = {

View File

@ -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',

View File

@ -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,
]; ];

View File

@ -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>;

View File

@ -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,
);
};

View 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);
}

View File

@ -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,
}, },

View File

@ -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) => {

View File

@ -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);
}); });

View File

@ -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