diff --git a/frontend/src/component/admin/users/InviteLinkBar/InviteLinkBar.tsx b/frontend/src/component/admin/users/InviteLinkBar/InviteLinkBar.tsx
index e375ebc6c2..0f6bb7a29a 100644
--- a/frontend/src/component/admin/users/InviteLinkBar/InviteLinkBar.tsx
+++ b/frontend/src/component/admin/users/InviteLinkBar/InviteLinkBar.tsx
@@ -9,51 +9,9 @@ import { add, formatDistanceToNowStrict, isAfter, parseISO } from 'date-fns';
import { formatDateYMD } from 'utils/formatDate';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
+import { InviteLinkBarContent } from './InviteLinkBarContent';
export const InviteLinkBar: VFC = () => {
- const navigate = useNavigate();
- const { data, loading } = useInviteTokens();
- const ref = useLoading(loading);
- const { trackEvent } = usePlausibleTracker();
- const inviteToken =
- data?.tokens?.find((token) => token.name === 'default') ?? null;
- const inviteLink = inviteToken?.url;
- const createdAt = data?.tokens?.[0]?.createdAt ?? '';
- const expiresAt = data?.tokens?.[0]?.expiresAt ?? '';
- const expires = expiresAt || false;
- const isExpired = Boolean(
- expires && isAfter(new Date(), parseISO(expires)),
- );
- const willExpireSoon =
- expires && isAfter(add(new Date(), { days: 14 }), parseISO(expires));
- const expiresIn = expires
- ? formatDistanceToNowStrict(parseISO(expires))
- : false;
- const { locationSettings } = useLocationSettings();
-
- const expireDateComponent = (
-
- {expiresIn}
-
- );
-
- const onInviteLinkActionClick = () => {
- trackEvent('invite', {
- props: {
- eventType: inviteLink
- ? 'link bar action: edit'
- : 'link bar action: create',
- },
- });
-
- navigate('/admin/invite-link');
- };
-
return (
({
@@ -67,70 +25,8 @@ export const InviteLinkBar: VFC = () => {
border: '2px solid',
borderColor: theme.palette.background.alternative,
})}
- ref={ref}
>
-
-
-
- {`You have an invite link created on ${formatDateYMD(
- createdAt,
- locationSettings.locale,
- )} `}
-
- that expired {expireDateComponent}{' '}
- ago
- >
- }
- elseShow={
- <>
- that will expire in{' '}
- {expireDateComponent}
- >
- }
- />
-
-
- >
- }
- elseShow={
-
- You can easily create an invite link here that you
- can share and use to invite people from your company
- to your Unleash setup.
-
- }
- />
-
-
-
-
+
);
};
diff --git a/frontend/src/component/admin/users/InviteLinkBar/InviteLinkBarContent.tsx b/frontend/src/component/admin/users/InviteLinkBar/InviteLinkBarContent.tsx
new file mode 100644
index 0000000000..c4cc222f51
--- /dev/null
+++ b/frontend/src/component/admin/users/InviteLinkBar/InviteLinkBarContent.tsx
@@ -0,0 +1,135 @@
+import { useNavigate } from 'react-router-dom';
+import { Box, Button, styled, Typography } from '@mui/material';
+import useLoading from 'hooks/useLoading';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { useInviteTokens } from 'hooks/api/getters/useInviteTokens/useInviteTokens';
+import { LinkField } from '../LinkField/LinkField';
+import { add, formatDistanceToNowStrict, isAfter, parseISO } from 'date-fns';
+import { formatDateYMD } from 'utils/formatDate';
+import { useLocationSettings } from 'hooks/useLocationSettings';
+import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
+
+interface IInviteLinkBarContentProps {
+ onActionClick?: () => void;
+}
+
+export const StyledBox = styled(Box)(() => ({
+ mb: {
+ xs: 1,
+ md: 0,
+ },
+ display: 'flex',
+ justifyContent: 'center',
+ flexDirection: 'column',
+}));
+
+export const StyledButtonBox = styled(Box)(() => ({
+ display: 'flex',
+ alignItems: 'center',
+ flexGrow: 1,
+}));
+
+export const InviteLinkBarContent = ({
+ onActionClick,
+}: IInviteLinkBarContentProps) => {
+ const navigate = useNavigate();
+ const { data, loading } = useInviteTokens();
+ const ref = useLoading(loading);
+ const { trackEvent } = usePlausibleTracker();
+ const inviteToken =
+ data?.tokens?.find((token) => token.name === 'default') ?? null;
+ const inviteLink = inviteToken?.url;
+ const createdAt = data?.tokens?.[0]?.createdAt ?? '';
+ const expiresAt = data?.tokens?.[0]?.expiresAt ?? '';
+ const expires = expiresAt || false;
+ const isExpired = Boolean(
+ expires && isAfter(new Date(), parseISO(expires)),
+ );
+ const willExpireSoon =
+ expires && isAfter(add(new Date(), { days: 14 }), parseISO(expires));
+ const expiresIn = expires
+ ? formatDistanceToNowStrict(parseISO(expires))
+ : false;
+ const { locationSettings } = useLocationSettings();
+
+ const expireDateComponent = (
+
+ {expiresIn}
+
+ );
+
+ const onInviteLinkActionClick = () => {
+ trackEvent('invite', {
+ props: {
+ eventType: inviteLink
+ ? 'link bar action: edit'
+ : 'link bar action: create',
+ },
+ });
+ navigate('/admin/invite-link');
+ onActionClick?.();
+ };
+ return (
+ <>
+
+
+
+ {`You have an invite link created on ${formatDateYMD(
+ createdAt,
+ locationSettings.locale,
+ )} `}
+
+ that expired {expireDateComponent}{' '}
+ ago
+ >
+ }
+ elseShow={
+ <>
+ that will expire in{' '}
+ {expireDateComponent}
+ >
+ }
+ />
+
+
+ >
+ }
+ elseShow={
+
+ You can easily create an invite link here that you
+ can share and use to invite people from your company
+ to your Unleash setup.
+
+ }
+ />
+
+
+
+
+ >
+ );
+};
diff --git a/frontend/src/component/menu/Header/Header.tsx b/frontend/src/component/menu/Header/Header.tsx
index 924bd95e3f..04cd0ba178 100644
--- a/frontend/src/component/menu/Header/Header.tsx
+++ b/frontend/src/component/menu/Header/Header.tsx
@@ -34,6 +34,8 @@ import { ThemeMode } from 'component/common/ThemeMode/ThemeMode';
import { useThemeMode } from 'hooks/useThemeMode';
import { Notifications } from 'component/common/Notifications/Notifications';
import { useAdminRoutes } from 'component/admin/useAdminRoutes';
+import InviteLinkButton from './InviteLink/InviteLinkButton/InviteLinkButton';
+import { useUiFlag } from '../../../hooks/useUiFlag';
const StyledHeader = styled(AppBar)(({ theme }) => ({
backgroundColor: theme.palette.background.paper,
@@ -43,7 +45,7 @@ const StyledHeader = styled(AppBar)(({ theme }) => ({
zIndex: 300,
}));
-const StyledContainer = styled(Container)(({ theme }) => ({
+const StyledContainer = styled(Container)(() => ({
display: 'flex',
alignItems: 'center',
maxWidth: 1280,
@@ -111,6 +113,7 @@ const Header: VFC = () => {
const [adminRef, setAdminRef] = useState(null);
const [configRef, setConfigRef] = useState(null);
+ const disableNotifications = useUiFlag('disableNotifications');
const { uiConfig, isOss } = useUiConfig();
const smallScreen = useMediaQuery(theme.breakpoints.down('md'));
const [openDrawer, setOpenDrawer] = useState(false);
@@ -198,6 +201,7 @@ const Header: VFC = () => {
/>
+
{
{' '}
}
/>
@@ -250,7 +251,10 @@ const Header: VFC = () => {
options={filteredMainRoutes.adminRoutes}
anchorEl={adminRef}
handleClose={onAdminClose}
- style={{ top: 5, left: -100 }}
+ style={{
+ top: 5,
+ left: -100,
+ }}
/>{' '}
diff --git a/frontend/src/component/menu/Header/InviteLink/InviteLinkButton/InviteLinkButton.test.tsx b/frontend/src/component/menu/Header/InviteLink/InviteLinkButton/InviteLinkButton.test.tsx
new file mode 100644
index 0000000000..b00f78439f
--- /dev/null
+++ b/frontend/src/component/menu/Header/InviteLink/InviteLinkButton/InviteLinkButton.test.tsx
@@ -0,0 +1,34 @@
+import { render } from 'utils/testRenderer';
+import { screen } from '@testing-library/react';
+import React from 'react';
+import InviteLinkButton from './InviteLinkButton';
+import { AccessProviderMock } from 'component/providers/AccessProvider/AccessProviderMock';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import { testServerRoute, testServerSetup } from 'utils/testServer';
+
+const server = testServerSetup();
+
+const setupApi = () => {
+ testServerRoute(server, '/api/admin/ui-config', {
+ flags: {
+ newInviteLink: true,
+ },
+ });
+};
+test('Do not show button to non admins', async () => {
+ setupApi();
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryByLabelText('Invite users')).not.toBeInTheDocument();
+});
+
+test('Show button to non admins', async () => {
+ setupApi();
+ render(, { permissions: [{ permission: ADMIN }] });
+
+ await screen.findByLabelText('Invite users');
+});
diff --git a/frontend/src/component/menu/Header/InviteLink/InviteLinkButton/InviteLinkButton.tsx b/frontend/src/component/menu/Header/InviteLink/InviteLinkButton/InviteLinkButton.tsx
new file mode 100644
index 0000000000..99efef1262
--- /dev/null
+++ b/frontend/src/component/menu/Header/InviteLink/InviteLinkButton/InviteLinkButton.tsx
@@ -0,0 +1,52 @@
+import React, { useContext, useState } from 'react';
+import { ClickAwayListener, IconButton, styled, Tooltip } from '@mui/material';
+import { useId } from 'hooks/useId';
+import { focusable } from 'themes/themeStyles';
+import AccessContext from 'contexts/AccessContext';
+import { PersonAdd } from '@mui/icons-material';
+import { InviteLinkContent } from '../InviteLinkContent';
+import { useUiFlag } from '../../../../../hooks/useUiFlag';
+
+const StyledContainer = styled('div')(() => ({
+ position: 'relative',
+}));
+
+const StyledIconButton = styled(IconButton)(({ theme }) => ({
+ ...focusable(theme),
+ borderRadius: 100,
+}));
+
+const InviteLinkButton = () => {
+ const [showInviteLinkContent, setShowInviteLinkContent] = useState(false);
+ const newInviteLink = useUiFlag('newInviteLink');
+ const modalId = useId();
+
+ const { isAdmin } = useContext(AccessContext);
+
+ if (!isAdmin || !newInviteLink) {
+ return null;
+ }
+
+ return (
+ setShowInviteLinkContent(false)}>
+
+
+ setShowInviteLinkContent(true)}
+ size='large'
+ disableRipple
+ >
+
+
+
+
+
+
+ );
+};
+
+export default InviteLinkButton;
diff --git a/frontend/src/component/menu/Header/InviteLink/InviteLinkContent.tsx b/frontend/src/component/menu/Header/InviteLink/InviteLinkContent.tsx
new file mode 100644
index 0000000000..37a73e71ce
--- /dev/null
+++ b/frontend/src/component/menu/Header/InviteLink/InviteLinkContent.tsx
@@ -0,0 +1,52 @@
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { Button, Paper, Typography, styled, Link } from '@mui/material';
+import { basePath } from 'utils/formatPath';
+import { IUser } from 'interfaces/user';
+import OpenInNew from '@mui/icons-material/OpenInNew';
+import { Link as RouterLink } from 'react-router-dom';
+import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
+import { InviteLinkBar } from '../../../admin/users/InviteLinkBar/InviteLinkBar';
+import { InviteLinkBarContent } from '../../../admin/users/InviteLinkBar/InviteLinkBarContent';
+
+const StyledPaper = styled(Paper)(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: theme.spacing(2),
+ alignItems: 'flex-start',
+ padding: theme.spacing(3),
+ borderRadius: theme.shape.borderRadiusMedium,
+ boxShadow: theme.boxShadows.popup,
+ position: 'absolute',
+ zIndex: 5000,
+ right: -255,
+ minWidth: theme.spacing(80),
+ [theme.breakpoints.down('md')]: {
+ width: '100%',
+ padding: '1rem',
+ },
+}));
+
+interface IInviteLinkContentProps {
+ id: string;
+ showInviteLinkContent: boolean;
+ setShowInviteLinkContent: (showInviteLinkContent: boolean) => void;
+}
+
+export const InviteLinkContent = ({
+ id,
+ showInviteLinkContent,
+ setShowInviteLinkContent,
+}: IInviteLinkContentProps) => (
+
+ {
+ setShowInviteLinkContent(false);
+ }}
+ />
+
+ }
+ />
+);
diff --git a/frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/ProjectEnterpriseSettingsForm.tsx b/frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/ProjectEnterpriseSettingsForm.tsx
index 3fedbd3f6d..559d04e590 100644
--- a/frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/ProjectEnterpriseSettingsForm.tsx
+++ b/frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/ProjectEnterpriseSettingsForm.tsx
@@ -212,7 +212,7 @@ const ProjectEnterpriseSettingsForm: React.FC =
};
const onSetFeatureNamingExample = (example: string) => {
- setFeatureNamingExample && setFeatureNamingExample(example);
+ setFeatureNamingExample?.(example);
updateNamingExampleError({
pattern: featureNamingPattern || '',
example,
diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap
index 5361d45981..b45407c367 100644
--- a/src/lib/__snapshots__/create-config.test.ts.snap
+++ b/src/lib/__snapshots__/create-config.test.ts.snap
@@ -105,6 +105,7 @@ exports[`should create default config 1`] = `
},
"migrationLock": true,
"multipleRoles": false,
+ "newInviteLink": false,
"personalAccessTokensKillSwitch": false,
"privateProjects": false,
"proPlanAutoCharge": false,
@@ -147,6 +148,7 @@ exports[`should create default config 1`] = `
},
"migrationLock": true,
"multipleRoles": false,
+ "newInviteLink": false,
"personalAccessTokensKillSwitch": false,
"privateProjects": false,
"proPlanAutoCharge": false,
diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts
index 8cbac5f959..307835bbfe 100644
--- a/src/lib/types/experimental.ts
+++ b/src/lib/types/experimental.ts
@@ -28,6 +28,7 @@ export type IFlagKey =
| 'doraMetrics'
| 'variantTypeNumber'
| 'accessOverview'
+ | 'newInviteLink'
| 'privateProjects'
| 'dependentFeatures'
| 'datadogJsonTemplate'
@@ -138,6 +139,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_PRIVATE_PROJECTS,
false,
),
+ newInviteLink: parseEnvVarBoolean(
+ process.env.UNLEASH_EXPERIMENTAL_NEW_INVITE_LINK,
+ false,
+ ),
accessOverview: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_ACCESS_OVERVIEW,
false,
diff --git a/src/server-dev.ts b/src/server-dev.ts
index 6f412585a9..96d5ee5ba9 100644
--- a/src/server-dev.ts
+++ b/src/server-dev.ts
@@ -42,6 +42,7 @@ process.nextTick(async () => {
doraMetrics: true,
variantTypeNumber: true,
privateProjects: true,
+ newInviteLink: true,
accessOverview: true,
datadogJsonTemplate: true,
dependentFeatures: true,