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

@ -1,4 +1,5 @@
import { useCreateAppRouter } from '@/app/hooks/useCreateAppRouter';
import { currentUserState } from '@/auth/states/currentUserState';
import { billingState } from '@/client-config/states/billingState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { RouterProvider } from 'react-router-dom';
@ -16,6 +17,10 @@ export const AppRouter = () => {
const isBillingPageEnabled =
billing?.isBillingEnabled && !isFreeAccessEnabled;
const currentUser = useRecoilValue(currentUserState);
const isAdminPageEnabled = currentUser?.canImpersonate;
return (
<RouterProvider
router={useCreateAppRouter(
@ -23,6 +28,7 @@ export const AppRouter = () => {
isCRMMigrationEnabled,
isServerlessFunctionSettingsEnabled,
isSSOEnabled,
isAdminPageEnabled,
)}
/>
);

View File

@ -242,11 +242,26 @@ const SettingsSecuritySSOIdentifyProvider = lazy(() =>
),
);
const SettingsAdmin = lazy(() =>
import('~/pages/settings/admin-panel/SettingsAdmin').then((module) => ({
default: module.SettingsAdmin,
})),
);
const SettingsAdminFeatureFlags = lazy(() =>
import('~/pages/settings/admin-panel/SettingsAdminFeatureFlags').then(
(module) => ({
default: module.SettingsAdminFeatureFlags,
}),
),
);
type SettingsRoutesProps = {
isBillingEnabled?: boolean;
isCRMMigrationEnabled?: boolean;
isServerlessFunctionSettingsEnabled?: boolean;
isSSOEnabled?: boolean;
isAdminPageEnabled?: boolean;
};
export const SettingsRoutes = ({
@ -254,6 +269,7 @@ export const SettingsRoutes = ({
isCRMMigrationEnabled,
isServerlessFunctionSettingsEnabled,
isSSOEnabled,
isAdminPageEnabled,
}: SettingsRoutesProps) => (
<Suspense fallback={<SettingsSkeletonLoader />}>
<Routes>
@ -375,6 +391,15 @@ export const SettingsRoutes = ({
/>
</>
)}
{isAdminPageEnabled && (
<>
<Route path={SettingsPath.AdminPanel} element={<SettingsAdmin />} />
<Route
path={SettingsPath.FeatureFlags}
element={<SettingsAdminFeatureFlags />}
/>
</>
)}
</Routes>
</Suspense>
);

View File

@ -14,7 +14,6 @@ import { Authorize } from '~/pages/auth/Authorize';
import { Invite } from '~/pages/auth/Invite';
import { PasswordReset } from '~/pages/auth/PasswordReset';
import { SignInUp } from '~/pages/auth/SignInUp';
import { ImpersonateEffect } from '~/pages/impersonate/ImpersonateEffect';
import { NotFound } from '~/pages/not-found/NotFound';
import { RecordIndexPage } from '~/pages/object-record/RecordIndexPage';
import { RecordShowPage } from '~/pages/object-record/RecordShowPage';
@ -30,6 +29,7 @@ export const useCreateAppRouter = (
isCRMMigrationEnabled?: boolean,
isServerlessFunctionSettingsEnabled?: boolean,
isSSOEnabled?: boolean,
isAdminPageEnabled?: boolean,
) =>
createBrowserRouter(
createRoutesFromElements(
@ -54,7 +54,6 @@ export const useCreateAppRouter = (
element={<PaymentSuccess />}
/>
<Route path={indexAppPath.getIndexAppPath()} element={<></>} />
<Route path={AppPath.Impersonate} element={<ImpersonateEffect />} />
<Route path={AppPath.RecordIndexPage} element={<RecordIndexPage />} />
<Route path={AppPath.RecordShowPage} element={<RecordShowPage />} />
<Route
@ -67,6 +66,7 @@ export const useCreateAppRouter = (
isServerlessFunctionSettingsEnabled
}
isSSOEnabled={isSSOEnabled}
isAdminPageEnabled={isAdminPageEnabled}
/>
}
/>

View File

@ -69,6 +69,49 @@ export const useAuth = () => {
const setDateTimeFormat = useSetRecoilState(dateTimeFormatState);
const clearSession = useRecoilCallback(
({ snapshot }) =>
async () => {
const emptySnapshot = snapshot_UNSTABLE();
const iconsValue = snapshot.getLoadable(iconsState).getValue();
const authProvidersValue = snapshot
.getLoadable(authProvidersState)
.getValue();
const billing = snapshot.getLoadable(billingState).getValue();
const isSignInPrefilled = snapshot
.getLoadable(isSignInPrefilledState)
.getValue();
const supportChat = snapshot.getLoadable(supportChatState).getValue();
const isDebugMode = snapshot.getLoadable(isDebugModeState).getValue();
const captchaProvider = snapshot
.getLoadable(captchaProviderState)
.getValue();
const clientConfigApiStatus = snapshot
.getLoadable(clientConfigApiStatusState)
.getValue();
const isCurrentUserLoaded = snapshot
.getLoadable(isCurrentUserLoadedState)
.getValue();
const initialSnapshot = emptySnapshot.map(({ set }) => {
set(iconsState, iconsValue);
set(authProvidersState, authProvidersValue);
set(billingState, billing);
set(isSignInPrefilledState, isSignInPrefilled);
set(supportChatState, supportChat);
set(isDebugModeState, isDebugMode);
set(captchaProviderState, captchaProvider);
set(clientConfigApiStatusState, clientConfigApiStatus);
set(isCurrentUserLoadedState, isCurrentUserLoaded);
return undefined;
});
goToRecoilSnapshot(initialSnapshot);
await client.clearStore();
sessionStorage.clear();
localStorage.clear();
},
[client, goToRecoilSnapshot],
);
const handleChallenge = useCallback(
async (email: string, password: string, captchaToken?: string) => {
const challengeResult = await challenge({
@ -212,51 +255,9 @@ export const useAuth = () => {
[handleChallenge, handleVerify, setIsVerifyPendingState],
);
const handleSignOut = useRecoilCallback(
({ snapshot }) =>
async () => {
const emptySnapshot = snapshot_UNSTABLE();
const iconsValue = snapshot.getLoadable(iconsState).getValue();
const authProvidersValue = snapshot
.getLoadable(authProvidersState)
.getValue();
const billing = snapshot.getLoadable(billingState).getValue();
const isSignInPrefilled = snapshot
.getLoadable(isSignInPrefilledState)
.getValue();
const supportChat = snapshot.getLoadable(supportChatState).getValue();
const isDebugMode = snapshot.getLoadable(isDebugModeState).getValue();
const captchaProvider = snapshot
.getLoadable(captchaProviderState)
.getValue();
const clientConfigApiStatus = snapshot
.getLoadable(clientConfigApiStatusState)
.getValue();
const isCurrentUserLoaded = snapshot
.getLoadable(isCurrentUserLoadedState)
.getValue();
const initialSnapshot = emptySnapshot.map(({ set }) => {
set(iconsState, iconsValue);
set(authProvidersState, authProvidersValue);
set(billingState, billing);
set(isSignInPrefilledState, isSignInPrefilled);
set(supportChatState, supportChat);
set(isDebugModeState, isDebugMode);
set(captchaProviderState, captchaProvider);
set(clientConfigApiStatusState, clientConfigApiStatus);
set(isCurrentUserLoadedState, isCurrentUserLoaded);
return undefined;
});
goToRecoilSnapshot(initialSnapshot);
await client.clearStore();
sessionStorage.clear();
localStorage.clear();
},
[client, goToRecoilSnapshot],
);
const handleSignOut = useCallback(async () => {
await clearSession();
}, [clearSession]);
const handleCredentialsSignUp = useCallback(
async (
@ -340,7 +341,7 @@ export const useAuth = () => {
verify: handleVerify,
checkUserExists: { checkUserExistsData, checkUserExistsQuery },
clearSession,
signOut: handleSignOut,
signUpWithCredentials: handleCredentialsSignUp,
signInWithCredentials: handleCrendentialsSignIn,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,6 +15,7 @@ type TabProps = {
disabled?: boolean;
pill?: string | ReactElement;
to?: string;
logo?: string;
};
const StyledTab = styled('button', {
@ -61,6 +62,10 @@ const StyledHover = styled.span`
background: ${({ theme }) => theme.background.quaternary};
}
`;
const StyledLogo = styled.img`
height: 14px;
width: 14px;
`;
export const Tab = ({
id,
@ -72,6 +77,7 @@ export const Tab = ({
disabled,
pill,
to,
logo,
}: TabProps) => {
const theme = useTheme();
return (
@ -85,6 +91,7 @@ export const Tab = ({
to={to}
>
<StyledHover>
{logo && <StyledLogo src={logo} alt={`${title} logo`} />}
{Icon && <Icon size={theme.icon.size.md} />}
{title}
{pill && typeof pill === 'string' ? <Pill label={pill} /> : pill}

View File

@ -19,6 +19,7 @@ export type SingleTabProps = {
disabled?: boolean;
pill?: string | React.ReactElement;
cards?: LayoutCard[];
logo?: string;
};
type TabListProps = {
@ -71,6 +72,7 @@ export const TabList = ({
key={tab.id}
title={tab.title}
Icon={tab.Icon}
logo={tab.logo}
active={tab.id === activeTabId}
disabled={tab.disabled ?? loading}
pill={tab.pill}

View File

@ -23,7 +23,7 @@ export const useWorkspaceSwitching = () => {
availableSSOIdentityProvidersState,
);
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const { signOut } = useAuth();
const { clearSession } = useAuth();
const switchWorkspace = async (workspaceId: string) => {
if (currentWorkspace?.id === workspaceId) return;
@ -50,7 +50,7 @@ export const useWorkspaceSwitching = () => {
}
if (jwt.data.generateJWT.availableSSOIDPs.length > 1) {
await signOut();
await clearSession();
setAvailableWorkspacesForSSOState(
jwt.data.generateJWT.availableSSOIDPs,
);