diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index f6f3f6f7f7ab..b8b72785806d 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -60,7 +60,6 @@ module.exports = { 'Radio': true, 'React': true, 'ReactDOM': true, - 'RemoveIcon': true, 'RequiredElement': true, 'RequiredFunc': true, 'RequiredObject': true, diff --git a/frontend/common/constants.ts b/frontend/common/constants.ts index 2d8830ce88a3..3438e4f41fdb 100644 --- a/frontend/common/constants.ts +++ b/frontend/common/constants.ts @@ -163,7 +163,7 @@ export default { ), }), - 'USER_TRAITS': (envId: string, userId?: string) => ({ + 'USER_TRAITS': (envId: string, userId = keywords.USER_ID) => ({ '.NET': require('./code-help/traits/traits-dotnet')( envId, keywords, @@ -522,4 +522,5 @@ export default { '#DE3163', ], untaggedTag: { color: '#dedede', label: 'Untagged' }, + upgradeURL: '/organisation-settings?tab=billing', } diff --git a/frontend/common/providers/OrganisationProvider.tsx b/frontend/common/providers/OrganisationProvider.tsx index 97c548e7720f..9ae19c89637b 100644 --- a/frontend/common/providers/OrganisationProvider.tsx +++ b/frontend/common/providers/OrganisationProvider.tsx @@ -15,12 +15,13 @@ import { useGetGroupsQuery } from 'common/services/useGroup' type OrganisationProviderType = { onRemoveProject?: () => void onSave?: (data: { environmentId: number; projectId: number }) => void - id?: number + id?: number | string children: (props: { createProject: typeof AppActions.createProject invalidateInviteLink: typeof AppActions.invalidateInviteLink inviteLinks: InviteLink[] | null invites: Invite[] | null + error: any isLoading: boolean isSaving: boolean name: string @@ -28,7 +29,7 @@ type OrganisationProviderType = { groups: UserGroupSummary[] | null projects: Project[] | null subscriptionMeta: SubscriptionMeta | null - users: User[] | null + users: User[] }) => ReactNode } @@ -76,6 +77,7 @@ const OrganisationProvider: FC = ({ <> {children({ createProject: AppActions.createProject, + error: AccountStore.error, groups: groups?.results || [], invalidateInviteLink: AppActions.invalidateInviteLink, inviteLinks: OrganisationStore.getInviteLinks(), diff --git a/frontend/common/providers/Permission.tsx b/frontend/common/providers/Permission.tsx index 1852b7dc940b..0be20f8697fb 100644 --- a/frontend/common/providers/Permission.tsx +++ b/frontend/common/providers/Permission.tsx @@ -39,7 +39,7 @@ const Permission: FC = ({ {children({ isLoading, permission: hasPermission || AccountStore.isAdmin(), - }) ||
} + }) || null} ) } diff --git a/frontend/common/services/useEnvironment.ts b/frontend/common/services/useEnvironment.ts index 2a0bd2aaf652..e40d7e747bd1 100644 --- a/frontend/common/services/useEnvironment.ts +++ b/frontend/common/services/useEnvironment.ts @@ -32,12 +32,9 @@ export async function getEnvironments( typeof environmentService.endpoints.getEnvironments.initiate >[1], ) { - store.dispatch( + return store.dispatch( environmentService.endpoints.getEnvironments.initiate(data, options), ) - return Promise.all( - store.dispatch(environmentService.util.getRunningQueriesThunk()), - ) } export async function getEnvironment( store: any, diff --git a/frontend/common/stores/account-store.js b/frontend/common/stores/account-store.js index 8a05dd10f7fb..67b375919088 100644 --- a/frontend/common/stores/account-store.js +++ b/frontend/common/stores/account-store.js @@ -1,3 +1,5 @@ +import { matchPath } from 'react-router' + const Dispatcher = require('../dispatcher/dispatcher') const BaseStore = require('./base/_store') const data = require('../data/base/_data') @@ -52,7 +54,7 @@ const controller = { store.saved() if (isLoginPage) { - window.location.href = `/organisation-settings` + window.location.href = `/organisations` } }) .catch((e) => { @@ -277,6 +279,7 @@ const controller = { }, selectOrganisation: (id) => { + API.setCookie('organisation', `${id}`) store.organisation = _.find(store.model.organisations, { id }) store.changed() }, @@ -299,12 +302,18 @@ const controller = { if (user && user.organisations) { store.organisation = user.organisations[0] const cookiedID = API.getCookie('organisation') - if (cookiedID) { + const pathID = matchPath(document.location.pathname, { + path: '/organisation/:organisationId', + strict: false, + })?.params?.organisationId + const orgId = pathID || cookiedID + if (orgId) { const foundOrganisation = user.organisations.find( - (v) => `${v.id}` === cookiedID, + (v) => `${v.id}` === orgId, ) if (foundOrganisation) { store.organisation = foundOrganisation + AppActions.getOrganisation(orgId) } } } diff --git a/frontend/common/stores/organisation-store.js b/frontend/common/stores/organisation-store.js index 977fac78fb43..55ec16edcc1e 100644 --- a/frontend/common/stores/organisation-store.js +++ b/frontend/common/stores/organisation-store.js @@ -57,6 +57,7 @@ const controller = { environmentId: res[0].api_key, projectId: project.id, } + AppActions.refreshOrganisation() store.saved() }) }) @@ -125,7 +126,7 @@ const controller = { }) }, getOrganisation: (id, force) => { - if (id !== store.id || force) { + if (`${id}` !== `${store.id}` || force) { store.id = id store.loading() @@ -146,7 +147,7 @@ const controller = { : [], ), ).then((res) => { - if (id === store.id) { + if (`${id}` === `${store.id}`) { // eslint-disable-next-line prefer-const let [_projects, users, invites, subscriptionMeta] = res let projects = _.sortBy(_projects, 'name') diff --git a/frontend/common/stores/project-store.js b/frontend/common/stores/project-store.js index 7c3a2f9bd4c3..0fe419143906 100644 --- a/frontend/common/stores/project-store.js +++ b/frontend/common/stores/project-store.js @@ -91,6 +91,7 @@ const controller = { data.put(`${Project.api}projects/${project.id}/`, project).then((res) => { store.model = Object.assign(store.model, res) getStore().dispatch(projectService.util.invalidateTags(['Project'])) + AppActions.refreshOrganisation() store.saved() }) }, diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index b224ccd18d1f..184f563db631 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -316,6 +316,7 @@ export type githubIntegration = { export type User = { id: number email: string + last_login?: string first_name: string last_name: string role: 'ADMIN' | 'USER' @@ -547,6 +548,7 @@ export type Invite = { email: string date_created: string invited_by: User + link: string permission_groups: number[] } diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 4eeae5bb26d4..f38087533795 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -245,6 +245,13 @@ const Utils = Object.assign({}, require('./base/_utils'), { } return 'UPDATE_FEATURE_STATE' }, + canCreateOrganisation() { + return ( + !Utils.getFlagsmithHasFeature('disable_create_org') && + (!Project.superUserCreateOnly || + (Project.superUserCreateOnly && AccountStore.isSuper())) + ) + }, getManageFeaturePermissionDescription(isChangeRequest: boolean) { if (isChangeRequest) { return 'Create Change Request' @@ -257,6 +264,13 @@ const Utils = Object.assign({}, require('./base/_utils'), { getManageUserPermissionDescription() { return 'Manage Identities' }, + getOrganisationHomePage(id?: string) { + const orgId = id || AccountStore.getOrganisation()?.id + if (!orgId) { + return `/organisations` + } + return `/organisation/${orgId}/projects` + }, getPermissionList( isAdmin: boolean, permissions: string[] | undefined | null, @@ -287,6 +301,7 @@ const Utils = Object.assign({}, require('./base/_utils'), { .map((item) => `${Format.enumeration.get(item)}`), } }, + getPlanName: (plan: string) => { if (plan && plan.includes('scale-up')) { return planNames.scaleUp @@ -380,10 +395,10 @@ const Utils = Object.assign({}, require('./base/_utils'), { ) return !!found }, + getProjectColour(index: number) { return Constants.projectColors[index % (Constants.projectColors.length - 1)] }, - getSDKEndpoint(_project: ProjectType) { const project = _project || ProjectStore.model diff --git a/frontend/e2e/helpers.cafe.ts b/frontend/e2e/helpers.cafe.ts index 11184e16be28..300f9aede73b 100644 --- a/frontend/e2e/helpers.cafe.ts +++ b/frontend/e2e/helpers.cafe.ts @@ -103,6 +103,7 @@ export const getLogger = () => }) export const gotoTraits = async () => { + await click('#features-link') await click('#users-link') await click(byId('user-item-0')) await waitForElementVisible('#add-trait') @@ -219,6 +220,7 @@ export const saveFeatureSegments = async () => { } export const goToUser = async (index: number) => { + await click('#features-link') await click('#users-link') await click(byId(`user-item-${index}`)) } @@ -331,6 +333,7 @@ export const createRemoteConfig = async ( export const createOrganisationAndProject = async (organisationName:string,projectName:string) =>{ log('Create Organisation') + await click(byId('home-link')) await click(byId('create-organisation-btn')) await setText('[name="orgName"]', organisationName) await click('#create-org-btn') diff --git a/frontend/e2e/tests/initialise-tests.ts b/frontend/e2e/tests/initialise-tests.ts index 91fafe9d7832..348628bb6d74 100644 --- a/frontend/e2e/tests/initialise-tests.ts +++ b/frontend/e2e/tests/initialise-tests.ts @@ -26,6 +26,7 @@ export default async function () { await waitForElementVisible(byId('features-page')) log('Hide disabled flags') + await click('#project-link') await click('#project-settings-link') await click(byId('js-sdk-settings')) await click(byId('js-hide-disabled-flags')) diff --git a/frontend/e2e/tests/invite-test.ts b/frontend/e2e/tests/invite-test.ts index 253133c8589b..6a1502647535 100644 --- a/frontend/e2e/tests/invite-test.ts +++ b/frontend/e2e/tests/invite-test.ts @@ -18,6 +18,7 @@ export default async function () { log('Get Invite url') await t.navigateTo('http://localhost:3000/organisation-settings') await Selector(byId('organisation-name')).value + await click(byId('users-and-permissions')) const inviteLink = await Selector(byId('invite-link')).value log('Accept invite') await t.navigateTo(inviteLink) diff --git a/frontend/e2e/tests/project-test.ts b/frontend/e2e/tests/project-test.ts index bfe63a0f60f7..48252d2c712b 100644 --- a/frontend/e2e/tests/project-test.ts +++ b/frontend/e2e/tests/project-test.ts @@ -1,11 +1,12 @@ import { + assertTextContent, byId, click, log, login, setText, waitForElementVisible, -} from '../helpers.cafe' +} from '../helpers.cafe'; import { E2E_USER, PASSWORD } from '../config' export default async function () { @@ -13,8 +14,10 @@ export default async function () { await login(E2E_USER, PASSWORD) await click('#project-select-0') log('Edit Project') + await click('#project-link') await click('#project-settings-link') await setText("[name='proj-name']", 'Test Project') await click('#save-proj-btn') - await waitForElementVisible(byId('switch-project-test-project')) + await assertTextContent(`#project-link`, 'Test Project') + } diff --git a/frontend/e2e/tests/versioning-tests.ts b/frontend/e2e/tests/versioning-tests.ts index 7d395791309b..20be8b42c21e 100644 --- a/frontend/e2e/tests/versioning-tests.ts +++ b/frontend/e2e/tests/versioning-tests.ts @@ -9,7 +9,6 @@ import { editRemoteConfig, log, login, refreshUntilElementVisible, - waitAndRefresh, waitForElementVisible } from "../helpers.cafe"; import { E2E_USER, PASSWORD } from '../config'; diff --git a/frontend/global.d.ts b/frontend/global.d.ts index dd9f6e9e8da6..fcbd2ee93343 100644 --- a/frontend/global.d.ts +++ b/frontend/global.d.ts @@ -33,7 +33,6 @@ declare global { const FormGroup: typeof Component const Select: typeof _Select const Column: typeof Component - const RemoveIcon: typeof Component const Loader: typeof Component const E2E: boolean const DYNATRACE_URL: string | undefined diff --git a/frontend/web/components/AlertBar.js b/frontend/web/components/AlertBar.js deleted file mode 100644 index 9e69aca3a8a6..000000000000 --- a/frontend/web/components/AlertBar.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react' -import ModalClose from './modals/base/ModalClose' - -const AlertBar = class extends React.Component { - state = {} - - componentDidMount() { - document.body.classList.add('alert-shown') - } - - componentWillUnmount() { - document.body.classList.remove('alert-shown', 'hide') - } - - hide = () => { - document.body.classList.add('hide') - this.setState({ hide: true }) - } - - render() { - return ( - - {this.props.children} - {!this.props.preventClose && } - - ) - } -} - -module.exports = AlertBar diff --git a/frontend/web/components/App.js b/frontend/web/components/App.js index d177591bce23..27c9c3287998 100644 --- a/frontend/web/components/App.js +++ b/frontend/web/components/App.js @@ -1,9 +1,8 @@ import React, { Component, Fragment } from 'react' import { matchPath } from 'react-router' -import { withRouter } from 'react-router-dom' +import { Link, withRouter } from 'react-router-dom' import amplitude from 'amplitude-js' import NavLink from 'react-router-dom/NavLink' -import Aside from './Aside' import TwoFactorPrompt from './SimpleTwoFactor/prompt' import Maintenance from './Maintenance' import Blocked from './Blocked' @@ -25,6 +24,17 @@ import InfoMessage from './InfoMessage' import OrganisationLimit from './OrganisationLimit' import GithubStar from './GithubStar' import Tooltip from './Tooltip' +import classNames from 'classnames' +import { apps, gitBranch, gitCompare, home, statsChart } from 'ionicons/icons'; +import NavSubLink from './NavSubLink' +import SettingsIcon from './svg/SettingsIcon' +import UsersIcon from './svg/UsersIcon' +import BreadcrumbSeparator from './BreadcrumbSeparator' +import OrganisationStore from 'common/stores/organisation-store' +import SegmentsIcon from './svg/SegmentsIcon' +import AuditLogIcon from './svg/AuditLogIcon' +import Permission from 'common/providers/Permission' +import HomeAside from './pages/HomeAside' const App = class extends Component { static propTypes = { @@ -49,8 +59,45 @@ const App = class extends Component { ES6Component(this) } + getProjectId = (props) => { + const { location } = props + const pathname = location.pathname + + const match = matchPath(pathname, { + exact: false, + path: '/project/:projectId/environment/:environmentId', + strict: false, + }) + const match2 = matchPath(pathname, { + exact: false, + path: '/project/:projectId', + strict: false, + }) + const projectId = + _.get(match, 'params.projectId') || _.get(match2, 'params.projectId') + return projectId + } + getEnvironmentId = (props) => { + const { location } = props + const pathname = location.pathname + + const match = matchPath(pathname, { + exact: false, + path: '/project/:projectId/environment/:environmentId', + strict: false, + }) + + const environmentId = _.get(match, 'params.environmentId') + return environmentId + } + componentDidMount = () => { getBuildVersion() + this.state.projectId = this.getProjectId(this.props) + if (this.state.projectId) { + AppActions.getProject(this.state.projectId) + } + this.listenTo(OrganisationStore, 'change', () => this.forceUpdate()) this.listenTo(ProjectStore, 'change', () => this.forceUpdate()) this.listenTo(AccountStore, 'change', this.getOrganisationUsage) this.getOrganisationUsage() @@ -93,6 +140,12 @@ const App = class extends Component { componentDidUpdate(prevProps) { if (prevProps.location.pathname !== this.props.location.pathname) { + const newProjectId = this.getProjectId(this.props) + if (this.state.projectId !== newProjectId && !!newProjectId) { + this.state.projectId = newProjectId + AppActions.getProject(this.state.projectId) + } + if (isMobile) { this.setState({ asideIsVisible: false }) } @@ -176,12 +229,15 @@ const App = class extends Component { id: lastEnv.orgId, }) if (!lastOrg) { - this.context.router.history.replace('/organisation-settings') + this.context.router.history.replace('/select-organistion') return } const org = AccountStore.getOrganisation() - if (!org || org.id !== lastOrg.id) { + if ( + !org || + (org.id !== lastOrg.id && this.getEnvironmentId(this.props)) + ) { AppActions.selectOrganisation(lastOrg.id) AppActions.getOrganisation(lastOrg.id) } @@ -192,7 +248,7 @@ const App = class extends Component { return } - this.context.router.history.replace('/organisation-settings') + this.context.router.history.replace(Utils.getOrganisationHomePage()) }) } } @@ -236,20 +292,10 @@ const App = class extends Component { const { location } = this.props const pathname = location.pathname const { asideIsVisible, lastEnvironmentId, lastProjectId } = this.state - const match = matchPath(pathname, { - exact: false, - path: '/project/:projectId/environment/:environmentId', - strict: false, - }) - const match2 = matchPath(pathname, { - exact: false, - path: '/project/:projectId', - strict: false, - }) - const projectId = - _.get(match, 'params.projectId') || _.get(match2, 'params.projectId') - const environmentId = _.get(match, 'params.environmentId') - + const projectId = this.getProjectId(this.props) + const environmentId = this.getEnvironmentId(this.props) + const isCreateEnvironment = environmentId === 'create' + const isCreateOrganisation = document.location.pathname === '/create' const storageHasParams = lastEnvironmentId || lastProjectId const pageHasAside = environmentId || projectId || storageHasParams const isHomepage = @@ -270,6 +316,10 @@ const App = class extends Component { if (Project.maintenance || this.props.error || !window.projectOverrides) { return } + const activeProject = OrganisationStore.getProject(projectId) + const projectNotLoaded = + !activeProject && document.location.href.includes('project/') + if (this.props.isLoading) { return ( } - const projectNotLoaded = - !ProjectStore.model && document.location.href.includes('project/') if (document.location.href.includes('widget')) { return
{this.props.children}
} @@ -300,7 +348,10 @@ const App = class extends Component { (!dismissed || dismissed !== announcementValue.id) && Utils.getFlagsmithHasFeature('announcement') && this.state.showAnnouncement - + const isOrganisationSelect = document.location.pathname === '/organisations' + const integrations = Object.keys( + JSON.parse(Utils.getFlagsmithValue('integration_data') || '{}'), + ) return ( - {({ isSaving, user }, { twoFactorLogin }) => - user && user.twoFactorPrompt ? ( + {({ isSaving, user }, { twoFactorLogin }) => { + const inner = ( +
+ + {projectNotLoaded ? ( +
+ +
+ ) : ( + + {user && ( + + )} + {user && showBanner && ( + + + this.closeAnnouncement(announcementValue.id) + } + buttonText={announcementValue.buttonText} + url={announcementValue.url} + > +
+
{announcementValue.description}
+
+
+
+ )} + {this.props.children} +
+ )} +
+ ) + return user && user.twoFactorPrompt ? (
) : ( -
-
+
+
{!isHomepage && (!pageHasAside || !asideIsVisible || !isMobile) && ( -
) - } + }} ) diff --git a/frontend/web/components/Aside.js b/frontend/web/components/Aside.js deleted file mode 100644 index c9b3f67a4e4d..000000000000 --- a/frontend/web/components/Aside.js +++ /dev/null @@ -1,663 +0,0 @@ -import React, { Component } from 'react' -import propTypes from 'prop-types' -import NavLink from 'react-router-dom/NavLink' -import AsideTitleLink from './AsideTitleLink' -import Collapsible from './Collapsible' -import OrgSettingsIcon from './svg/OrgSettingsIcon' -import EnvironmentDropdown from './EnvironmentDropdown' -import ProjectStore from 'common/stores/project-store' -import ChangeRequestStore from 'common/stores/change-requests-store' -import getBuildVersion from 'project/getBuildVersion' -import ConfigProvider from 'common/providers/ConfigProvider' -import Permission from 'common/providers/Permission' -import Icon from './Icon' -import ProjectSelect from './ProjectSelect' -import AsideProjectButton from './AsideProjectButton' -import Constants from 'common/constants' -import { star, warning, pricetag } from 'ionicons/icons' -import { IonIcon } from '@ionic/react' - -const Aside = class extends Component { - static displayName = 'Aside' - - static contextTypes = { - router: propTypes.object.isRequired, - } - - static propTypes = { - asideIsVisible: propTypes.bool, - className: propTypes.string, - toggleAside: propTypes.func, - } - - constructor(props, context) { - super(props, context) - this.state = { isOpenProject: false } - ES6Component(this) - if (!this.props.disabled) { - AppActions.getProject(this.props.projectId) - if (this.props.environmentId && this.props.environmentId !== 'create') { - AppActions.getChangeRequests(this.props.environmentId, {}) - } - this.listenTo(ChangeRequestStore, 'change', () => this.forceUpdate()) - this.listenTo(ProjectStore, 'loaded', () => { - const environment = ProjectStore.getEnvironment( - this.props.environmentId, - ) - if (environment) { - AppActions.getChangeRequests( - this.props.environmentId, - Utils.changeRequestsEnabled( - environment.minimum_change_request_approvals, - ) - ? {} - : { live_from_after: new Date().toISOString() }, - ) - } - }) - } - } - - componentDidMount() { - getBuildVersion().then((version) => { - this.setState({ version }) - }) - } - - componentDidUpdate(prevProps) { - if (!this.props.disabled) { - const environment = ProjectStore.getEnvironment(this.props.environmentId) - if (this.props.projectId !== prevProps.projectId) { - AppActions.getProject(this.props.projectId) - } - if (this.props.environmentId !== prevProps.environmentId) { - if (environment) { - AppActions.getChangeRequests( - this.props.environmentId, - Utils.changeRequestsEnabled( - environment.minimum_change_request_approvals, - ) - ? {} - : { live_from_after: new Date().toISOString() }, - ) - } - } - } - } - - onProjectSave = () => { - AppActions.refreshOrganisation() - } - - render() { - const { asideIsVisible, disabled, toggleAside } = this.props - const integrations = Object.keys( - JSON.parse(Utils.getFlagsmithValue('integration_data') || '{}'), - ) - const environmentId = - (this.props.environmentId !== 'create' && this.props.environmentId) || - (ProjectStore.model && - ProjectStore.model.environments[0] && - ProjectStore.model.environments[0].api_key) - const environment = ProjectStore.getEnvironment(this.props.environmentId) - const hasRbacPermission = Utils.getPlansPermission('AUDIT') - const changeRequest = - environment && - Utils.changeRequestsEnabled(environment.minimum_change_request_approvals) - ? ChangeRequestStore.model[this.props.environmentId] - : null - const changeRequests = changeRequest?.count || 0 - const scheduled = - (environment && - ChangeRequestStore.scheduled[this.props.environmentId]?.count) || - 0 - return ( - - {() => ( - - {({ project }) => ( - -
- {isMobile && ( -
- {!asideIsVisible ? ( - - ) : ( - - )} -
- )} -
- { - -
- -
{project.name}
- - - ) : ( - 'No Project' - ) - } - onClickOutside={() => { - this.setState({ isOpenProject: false }) - }} - onClick={() => { - this.setState((prev) => { - return { - isOpenProject: !prev.isOpenProject, - } - }) - }} - active={this.state.isOpenProject} - isProjectSelect - className='collapsible-project' - > - { - return ( - { - this.setState({ isOpenProject: false }) - onClick() - }} - name={_project.name} - active={project.id === _project.id} - /> - ) - }} - projectId={this.props.projectId} - environmentId={environmentId} - onChange={(project) => { - AppActions.getProject(project.id) - if (project.environments[0]) { - this.context.router.history.push( - `/project/${project.id}/environment/${project.environments[0].api_key}/features`, - ) - } else { - this.context.router.history.push( - `/project/${project.id}/environment/create`, - ) - } - AsyncStorage.setItem( - 'lastEnv', - JSON.stringify({ - environmentId: - project.environments[0].api_key, - orgId: AccountStore.getOrganisation().id, - projectId: project.id, - }), - ) - }} - /> -
-
- - {({ permission }) => - permission && ( - - - - - Project Settings - - ) - } - - - - - - - Segments - - - - - - Compare - - - - {({ permission }) => - permission && - hasRbacPermission && ( - - - - - Audit Log - - ) - } - - - {!hasRbacPermission && ( - - - - - Audit Log - - } - > - This feature is available with our scaleup plan - - )} - {!!integrations.length && ( - - {({ permission }) => - permission && ( - - - - - Integrations - - ) - } - - )} -
- - {({ permission }) => - permission && ( - - - - ) - } - - - { -
- ( - - - {({ - isLoading: manageIdentityLoading, - permission: manageIdentityPermission, - }) => ( - - {({ - isLoading, - permission: environmentAdmin, - }) => - isLoading || - manageIdentityLoading ? ( -
- -
- ) : ( -
- - - - - Features - - - - - - Scheduling - {scheduled ? ( - - {scheduled} - - ) : null} - - - - - - Change Requests{' '} - {changeRequests ? ( - - {changeRequests} - - ) : null} - - {environment.use_v2_feature_versioning && ( - - - - - History - - )} - {Utils.renderWithPermission( - manageIdentityPermission, - Constants.environmentPermissions( - 'View Identities', - ), - - - - - Identities - , - )} - - {environmentAdmin && ( - - - - - Settings - - )} -
- ) - } -
- )} -
-
- )} - projectId={this.props.projectId} - environmentId={environmentId} - clearableValue={false} - onChange={(environment) => { - this.context.router.history.push( - `/project/${this.props.projectId}/environment/${environment}/features`, - ) - AsyncStorage.setItem( - 'lastEnv', - JSON.stringify({ - environmentId: environment, - orgId: AccountStore.getOrganisation().id, - projectId: this.props.projectId, - }), - ) - }} - /> -
- } - -
- -
- {Utils.getFlagsmithHasFeature('demo_feature') && ( - - - - - Super cool demo feature! - - )} - - {Utils.getFlagsmithHasFeature('broken_feature') && ( - - - - - Demo Broken Feature - - )} - {this.state.version && ( -
- {this.state.version.tag !== 'Unknown' && ( - - - - {' '} - {this.state.version.tag} - - } - > - {`${ - this.state.version.frontend_sha !== - 'Unknown' - ? `Frontend SHA: ${this.state.version.frontend_sha}` - : '' - }${ - this.state.version.backend_sha !== - 'Unknown' - ? `${ - this.state.version.frontend_sha !== - 'Unknown' - ? '
' - : '' - }Backend SHA: ${ - this.state.version.backend_sha - }` - : '' - }`} -
- )} -
- )} - - {E2E && - AccountStore.getOrganisationRole() === - 'ADMIN' && ( - - - Organisation - - )} -
-
- - } -
-
- - )} - - )} - - ) - } -} - -module.exports = ConfigProvider(Aside) diff --git a/frontend/web/components/BreadcrumbSeparator.tsx b/frontend/web/components/BreadcrumbSeparator.tsx new file mode 100644 index 000000000000..50527a06646b --- /dev/null +++ b/frontend/web/components/BreadcrumbSeparator.tsx @@ -0,0 +1,439 @@ +import React, { FC, ReactNode, useEffect, useRef, useState } from 'react' +import { IonIcon } from '@ionic/react' +import { + checkmarkCircle, + chevronDown, + chevronUp, + createOutline, +} from 'ionicons/icons' +import InlineModal from './InlineModal' +import Input from './base/forms/Input' +import { + Environment, + Organisation, + PagedResponse, + Project, + User, +} from 'common/types/responses' +import AccountStore from 'common/stores/account-store' +import { useGetProjectsQuery } from 'common/services/useProject' +import AccountProvider from 'common/providers/AccountProvider' +import { RouterChildContext } from 'react-router' +import OrganisationStore from 'common/stores/organisation-store' +import AppActions from 'common/dispatcher/app-actions' +import Utils from 'common/utils/utils' +import { getStore } from 'common/store' +import { getEnvironments } from 'common/services/useEnvironment' +import classNames from 'classnames' +import Button from './base/forms/Button' +import { Link } from 'react-router-dom' +import CreateOrganisationModal from './modals/CreateOrganisation' +import { useHasPermission } from 'common/providers/Permission' +import Constants from 'common/constants' +import CreateProjectModal from './modals/CreateProject' + +type BreadcrumbSeparatorType = { + hideDropdown?: boolean + hideSlash?: boolean + children: ReactNode + focus?: 'organisation' | 'project' + projectId: string | undefined + router: RouterChildContext['router'] +} + +type ItemListType = { + items: any[] | undefined + onHover?: (item: any) => void + onClick: (item: any) => void + value?: any + hoverValue?: any + isLoading?: boolean + className?: string + title: string + footer?: ReactNode + search?: string +} + +const ItemList: FC = ({ + className, + footer, + hoverValue, + isLoading, + items: _items, + onClick, + onHover, + search, + title, + value, +}) => { + const items = search + ? _items?.filter((v) => { + return v.name.toLowerCase().includes(search.toLowerCase()) + }) + : _items + const ref = useRef(Utils.GUID()) + useEffect(() => { + const index = items?.findIndex((v) => `${v.id}` === `${hoverValue}`) + const el = document.getElementById(ref.current) + const childEl = document.getElementById(`${ref.current}-${index}`) + if (el && childEl) { + const containerBounds = el.getBoundingClientRect() + const elementBounds = childEl.getBoundingClientRect() + const isInView = + elementBounds.top >= containerBounds.top && + elementBounds.bottom <= containerBounds.bottom + if (!isInView) { + // Calculate how much to scroll the container to bring the element into view + const scrollAmount = elementBounds.top - containerBounds.top + + // Scroll the container + el.scrollTop += scrollAmount + } + } + }, [hoverValue]) + return ( +
+
{title}
+ {isLoading && ( +
+ +
+ )} + {items?.length === 0 ? ( + search ? ( +
+ No results found for "{search}" +
+ ) : ( +
No Results
+ ) + ) : null} + {items?.map((v, i) => { + const isActive = `${v.id}` === `${value}` + const isHovered = `${v.id}` === `${hoverValue}` + return ( + onHover?.(v)} + onClick={() => onClick(v)} + key={v.id} + className={classNames( + 'breadcrumb-link py-2 d-flex align-items-center justify-content-between', + { active: isActive }, + { hovered: isHovered }, + )} + > + {v.name} + {isActive && ( + + )} + + ) + })} +
+
+ {footer} +
+
+ ) +} + +const BreadcrumbSeparator: FC = ({ + children, + focus, + hideDropdown, + hideSlash, + projectId, + router, +}) => { + const [open, setOpen] = useState(false) + const [organisationSearch, setOrganisationSearch] = useState('') + const [projectSearch, setProjectSearch] = useState('') + + const [activeOrganisation, setActiveOrganisation] = useState( + `${AccountStore.getOrganisation()?.id}`, + ) + const [hoveredOrganisation, setHoveredOrganisation] = useState( + AccountStore.getOrganisation(), + ) + const [hoveredProject, setHoveredProject] = useState( + focus === 'organisation' ? undefined : projectId, + ) + + useEffect(() => { + const onChangeAccountStore = () => { + if ( + AccountStore.getOrganisation()?.id !== activeOrganisation && + !!AccountStore.getOrganisation()?.id + ) { + setActiveOrganisation(AccountStore.getOrganisation()?.id) + setHoveredOrganisation(AccountStore.getOrganisation()) + } + } + AccountStore.on('change', onChangeAccountStore) + return () => { + OrganisationStore.off('change', onChangeAccountStore) + } + //eslint-disable-next-line + }, []) + + const { data: projects } = useGetProjectsQuery( + { + organisationId: `${hoveredOrganisation?.id}`, + }, + { + skip: !hoveredOrganisation, + }, + ) + + const navigateOrganisations = ( + e: KeyboardEvent, + organisations: Organisation[], + ) => { + const currentIndex = organisations + ? organisations.findIndex((v) => `${v.id}` === `${hoveredOrganisation}`) + : -1 + const newIndex = getNewIndex(e, currentIndex, organisations, goOrganisation) + if (newIndex > -1) { + setHoveredProject(undefined) + setHoveredOrganisation(organisations![newIndex]) + } + } + + const getNewIndex = ( + e: KeyboardEvent, + currentIndex: number, + items: any[] | undefined, + go: (item: any) => void, + ) => { + if (!items?.length) { + return -1 + } + + if (e.key === 'Enter' && items[currentIndex]) { + go(items[currentIndex]) + return currentIndex + } + if (e.key === 'ArrowDown') { + if (currentIndex + 1 < items?.length) { + return currentIndex + 1 + } else { + return items?.length - 1 + } + } else if (e.key === 'ArrowUp') { + return Math.max(-1, currentIndex - 1) + } + + return -1 + } + const navigateProjects = (e: KeyboardEvent) => { + const currentIndex = projects + ? projects.findIndex((v) => `${v.id}` === `${hoveredProject}`) + : -1 + const newIndex = getNewIndex(e, currentIndex, projects, goProject) + if (newIndex > -1) { + setHoveredProject(`${projects![newIndex]!.id}`) + } + } + const goOrganisation = (organisation: Organisation) => { + AppActions.selectOrganisation(organisation.id) + AppActions.getOrganisation(organisation.id) + router.history.push(Utils.getOrganisationHomePage()) + setOpen(false) + } + const goProject = (project: Project) => { + getEnvironments(getStore(), { + projectId: `${project.id}`, + }).then((res: { data: PagedResponse }) => { + router.history.push(`/project/${project.id}`) + setOpen(false) + }) + } + const [hoveredSection, setHoveredSection] = useState(focus) + const { permission: canCreateProject } = useHasPermission({ + id: hoveredOrganisation, + level: 'organisation', + permission: Utils.getCreateProjectPermission(hoveredOrganisation), + }) + return ( +
+ {children} + {!hideDropdown && ( + setOpen(true)} + className='breadcrumb-link user-select-none cursor-pointer d-flex flex-column mx-0 fs-captionSmall' + > + + + + )} + {!hideSlash && ( + + + + )} + { + setOpen(false) + setProjectSearch('') + setOrganisationSearch('') + }} + containerClassName={'p-0'} + className={ + 'inline-modal left-0 top-form-item inline-modal--sm max-w-auto' + } + > + {!!open && ( + + {({ user }: { user: User }) => { + return ( +
+
setHoveredSection('organisation')} + style={{ width: 260 }} + > + + navigateOrganisations(e, user.organisations) + } + onChange={(e: KeyboardEvent) => { + setOrganisationSearch(Utils.safeParseEventValue(e)) + }} + search + inputClassName='border-0 bg-transparent border-bottom-1' + size='xSmall' + className='full-width' + placeholder='Search Organisations...' + /> + { + setHoveredOrganisation(organisation) + setHoveredProject(undefined) + }} + onClick={goOrganisation} + footer={ + Utils.canCreateOrganisation() && ( + + ) + } + /> +
+
setHoveredSection('project')} + style={{ width: 260 }} + className={classNames( + { + 'bg-faint rounded': hoveredSection === 'organisation', + }, + 'border-left-1', + )} + > + { + setProjectSearch(Utils.safeParseEventValue(e)) + }} + autoFocus={focus === 'project'} + onKeyDown={(e: KeyboardEvent) => navigateProjects(e)} + search + className='full-width' + inputClassName='border-0 bg-transparent border-bottom-1' + size='xSmall' + placeholder='Search Projects...' + /> + setHoveredProject(v.id)} + onClick={goProject} + footer={Utils.renderWithPermission( + canCreateProject, + Constants.organisationPermissions( + Utils.getCreateProjectPermissionDescription( + AccountStore.getOrganisation(), + ), + ), + , + )} + /> +
+
+ ) + }} +
+ )} +
+
+ ) +} + +export default BreadcrumbSeparator diff --git a/frontend/web/components/ButterBar.tsx b/frontend/web/components/ButterBar.tsx index 3d2987c91ee3..3fb8660d9fd0 100644 --- a/frontend/web/components/ButterBar.tsx +++ b/frontend/web/components/ButterBar.tsx @@ -8,6 +8,7 @@ import Utils from 'common/utils/utils' import { Environment, FeatureImport, Res } from 'common/types/responses' import { useGetFeatureImportsQuery } from 'common/services/useFeatureImport' import AppActions from 'common/dispatcher/app-actions' +import Constants from 'common/constants' interface ButterBarProps { billingStatus?: string @@ -18,9 +19,12 @@ const ButterBar: React.FC = ({ billingStatus, projectId }) => { const matches = document.location.href.match(/\/environment\/([^/]*)/) const environment = matches && matches[1] const timerRef = useRef() - const { data: featureImports, refetch } = useGetFeatureImportsQuery({ - projectId, - }) + const { data: featureImports, refetch } = useGetFeatureImportsQuery( + { + projectId, + }, + { skip: !projectId }, + ) const processingRef = useRef(false) const checkProcessing = useCallback( (processing: FeatureImport | undefined) => { @@ -92,12 +96,12 @@ const ButterBar: React.FC = ({ billingStatus, projectId }) => { {Utils.getFlagsmithHasFeature('read_only_mode') && (
Your organisation is over its usage limit, please{' '} - upgrade your plan. + upgrade your plan.
)} {Utils.getFlagsmithHasFeature('show_dunning_banner') && billingStatus === 'DUNNING' && ( -
+
diff --git a/frontend/web/components/CodeHelp.js b/frontend/web/components/CodeHelp.js index 8f33f9f4ce5b..ac30cee5b78e 100644 --- a/frontend/web/components/CodeHelp.js +++ b/frontend/web/components/CodeHelp.js @@ -178,9 +178,7 @@ const CodeHelp = class extends Component { styles={{ control: (base) => ({ ...base, - '&:hover': { borderColor: '$bt-brand-secondary' }, alignSelf: 'flex-end', - border: '1px solid $bt-brand-secondary', width: 200, }), }} diff --git a/frontend/web/components/Collapsible.js b/frontend/web/components/Collapsible.js index 6ea77bb90f26..f49eb7673588 100644 --- a/frontend/web/components/Collapsible.js +++ b/frontend/web/components/Collapsible.js @@ -1,5 +1,7 @@ -import { PureComponent } from 'react' +import React, { PureComponent } from 'react' import Icon from './Icon' +import { IonIcon } from '@ionic/react' +import { chevronDown, chevronForward, createOutline } from 'ionicons/icons' const cn = require('classnames') @@ -36,22 +38,19 @@ const Collapsible = class extends PureComponent { })} ref={this.ref} > -
- +
+
+ +
{this.props.title}
-
- -
- +
{this.props.active ? (
{this.props.children}
diff --git a/frontend/web/components/EditPermissions.tsx b/frontend/web/components/EditPermissions.tsx index f62410437b23..29ffa7bf4240 100644 --- a/frontend/web/components/EditPermissions.tsx +++ b/frontend/web/components/EditPermissions.tsx @@ -721,6 +721,15 @@ const _EditPermissionsModal: FC = withAdminPermissions(
)} + {!hasRbacPermission && ( + + Role-based access is not available on our Free Plan. Please visit{' '} + + our Pricing Page + {' '} + for more information on our licensing options. + + )} { const name = Format.enumeration.get(item.key).toLowerCase() diff --git a/frontend/web/components/EnvironmentDropdown.js b/frontend/web/components/EnvironmentDropdown.js index 756aee4316fb..1f495e45a5a5 100644 --- a/frontend/web/components/EnvironmentDropdown.js +++ b/frontend/web/components/EnvironmentDropdown.js @@ -37,4 +37,4 @@ const EnvironmentSelect = class extends Component { EnvironmentSelect.propTypes = {} -module.exports = ConfigProvider(EnvironmentSelect) +export default ConfigProvider(EnvironmentSelect) diff --git a/frontend/web/components/EnvironmentSelect.tsx b/frontend/web/components/EnvironmentSelect.tsx index c8a9ec98c9d3..6fe61e556df6 100644 --- a/frontend/web/components/EnvironmentSelect.tsx +++ b/frontend/web/components/EnvironmentSelect.tsx @@ -1,9 +1,11 @@ import React, { FC, useMemo } from 'react' import { useGetEnvironmentsQuery } from 'common/services/useEnvironment' +import { Props } from 'react-select/lib/Select' -export type EnvironmentSelectType = { +export type EnvironmentSelectType = Partial & { projectId: string value?: string + label?: string onChange: (value: string) => void showAll?: boolean readOnly?: boolean @@ -14,11 +16,13 @@ export type EnvironmentSelectType = { const EnvironmentSelect: FC = ({ idField = 'api_key', ignore, + label, onChange, projectId, readOnly, showAll, value, + ...rest }) => { const { data } = useGetEnvironmentsQuery({ projectId: `${projectId}` }) const foundValue = useMemo( @@ -45,11 +49,14 @@ const EnvironmentSelect: FC = ({ return (
({ - ...base, - '&:hover': { borderColor: '$bt-brand-secondary' }, - border: '1px solid $bt-brand-secondary', - height: 30, - }), - }} - onChange={(v) => { - this.setState({ exact: v.label === 'Exact' }) - if (this.props.search) { - this.props.onChange && - this.props.onChange( - !this.state.exact - ? `"${this.props.search}"` - : this.props.search.replace(/^"+|"+$/g, ''), - ) - } - }} - value={{ - label: this.state.exact - ? 'Exact' - : this.props.filterLabel || - (Utils.getIsEdge() ? 'Starts with' : 'Contains'), - }} - options={[ - { - label: Utils.getIsEdge() - ? 'Starts with' - : 'Contains', - value: 'Contains', - }, - { - label: 'Exact', - value: 'Exact', - }, - ]} - /> -
- )} this.input.focus()}> (this.input = c)} diff --git a/frontend/web/components/ProjectManageWidget.tsx b/frontend/web/components/ProjectManageWidget.tsx index dffc4290c768..c1073de1cfd3 100644 --- a/frontend/web/components/ProjectManageWidget.tsx +++ b/frontend/web/components/ProjectManageWidget.tsx @@ -1,4 +1,4 @@ -import { FC, useCallback, useEffect, useMemo } from 'react' +import React, { FC, useCallback, useEffect, useMemo } from 'react' import { Link } from 'react-router-dom' import { RouterChildContext } from 'react-router' @@ -13,8 +13,8 @@ import { Project } from 'common/types/responses' import Button from './base/forms/Button' import PanelSearch from './PanelSearch' import Icon from './Icon' - -const CreateProjectModal = require('components/modals/CreateProject') +import AppActions from 'common/dispatcher/app-actions' +import CreateProjectModal from './modals/CreateProject' type SegmentsPageType = { router: RouterChildContext['router'] @@ -26,7 +26,7 @@ const ProjectManageWidget: FC = ({ router, }) => { const isAdmin = AccountStore.isAdmin() - + const create = Utils.fromParam()?.create const { data: organisations } = useGetOrganisationsQuery({}) const organisation = useMemo( () => organisations?.results?.find((v) => `${v.id}` === organisationId), @@ -39,22 +39,15 @@ const ProjectManageWidget: FC = ({ permission: Utils.getCreateProjectPermission(organisation), }) + useEffect(() => { + if (create && canCreateProject && organisation) { + handleCreateProjectClick() + } + }, [organisationId, organisation, canCreateProject, create]) const handleCreateProjectClick = useCallback(() => { openModal( 'Create Project', - { - router.history.push( - `/project/${projectId}/environment/${environmentId}/features?new=true`, - ) - }} - />, + , 'p-0 side-modal', ) }, [router.history]) @@ -65,48 +58,40 @@ const ProjectManageWidget: FC = ({ handleCreateProjectClick() } }, [handleCreateProjectClick, router.route.location]) - + useEffect(() => { + if (organisationId) { + AppActions.getOrganisation(organisationId) + } + }, [organisationId]) return ( { toast('Your project has been removed') }} > - {({ - isLoading, - projects, - }: { - isLoading: boolean - projects: Project[] - }) => ( + {({ isLoading, projects }) => (
{(projects && projects.length) || isLoading ? (
- ) : isAdmin ? ( -
-
- Great! Now you can create your first project. -
-

- When you create a project we'll also generate a{' '} - development and production{' '} - environment for you. -

-

- You can create features for your project, then enable and - configure them per environment. -

-
) : ( -
-

- You do not have access to any projects within this - Organisation. If this is unexpected please contact a member of - the Project who has Administrator privileges. Users can be - added to Projects from the Project settings menu. -

-
+ isAdmin && ( +
+
+ Great! Now you can create your first project. +
+

+ When you create a project we'll also generate a{' '} + development and production{' '} + environment for you. +

+

+ You can create features for your project, then enable and + configure them per environment. +

+
+ ) )} {(isLoading || !projects) && (
@@ -119,8 +104,14 @@ const ProjectManageWidget: FC = ({ + Projects let you create and manage a set of features and + configure them between multiple app environments. +
+ } items={projects} renderRow={( { environments, id, name }: Project, @@ -190,6 +181,20 @@ const ProjectManageWidget: FC = ({ }} renderNoResults={
+ {!canCreateProject && ( + <> +
Projects
+
+

+ You do not have access to any projects within + this Organisation. If this is unexpected please + contact a member of the Project who has + Administrator privileges. Users can be added to + Projects from the Project settings menu. +

+
+ + )} {Utils.renderWithPermission( canCreateProject, Constants.organisationPermissions( diff --git a/frontend/web/components/ProjectsPage.tsx b/frontend/web/components/ProjectsPage.tsx new file mode 100644 index 000000000000..8bbaeb703a4e --- /dev/null +++ b/frontend/web/components/ProjectsPage.tsx @@ -0,0 +1,27 @@ +import React, { FC } from 'react' +import ProjectManageWidget from './ProjectManageWidget' +import OrganisationProvider from 'common/providers/OrganisationProvider' +import ConfigProvider from 'common/providers/ConfigProvider' + +type ProjectsPageType = { + match: { + params: { + organisationId: string + } + } +} +const ProjectsPage: FC = ({ match }) => { + return ( + + {() => { + return ( +
+ +
+ ) + }} +
+ ) +} + +export default ConfigProvider(ProjectsPage) diff --git a/frontend/web/components/RemoveIcon.js b/frontend/web/components/RemoveIcon.js deleted file mode 100644 index 003fc3322c47..000000000000 --- a/frontend/web/components/RemoveIcon.js +++ /dev/null @@ -1,40 +0,0 @@ -import { PureComponent } from 'react' - -const RemoveIcon = class extends PureComponent { - static displayName = 'RemoveIcon' - - render() { - return ( - - - - - - - - - - - ) - } -} - -export default RemoveIcon diff --git a/frontend/web/components/SDKKeysPage.tsx b/frontend/web/components/SDKKeysPage.tsx new file mode 100644 index 000000000000..da11e396783f --- /dev/null +++ b/frontend/web/components/SDKKeysPage.tsx @@ -0,0 +1,67 @@ +import React, { FC } from 'react' +import Button from './base/forms/Button' +import Input from './base/forms/Input' +import Icon from './Icon' +import ServerSideSDKKeys from './ServerSideSDKKeys' +import PageTitle from './PageTitle' + +type SDKKeysType = { + match: { + params: { + environmentId: string + projectId: string + } + } +} + +const SDKKeysPage: FC = ({ + match: { + params: { environmentId }, + }, +}) => { + return ( +
+ + Use this key to initialise{' '} + {' '} + SDKs. + +
+ + + Client-side Environment Key} + placeholder='Client-side Environment Key' + /> + + + +
+
+ +
+ ) +} + +export default SDKKeysPage diff --git a/frontend/web/components/SegmentSelect.tsx b/frontend/web/components/SegmentSelect.tsx index 4139ed717d75..50fec249010c 100644 --- a/frontend/web/components/SegmentSelect.tsx +++ b/frontend/web/components/SegmentSelect.tsx @@ -80,13 +80,6 @@ const SegmentSelect: FC = ({ ), }} options={options} - styles={{ - control: (base: any) => ({ - ...base, - '&:hover': { borderColor: '$bt-brand-secondary' }, - border: '1px solid $bt-brand-secondary', - }), - }} /> ) } diff --git a/frontend/web/components/StaleFlagWarning.tsx b/frontend/web/components/StaleFlagWarning.tsx index 567fa355216a..641782d25e01 100644 --- a/frontend/web/components/StaleFlagWarning.tsx +++ b/frontend/web/components/StaleFlagWarning.tsx @@ -20,7 +20,6 @@ const StaleFlagWarning: FC = ({ projectFlag }) => { } const created_date = projectFlag?.created_date const daysAgo = created_date && moment().diff(moment(created_date), 'days') - console.log(ProjectStore.model) const suggestStale = daysAgo >= ((ProjectStore.model as Project | null)?.stale_flags_limit_days || 30) diff --git a/frontend/web/components/UserAction.tsx b/frontend/web/components/UserAction.tsx index ef60325568b5..7b355bd3d3b6 100644 --- a/frontend/web/components/UserAction.tsx +++ b/frontend/web/components/UserAction.tsx @@ -13,7 +13,7 @@ interface FeatureActionProps { canRemove: boolean onRemove: () => void onEdit: () => void - canEdit: () => void + canEdit: boolean } type ActionType = 'edit' | 'remove' diff --git a/frontend/web/components/UserGroupList.tsx b/frontend/web/components/UserGroupList.tsx index 24ba6466f0af..96d1f3c0dbc9 100644 --- a/frontend/web/components/UserGroupList.tsx +++ b/frontend/web/components/UserGroupList.tsx @@ -19,7 +19,7 @@ import { useGetGroupSummariesQuery } from 'common/services/useGroupSummary' type UserGroupListType = { noTitle?: boolean orgId: string - projectId: string | boolean + projectId?: string | boolean showRemove?: boolean onClick: (group: UserGroup) => void onEditPermissions?: (group: UserGroup) => void diff --git a/frontend/web/components/WarningMessage.tsx b/frontend/web/components/WarningMessage.tsx index 3ae86413a151..0b290a304105 100644 --- a/frontend/web/components/WarningMessage.tsx +++ b/frontend/web/components/WarningMessage.tsx @@ -1,6 +1,7 @@ import React, { FC } from 'react' import Icon from './Icon' import Button from './base/forms/Button' +import Constants from 'common/constants'; type WarningMessageType = { warningMessage: string @@ -26,7 +27,7 @@ const WarningMessage: FC = (props) => {
- - -
Client-side Environment Key
-
-

- Use this key to initialise{' '} - {' '} - SDKs. -

- - - Client-side Environment Key} - placeholder='Client-side Environment Key' - /> - - - -
-
-
- -
= ({ + environmentId, + history, + projectId, +}) => { + useEffect(() => { + if (environmentId) { + AppActions.getChangeRequests(environmentId, {}) + } + }, [environmentId]) + const environment: Environment | null = + environmentId === 'create' + ? null + : (ProjectStore.getEnvironment(environmentId) as any) + const changeRequest = Utils.changeRequestsEnabled( + environment?.minimum_change_request_approvals, + ) + ? ChangeRequestStore.model[environmentId] + : null + const changeRequests = changeRequest?.count || 0 + const scheduled = + (environment && ChangeRequestStore.scheduled[environmentId]?.count) || 0 + const onProjectSave = () => { + AppActions.refreshOrganisation() + } + return ( + + {() => ( + + {({ project }: { project: Project }) => { + const createEnvironmentButton = ( + + {({ permission }) => + permission && ( + + + Create Environment + + ) + } + + ) + return ( +
+
+
+
+ {!!environment && ( + + `switch-environment-${label.toLowerCase()}` + } + id='environment-select' + data-test={`switch-environment-${environment.name.toLowerCase()}-active`} + styles={{ + container: (base: any) => ({ + ...base, + border: 'none', + padding: 0, + }), + }} + label={environment.name} + value={environmentId} + projectId={projectId} + components={{ + Menu: ({ ...props }: any) => { + return ( + + {props.children} + {createEnvironmentButton} + + ) + }, + }} + onChange={(newEnvironmentId) => { + if (newEnvironmentId !== environmentId) { + history.push( + `${document.location.pathname}${ + document.location.search || '' + }`.replace(environmentId, newEnvironmentId), + ) + } + }} + /> + )} + {E2E && createEnvironmentButton} +
+
+
+ + environment?.api_key === environmentId && ( +
+ + {({ + isLoading: manageIdentityLoading, + permission: manageIdentityPermission, + }) => ( + + {({ isLoading, permission: environmentAdmin }) => + isLoading || manageIdentityLoading ? ( +
+ +
+ ) : ( +
+ + + + + Features + + + + + + Scheduling + {scheduled ? ( + + {scheduled} + + ) : null} + + + + + + Change Requests{' '} + {changeRequests ? ( + + {changeRequests} + + ) : null} + + {environment.use_v2_feature_versioning && ( + + + + + History + + )} + {Utils.renderWithPermission( + manageIdentityPermission, + Constants.environmentPermissions( + 'View Identities', + ), + + + + + Identities + , + )} + + + SDK Keys + + {environmentAdmin && ( + + + + + Environment Settings + + )} +
+ ) + } +
+ )} +
+
+ ) + } + projectId={projectId} + environmentId={environmentId} + clearableValue={false} + onChange={(environment: string) => { + history.push( + `/project/${projectId}/environment/${environment}/features`, + ) + AsyncStorage.setItem( + 'lastEnv', + JSON.stringify({ + environmentId: environment, + orgId: AccountStore.getOrganisation().id, + projectId: projectId, + }), + ) + }} + /> +
+ ) + }} +
+ )} +
+ ) +} + +export default ConfigProvider(HomeAside) diff --git a/frontend/web/components/pages/HomePage.js b/frontend/web/components/pages/HomePage.js index fb106edae4d9..d5cc948e86f9 100644 --- a/frontend/web/components/pages/HomePage.js +++ b/frontend/web/components/pages/HomePage.js @@ -162,7 +162,7 @@ const HomePage = class extends React.Component { const disableOauthRegister = Utils.getFlagsmithHasFeature( 'disable_oauth_registration', ) - const oauthClasses = 'col-12 col-md-4' + const oauthClasses = 'col-12 col-xl-4' if ((!isSignup || !disableOauthRegister) && !disableSignup) { if (Utils.getFlagsmithValue('oauth_github')) { @@ -170,7 +170,7 @@ const HomePage = class extends React.Component {
@@ -309,7 +310,9 @@ const HomePage = class extends React.Component { ) => ( <> {!!oauths.length && ( -
{oauths}
+
+ {oauths} +
)} {!preventEmailPassword && (
{!!oauths.length && ( -
{oauths}
+
+ {oauths} +
)} {!preventEmailPassword && ( diff --git a/frontend/web/components/pages/InvitePage.js b/frontend/web/components/pages/InvitePage.js index 29e1850c240c..aa029630eb47 100644 --- a/frontend/web/components/pages/InvitePage.js +++ b/frontend/web/components/pages/InvitePage.js @@ -20,7 +20,7 @@ const InvitePage = class extends Component { onSave = (id) => { AppActions.selectOrganisation(id) - this.context.router.history.replace('/organisation-settings') + this.context.router.history.replace(Utils.getOrganisationHomePage(id)) } render() { diff --git a/frontend/web/components/pages/OrganisationSettingsPage.js b/frontend/web/components/pages/OrganisationSettingsPage.js index c7456ecfd519..4ad992784920 100644 --- a/frontend/web/components/pages/OrganisationSettingsPage.js +++ b/frontend/web/components/pages/OrganisationSettingsPage.js @@ -1,9 +1,6 @@ import React, { Component } from 'react' -import InviteUsersModal from 'components/modals/InviteUsers' -import UserGroupList from 'components/UserGroupList' import ConfirmRemoveOrganisation from 'components/modals/ConfirmRemoveOrganisation' import Payment from 'components/modals/Payment' -import CreateGroupModal from 'components/modals/CreateGroup' import withAuditWebhooks from 'common/providers/withAuditWebhooks' import CreateAuditWebhookModal from 'components/modals/CreateAuditWebhook' import ConfirmRemoveAuditWebhook from 'components/modals/ConfirmRemoveAuditWebhook' @@ -11,34 +8,18 @@ import Button from 'components/base/forms/Button' import AdminAPIKeys from 'components/AdminAPIKeys' import Tabs from 'components/base/forms/Tabs' import TabItem from 'components/base/forms/TabItem' -import InfoMessage from 'components/InfoMessage' import JSONReference from 'components/JSONReference' import ConfigProvider from 'common/providers/ConfigProvider' -import OrganisationUsage from 'components/OrganisationUsage' import Constants from 'common/constants' -import ErrorMessage from 'components/ErrorMessage' -import Format from 'common/utils/format' import Icon from 'components/Icon' -import OrganisationManageWidget from 'components/OrganisationManageWidget' -import ProjectManageWidget from 'components/ProjectManageWidget' -import { getStore } from 'common/store' -import { getRoles } from 'common/services/useRole' import _data from 'common/data/base/_data' -import RolesTable from 'components/RolesTable' -import CreateGroup from 'components/modals/CreateGroup' -import PermissionsTabs from 'components/PermissionsTabs' -import UserAction from 'components/UserAction' -import classNames from 'classnames' import AccountStore from 'common/stores/account-store' - -const widths = [300, 200, 80] +import PageTitle from 'components/PageTitle' const SettingsTab = { 'Billing': 'billing', 'General': 'general', 'Keys': 'keys', - 'Members': 'members', - 'Projects': 'projects', 'Usage': 'usage', 'Webhooks': 'webhooks', } @@ -55,8 +36,6 @@ const OrganisationSettingsPage = class extends Component { this.state = { manageSubscriptionLoaded: true, permissions: [], - role: 'ADMIN', - roles: [], } if (!AccountStore.getOrganisation()) { return @@ -71,21 +50,8 @@ const OrganisationSettingsPage = class extends Component { if (!AccountStore.getOrganisation()) { return } - getRoles( - getStore(), - { organisation_id: AccountStore.getOrganisation().id }, - { forceRefetch: true }, - ).then((roles) => { - this.setState({ roles: roles.data.results }) - }) API.trackPage(Constants.pages.ORGANISATION_SETTINGS) $('body').trigger('click') - if ( - AccountStore.getUser() && - AccountStore.getOrganisationRole() !== 'ADMIN' - ) { - this.setState({ permissionsError: true }) - } } onSave = () => { @@ -109,43 +75,12 @@ const OrganisationSettingsPage = class extends Component { onRemove = () => { toast('Your organisation has been removed') if (AccountStore.getOrganisation()) { - this.context.router.history.replace('/organisation-settings') + this.context.router.history.replace(Utils.getOrganisationHomePage()) } else { this.context.router.history.replace('/create') } } - deleteInvite = (id) => { - openConfirm({ - body: ( -
- Are you sure you want to delete this invite? This action cannot be - undone. -
- ), - destructive: true, - onYes: () => AppActions.deleteInvite(id), - title: 'Delete Invite', - yesText: 'Confirm', - }) - } - - deleteUser = (id, userDisplayName) => { - openConfirm({ - body: ( -
- Are you sure you want to remove the user{' '} - {userDisplayName} from the organisation? This action - cannot be undone. -
- ), - destructive: true, - onYes: () => AppActions.deleteUser(id), - title: 'Delete User', - yesText: 'Confirm', - }) - } - save = (e) => { e && e.preventDefault() const { @@ -223,10 +158,6 @@ const OrganisationSettingsPage = class extends Component { ) } - roleChanged = (id, { value: role }) => { - AppActions.updateUserRole(id, role) - } - createWebhook = () => { openModal( 'New Webhook', @@ -251,19 +182,6 @@ const OrganisationSettingsPage = class extends Component { ) } - editGroup = (group) => { - openModal( - 'Edit Group', - , - 'side-modal', - ) - } - deleteWebhook = (webhook) => { openModal( 'Remove Webhook', @@ -275,33 +193,6 @@ const OrganisationSettingsPage = class extends Component { ) } - editUserPermissions = (user, organisationId) => { - openModal( - 'Edit Organisation Permissions', -
- -
, - 'p-0 side-modal', - ) - } - formatLastLoggedIn = (last_login) => { - if (!last_login) return 'Never' - - const diff = moment().diff(moment(last_login), 'days') - if (diff >= 30) { - return ( -
- {`${diff} days ago`} -
-
- {moment(last_login).format('Do MMM YYYY')} -
-
- ) - } - return 'Within 30 days' - } - getOrganisationPermissions = (id) => { if (this.state.permissions.length) return @@ -315,12 +206,8 @@ const OrganisationSettingsPage = class extends Component { const { props: { webhooks, webhooksLoading }, } = this - const hasRbacPermission = Utils.getPlansPermission('RBAC') const paymentsEnabled = Utils.getFlagsmithHasFeature('payments_enabled') const force2faPermission = Utils.getPlansPermission('FORCE_2FA') - const verifySeatsLimit = Utils.getFlagsmithHasFeature( - 'verify_seats_limit_for_invite_links', - ) return (
@@ -332,74 +219,36 @@ const OrganisationSettingsPage = class extends Component { {({ isSaving, organisation }, { deleteOrganisation }) => !!organisation && ( - {({ - error, - invalidateInviteLink, - inviteLinks, - invites, - isLoading, - name, - subscriptionMeta, - users, - }) => { - const { max_seats } = subscriptionMeta || - organisation.subscription || { max_seats: 1 } + {({ name, subscriptionMeta }) => { const isAWS = AccountStore.getPaymentMethod() === 'AWS_MARKETPLACE' const { chargebee_email } = subscriptionMeta || {} - const autoSeats = - !isAWS && Utils.getPlansPermission('AUTO_SEATS') - const usedSeats = - paymentsEnabled && organisation.num_seats >= max_seats - const overSeats = - paymentsEnabled && organisation.num_seats > max_seats - const needsUpgradeForAdditionalSeats = - (overSeats && (!verifySeatsLimit || !autoSeats)) || - (!autoSeats && usedSeats) - const displayedTabs = [SettingsTab.Projects] - if (this.state.permissionsError) { - displayedTabs.push( - ...[SettingsTab.Members].filter((v) => !!v), - ) - } else { + const displayedTabs = [] + + if ( + AccountStore.getUser() && + AccountStore.getOrganisationRole() === 'ADMIN' + ) { displayedTabs.push( ...[ SettingsTab.General, paymentsEnabled && !isAWS ? SettingsTab.Billing : null, SettingsTab.Keys, - SettingsTab.Members, SettingsTab.Webhooks, ].filter((v) => !!v), ) - - if (!Project.disableAnalytics) { - displayedTabs.push(SettingsTab.Usage) - } + } else { + return ( +
+ You do not have permission to view this page +
+ ) } return (
-
- -
- + - {displayedTabs.includes(SettingsTab.Projects) && ( - -
Projects
- -

- Projects let you create and manage a set of - features and configure them between multiple app - environments. -

- - -
- )} - {displayedTabs.includes(SettingsTab.General) && ( @@ -638,774 +487,6 @@ const OrganisationSettingsPage = class extends Component { )} - {displayedTabs.includes(SettingsTab.Members) && ( - - - - - -
-
- Manage Users and Permissions -
-

- Flagsmith lets you manage fine-grained - permissions for your projects and - environments, invite members as a user or an - administrator and then set permission in your - Project and Environment settings.{' '} - -

-
-
-
-
- {isLoading && ( -
- -
- )} - {!isLoading && ( -
- - - -
- Team Members -
- {Utils.renderWithPermission( - !this.state.permissionsError, - Constants.organisationPermissions( - 'Admin', - ), - , - )} -
- - {paymentsEnabled && - !isLoading && ( -
- - { - 'You are currently using ' - } - - {`${organisation.num_seats} of ${max_seats}`} - - {` seat${ - organisation.num_seats === - 1 - ? '' - : 's' - } `}{' '} - for your plan.{' '} - {usedSeats && ( - <> - {overSeats && - (!verifySeatsLimit || - !autoSeats) ? ( - - If you wish to - invite any - additional - members, please{' '} - { - - Contact us - - } - . - - ) : needsUpgradeForAdditionalSeats ? ( - - If you wish to - invite any - additional - members, please{' '} - { - { - this.props.router.history.replace( - `${ - document - .location - .pathname - }?${Utils.toParam( - { - ...Utils.fromParam(), - tab: this - .state - .tab - ? 'closed' - : 'open', - }, - )}`, - ) - }} - > - Upgrade your - plan - - } - . - - ) : ( - - You will - automatically be - charged $20/month - for each - additional member - that joins your - organisation. - - )} - - )} - -
- )} - {inviteLinks && - (!verifySeatsLimit || - !needsUpgradeForAdditionalSeats) && ( - { - e.preventDefault() - }} - > -
- -
- - f.role === - this.state - .role, - ).hash - }`} - data-test='invite-link' - inputClassName='input input--wide' - type='text' - readonly='readonly' - title={ -

Link

- } - placeholder='Link' - size='small' - /> - - - - - - - )} - -
-

- Anyone with link can join - as a standard user, once - they have joined you can - edit their role from the - team members panel.{' '} - -

-
- {error && ( - - )} -
- - )} - - - User - -
- Role -
-
- Last logged in -
-
- Actions -
-
- } - items={users} - itemHeight={65} - renderRow={(user, i) => { - const { - email, - first_name, - id, - last_login, - last_name, - role, - } = user - - const onRemoveClick = () => { - this.deleteUser( - id, - Format.userDisplayName({ - email, - firstName: first_name, - lastName: last_name, - }), - email, - ) - } - const onEditClick = () => { - if (role !== 'ADMIN') { - this.editUserPermissions( - user, - organisation.id, - ) - } - } - return ( - - - {`${first_name} ${last_name}`}{' '} - {id === - AccountStore.getUserId() && - '(You)'} -
- {email} -
-
- -
-
- {organisation.role === - 'ADMIN' && - id !== - AccountStore.getUserId() ? ( -
- setRole(v.value)} + options={[ + { + label: 'Organisation Administrator', + value: 'ADMIN', + }, + { + isDisabled: !hasRbacPermission, + label: hasRbacPermission + ? 'User' + : 'User - Please upgrade for role based access', + value: 'USER', + }, + ]} + className='react-select select-sm' + /> +
+ {inviteLinks?.find( + (f) => f.role === role, + ) && ( + <> + + f.role === role, + )?.hash + }`} + data-test='invite-link' + inputClassName='input input--wide' + type='text' + readonly='readonly' + title={

Link

} + placeholder='Link' + size='small' + /> +
+ + + + + + )} + +
+

+ Anyone with link can join as a standard user, + once they have joined you can edit their role + from the team members panel.{' '} + +

+
+ {error && } +
+ + )} + + User +
+ Role +
+
+ Last logged in +
+
+ Actions +
+ + } + items={users} + itemHeight={65} + renderRow={(user: User, i: number) => { + const { + email, + first_name, + id, + last_login, + last_name, + role, + } = user + + const onRemoveClick = () => { + deleteUser( + id, + Format.userDisplayName({ + email, + firstName: first_name, + lastName: last_name, + }), + ) + } + const onEditClick = () => { + if (role !== 'ADMIN') { + editUserPermissions(user, organisation.id) + } + } + return ( + + + {`${first_name} ${last_name}`}{' '} + {id === AccountStore.getUserId() && '(You)'} +
+ {email} +
+
+ +
+
+ {organisation.role === 'ADMIN' && + id !== AccountStore.getUserId() ? ( +
+