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:
@ -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) {
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,2 @@
|
||||
export const SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID =
|
||||
'settings-admin-feature-flags-tab-id';
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
`;
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,4 @@
|
||||
export type FeatureFlag = {
|
||||
key: string;
|
||||
value: boolean;
|
||||
};
|
||||
@ -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[];
|
||||
};
|
||||
@ -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[];
|
||||
};
|
||||
@ -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}
|
||||
|
||||
@ -26,9 +26,6 @@ export enum AppPath {
|
||||
Developers = `developers`,
|
||||
DevelopersCatchAll = `/${Developers}/*`,
|
||||
|
||||
// Impersonate
|
||||
Impersonate = '/impersonate/:userId',
|
||||
|
||||
Authorize = '/authorize',
|
||||
|
||||
// 404 page not found
|
||||
|
||||
@ -35,4 +35,6 @@ export enum SettingsPath {
|
||||
DevelopersNewWebhook = 'webhooks/new',
|
||||
DevelopersNewWebhookDetail = 'webhooks/:webhookId',
|
||||
Releases = 'releases',
|
||||
AdminPanel = 'admin-panel',
|
||||
FeatureFlags = 'admin-panel/feature-flags',
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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 <></>;
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 {}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user