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:
@ -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}
|
||||
|
||||
Reference in New Issue
Block a user