Admin panel init (#8742)

WIP
Related issues - 
#7090 
#8547 
Master issue - 
#4499

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
nitin
2024-11-28 18:13:11 +05:30
committed by GitHub
parent abe9185f48
commit e96ad9a1f2
38 changed files with 1197 additions and 232 deletions

View File

@ -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<FileFolder>;
@ -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<Scalars['String']>;
id: Scalars['String'];
lastName?: Maybe<Scalars['String']>;
};
export type UserLookup = {
__typename?: 'UserLookup';
user: UserInfo;
workspaces: Array<WorkspaceInfo>;
};
export type UserMappingOptionsUser = {
__typename?: 'UserMappingOptionsUser';
user?: Maybe<Scalars['String']>;
@ -1285,6 +1313,7 @@ export type Workspace = {
__typename?: 'Workspace';
activationStatus: WorkspaceActivationStatus;
allowImpersonation: Scalars['Boolean'];
billingEntitlements?: Maybe<Array<BillingEntitlement>>;
billingSubscriptions?: Maybe<Array<BillingSubscription>>;
createdAt: Scalars['DateTime'];
currentBillingSubscription?: Maybe<BillingSubscription>;
@ -1305,6 +1334,12 @@ export type Workspace = {
};
export type WorkspaceBillingEntitlementsArgs = {
filter?: BillingEntitlementFilter;
sorting?: Array<BillingEntitlementSort>;
};
export type WorkspaceBillingSubscriptionsArgs = {
filter?: BillingSubscriptionFilter;
sorting?: Array<BillingSubscriptionSort>;
@ -1331,6 +1366,16 @@ export type WorkspaceEdge = {
node: Workspace;
};
export type WorkspaceInfo = {
__typename?: 'WorkspaceInfo';
featureFlags: Array<FeatureFlag>;
id: Scalars['String'];
logo?: Maybe<Scalars['String']>;
name: Scalars['String'];
totalUsers: Scalars['Float'];
users: Array<UserInfo>;
};
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<Array<BillingEntitlementFilter>>;
id?: InputMaybe<UuidFilterComparison>;
or?: InputMaybe<Array<BillingEntitlementFilter>>;
};
export type BillingEntitlementSort = {
direction: SortDirection;
field: BillingEntitlementSortFields;
nulls?: InputMaybe<SortNulls>;
};
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<typeof useSkipSyncEmailOnboardingStepMutation>;
export type SkipSyncEmailOnboardingStepMutationResult = Apollo.MutationResult<SkipSyncEmailOnboardingStepMutation>;
export type SkipSyncEmailOnboardingStepMutationOptions = Apollo.BaseMutationOptions<SkipSyncEmailOnboardingStepMutation, SkipSyncEmailOnboardingStepMutationVariables>;
export const UpdateWorkspaceFeatureFlagDocument = gql`
mutation UpdateWorkspaceFeatureFlag($workspaceId: String!, $featureFlag: String!, $value: Boolean!) {
updateWorkspaceFeatureFlag(
workspaceId: $workspaceId
featureFlag: $featureFlag
value: $value
)
}
`;
export type UpdateWorkspaceFeatureFlagMutationFn = Apollo.MutationFunction<UpdateWorkspaceFeatureFlagMutation, UpdateWorkspaceFeatureFlagMutationVariables>;
/**
* __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<UpdateWorkspaceFeatureFlagMutation, UpdateWorkspaceFeatureFlagMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpdateWorkspaceFeatureFlagMutation, UpdateWorkspaceFeatureFlagMutationVariables>(UpdateWorkspaceFeatureFlagDocument, options);
}
export type UpdateWorkspaceFeatureFlagMutationHookResult = ReturnType<typeof useUpdateWorkspaceFeatureFlagMutation>;
export type UpdateWorkspaceFeatureFlagMutationResult = Apollo.MutationResult<UpdateWorkspaceFeatureFlagMutation>;
export type UpdateWorkspaceFeatureFlagMutationOptions = Apollo.BaseMutationOptions<UpdateWorkspaceFeatureFlagMutation, UpdateWorkspaceFeatureFlagMutationVariables>;
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<UserLookupAdminPanelMutation, UserLookupAdminPanelMutationVariables>;
/**
* __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<UserLookupAdminPanelMutation, UserLookupAdminPanelMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UserLookupAdminPanelMutation, UserLookupAdminPanelMutationVariables>(UserLookupAdminPanelDocument, options);
}
export type UserLookupAdminPanelMutationHookResult = ReturnType<typeof useUserLookupAdminPanelMutation>;
export type UserLookupAdminPanelMutationResult = Apollo.MutationResult<UserLookupAdminPanelMutation>;
export type UserLookupAdminPanelMutationOptions = Apollo.BaseMutationOptions<UserLookupAdminPanelMutation, UserLookupAdminPanelMutationVariables>;
export const CreateOidcIdentityProviderDocument = gql`
mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) {
createOIDCIdentityProvider(input: $input) {

View File

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

View File

@ -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 (
<RouterProvider
router={useCreateAppRouter(
@ -23,6 +28,7 @@ export const AppRouter = () => {
isCRMMigrationEnabled,
isServerlessFunctionSettingsEnabled,
isSSOEnabled,
isAdminPageEnabled,
)}
/>
);

View File

@ -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) => (
<Suspense fallback={<SettingsSkeletonLoader />}>
<Routes>
@ -375,6 +391,15 @@ export const SettingsRoutes = ({
/>
</>
)}
{isAdminPageEnabled && (
<>
<Route path={SettingsPath.AdminPanel} element={<SettingsAdmin />} />
<Route
path={SettingsPath.FeatureFlags}
element={<SettingsAdminFeatureFlags />}
/>
</>
)}
</Routes>
</Suspense>
);

View File

@ -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={<PaymentSuccess />}
/>
<Route path={indexAppPath.getIndexAppPath()} element={<></>} />
<Route path={AppPath.Impersonate} element={<ImpersonateEffect />} />
<Route path={AppPath.RecordIndexPage} element={<RecordIndexPage />} />
<Route path={AppPath.RecordShowPage} element={<RecordShowPage />} />
<Route
@ -67,6 +66,7 @@ export const useCreateAppRouter = (
isServerlessFunctionSettingsEnabled
}
isSSOEnabled={isSSOEnabled}
isAdminPageEnabled={isAdminPageEnabled}
/>
}
/>

View File

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

View File

@ -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 (
<Section>
<H2Title
title="Impersonate"
description="You don't have permission to impersonate other users. Please contact your administrator if you need this access."
/>
</Section>
);
}
return (
<Section>
<H2Title title="Impersonate" description="Impersonate a user." />
<StyledContainer>
<StyledLinkContainer>
<TextInput
value={userId}
onChange={setUserId}
placeholder="Enter user ID or email address"
fullWidth
disabled={isLoading}
dataTestId="impersonate-input"
onInputEnter={() => handleImpersonate(userId)}
/>
</StyledLinkContainer>
<Button
Icon={IconUser}
variant="primary"
accent="blue"
title={'Impersonate'}
onClick={() => handleImpersonate(userId)}
disabled={!userId.trim() || isLoading}
dataTestId="impersonate-button"
/>
</StyledContainer>
{error && <StyledErrorSection>{error}</StyledErrorSection>}
</Section>
);
};

View File

@ -0,0 +1,2 @@
export const SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID =
'settings-admin-feature-flags-tab-id';

View File

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

View File

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

View File

@ -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<UserLookup | null>(
null,
);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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,
};
};

View File

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

View File

@ -0,0 +1,4 @@
export type FeatureFlag = {
key: string;
value: boolean;
};

View File

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

View File

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

View File

@ -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 = () => {
</AnimatePresence>
<NavigationDrawerSection>
<NavigationDrawerSectionTitle label="Other" />
{isAdminPageEnabled && (
<SettingsNavigationDrawerItem
label="Server Admin Panel"
path={SettingsPath.AdminPanel}
Icon={IconServer}
/>
)}
<SettingsNavigationDrawerItem
label="Releases"
path={SettingsPath.Releases}

View File

@ -26,9 +26,6 @@ export enum AppPath {
Developers = `developers`,
DevelopersCatchAll = `/${Developers}/*`,
// Impersonate
Impersonate = '/impersonate/:userId',
Authorize = '/authorize',
// 404 page not found

View File

@ -35,4 +35,6 @@ export enum SettingsPath {
DevelopersNewWebhook = 'webhooks/new',
DevelopersNewWebhookDetail = 'webhooks/:webhookId',
Releases = 'releases',
AdminPanel = 'admin-panel',
FeatureFlags = 'admin-panel/feature-flags',
}

View File

@ -243,17 +243,6 @@ const testCases = [
{ loc: AppPath.DevelopersCatchAll, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: true },
{ loc: AppPath.DevelopersCatchAll, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: false },
{ loc: AppPath.Impersonate, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true },
{ loc: AppPath.Impersonate, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: false },
{ loc: AppPath.Impersonate, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: false },
{ loc: AppPath.Impersonate, isLogged: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: false },
{ loc: AppPath.Impersonate, isLogged: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: true },
{ loc: AppPath.Impersonate, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: true },
{ loc: AppPath.Impersonate, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: true },
{ loc: AppPath.Impersonate, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: true },
{ loc: AppPath.Impersonate, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: true },
{ loc: AppPath.Impersonate, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: false },
{ loc: AppPath.Authorize, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true },
{ loc: AppPath.Authorize, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: false },
{ loc: AppPath.Authorize, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: false },

View File

@ -15,6 +15,7 @@ type TabProps = {
disabled?: boolean;
pill?: string | ReactElement;
to?: string;
logo?: string;
};
const StyledTab = styled('button', {
@ -61,6 +62,10 @@ const StyledHover = styled.span`
background: ${({ theme }) => 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}
>
<StyledHover>
{logo && <StyledLogo src={logo} alt={`${title} logo`} />}
{Icon && <Icon size={theme.icon.size.md} />}
{title}
{pill && typeof pill === 'string' ? <Pill label={pill} /> : pill}

View File

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

View File

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

View File

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

View File

@ -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<PageDecoratorArgs> = {
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<typeof ImpersonateEffect>;
export const Default: Story = {
play: async () => {
await sleep(100);
},
};

View File

@ -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 (
<SubMenuTopBarContainer
title="Server Admin Panel"
links={[
{
children: 'Other',
href: getSettingsPagePath(SettingsPath.AdminPanel),
},
{ children: 'Server Admin Panel' },
]}
>
<SettingsPageContainer>
<SettingsAdminImpersonateUsers />
<UndecoratedLink to={getSettingsPagePath(SettingsPath.FeatureFlags)}>
<SettingsCard
Icon={
<IconFlag
size={theme.icon.size.lg}
stroke={theme.icon.stroke.sm}
/>
}
title="Feature Flags"
/>
</UndecoratedLink>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -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 (
<>
<H2Title title={activeWorkspace.name} description={'Workspace Name'} />
<H2Title
title={`${activeWorkspace.totalUsers} ${
activeWorkspace.totalUsers > 1 ? 'Users' : 'User'
}`}
description={'Total Users'}
/>
<StyledTable>
<TableRow
gridAutoColumns="1fr 100px"
mobileGridAutoColumns="1fr 80px"
>
<TableHeader>Feature Flag</TableHeader>
<TableHeader align="right">Status</TableHeader>
</TableRow>
{activeWorkspace.featureFlags.map((flag) => (
<TableRow
gridAutoColumns="1fr 100px"
mobileGridAutoColumns="1fr 80px"
key={flag.key}
>
<TableCell>{flag.key}</TableCell>
<TableCell align="right">
<Toggle
value={flag.value}
onChange={(newValue) =>
handleFeatureFlagUpdate(
activeWorkspace.id,
flag.key,
newValue,
)
}
/>
</TableCell>
</TableRow>
))}
</StyledTable>
</>
);
};
return (
<SubMenuTopBarContainer
title="Feature Flags"
links={[
{
children: 'Other',
href: getSettingsPagePath(SettingsPath.AdminPanel),
},
{
children: 'Server Admin Panel',
href: getSettingsPagePath(SettingsPath.AdminPanel),
},
{ children: 'Feature Flags' },
]}
>
<SettingsPageContainer>
<Section>
<H2Title
title="Feature Flags Management"
description="Look up users and manage their workspace feature flags."
/>
<StyledContainer>
<StyledLinkContainer>
<TextInput
value={userIdentifier}
onChange={setUserIdentifier}
onInputEnter={handleSearch}
placeholder="Enter user ID or email address"
fullWidth
disabled={isLoading}
/>
</StyledLinkContainer>
<Button
Icon={IconSearch}
variant="primary"
accent="blue"
title="Search"
onClick={handleSearch}
disabled={!userIdentifier.trim() || isLoading}
/>
</StyledContainer>
{error && <StyledErrorSection>{error}</StyledErrorSection>}
</Section>
{shouldShowUserData && (
<Section>
<StyledUserInfo>
<H1Title title="User Info" fontColor={H1TitleFontColor.Primary} />
<H2Title
title={`${userLookupResult.user.firstName || ''} ${
userLookupResult.user.lastName || ''
}`.trim()}
description="User Name"
/>
<H2Title
title={userLookupResult.user.email}
description="User Email"
/>
<H2Title title={userLookupResult.user.id} description="User ID" />
</StyledUserInfo>
<H1Title title="Workspaces" fontColor={H1TitleFontColor.Primary} />
<StyledTabListContainer>
<TabList
tabs={tabs}
tabListInstanceId={SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID}
behaveAsLinks={false}
/>
</StyledTabListContainer>
<StyledContentContainer>
{renderWorkspaceContent()}
</StyledContentContainer>
</Section>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

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

View File

@ -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<Verify> {
return await this.adminService.impersonate(impersonateInput.userId, user);
}
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
@Mutation(() => UserLookup)
async userLookupAdminPanel(
@Args() userLookupInput: UserLookupInput,
@AuthUser() user: User,
): Promise<UserLookup> {
return await this.adminService.userLookup(
userLookupInput.userIdentifier,
user,
);
}
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
@Mutation(() => Boolean)
async updateWorkspaceFeatureFlag(
@Args() updateFlagInput: UpdateWorkspaceFeatureFlagInput,
@AuthUser() user: User,
): Promise<boolean> {
await this.adminService.updateWorkspaceFeatureFlags(
updateFlagInput.workspaceId,
updateFlagInput.featureFlag,
user,
updateFlagInput.value,
);
return true;
}
}

View File

@ -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<User>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(FeatureFlagEntity, 'core')
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
) {}
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<UserLookup> {
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,
});
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Verify> {
return await this.authService.impersonate(impersonateInput.userId, user);
}
@UseGuards(WorkspaceAuthGuard)
@Mutation(() => ApiKeyToken)
async generateApiKeyToken(

View File

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

View File

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

View File

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