Admin panel init (#8742)

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

---------

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

View File

@ -0,0 +1,67 @@
import { useImpersonate } from '@/settings/admin-panel/hooks/useImpersonate';
import { TextInput } from '@/ui/input/components/TextInput';
import styled from '@emotion/styled';
import { useState } from 'react';
import { Button, H2Title, IconUser, Section } from 'twenty-ui';
const StyledLinkContainer = styled.div`
margin-right: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
`;
const StyledErrorSection = styled.div`
color: ${({ theme }) => theme.font.color.danger};
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsAdminImpersonateUsers = () => {
const [userId, setUserId] = useState('');
const { handleImpersonate, isLoading, error, canImpersonate } =
useImpersonate();
if (!canImpersonate) {
return (
<Section>
<H2Title
title="Impersonate"
description="You don't have permission to impersonate other users. Please contact your administrator if you need this access."
/>
</Section>
);
}
return (
<Section>
<H2Title title="Impersonate" description="Impersonate a user." />
<StyledContainer>
<StyledLinkContainer>
<TextInput
value={userId}
onChange={setUserId}
placeholder="Enter user ID or email address"
fullWidth
disabled={isLoading}
dataTestId="impersonate-input"
onInputEnter={() => handleImpersonate(userId)}
/>
</StyledLinkContainer>
<Button
Icon={IconUser}
variant="primary"
accent="blue"
title={'Impersonate'}
onClick={() => handleImpersonate(userId)}
disabled={!userId.trim() || isLoading}
dataTestId="impersonate-button"
/>
</StyledContainer>
{error && <StyledErrorSection>{error}</StyledErrorSection>}
</Section>
);
};

View File

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

View File

@ -0,0 +1,15 @@
import { gql } from '@apollo/client';
export const UPDATE_WORKSPACE_FEATURE_FLAG = gql`
mutation UpdateWorkspaceFeatureFlag(
$workspaceId: String!
$featureFlag: String!
$value: Boolean!
) {
updateWorkspaceFeatureFlag(
workspaceId: $workspaceId
featureFlag: $featureFlag
value: $value
)
}
`;

View File

@ -0,0 +1,30 @@
import { gql } from '@apollo/client';
export const USER_LOOKUP_ADMIN_PANEL = gql`
mutation UserLookupAdminPanel($userIdentifier: String!) {
userLookupAdminPanel(userIdentifier: $userIdentifier) {
user {
id
email
firstName
lastName
}
workspaces {
id
name
logo
totalUsers
users {
id
email
firstName
lastName
}
featureFlags {
key
value
}
}
}
}
`;

View File

@ -0,0 +1,91 @@
import { UserLookup } from '@/settings/admin-panel/types/UserLookup';
import { useState } from 'react';
import { isDefined } from 'twenty-ui';
import {
useUpdateWorkspaceFeatureFlagMutation,
useUserLookupAdminPanelMutation,
} from '~/generated/graphql';
export const useFeatureFlagsManagement = () => {
const [userLookupResult, setUserLookupResult] = useState<UserLookup | null>(
null,
);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [userLookup] = useUserLookupAdminPanelMutation({
onCompleted: (data) => {
setIsLoading(false);
if (isDefined(data?.userLookupAdminPanel)) {
setUserLookupResult(data.userLookupAdminPanel);
}
},
onError: (error) => {
setIsLoading(false);
setError(error.message);
},
});
const [updateFeatureFlag] = useUpdateWorkspaceFeatureFlagMutation();
const handleUserLookup = async (userIdentifier: string) => {
setError(null);
setIsLoading(true);
setUserLookupResult(null);
const response = await userLookup({
variables: { userIdentifier },
});
return response.data?.userLookupAdminPanel;
};
const handleFeatureFlagUpdate = async (
workspaceId: string,
featureFlag: string,
value: boolean,
) => {
setError(null);
const previousState = userLookupResult;
if (isDefined(userLookupResult)) {
setUserLookupResult({
...userLookupResult,
workspaces: userLookupResult.workspaces.map((workspace) =>
workspace.id === workspaceId
? {
...workspace,
featureFlags: workspace.featureFlags.map((flag) =>
flag.key === featureFlag ? { ...flag, value } : flag,
),
}
: workspace,
),
});
}
const response = await updateFeatureFlag({
variables: {
workspaceId,
featureFlag,
value,
},
onError: (error) => {
if (isDefined(previousState)) {
setUserLookupResult(previousState);
}
setError(error.message);
},
});
return !!response.data;
};
return {
userLookupResult,
handleUserLookup,
handleFeatureFlagUpdate,
isLoading,
error,
};
};

View File

@ -0,0 +1,60 @@
import { useAuth } from '@/auth/hooks/useAuth';
import { currentUserState } from '@/auth/states/currentUserState';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { AppPath } from '@/types/AppPath';
import { useState } from 'react';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { useImpersonateMutation } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { sleep } from '~/utils/sleep';
export const useImpersonate = () => {
const { clearSession } = useAuth();
const [currentUser, setCurrentUser] = useRecoilState(currentUserState);
const setTokenPair = useSetRecoilState(tokenPairState);
const [impersonate] = useImpersonateMutation();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleImpersonate = async (userId: string) => {
if (!userId.trim()) {
setError('Please enter a user ID');
return;
}
setIsLoading(true);
setError(null);
try {
const impersonateResult = await impersonate({
variables: { userId },
});
if (isDefined(impersonateResult.errors)) {
throw impersonateResult.errors;
}
if (!impersonateResult.data?.impersonate) {
throw new Error('No impersonate result');
}
const { user, tokens } = impersonateResult.data.impersonate;
await clearSession();
setCurrentUser(user);
setTokenPair(tokens);
await sleep(0); // This hacky workaround is necessary to ensure the tokens stored in the cookie are updated correctly.
window.location.href = AppPath.Index;
} catch (error) {
setError('Failed to impersonate user. Please try again.');
setIsLoading(false);
}
};
return {
handleImpersonate,
isLoading,
error,
canImpersonate: currentUser?.canImpersonate,
};
};

View File

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

View File

@ -0,0 +1,11 @@
import { WorkspaceInfo } from '@/settings/admin-panel/types/WorkspaceInfo';
export type UserLookup = {
user: {
id: string;
email: string;
firstName?: string | null;
lastName?: string | null;
};
workspaces: WorkspaceInfo[];
};

View File

@ -0,0 +1,15 @@
import { FeatureFlag } from '@/settings/admin-panel/types/FeatureFlag';
export type WorkspaceInfo = {
id: string;
name: string;
logo?: string | null;
totalUsers: number;
users: {
id: string;
email: string;
firstName?: string | null;
lastName?: string | null;
}[];
featureFlags: FeatureFlag[];
};

View File

@ -13,6 +13,7 @@ import {
IconKey,
IconMail,
IconRocket,
IconServer,
IconSettings,
IconTool,
IconUserCircle,
@ -21,6 +22,7 @@ import {
} from 'twenty-ui';
import { useAuth } from '@/auth/hooks/useAuth';
import { currentUserState } from '@/auth/states/currentUserState';
import { billingState } from '@/client-config/states/billingState';
import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem';
import { useExpandedAnimation } from '@/settings/hooks/useExpandedAnimation';
@ -84,6 +86,8 @@ export const SettingsNavigationDrawerItems = () => {
const isBillingPageEnabled =
billing?.isBillingEnabled && !isFreeAccessEnabled;
const currentUser = useRecoilValue(currentUserState);
const isAdminPageEnabled = currentUser?.canImpersonate;
// TODO: Refactor this part to only have arrays of navigation items
const currentPathName = useLocation().pathname;
@ -230,6 +234,13 @@ export const SettingsNavigationDrawerItems = () => {
</AnimatePresence>
<NavigationDrawerSection>
<NavigationDrawerSectionTitle label="Other" />
{isAdminPageEnabled && (
<SettingsNavigationDrawerItem
label="Server Admin Panel"
path={SettingsPath.AdminPanel}
Icon={IconServer}
/>
)}
<SettingsNavigationDrawerItem
label="Releases"
path={SettingsPath.Releases}