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;
|
updateOneServerlessFunction: ServerlessFunction;
|
||||||
updatePasswordViaResetToken: InvalidatePassword;
|
updatePasswordViaResetToken: InvalidatePassword;
|
||||||
updateWorkspace: Workspace;
|
updateWorkspace: Workspace;
|
||||||
|
updateWorkspaceFeatureFlag: Scalars['Boolean'];
|
||||||
uploadFile: Scalars['String'];
|
uploadFile: Scalars['String'];
|
||||||
uploadImage: Scalars['String'];
|
uploadImage: Scalars['String'];
|
||||||
uploadProfilePicture: Scalars['String'];
|
uploadProfilePicture: Scalars['String'];
|
||||||
uploadWorkspaceLogo: Scalars['String'];
|
uploadWorkspaceLogo: Scalars['String'];
|
||||||
|
userLookupAdminPanel: UserLookup;
|
||||||
verify: Verify;
|
verify: Verify;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -679,6 +681,13 @@ export type MutationUpdateWorkspaceArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationUpdateWorkspaceFeatureFlagArgs = {
|
||||||
|
featureFlag: Scalars['String'];
|
||||||
|
value: Scalars['Boolean'];
|
||||||
|
workspaceId: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationUploadFileArgs = {
|
export type MutationUploadFileArgs = {
|
||||||
file: Scalars['Upload'];
|
file: Scalars['Upload'];
|
||||||
fileFolder?: InputMaybe<FileFolder>;
|
fileFolder?: InputMaybe<FileFolder>;
|
||||||
@ -701,6 +710,11 @@ export type MutationUploadWorkspaceLogoArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationUserLookupAdminPanelArgs = {
|
||||||
|
userIdentifier: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationVerifyArgs = {
|
export type MutationVerifyArgs = {
|
||||||
loginToken: Scalars['String'];
|
loginToken: Scalars['String'];
|
||||||
};
|
};
|
||||||
@ -1247,6 +1261,20 @@ export type UserExists = {
|
|||||||
exists: Scalars['Boolean'];
|
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 = {
|
export type UserMappingOptionsUser = {
|
||||||
__typename?: 'UserMappingOptionsUser';
|
__typename?: 'UserMappingOptionsUser';
|
||||||
user?: Maybe<Scalars['String']>;
|
user?: Maybe<Scalars['String']>;
|
||||||
@ -1285,6 +1313,7 @@ export type Workspace = {
|
|||||||
__typename?: 'Workspace';
|
__typename?: 'Workspace';
|
||||||
activationStatus: WorkspaceActivationStatus;
|
activationStatus: WorkspaceActivationStatus;
|
||||||
allowImpersonation: Scalars['Boolean'];
|
allowImpersonation: Scalars['Boolean'];
|
||||||
|
billingEntitlements?: Maybe<Array<BillingEntitlement>>;
|
||||||
billingSubscriptions?: Maybe<Array<BillingSubscription>>;
|
billingSubscriptions?: Maybe<Array<BillingSubscription>>;
|
||||||
createdAt: Scalars['DateTime'];
|
createdAt: Scalars['DateTime'];
|
||||||
currentBillingSubscription?: Maybe<BillingSubscription>;
|
currentBillingSubscription?: Maybe<BillingSubscription>;
|
||||||
@ -1305,6 +1334,12 @@ export type Workspace = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type WorkspaceBillingEntitlementsArgs = {
|
||||||
|
filter?: BillingEntitlementFilter;
|
||||||
|
sorting?: Array<BillingEntitlementSort>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type WorkspaceBillingSubscriptionsArgs = {
|
export type WorkspaceBillingSubscriptionsArgs = {
|
||||||
filter?: BillingSubscriptionFilter;
|
filter?: BillingSubscriptionFilter;
|
||||||
sorting?: Array<BillingSubscriptionSort>;
|
sorting?: Array<BillingSubscriptionSort>;
|
||||||
@ -1331,6 +1366,16 @@ export type WorkspaceEdge = {
|
|||||||
node: Workspace;
|
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 = {
|
export type WorkspaceInvitation = {
|
||||||
__typename?: 'WorkspaceInvitation';
|
__typename?: 'WorkspaceInvitation';
|
||||||
email: Scalars['String'];
|
email: Scalars['String'];
|
||||||
@ -1376,6 +1421,30 @@ export type WorkspaceNameAndId = {
|
|||||||
id: Scalars['String'];
|
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 = {
|
export type Field = {
|
||||||
__typename?: 'field';
|
__typename?: 'field';
|
||||||
createdAt: Scalars['DateTime'];
|
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 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<{
|
export type CreateOidcIdentityProviderMutationVariables = Exact<{
|
||||||
input: SetupOidcSsoInput;
|
input: SetupOidcSsoInput;
|
||||||
}>;
|
}>;
|
||||||
@ -3178,6 +3263,97 @@ export function useSkipSyncEmailOnboardingStepMutation(baseOptions?: Apollo.Muta
|
|||||||
export type SkipSyncEmailOnboardingStepMutationHookResult = ReturnType<typeof useSkipSyncEmailOnboardingStepMutation>;
|
export type SkipSyncEmailOnboardingStepMutationHookResult = ReturnType<typeof useSkipSyncEmailOnboardingStepMutation>;
|
||||||
export type SkipSyncEmailOnboardingStepMutationResult = Apollo.MutationResult<SkipSyncEmailOnboardingStepMutation>;
|
export type SkipSyncEmailOnboardingStepMutationResult = Apollo.MutationResult<SkipSyncEmailOnboardingStepMutation>;
|
||||||
export type SkipSyncEmailOnboardingStepMutationOptions = Apollo.BaseMutationOptions<SkipSyncEmailOnboardingStepMutation, SkipSyncEmailOnboardingStepMutationVariables>;
|
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`
|
export const CreateOidcIdentityProviderDocument = gql`
|
||||||
mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) {
|
mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) {
|
||||||
createOIDCIdentityProvider(input: $input) {
|
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.InviteTeam, res: AppPath.InviteTeam },
|
||||||
{ loc: AppPath.DevelopersCatchAll, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: undefined },
|
{ 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: 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.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' },
|
||||||
{ loc: AppPath.Authorize, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, 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 { useCreateAppRouter } from '@/app/hooks/useCreateAppRouter';
|
||||||
|
import { currentUserState } from '@/auth/states/currentUserState';
|
||||||
import { billingState } from '@/client-config/states/billingState';
|
import { billingState } from '@/client-config/states/billingState';
|
||||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
@ -16,6 +17,10 @@ export const AppRouter = () => {
|
|||||||
const isBillingPageEnabled =
|
const isBillingPageEnabled =
|
||||||
billing?.isBillingEnabled && !isFreeAccessEnabled;
|
billing?.isBillingEnabled && !isFreeAccessEnabled;
|
||||||
|
|
||||||
|
const currentUser = useRecoilValue(currentUserState);
|
||||||
|
|
||||||
|
const isAdminPageEnabled = currentUser?.canImpersonate;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RouterProvider
|
<RouterProvider
|
||||||
router={useCreateAppRouter(
|
router={useCreateAppRouter(
|
||||||
@ -23,6 +28,7 @@ export const AppRouter = () => {
|
|||||||
isCRMMigrationEnabled,
|
isCRMMigrationEnabled,
|
||||||
isServerlessFunctionSettingsEnabled,
|
isServerlessFunctionSettingsEnabled,
|
||||||
isSSOEnabled,
|
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 = {
|
type SettingsRoutesProps = {
|
||||||
isBillingEnabled?: boolean;
|
isBillingEnabled?: boolean;
|
||||||
isCRMMigrationEnabled?: boolean;
|
isCRMMigrationEnabled?: boolean;
|
||||||
isServerlessFunctionSettingsEnabled?: boolean;
|
isServerlessFunctionSettingsEnabled?: boolean;
|
||||||
isSSOEnabled?: boolean;
|
isSSOEnabled?: boolean;
|
||||||
|
isAdminPageEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsRoutes = ({
|
export const SettingsRoutes = ({
|
||||||
@ -254,6 +269,7 @@ export const SettingsRoutes = ({
|
|||||||
isCRMMigrationEnabled,
|
isCRMMigrationEnabled,
|
||||||
isServerlessFunctionSettingsEnabled,
|
isServerlessFunctionSettingsEnabled,
|
||||||
isSSOEnabled,
|
isSSOEnabled,
|
||||||
|
isAdminPageEnabled,
|
||||||
}: SettingsRoutesProps) => (
|
}: SettingsRoutesProps) => (
|
||||||
<Suspense fallback={<SettingsSkeletonLoader />}>
|
<Suspense fallback={<SettingsSkeletonLoader />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
@ -375,6 +391,15 @@ export const SettingsRoutes = ({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isAdminPageEnabled && (
|
||||||
|
<>
|
||||||
|
<Route path={SettingsPath.AdminPanel} element={<SettingsAdmin />} />
|
||||||
|
<Route
|
||||||
|
path={SettingsPath.FeatureFlags}
|
||||||
|
element={<SettingsAdminFeatureFlags />}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import { Authorize } from '~/pages/auth/Authorize';
|
|||||||
import { Invite } from '~/pages/auth/Invite';
|
import { Invite } from '~/pages/auth/Invite';
|
||||||
import { PasswordReset } from '~/pages/auth/PasswordReset';
|
import { PasswordReset } from '~/pages/auth/PasswordReset';
|
||||||
import { SignInUp } from '~/pages/auth/SignInUp';
|
import { SignInUp } from '~/pages/auth/SignInUp';
|
||||||
import { ImpersonateEffect } from '~/pages/impersonate/ImpersonateEffect';
|
|
||||||
import { NotFound } from '~/pages/not-found/NotFound';
|
import { NotFound } from '~/pages/not-found/NotFound';
|
||||||
import { RecordIndexPage } from '~/pages/object-record/RecordIndexPage';
|
import { RecordIndexPage } from '~/pages/object-record/RecordIndexPage';
|
||||||
import { RecordShowPage } from '~/pages/object-record/RecordShowPage';
|
import { RecordShowPage } from '~/pages/object-record/RecordShowPage';
|
||||||
@ -30,6 +29,7 @@ export const useCreateAppRouter = (
|
|||||||
isCRMMigrationEnabled?: boolean,
|
isCRMMigrationEnabled?: boolean,
|
||||||
isServerlessFunctionSettingsEnabled?: boolean,
|
isServerlessFunctionSettingsEnabled?: boolean,
|
||||||
isSSOEnabled?: boolean,
|
isSSOEnabled?: boolean,
|
||||||
|
isAdminPageEnabled?: boolean,
|
||||||
) =>
|
) =>
|
||||||
createBrowserRouter(
|
createBrowserRouter(
|
||||||
createRoutesFromElements(
|
createRoutesFromElements(
|
||||||
@ -54,7 +54,6 @@ export const useCreateAppRouter = (
|
|||||||
element={<PaymentSuccess />}
|
element={<PaymentSuccess />}
|
||||||
/>
|
/>
|
||||||
<Route path={indexAppPath.getIndexAppPath()} element={<></>} />
|
<Route path={indexAppPath.getIndexAppPath()} element={<></>} />
|
||||||
<Route path={AppPath.Impersonate} element={<ImpersonateEffect />} />
|
|
||||||
<Route path={AppPath.RecordIndexPage} element={<RecordIndexPage />} />
|
<Route path={AppPath.RecordIndexPage} element={<RecordIndexPage />} />
|
||||||
<Route path={AppPath.RecordShowPage} element={<RecordShowPage />} />
|
<Route path={AppPath.RecordShowPage} element={<RecordShowPage />} />
|
||||||
<Route
|
<Route
|
||||||
@ -67,6 +66,7 @@ export const useCreateAppRouter = (
|
|||||||
isServerlessFunctionSettingsEnabled
|
isServerlessFunctionSettingsEnabled
|
||||||
}
|
}
|
||||||
isSSOEnabled={isSSOEnabled}
|
isSSOEnabled={isSSOEnabled}
|
||||||
|
isAdminPageEnabled={isAdminPageEnabled}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -69,6 +69,49 @@ export const useAuth = () => {
|
|||||||
|
|
||||||
const setDateTimeFormat = useSetRecoilState(dateTimeFormatState);
|
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(
|
const handleChallenge = useCallback(
|
||||||
async (email: string, password: string, captchaToken?: string) => {
|
async (email: string, password: string, captchaToken?: string) => {
|
||||||
const challengeResult = await challenge({
|
const challengeResult = await challenge({
|
||||||
@ -212,51 +255,9 @@ export const useAuth = () => {
|
|||||||
[handleChallenge, handleVerify, setIsVerifyPendingState],
|
[handleChallenge, handleVerify, setIsVerifyPendingState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSignOut = useRecoilCallback(
|
const handleSignOut = useCallback(async () => {
|
||||||
({ snapshot }) =>
|
await clearSession();
|
||||||
async () => {
|
}, [clearSession]);
|
||||||
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 handleCredentialsSignUp = useCallback(
|
const handleCredentialsSignUp = useCallback(
|
||||||
async (
|
async (
|
||||||
@ -340,7 +341,7 @@ export const useAuth = () => {
|
|||||||
verify: handleVerify,
|
verify: handleVerify,
|
||||||
|
|
||||||
checkUserExists: { checkUserExistsData, checkUserExistsQuery },
|
checkUserExists: { checkUserExistsData, checkUserExistsQuery },
|
||||||
|
clearSession,
|
||||||
signOut: handleSignOut,
|
signOut: handleSignOut,
|
||||||
signUpWithCredentials: handleCredentialsSignUp,
|
signUpWithCredentials: handleCredentialsSignUp,
|
||||||
signInWithCredentials: handleCrendentialsSignIn,
|
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,
|
IconKey,
|
||||||
IconMail,
|
IconMail,
|
||||||
IconRocket,
|
IconRocket,
|
||||||
|
IconServer,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconTool,
|
IconTool,
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
@ -21,6 +22,7 @@ import {
|
|||||||
} from 'twenty-ui';
|
} from 'twenty-ui';
|
||||||
|
|
||||||
import { useAuth } from '@/auth/hooks/useAuth';
|
import { useAuth } from '@/auth/hooks/useAuth';
|
||||||
|
import { currentUserState } from '@/auth/states/currentUserState';
|
||||||
import { billingState } from '@/client-config/states/billingState';
|
import { billingState } from '@/client-config/states/billingState';
|
||||||
import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem';
|
import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem';
|
||||||
import { useExpandedAnimation } from '@/settings/hooks/useExpandedAnimation';
|
import { useExpandedAnimation } from '@/settings/hooks/useExpandedAnimation';
|
||||||
@ -84,6 +86,8 @@ export const SettingsNavigationDrawerItems = () => {
|
|||||||
const isBillingPageEnabled =
|
const isBillingPageEnabled =
|
||||||
billing?.isBillingEnabled && !isFreeAccessEnabled;
|
billing?.isBillingEnabled && !isFreeAccessEnabled;
|
||||||
|
|
||||||
|
const currentUser = useRecoilValue(currentUserState);
|
||||||
|
const isAdminPageEnabled = currentUser?.canImpersonate;
|
||||||
// TODO: Refactor this part to only have arrays of navigation items
|
// TODO: Refactor this part to only have arrays of navigation items
|
||||||
const currentPathName = useLocation().pathname;
|
const currentPathName = useLocation().pathname;
|
||||||
|
|
||||||
@ -230,6 +234,13 @@ export const SettingsNavigationDrawerItems = () => {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
<NavigationDrawerSection>
|
<NavigationDrawerSection>
|
||||||
<NavigationDrawerSectionTitle label="Other" />
|
<NavigationDrawerSectionTitle label="Other" />
|
||||||
|
{isAdminPageEnabled && (
|
||||||
|
<SettingsNavigationDrawerItem
|
||||||
|
label="Server Admin Panel"
|
||||||
|
path={SettingsPath.AdminPanel}
|
||||||
|
Icon={IconServer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<SettingsNavigationDrawerItem
|
<SettingsNavigationDrawerItem
|
||||||
label="Releases"
|
label="Releases"
|
||||||
path={SettingsPath.Releases}
|
path={SettingsPath.Releases}
|
||||||
|
|||||||
@ -26,9 +26,6 @@ export enum AppPath {
|
|||||||
Developers = `developers`,
|
Developers = `developers`,
|
||||||
DevelopersCatchAll = `/${Developers}/*`,
|
DevelopersCatchAll = `/${Developers}/*`,
|
||||||
|
|
||||||
// Impersonate
|
|
||||||
Impersonate = '/impersonate/:userId',
|
|
||||||
|
|
||||||
Authorize = '/authorize',
|
Authorize = '/authorize',
|
||||||
|
|
||||||
// 404 page not found
|
// 404 page not found
|
||||||
|
|||||||
@ -35,4 +35,6 @@ export enum SettingsPath {
|
|||||||
DevelopersNewWebhook = 'webhooks/new',
|
DevelopersNewWebhook = 'webhooks/new',
|
||||||
DevelopersNewWebhookDetail = 'webhooks/:webhookId',
|
DevelopersNewWebhookDetail = 'webhooks/:webhookId',
|
||||||
Releases = 'releases',
|
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.InviteTeam, res: true },
|
||||||
{ loc: AppPath.DevelopersCatchAll, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: false },
|
{ 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: 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.Canceled, onboardingStatus: OnboardingStatus.Completed, res: false },
|
||||||
{ loc: AppPath.Authorize, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, 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;
|
disabled?: boolean;
|
||||||
pill?: string | ReactElement;
|
pill?: string | ReactElement;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
logo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const StyledTab = styled('button', {
|
const StyledTab = styled('button', {
|
||||||
@ -61,6 +62,10 @@ const StyledHover = styled.span`
|
|||||||
background: ${({ theme }) => theme.background.quaternary};
|
background: ${({ theme }) => theme.background.quaternary};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
const StyledLogo = styled.img`
|
||||||
|
height: 14px;
|
||||||
|
width: 14px;
|
||||||
|
`;
|
||||||
|
|
||||||
export const Tab = ({
|
export const Tab = ({
|
||||||
id,
|
id,
|
||||||
@ -72,6 +77,7 @@ export const Tab = ({
|
|||||||
disabled,
|
disabled,
|
||||||
pill,
|
pill,
|
||||||
to,
|
to,
|
||||||
|
logo,
|
||||||
}: TabProps) => {
|
}: TabProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
return (
|
return (
|
||||||
@ -85,6 +91,7 @@ export const Tab = ({
|
|||||||
to={to}
|
to={to}
|
||||||
>
|
>
|
||||||
<StyledHover>
|
<StyledHover>
|
||||||
|
{logo && <StyledLogo src={logo} alt={`${title} logo`} />}
|
||||||
{Icon && <Icon size={theme.icon.size.md} />}
|
{Icon && <Icon size={theme.icon.size.md} />}
|
||||||
{title}
|
{title}
|
||||||
{pill && typeof pill === 'string' ? <Pill label={pill} /> : pill}
|
{pill && typeof pill === 'string' ? <Pill label={pill} /> : pill}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export type SingleTabProps = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
pill?: string | React.ReactElement;
|
pill?: string | React.ReactElement;
|
||||||
cards?: LayoutCard[];
|
cards?: LayoutCard[];
|
||||||
|
logo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TabListProps = {
|
type TabListProps = {
|
||||||
@ -71,6 +72,7 @@ export const TabList = ({
|
|||||||
key={tab.id}
|
key={tab.id}
|
||||||
title={tab.title}
|
title={tab.title}
|
||||||
Icon={tab.Icon}
|
Icon={tab.Icon}
|
||||||
|
logo={tab.logo}
|
||||||
active={tab.id === activeTabId}
|
active={tab.id === activeTabId}
|
||||||
disabled={tab.disabled ?? loading}
|
disabled={tab.disabled ?? loading}
|
||||||
pill={tab.pill}
|
pill={tab.pill}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export const useWorkspaceSwitching = () => {
|
|||||||
availableSSOIdentityProvidersState,
|
availableSSOIdentityProvidersState,
|
||||||
);
|
);
|
||||||
const setSignInUpStep = useSetRecoilState(signInUpStepState);
|
const setSignInUpStep = useSetRecoilState(signInUpStepState);
|
||||||
const { signOut } = useAuth();
|
const { clearSession } = useAuth();
|
||||||
|
|
||||||
const switchWorkspace = async (workspaceId: string) => {
|
const switchWorkspace = async (workspaceId: string) => {
|
||||||
if (currentWorkspace?.id === workspaceId) return;
|
if (currentWorkspace?.id === workspaceId) return;
|
||||||
@ -50,7 +50,7 @@ export const useWorkspaceSwitching = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (jwt.data.generateJWT.availableSSOIDPs.length > 1) {
|
if (jwt.data.generateJWT.availableSSOIDPs.length > 1) {
|
||||||
await signOut();
|
await clearSession();
|
||||||
setAvailableWorkspacesForSSOState(
|
setAvailableWorkspacesForSSOState(
|
||||||
jwt.data.generateJWT.availableSSOIDPs,
|
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 { 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 { 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 { 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 { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
|
||||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||||
@ -96,6 +97,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
|||||||
MicrosoftAPIsService,
|
MicrosoftAPIsService,
|
||||||
AppTokenService,
|
AppTokenService,
|
||||||
AccessTokenService,
|
AccessTokenService,
|
||||||
|
RefreshTokenService,
|
||||||
LoginTokenService,
|
LoginTokenService,
|
||||||
ResetPasswordService,
|
ResetPasswordService,
|
||||||
SwitchWorkspaceService,
|
SwitchWorkspaceService,
|
||||||
@ -103,6 +105,6 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
|||||||
ApiKeyService,
|
ApiKeyService,
|
||||||
OAuthService,
|
OAuthService,
|
||||||
],
|
],
|
||||||
exports: [AccessTokenService, LoginTokenService],
|
exports: [AccessTokenService, LoginTokenService, RefreshTokenService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
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 { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||||
|
|
||||||
import { ChallengeInput } from './dto/challenge.input';
|
import { ChallengeInput } from './dto/challenge.input';
|
||||||
import { ImpersonateInput } from './dto/impersonate.input';
|
|
||||||
import { LoginToken } from './dto/login-token.entity';
|
import { LoginToken } from './dto/login-token.entity';
|
||||||
import { SignUpInput } from './dto/sign-up.input';
|
import { SignUpInput } from './dto/sign-up.input';
|
||||||
import { ApiKeyToken, AuthTokens } from './dto/token.entity';
|
import { ApiKeyToken, AuthTokens } from './dto/token.entity';
|
||||||
@ -228,15 +227,6 @@ export class AuthResolver {
|
|||||||
return { tokens: tokens };
|
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)
|
@UseGuards(WorkspaceAuthGuard)
|
||||||
@Mutation(() => ApiKeyToken)
|
@Mutation(() => ApiKeyToken)
|
||||||
async generateApiKeyToken(
|
async generateApiKeyToken(
|
||||||
|
|||||||
@ -188,53 +188,6 @@ export class AuthService {
|
|||||||
return { isValid: !!workspace };
|
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(
|
async generateAuthorizationCode(
|
||||||
authorizeAppInput: AuthorizeAppInput,
|
authorizeAppInput: AuthorizeAppInput,
|
||||||
user: User,
|
user: User,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { HttpAdapterHost } from '@nestjs/core';
|
|||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
|
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 { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module';
|
||||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||||
@ -70,6 +71,7 @@ import { FileModule } from './file/file.module';
|
|||||||
WorkspaceEventEmitterModule,
|
WorkspaceEventEmitterModule,
|
||||||
ActorModule,
|
ActorModule,
|
||||||
TelemetryModule,
|
TelemetryModule,
|
||||||
|
AdminPanelModule,
|
||||||
EnvironmentModule.forRoot({}),
|
EnvironmentModule.forRoot({}),
|
||||||
RedisClientModule,
|
RedisClientModule,
|
||||||
FileStorageModule.forRootAsync({
|
FileStorageModule.forRootAsync({
|
||||||
|
|||||||
@ -130,6 +130,7 @@ export {
|
|||||||
IconFilter,
|
IconFilter,
|
||||||
IconFilterCog,
|
IconFilterCog,
|
||||||
IconFilterOff,
|
IconFilterOff,
|
||||||
|
IconFlag,
|
||||||
IconFocusCentered,
|
IconFocusCentered,
|
||||||
IconFolder,
|
IconFolder,
|
||||||
IconFolderPlus,
|
IconFolderPlus,
|
||||||
@ -215,10 +216,11 @@ export {
|
|||||||
IconRotate2,
|
IconRotate2,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconSend,
|
IconSend,
|
||||||
|
IconServer,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconSettingsAutomation,
|
IconSettingsAutomation,
|
||||||
IconSortAZ,
|
|
||||||
IconSlash,
|
IconSlash,
|
||||||
|
IconSortAZ,
|
||||||
IconSortDescending,
|
IconSortDescending,
|
||||||
IconSortZA,
|
IconSortZA,
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
|
|||||||
Reference in New Issue
Block a user