From e96ad9a1f2bdae40ec633fa8b4b02cd27cd3c88d Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Thu, 28 Nov 2024 18:13:11 +0530 Subject: [PATCH] Admin panel init (#8742) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP Related issues - #7090 #8547 Master issue - #4499 --------- Co-authored-by: Félix Malfait --- .../twenty-front/src/generated/graphql.tsx | 176 +++++++++++++ ...sePageChangeEffectNavigateLocation.test.ts | 11 - .../src/modules/app/components/AppRouter.tsx | 6 + .../modules/app/components/SettingsRoutes.tsx | 25 ++ .../modules/app/hooks/useCreateAppRouter.tsx | 4 +- .../src/modules/auth/hooks/useAuth.ts | 93 +++---- .../SettingsAdminImpersonateUsers.tsx | 67 +++++ .../SettingsAdminFeatureFlagsTabs.ts | 2 + .../mutations/updateWorkspaceFeatureFlag.ts | 15 ++ .../graphql/mutations/userLookupAdminPanel.ts | 30 +++ .../hooks/useFeatureFlagsManagement.ts | 91 +++++++ .../admin-panel/hooks/useImpersonate.ts | 60 +++++ .../settings/admin-panel/types/FeatureFlag.ts | 4 + .../settings/admin-panel/types/UserLookup.ts | 11 + .../admin-panel/types/WorkspaceInfo.ts | 15 ++ .../SettingsNavigationDrawerItems.tsx | 11 + .../twenty-front/src/modules/types/AppPath.ts | 3 - .../src/modules/types/SettingsPath.ts | 2 + .../hooks/__tests__/useShowAuthModal.test.tsx | 11 - .../modules/ui/layout/tab/components/Tab.tsx | 7 + .../ui/layout/tab/components/TabList.tsx | 2 + .../hooks/useWorkspaceSwitching.ts | 4 +- .../pages/impersonate/ImpersonateEffect.tsx | 64 ----- .../__stories__/ImpersonateEffect.stories.tsx | 34 --- .../settings/admin-panel/SettingsAdmin.tsx | 39 +++ .../admin-panel/SettingsAdminFeatureFlags.tsx | 240 ++++++++++++++++++ .../admin-panel/admin-panel.module.ts | 19 ++ .../admin-panel/admin-panel.resolver.ts | 57 +++++ .../admin-panel/admin-panel.service.ts | 179 +++++++++++++ .../dtos}/impersonate.input.ts | 0 .../update-workspace-feature-flag.input.ts | 21 ++ .../admin-panel/dtos/user-lookup.entity.ts | 48 ++++ .../admin-panel/dtos/user-lookup.input.ts | 11 + .../engine/core-modules/auth/auth.module.ts | 4 +- .../engine/core-modules/auth/auth.resolver.ts | 10 - .../auth/services/auth.service.ts | 47 ---- .../engine/core-modules/core-engine.module.ts | 2 + .../display/icon/components/TablerIcons.ts | 4 +- 38 files changed, 1197 insertions(+), 232 deletions(-) create mode 100644 packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminImpersonateUsers.tsx create mode 100644 packages/twenty-front/src/modules/settings/admin-panel/constants/SettingsAdminFeatureFlagsTabs.ts create mode 100644 packages/twenty-front/src/modules/settings/admin-panel/graphql/mutations/updateWorkspaceFeatureFlag.ts create mode 100644 packages/twenty-front/src/modules/settings/admin-panel/graphql/mutations/userLookupAdminPanel.ts create mode 100644 packages/twenty-front/src/modules/settings/admin-panel/hooks/useFeatureFlagsManagement.ts create mode 100644 packages/twenty-front/src/modules/settings/admin-panel/hooks/useImpersonate.ts create mode 100644 packages/twenty-front/src/modules/settings/admin-panel/types/FeatureFlag.ts create mode 100644 packages/twenty-front/src/modules/settings/admin-panel/types/UserLookup.ts create mode 100644 packages/twenty-front/src/modules/settings/admin-panel/types/WorkspaceInfo.ts delete mode 100644 packages/twenty-front/src/pages/impersonate/ImpersonateEffect.tsx delete mode 100644 packages/twenty-front/src/pages/impersonate/__stories__/ImpersonateEffect.stories.tsx create mode 100644 packages/twenty-front/src/pages/settings/admin-panel/SettingsAdmin.tsx create mode 100644 packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminFeatureFlags.tsx create mode 100644 packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts create mode 100644 packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts rename packages/twenty-server/src/engine/core-modules/{auth/dto => admin-panel/dtos}/impersonate.input.ts (100%) create mode 100644 packages/twenty-server/src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/admin-panel/dtos/user-lookup.entity.ts create mode 100644 packages/twenty-server/src/engine/core-modules/admin-panel/dtos/user-lookup.input.ts diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index de3516360..513273ddd 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -477,10 +477,12 @@ export type Mutation = { updateOneServerlessFunction: ServerlessFunction; updatePasswordViaResetToken: InvalidatePassword; updateWorkspace: Workspace; + updateWorkspaceFeatureFlag: Scalars['Boolean']; uploadFile: Scalars['String']; uploadImage: Scalars['String']; uploadProfilePicture: Scalars['String']; uploadWorkspaceLogo: Scalars['String']; + userLookupAdminPanel: UserLookup; verify: Verify; }; @@ -679,6 +681,13 @@ export type MutationUpdateWorkspaceArgs = { }; +export type MutationUpdateWorkspaceFeatureFlagArgs = { + featureFlag: Scalars['String']; + value: Scalars['Boolean']; + workspaceId: Scalars['String']; +}; + + export type MutationUploadFileArgs = { file: Scalars['Upload']; fileFolder?: InputMaybe; @@ -701,6 +710,11 @@ export type MutationUploadWorkspaceLogoArgs = { }; +export type MutationUserLookupAdminPanelArgs = { + userIdentifier: Scalars['String']; +}; + + export type MutationVerifyArgs = { loginToken: Scalars['String']; }; @@ -1247,6 +1261,20 @@ export type UserExists = { exists: Scalars['Boolean']; }; +export type UserInfo = { + __typename?: 'UserInfo'; + email: Scalars['String']; + firstName?: Maybe; + id: Scalars['String']; + lastName?: Maybe; +}; + +export type UserLookup = { + __typename?: 'UserLookup'; + user: UserInfo; + workspaces: Array; +}; + export type UserMappingOptionsUser = { __typename?: 'UserMappingOptionsUser'; user?: Maybe; @@ -1285,6 +1313,7 @@ export type Workspace = { __typename?: 'Workspace'; activationStatus: WorkspaceActivationStatus; allowImpersonation: Scalars['Boolean']; + billingEntitlements?: Maybe>; billingSubscriptions?: Maybe>; createdAt: Scalars['DateTime']; currentBillingSubscription?: Maybe; @@ -1305,6 +1334,12 @@ export type Workspace = { }; +export type WorkspaceBillingEntitlementsArgs = { + filter?: BillingEntitlementFilter; + sorting?: Array; +}; + + export type WorkspaceBillingSubscriptionsArgs = { filter?: BillingSubscriptionFilter; sorting?: Array; @@ -1331,6 +1366,16 @@ export type WorkspaceEdge = { node: Workspace; }; +export type WorkspaceInfo = { + __typename?: 'WorkspaceInfo'; + featureFlags: Array; + id: Scalars['String']; + logo?: Maybe; + name: Scalars['String']; + totalUsers: Scalars['Float']; + users: Array; +}; + export type WorkspaceInvitation = { __typename?: 'WorkspaceInvitation'; email: Scalars['String']; @@ -1376,6 +1421,30 @@ export type WorkspaceNameAndId = { id: Scalars['String']; }; +export type BillingEntitlement = { + __typename?: 'billingEntitlement'; + id: Scalars['UUID']; + key: Scalars['String']; + value: Scalars['Boolean']; + workspaceId: Scalars['String']; +}; + +export type BillingEntitlementFilter = { + and?: InputMaybe>; + id?: InputMaybe; + or?: InputMaybe>; +}; + +export type BillingEntitlementSort = { + direction: SortDirection; + field: BillingEntitlementSortFields; + nulls?: InputMaybe; +}; + +export enum BillingEntitlementSortFields { + Id = 'id' +} + export type Field = { __typename?: 'field'; createdAt: Scalars['DateTime']; @@ -1787,6 +1856,22 @@ export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string] export type SkipSyncEmailOnboardingStepMutation = { __typename?: 'Mutation', skipSyncEmailOnboardingStep: { __typename?: 'OnboardingStepSuccess', success: boolean } }; +export type UpdateWorkspaceFeatureFlagMutationVariables = Exact<{ + workspaceId: Scalars['String']; + featureFlag: Scalars['String']; + value: Scalars['Boolean']; +}>; + + +export type UpdateWorkspaceFeatureFlagMutation = { __typename?: 'Mutation', updateWorkspaceFeatureFlag: boolean }; + +export type UserLookupAdminPanelMutationVariables = Exact<{ + userIdentifier: Scalars['String']; +}>; + + +export type UserLookupAdminPanelMutation = { __typename?: 'Mutation', userLookupAdminPanel: { __typename?: 'UserLookup', user: { __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }, workspaces: Array<{ __typename?: 'WorkspaceInfo', id: string, name: string, logo?: string | null, totalUsers: number, users: Array<{ __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }>, featureFlags: Array<{ __typename?: 'FeatureFlag', key: string, value: boolean }> }> } }; + export type CreateOidcIdentityProviderMutationVariables = Exact<{ input: SetupOidcSsoInput; }>; @@ -3178,6 +3263,97 @@ export function useSkipSyncEmailOnboardingStepMutation(baseOptions?: Apollo.Muta export type SkipSyncEmailOnboardingStepMutationHookResult = ReturnType; export type SkipSyncEmailOnboardingStepMutationResult = Apollo.MutationResult; export type SkipSyncEmailOnboardingStepMutationOptions = Apollo.BaseMutationOptions; +export const UpdateWorkspaceFeatureFlagDocument = gql` + mutation UpdateWorkspaceFeatureFlag($workspaceId: String!, $featureFlag: String!, $value: Boolean!) { + updateWorkspaceFeatureFlag( + workspaceId: $workspaceId + featureFlag: $featureFlag + value: $value + ) +} + `; +export type UpdateWorkspaceFeatureFlagMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateWorkspaceFeatureFlagMutation__ + * + * To run a mutation, you first call `useUpdateWorkspaceFeatureFlagMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateWorkspaceFeatureFlagMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [updateWorkspaceFeatureFlagMutation, { data, loading, error }] = useUpdateWorkspaceFeatureFlagMutation({ + * variables: { + * workspaceId: // value for 'workspaceId' + * featureFlag: // value for 'featureFlag' + * value: // value for 'value' + * }, + * }); + */ +export function useUpdateWorkspaceFeatureFlagMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateWorkspaceFeatureFlagDocument, options); + } +export type UpdateWorkspaceFeatureFlagMutationHookResult = ReturnType; +export type UpdateWorkspaceFeatureFlagMutationResult = Apollo.MutationResult; +export type UpdateWorkspaceFeatureFlagMutationOptions = Apollo.BaseMutationOptions; +export const UserLookupAdminPanelDocument = gql` + mutation UserLookupAdminPanel($userIdentifier: String!) { + userLookupAdminPanel(userIdentifier: $userIdentifier) { + user { + id + email + firstName + lastName + } + workspaces { + id + name + logo + totalUsers + users { + id + email + firstName + lastName + } + featureFlags { + key + value + } + } + } +} + `; +export type UserLookupAdminPanelMutationFn = Apollo.MutationFunction; + +/** + * __useUserLookupAdminPanelMutation__ + * + * To run a mutation, you first call `useUserLookupAdminPanelMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUserLookupAdminPanelMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [userLookupAdminPanelMutation, { data, loading, error }] = useUserLookupAdminPanelMutation({ + * variables: { + * userIdentifier: // value for 'userIdentifier' + * }, + * }); + */ +export function useUserLookupAdminPanelMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UserLookupAdminPanelDocument, options); + } +export type UserLookupAdminPanelMutationHookResult = ReturnType; +export type UserLookupAdminPanelMutationResult = Apollo.MutationResult; +export type UserLookupAdminPanelMutationOptions = Apollo.BaseMutationOptions; export const CreateOidcIdentityProviderDocument = gql` mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) { createOIDCIdentityProvider(input: $input) { diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts index a7b683e66..d5166179e 100644 --- a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts +++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts @@ -234,17 +234,6 @@ const testCases = [ { loc: AppPath.DevelopersCatchAll, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.DevelopersCatchAll, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: undefined }, - { loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: AppPath.PlanRequired }, - { loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, - { loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, - { loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: undefined }, - { loc: AppPath.Impersonate, isLoggedIn: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: AppPath.CreateWorkspace }, - { loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: AppPath.CreateProfile }, - { loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: AppPath.SyncEmails }, - { loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: AppPath.InviteTeam }, - { loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: undefined }, - { loc: AppPath.Authorize, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: AppPath.PlanRequired }, { loc: AppPath.Authorize, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, { loc: AppPath.Authorize, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, diff --git a/packages/twenty-front/src/modules/app/components/AppRouter.tsx b/packages/twenty-front/src/modules/app/components/AppRouter.tsx index 45aa98098..1491921e3 100644 --- a/packages/twenty-front/src/modules/app/components/AppRouter.tsx +++ b/packages/twenty-front/src/modules/app/components/AppRouter.tsx @@ -1,4 +1,5 @@ import { useCreateAppRouter } from '@/app/hooks/useCreateAppRouter'; +import { currentUserState } from '@/auth/states/currentUserState'; import { billingState } from '@/client-config/states/billingState'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { RouterProvider } from 'react-router-dom'; @@ -16,6 +17,10 @@ export const AppRouter = () => { const isBillingPageEnabled = billing?.isBillingEnabled && !isFreeAccessEnabled; + const currentUser = useRecoilValue(currentUserState); + + const isAdminPageEnabled = currentUser?.canImpersonate; + return ( { isCRMMigrationEnabled, isServerlessFunctionSettingsEnabled, isSSOEnabled, + isAdminPageEnabled, )} /> ); diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index b758acdc1..f8286c398 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -242,11 +242,26 @@ const SettingsSecuritySSOIdentifyProvider = lazy(() => ), ); +const SettingsAdmin = lazy(() => + import('~/pages/settings/admin-panel/SettingsAdmin').then((module) => ({ + default: module.SettingsAdmin, + })), +); + +const SettingsAdminFeatureFlags = lazy(() => + import('~/pages/settings/admin-panel/SettingsAdminFeatureFlags').then( + (module) => ({ + default: module.SettingsAdminFeatureFlags, + }), + ), +); + type SettingsRoutesProps = { isBillingEnabled?: boolean; isCRMMigrationEnabled?: boolean; isServerlessFunctionSettingsEnabled?: boolean; isSSOEnabled?: boolean; + isAdminPageEnabled?: boolean; }; export const SettingsRoutes = ({ @@ -254,6 +269,7 @@ export const SettingsRoutes = ({ isCRMMigrationEnabled, isServerlessFunctionSettingsEnabled, isSSOEnabled, + isAdminPageEnabled, }: SettingsRoutesProps) => ( }> @@ -375,6 +391,15 @@ export const SettingsRoutes = ({ /> )} + {isAdminPageEnabled && ( + <> + } /> + } + /> + + )} ); diff --git a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx index 0aa19e6e1..80afc3c8a 100644 --- a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx +++ b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx @@ -14,7 +14,6 @@ import { Authorize } from '~/pages/auth/Authorize'; import { Invite } from '~/pages/auth/Invite'; import { PasswordReset } from '~/pages/auth/PasswordReset'; import { SignInUp } from '~/pages/auth/SignInUp'; -import { ImpersonateEffect } from '~/pages/impersonate/ImpersonateEffect'; import { NotFound } from '~/pages/not-found/NotFound'; import { RecordIndexPage } from '~/pages/object-record/RecordIndexPage'; import { RecordShowPage } from '~/pages/object-record/RecordShowPage'; @@ -30,6 +29,7 @@ export const useCreateAppRouter = ( isCRMMigrationEnabled?: boolean, isServerlessFunctionSettingsEnabled?: boolean, isSSOEnabled?: boolean, + isAdminPageEnabled?: boolean, ) => createBrowserRouter( createRoutesFromElements( @@ -54,7 +54,6 @@ export const useCreateAppRouter = ( element={} /> } /> - } /> } /> } /> } /> diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index e8ef0aab3..33b15e83d 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -69,6 +69,49 @@ export const useAuth = () => { const setDateTimeFormat = useSetRecoilState(dateTimeFormatState); + const clearSession = useRecoilCallback( + ({ snapshot }) => + async () => { + const emptySnapshot = snapshot_UNSTABLE(); + const iconsValue = snapshot.getLoadable(iconsState).getValue(); + const authProvidersValue = snapshot + .getLoadable(authProvidersState) + .getValue(); + const billing = snapshot.getLoadable(billingState).getValue(); + const isSignInPrefilled = snapshot + .getLoadable(isSignInPrefilledState) + .getValue(); + const supportChat = snapshot.getLoadable(supportChatState).getValue(); + const isDebugMode = snapshot.getLoadable(isDebugModeState).getValue(); + const captchaProvider = snapshot + .getLoadable(captchaProviderState) + .getValue(); + const clientConfigApiStatus = snapshot + .getLoadable(clientConfigApiStatusState) + .getValue(); + const isCurrentUserLoaded = snapshot + .getLoadable(isCurrentUserLoadedState) + .getValue(); + const initialSnapshot = emptySnapshot.map(({ set }) => { + set(iconsState, iconsValue); + set(authProvidersState, authProvidersValue); + set(billingState, billing); + set(isSignInPrefilledState, isSignInPrefilled); + set(supportChatState, supportChat); + set(isDebugModeState, isDebugMode); + set(captchaProviderState, captchaProvider); + set(clientConfigApiStatusState, clientConfigApiStatus); + set(isCurrentUserLoadedState, isCurrentUserLoaded); + return undefined; + }); + goToRecoilSnapshot(initialSnapshot); + await client.clearStore(); + sessionStorage.clear(); + localStorage.clear(); + }, + [client, goToRecoilSnapshot], + ); + const handleChallenge = useCallback( async (email: string, password: string, captchaToken?: string) => { const challengeResult = await challenge({ @@ -212,51 +255,9 @@ export const useAuth = () => { [handleChallenge, handleVerify, setIsVerifyPendingState], ); - const handleSignOut = useRecoilCallback( - ({ snapshot }) => - async () => { - const emptySnapshot = snapshot_UNSTABLE(); - const iconsValue = snapshot.getLoadable(iconsState).getValue(); - const authProvidersValue = snapshot - .getLoadable(authProvidersState) - .getValue(); - const billing = snapshot.getLoadable(billingState).getValue(); - const isSignInPrefilled = snapshot - .getLoadable(isSignInPrefilledState) - .getValue(); - const supportChat = snapshot.getLoadable(supportChatState).getValue(); - const isDebugMode = snapshot.getLoadable(isDebugModeState).getValue(); - const captchaProvider = snapshot - .getLoadable(captchaProviderState) - .getValue(); - const clientConfigApiStatus = snapshot - .getLoadable(clientConfigApiStatusState) - .getValue(); - const isCurrentUserLoaded = snapshot - .getLoadable(isCurrentUserLoadedState) - .getValue(); - - const initialSnapshot = emptySnapshot.map(({ set }) => { - set(iconsState, iconsValue); - set(authProvidersState, authProvidersValue); - set(billingState, billing); - set(isSignInPrefilledState, isSignInPrefilled); - set(supportChatState, supportChat); - set(isDebugModeState, isDebugMode); - set(captchaProviderState, captchaProvider); - set(clientConfigApiStatusState, clientConfigApiStatus); - set(isCurrentUserLoadedState, isCurrentUserLoaded); - return undefined; - }); - - goToRecoilSnapshot(initialSnapshot); - - await client.clearStore(); - sessionStorage.clear(); - localStorage.clear(); - }, - [client, goToRecoilSnapshot], - ); + const handleSignOut = useCallback(async () => { + await clearSession(); + }, [clearSession]); const handleCredentialsSignUp = useCallback( async ( @@ -340,7 +341,7 @@ export const useAuth = () => { verify: handleVerify, checkUserExists: { checkUserExistsData, checkUserExistsQuery }, - + clearSession, signOut: handleSignOut, signUpWithCredentials: handleCredentialsSignUp, signInWithCredentials: handleCrendentialsSignIn, diff --git a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminImpersonateUsers.tsx b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminImpersonateUsers.tsx new file mode 100644 index 000000000..146734e75 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminImpersonateUsers.tsx @@ -0,0 +1,67 @@ +import { useImpersonate } from '@/settings/admin-panel/hooks/useImpersonate'; +import { TextInput } from '@/ui/input/components/TextInput'; +import styled from '@emotion/styled'; +import { useState } from 'react'; +import { Button, H2Title, IconUser, Section } from 'twenty-ui'; + +const StyledLinkContainer = styled.div` + margin-right: ${({ theme }) => theme.spacing(2)}; + width: 100%; +`; + +const StyledContainer = styled.div` + align-items: center; + display: flex; + flex-direction: row; +`; + +const StyledErrorSection = styled.div` + color: ${({ theme }) => theme.font.color.danger}; + margin-top: ${({ theme }) => theme.spacing(2)}; +`; + +export const SettingsAdminImpersonateUsers = () => { + const [userId, setUserId] = useState(''); + const { handleImpersonate, isLoading, error, canImpersonate } = + useImpersonate(); + + if (!canImpersonate) { + return ( +
+ +
+ ); + } + + return ( +
+ + + + handleImpersonate(userId)} + /> + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/constants/SettingsAdminFeatureFlagsTabs.ts b/packages/twenty-front/src/modules/settings/admin-panel/constants/SettingsAdminFeatureFlagsTabs.ts new file mode 100644 index 000000000..e2e90e825 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/constants/SettingsAdminFeatureFlagsTabs.ts @@ -0,0 +1,2 @@ +export const SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID = + 'settings-admin-feature-flags-tab-id'; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/graphql/mutations/updateWorkspaceFeatureFlag.ts b/packages/twenty-front/src/modules/settings/admin-panel/graphql/mutations/updateWorkspaceFeatureFlag.ts new file mode 100644 index 000000000..8077e86c2 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/graphql/mutations/updateWorkspaceFeatureFlag.ts @@ -0,0 +1,15 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_WORKSPACE_FEATURE_FLAG = gql` + mutation UpdateWorkspaceFeatureFlag( + $workspaceId: String! + $featureFlag: String! + $value: Boolean! + ) { + updateWorkspaceFeatureFlag( + workspaceId: $workspaceId + featureFlag: $featureFlag + value: $value + ) + } +`; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/graphql/mutations/userLookupAdminPanel.ts b/packages/twenty-front/src/modules/settings/admin-panel/graphql/mutations/userLookupAdminPanel.ts new file mode 100644 index 000000000..a4f14c5bd --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/graphql/mutations/userLookupAdminPanel.ts @@ -0,0 +1,30 @@ +import { gql } from '@apollo/client'; + +export const USER_LOOKUP_ADMIN_PANEL = gql` + mutation UserLookupAdminPanel($userIdentifier: String!) { + userLookupAdminPanel(userIdentifier: $userIdentifier) { + user { + id + email + firstName + lastName + } + workspaces { + id + name + logo + totalUsers + users { + id + email + firstName + lastName + } + featureFlags { + key + value + } + } + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/hooks/useFeatureFlagsManagement.ts b/packages/twenty-front/src/modules/settings/admin-panel/hooks/useFeatureFlagsManagement.ts new file mode 100644 index 000000000..22ccd2b38 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/hooks/useFeatureFlagsManagement.ts @@ -0,0 +1,91 @@ +import { UserLookup } from '@/settings/admin-panel/types/UserLookup'; +import { useState } from 'react'; +import { isDefined } from 'twenty-ui'; +import { + useUpdateWorkspaceFeatureFlagMutation, + useUserLookupAdminPanelMutation, +} from '~/generated/graphql'; + +export const useFeatureFlagsManagement = () => { + const [userLookupResult, setUserLookupResult] = useState( + null, + ); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const [userLookup] = useUserLookupAdminPanelMutation({ + onCompleted: (data) => { + setIsLoading(false); + if (isDefined(data?.userLookupAdminPanel)) { + setUserLookupResult(data.userLookupAdminPanel); + } + }, + onError: (error) => { + setIsLoading(false); + setError(error.message); + }, + }); + + const [updateFeatureFlag] = useUpdateWorkspaceFeatureFlagMutation(); + + const handleUserLookup = async (userIdentifier: string) => { + setError(null); + setIsLoading(true); + setUserLookupResult(null); + + const response = await userLookup({ + variables: { userIdentifier }, + }); + + return response.data?.userLookupAdminPanel; + }; + + const handleFeatureFlagUpdate = async ( + workspaceId: string, + featureFlag: string, + value: boolean, + ) => { + setError(null); + const previousState = userLookupResult; + + if (isDefined(userLookupResult)) { + setUserLookupResult({ + ...userLookupResult, + workspaces: userLookupResult.workspaces.map((workspace) => + workspace.id === workspaceId + ? { + ...workspace, + featureFlags: workspace.featureFlags.map((flag) => + flag.key === featureFlag ? { ...flag, value } : flag, + ), + } + : workspace, + ), + }); + } + + const response = await updateFeatureFlag({ + variables: { + workspaceId, + featureFlag, + value, + }, + onError: (error) => { + if (isDefined(previousState)) { + setUserLookupResult(previousState); + } + setError(error.message); + }, + }); + + return !!response.data; + }; + + return { + userLookupResult, + handleUserLookup, + handleFeatureFlagUpdate, + isLoading, + error, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/hooks/useImpersonate.ts b/packages/twenty-front/src/modules/settings/admin-panel/hooks/useImpersonate.ts new file mode 100644 index 000000000..1046c70c9 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/hooks/useImpersonate.ts @@ -0,0 +1,60 @@ +import { useAuth } from '@/auth/hooks/useAuth'; +import { currentUserState } from '@/auth/states/currentUserState'; +import { tokenPairState } from '@/auth/states/tokenPairState'; +import { AppPath } from '@/types/AppPath'; +import { useState } from 'react'; +import { useRecoilState, useSetRecoilState } from 'recoil'; +import { useImpersonateMutation } from '~/generated/graphql'; +import { isDefined } from '~/utils/isDefined'; +import { sleep } from '~/utils/sleep'; + +export const useImpersonate = () => { + const { clearSession } = useAuth(); + const [currentUser, setCurrentUser] = useRecoilState(currentUserState); + const setTokenPair = useSetRecoilState(tokenPairState); + const [impersonate] = useImpersonateMutation(); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleImpersonate = async (userId: string) => { + if (!userId.trim()) { + setError('Please enter a user ID'); + return; + } + + setIsLoading(true); + setError(null); + + try { + const impersonateResult = await impersonate({ + variables: { userId }, + }); + + if (isDefined(impersonateResult.errors)) { + throw impersonateResult.errors; + } + + if (!impersonateResult.data?.impersonate) { + throw new Error('No impersonate result'); + } + + const { user, tokens } = impersonateResult.data.impersonate; + await clearSession(); + setCurrentUser(user); + setTokenPair(tokens); + await sleep(0); // This hacky workaround is necessary to ensure the tokens stored in the cookie are updated correctly. + window.location.href = AppPath.Index; + } catch (error) { + setError('Failed to impersonate user. Please try again.'); + setIsLoading(false); + } + }; + + return { + handleImpersonate, + isLoading, + error, + canImpersonate: currentUser?.canImpersonate, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/types/FeatureFlag.ts b/packages/twenty-front/src/modules/settings/admin-panel/types/FeatureFlag.ts new file mode 100644 index 000000000..2c9ab8913 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/types/FeatureFlag.ts @@ -0,0 +1,4 @@ +export type FeatureFlag = { + key: string; + value: boolean; +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/types/UserLookup.ts b/packages/twenty-front/src/modules/settings/admin-panel/types/UserLookup.ts new file mode 100644 index 000000000..0cb66e283 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/types/UserLookup.ts @@ -0,0 +1,11 @@ +import { WorkspaceInfo } from '@/settings/admin-panel/types/WorkspaceInfo'; + +export type UserLookup = { + user: { + id: string; + email: string; + firstName?: string | null; + lastName?: string | null; + }; + workspaces: WorkspaceInfo[]; +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/types/WorkspaceInfo.ts b/packages/twenty-front/src/modules/settings/admin-panel/types/WorkspaceInfo.ts new file mode 100644 index 000000000..3d36fe203 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/types/WorkspaceInfo.ts @@ -0,0 +1,15 @@ +import { FeatureFlag } from '@/settings/admin-panel/types/FeatureFlag'; + +export type WorkspaceInfo = { + id: string; + name: string; + logo?: string | null; + totalUsers: number; + users: { + id: string; + email: string; + firstName?: string | null; + lastName?: string | null; + }[]; + featureFlags: FeatureFlag[]; +}; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx index 54a46360a..4910e7a2a 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx @@ -13,6 +13,7 @@ import { IconKey, IconMail, IconRocket, + IconServer, IconSettings, IconTool, IconUserCircle, @@ -21,6 +22,7 @@ import { } from 'twenty-ui'; import { useAuth } from '@/auth/hooks/useAuth'; +import { currentUserState } from '@/auth/states/currentUserState'; import { billingState } from '@/client-config/states/billingState'; import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem'; import { useExpandedAnimation } from '@/settings/hooks/useExpandedAnimation'; @@ -84,6 +86,8 @@ export const SettingsNavigationDrawerItems = () => { const isBillingPageEnabled = billing?.isBillingEnabled && !isFreeAccessEnabled; + const currentUser = useRecoilValue(currentUserState); + const isAdminPageEnabled = currentUser?.canImpersonate; // TODO: Refactor this part to only have arrays of navigation items const currentPathName = useLocation().pathname; @@ -230,6 +234,13 @@ export const SettingsNavigationDrawerItems = () => { + {isAdminPageEnabled && ( + + )} theme.background.quaternary}; } `; +const StyledLogo = styled.img` + height: 14px; + width: 14px; +`; export const Tab = ({ id, @@ -72,6 +77,7 @@ export const Tab = ({ disabled, pill, to, + logo, }: TabProps) => { const theme = useTheme(); return ( @@ -85,6 +91,7 @@ export const Tab = ({ to={to} > + {logo && } {Icon && } {title} {pill && typeof pill === 'string' ? : pill} diff --git a/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx b/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx index 7d724d67e..7dc93e7ee 100644 --- a/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx +++ b/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx @@ -19,6 +19,7 @@ export type SingleTabProps = { disabled?: boolean; pill?: string | React.ReactElement; cards?: LayoutCard[]; + logo?: string; }; type TabListProps = { @@ -71,6 +72,7 @@ export const TabList = ({ key={tab.id} title={tab.title} Icon={tab.Icon} + logo={tab.logo} active={tab.id === activeTabId} disabled={tab.disabled ?? loading} pill={tab.pill} diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts index b7e7abf9f..976362e46 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts @@ -23,7 +23,7 @@ export const useWorkspaceSwitching = () => { availableSSOIdentityProvidersState, ); const setSignInUpStep = useSetRecoilState(signInUpStepState); - const { signOut } = useAuth(); + const { clearSession } = useAuth(); const switchWorkspace = async (workspaceId: string) => { if (currentWorkspace?.id === workspaceId) return; @@ -50,7 +50,7 @@ export const useWorkspaceSwitching = () => { } if (jwt.data.generateJWT.availableSSOIDPs.length > 1) { - await signOut(); + await clearSession(); setAvailableWorkspacesForSSOState( jwt.data.generateJWT.availableSSOIDPs, ); diff --git a/packages/twenty-front/src/pages/impersonate/ImpersonateEffect.tsx b/packages/twenty-front/src/pages/impersonate/ImpersonateEffect.tsx deleted file mode 100644 index 6ac9d3f6c..000000000 --- a/packages/twenty-front/src/pages/impersonate/ImpersonateEffect.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { isNonEmptyString } from '@sniptt/guards'; -import { useCallback, useEffect } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { useRecoilState, useSetRecoilState } from 'recoil'; - -import { useIsLogged } from '@/auth/hooks/useIsLogged'; -import { currentUserState } from '@/auth/states/currentUserState'; -import { tokenPairState } from '@/auth/states/tokenPairState'; -import { AppPath } from '@/types/AppPath'; -import { useImpersonateMutation } from '~/generated/graphql'; -import { isDefined } from '~/utils/isDefined'; - -export const ImpersonateEffect = () => { - const navigate = useNavigate(); - const { userId } = useParams(); - - const [currentUser, setCurrentUser] = useRecoilState(currentUserState); - const setTokenPair = useSetRecoilState(tokenPairState); - - const [impersonate] = useImpersonateMutation(); - - const isLogged = useIsLogged(); - - const handleImpersonate = useCallback(async () => { - if (!isNonEmptyString(userId)) { - return; - } - - const impersonateResult = await impersonate({ - variables: { userId }, - }); - - if (isDefined(impersonateResult.errors)) { - throw impersonateResult.errors; - } - - if (!impersonateResult.data?.impersonate) { - throw new Error('No impersonate result'); - } - - setCurrentUser({ - ...impersonateResult.data.impersonate.user, - // Todo also set WorkspaceMember - }); - setTokenPair(impersonateResult.data?.impersonate.tokens); - - return impersonateResult.data?.impersonate; - }, [userId, impersonate, setCurrentUser, setTokenPair]); - - useEffect(() => { - if ( - isLogged && - currentUser?.canImpersonate === true && - isNonEmptyString(userId) - ) { - handleImpersonate(); - } else { - // User is not allowed to impersonate or not logged in - navigate(AppPath.Index); - } - }, [userId, currentUser, isLogged, handleImpersonate, navigate]); - - return <>; -}; diff --git a/packages/twenty-front/src/pages/impersonate/__stories__/ImpersonateEffect.stories.tsx b/packages/twenty-front/src/pages/impersonate/__stories__/ImpersonateEffect.stories.tsx deleted file mode 100644 index b1b44773e..000000000 --- a/packages/twenty-front/src/pages/impersonate/__stories__/ImpersonateEffect.stories.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; - -import { - PageDecorator, - PageDecoratorArgs, -} from '~/testing/decorators/PageDecorator'; -import { graphqlMocks } from '~/testing/graphqlMocks'; -import { sleep } from '~/utils/sleep'; - -import { AppPath } from '@/types/AppPath'; -import { ImpersonateEffect } from '../ImpersonateEffect'; - -const meta: Meta = { - title: 'Pages/Impersonate/Impersonate', - component: ImpersonateEffect, - decorators: [PageDecorator], - args: { - routePath: AppPath.Impersonate, - routeParams: { ':userId': '1' }, - }, - parameters: { - msw: graphqlMocks, - }, -}; - -export default meta; - -export type Story = StoryObj; - -export const Default: Story = { - play: async () => { - await sleep(100); - }, -}; diff --git a/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdmin.tsx b/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdmin.tsx new file mode 100644 index 000000000..3060866ce --- /dev/null +++ b/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdmin.tsx @@ -0,0 +1,39 @@ +import { SettingsAdminImpersonateUsers } from '@/settings/admin-panel/components/SettingsAdminImpersonateUsers'; +import { SettingsCard } from '@/settings/components/SettingsCard'; +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; +import { SettingsPath } from '@/types/SettingsPath'; +import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { useTheme } from '@emotion/react'; +import { IconFlag, UndecoratedLink } from 'twenty-ui'; + +export const SettingsAdmin = () => { + const theme = useTheme(); + return ( + + + + + + } + title="Feature Flags" + /> + + + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminFeatureFlags.tsx b/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminFeatureFlags.tsx new file mode 100644 index 000000000..e4cf5a5e7 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminFeatureFlags.tsx @@ -0,0 +1,240 @@ +import { SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID } from '@/settings/admin-panel/constants/SettingsAdminFeatureFlagsTabs'; +import { useFeatureFlagsManagement } from '@/settings/admin-panel/hooks/useFeatureFlagsManagement'; +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; +import { SettingsPath } from '@/types/SettingsPath'; +import { TextInput } from '@/ui/input/components/TextInput'; +import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { TabList } from '@/ui/layout/tab/components/TabList'; +import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; +import { Table } from '@/ui/layout/table/components/Table'; +import { TableCell } from '@/ui/layout/table/components/TableCell'; +import { TableHeader } from '@/ui/layout/table/components/TableHeader'; +import { TableRow } from '@/ui/layout/table/components/TableRow'; +import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo'; +import styled from '@emotion/styled'; +import { useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import { + Button, + getImageAbsoluteURI, + H1Title, + H1TitleFontColor, + H2Title, + IconSearch, + isDefined, + Section, + Toggle, +} from 'twenty-ui'; + +const StyledLinkContainer = styled.div` + margin-right: ${({ theme }) => theme.spacing(2)}; + width: 100%; +`; + +const StyledContainer = styled.div` + align-items: center; + display: flex; + flex-direction: row; +`; + +const StyledErrorSection = styled.div` + color: ${({ theme }) => theme.font.color.danger}; + margin-top: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledUserInfo = styled.div` + margin-bottom: ${({ theme }) => theme.spacing(5)}; +`; + +const StyledTable = styled(Table)` + margin-top: ${({ theme }) => theme.spacing(0.5)}; +`; + +const StyledTabListContainer = styled.div` + align-items: center; + border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`}; + box-sizing: border-box; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledContentContainer = styled.div` + flex: 1; + width: 100%; + padding: ${({ theme }) => theme.spacing(4)} 0; +`; + +export const SettingsAdminFeatureFlags = () => { + const [userIdentifier, setUserIdentifier] = useState(''); + + const { activeTabIdState, setActiveTabId } = useTabList( + SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID, + ); + const activeTabId = useRecoilValue(activeTabIdState); + + const { + userLookupResult, + handleUserLookup, + handleFeatureFlagUpdate, + isLoading, + error, + } = useFeatureFlagsManagement(); + + const handleSearch = async () => { + setActiveTabId(''); + + const result = await handleUserLookup(userIdentifier); + + if ( + isDefined(result?.workspaces) && + result.workspaces.length > 0 && + !error + ) { + setActiveTabId(result.workspaces[0].id); + } + }; + + const shouldShowUserData = userLookupResult && !error; + + const activeWorkspace = userLookupResult?.workspaces.find( + (workspace) => workspace.id === activeTabId, + ); + + const tabs = + userLookupResult?.workspaces.map((workspace) => ({ + id: workspace.id, + title: workspace.name, + logo: + getImageAbsoluteURI( + workspace.logo === null ? DEFAULT_WORKSPACE_LOGO : workspace.logo, + ) ?? '', + })) ?? []; + + const renderWorkspaceContent = () => { + if (!activeWorkspace) return null; + + return ( + <> + + 1 ? 'Users' : 'User' + }`} + description={'Total Users'} + /> + + + Feature Flag + Status + + + {activeWorkspace.featureFlags.map((flag) => ( + + {flag.key} + + + handleFeatureFlagUpdate( + activeWorkspace.id, + flag.key, + newValue, + ) + } + /> + + + ))} + + + ); + }; + + return ( + + +
+ + + + + + +
+ + {shouldShowUserData && ( +
+ + + + + + + + + + + + + {renderWorkspaceContent()} + +
+ )} +
+
+ ); +}; diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts new file mode 100644 index 000000000..375a507c2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { AdminPanelResolver } from 'src/engine/core-modules/admin-panel/admin-panel.resolver'; +import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service'; +import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; +import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([User, Workspace, FeatureFlagEntity], 'core'), + AuthModule, + ], + providers: [AdminPanelResolver, AdminPanelService], + exports: [AdminPanelService], +}) +export class AdminPanelModule {} diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts new file mode 100644 index 000000000..2bc2f099c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts @@ -0,0 +1,57 @@ +import { UseFilters, UseGuards } from '@nestjs/common'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service'; +import { ImpersonateInput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.input'; +import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input'; +import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.entity'; +import { UserLookupInput } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.input'; +import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity'; +import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; +import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; + +@Resolver() +@UseFilters(AuthGraphqlApiExceptionFilter) +export class AdminPanelResolver { + constructor(private adminService: AdminPanelService) {} + + @UseGuards(WorkspaceAuthGuard, UserAuthGuard) + @Mutation(() => Verify) + async impersonate( + @Args() impersonateInput: ImpersonateInput, + @AuthUser() user: User, + ): Promise { + return await this.adminService.impersonate(impersonateInput.userId, user); + } + + @UseGuards(WorkspaceAuthGuard, UserAuthGuard) + @Mutation(() => UserLookup) + async userLookupAdminPanel( + @Args() userLookupInput: UserLookupInput, + @AuthUser() user: User, + ): Promise { + return await this.adminService.userLookup( + userLookupInput.userIdentifier, + user, + ); + } + + @UseGuards(WorkspaceAuthGuard, UserAuthGuard) + @Mutation(() => Boolean) + async updateWorkspaceFeatureFlag( + @Args() updateFlagInput: UpdateWorkspaceFeatureFlagInput, + @AuthUser() user: User, + ): Promise { + await this.adminService.updateWorkspaceFeatureFlags( + updateFlagInput.workspaceId, + updateFlagInput.featureFlag, + user, + updateFlagInput.value, + ); + + return true; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts new file mode 100644 index 000000000..7679e4b54 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts @@ -0,0 +1,179 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.entity'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +@Injectable() +export class AdminPanelService { + constructor( + private readonly accessTokenService: AccessTokenService, + private readonly refreshTokenService: RefreshTokenService, + @InjectRepository(User, 'core') + private readonly userRepository: Repository, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, + @InjectRepository(FeatureFlagEntity, 'core') + private readonly featureFlagRepository: Repository, + ) {} + + async impersonate(userIdentifier: string, userImpersonating: User) { + if (!userImpersonating.canImpersonate) { + throw new AuthException( + 'User cannot impersonate', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + const isEmail = userIdentifier.includes('@'); + + const user = await this.userRepository.findOne({ + where: isEmail ? { email: userIdentifier } : { id: userIdentifier }, + relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'], + }); + + if (!user) { + throw new AuthException( + 'User not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + if (!user.defaultWorkspace.allowImpersonation) { + throw new AuthException( + 'Impersonation not allowed', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + const accessToken = await this.accessTokenService.generateAccessToken( + user.id, + user.defaultWorkspaceId, + ); + const refreshToken = await this.refreshTokenService.generateRefreshToken( + user.id, + user.defaultWorkspaceId, + ); + + return { + user, + tokens: { + accessToken, + refreshToken, + }, + }; + } + + async userLookup( + userIdentifier: string, + userImpersonating: User, + ): Promise { + if (!userImpersonating.canImpersonate) { + throw new AuthException( + 'User cannot access user info', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + const isEmail = userIdentifier.includes('@'); + + const targetUser = await this.userRepository.findOne({ + where: isEmail ? { email: userIdentifier } : { id: userIdentifier }, + relations: [ + 'workspaces', + 'workspaces.workspace', + 'workspaces.workspace.workspaceUsers', + 'workspaces.workspace.workspaceUsers.user', + 'workspaces.workspace.featureFlags', + ], + }); + + if (!targetUser) { + throw new AuthException( + 'User not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + const allFeatureFlagKeys = Object.values(FeatureFlagKey); + + return { + user: { + id: targetUser.id, + email: targetUser.email, + firstName: targetUser.firstName, + lastName: targetUser.lastName, + }, + workspaces: targetUser.workspaces.map((userWorkspace) => ({ + id: userWorkspace.workspace.id, + name: userWorkspace.workspace.displayName ?? '', + totalUsers: userWorkspace.workspace.workspaceUsers.length, + logo: userWorkspace.workspace.logo, + users: userWorkspace.workspace.workspaceUsers.map((workspaceUser) => ({ + id: workspaceUser.user.id, + email: workspaceUser.user.email, + firstName: workspaceUser.user.firstName, + lastName: workspaceUser.user.lastName, + })), + featureFlags: allFeatureFlagKeys.map((key) => ({ + key, + value: + userWorkspace.workspace.featureFlags?.find( + (flag) => flag.key === key, + )?.value ?? false, + })) as FeatureFlagEntity[], + })), + }; + } + + async updateWorkspaceFeatureFlags( + workspaceId: string, + featureFlag: FeatureFlagKey, + userImpersonating: User, + value: boolean, + ) { + if (!userImpersonating.canImpersonate) { + throw new AuthException( + 'User cannot update feature flags', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + const workspace = await this.workspaceRepository.findOne({ + where: { id: workspaceId }, + relations: ['featureFlags'], + }); + + if (!workspace) { + throw new AuthException( + 'Workspace not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + const existingFlag = workspace.featureFlags?.find( + (flag) => flag.key === featureFlag, + ); + + if (existingFlag) { + await this.featureFlagRepository.update(existingFlag.id, { value }); + } else { + await this.featureFlagRepository.save({ + key: featureFlag, + value, + workspaceId: workspace.id, + }); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/impersonate.input.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/impersonate.input.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/auth/dto/impersonate.input.ts rename to packages/twenty-server/src/engine/core-modules/admin-panel/dtos/impersonate.input.ts diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input.ts new file mode 100644 index 000000000..5f134b7d9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input.ts @@ -0,0 +1,21 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { IsBoolean, IsNotEmpty, IsString } from 'class-validator'; + +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; + +@ArgsType() +export class UpdateWorkspaceFeatureFlagInput { + @Field(() => String) + @IsNotEmpty() + @IsString() + workspaceId: string; + + @Field(() => String) + @IsNotEmpty() + featureFlag: FeatureFlagKey; + + @Field(() => Boolean) + @IsBoolean() + value: boolean; +} diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/user-lookup.entity.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/user-lookup.entity.ts new file mode 100644 index 000000000..f67fe31a4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/user-lookup.entity.ts @@ -0,0 +1,48 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; + +@ObjectType() +class UserInfo { + @Field(() => String) + id: string; + + @Field(() => String) + email: string; + + @Field(() => String, { nullable: true }) + firstName?: string; + + @Field(() => String, { nullable: true }) + lastName?: string; +} + +@ObjectType() +class WorkspaceInfo { + @Field(() => String) + id: string; + + @Field(() => String) + name: string; + + @Field(() => String, { nullable: true }) + logo?: string; + + @Field(() => Number) + totalUsers: number; + + @Field(() => [UserInfo]) + users: UserInfo[]; + + @Field(() => [FeatureFlagEntity]) + featureFlags: FeatureFlagEntity[]; +} + +@ObjectType() +export class UserLookup { + @Field(() => UserInfo) + user: UserInfo; + + @Field(() => [WorkspaceInfo]) + workspaces: WorkspaceInfo[]; +} diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/user-lookup.input.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/user-lookup.input.ts new file mode 100644 index 000000000..971c18635 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/user-lookup.input.ts @@ -0,0 +1,11 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { IsNotEmpty, IsString } from 'class-validator'; + +@ArgsType() +export class UserLookupInput { + @Field(() => String) + @IsNotEmpty() + @IsString() + userIdentifier: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index 2d6fc31b6..e3387c5c6 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -22,6 +22,7 @@ import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/sw import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; @@ -96,6 +97,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; MicrosoftAPIsService, AppTokenService, AccessTokenService, + RefreshTokenService, LoginTokenService, ResetPasswordService, SwitchWorkspaceService, @@ -103,6 +105,6 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; ApiKeyService, OAuthService, ], - exports: [AccessTokenService, LoginTokenService], + exports: [AccessTokenService, LoginTokenService, RefreshTokenService], }) export class AuthModule {} diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index d819bc84c..c74afe573 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -38,7 +38,6 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { ChallengeInput } from './dto/challenge.input'; -import { ImpersonateInput } from './dto/impersonate.input'; import { LoginToken } from './dto/login-token.entity'; import { SignUpInput } from './dto/sign-up.input'; import { ApiKeyToken, AuthTokens } from './dto/token.entity'; @@ -228,15 +227,6 @@ export class AuthResolver { return { tokens: tokens }; } - @UseGuards(WorkspaceAuthGuard, UserAuthGuard) - @Mutation(() => Verify) - async impersonate( - @Args() impersonateInput: ImpersonateInput, - @AuthUser() user: User, - ): Promise { - return await this.authService.impersonate(impersonateInput.userId, user); - } - @UseGuards(WorkspaceAuthGuard) @Mutation(() => ApiKeyToken) async generateApiKeyToken( diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index 8a9965695..7de331e5d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -188,53 +188,6 @@ export class AuthService { return { isValid: !!workspace }; } - async impersonate(userIdToImpersonate: string, userImpersonating: User) { - if (!userImpersonating.canImpersonate) { - throw new AuthException( - 'User cannot impersonate', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - const user = await this.userRepository.findOne({ - where: { - id: userIdToImpersonate, - }, - relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'], - }); - - if (!user) { - throw new AuthException( - 'User not found', - AuthExceptionCode.USER_NOT_FOUND, - ); - } - - if (!user.defaultWorkspace.allowImpersonation) { - throw new AuthException( - 'Impersonation not allowed', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - const accessToken = await this.accessTokenService.generateAccessToken( - user.id, - user.defaultWorkspaceId, - ); - const refreshToken = await this.refreshTokenService.generateRefreshToken( - user.id, - user.defaultWorkspaceId, - ); - - return { - user, - tokens: { - accessToken, - refreshToken, - }, - }; - } - async generateAuthorizationCode( authorizeAppInput: AuthorizeAppInput, user: User, diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index a04e6cc67..7ba889915 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -3,6 +3,7 @@ import { HttpAdapterHost } from '@nestjs/core'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { ActorModule } from 'src/engine/core-modules/actor/actor.module'; +import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module'; import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module'; import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; @@ -70,6 +71,7 @@ import { FileModule } from './file/file.module'; WorkspaceEventEmitterModule, ActorModule, TelemetryModule, + AdminPanelModule, EnvironmentModule.forRoot({}), RedisClientModule, FileStorageModule.forRootAsync({ diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 2f29a2788..bbede9ec9 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -130,6 +130,7 @@ export { IconFilter, IconFilterCog, IconFilterOff, + IconFlag, IconFocusCentered, IconFolder, IconFolderPlus, @@ -215,10 +216,11 @@ export { IconRotate2, IconSearch, IconSend, + IconServer, IconSettings, IconSettingsAutomation, - IconSortAZ, IconSlash, + IconSortAZ, IconSortDescending, IconSortZA, IconSparkles,