Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Admin panel init #8742

Merged
merged 18 commits into from
Nov 28, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,13 +17,18 @@ export const AppRouter = () => {
const isBillingPageEnabled =
billing?.isBillingEnabled && !isFreeAccessEnabled;

const currentUser = useRecoilValue(currentUserState);

const isAdminPageEnabled = currentUser?.canImpersonate;
ehconitin marked this conversation as resolved.
Show resolved Hide resolved

return (
<RouterProvider
router={useCreateAppRouter(
isBillingPageEnabled,
isCRMMigrationEnabled,
isServerlessFunctionSettingsEnabled,
isSSOEnabled,
isAdminPageEnabled,
)}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,18 +242,26 @@ const SettingsSecuritySSOIdentifyProvider = lazy(() =>
),
);

const SettingsAdmin = lazy(() =>
import('~/pages/settings/admin/SettingsAdmin').then((module) => ({
default: module.SettingsAdmin,
})),
);

type SettingsRoutesProps = {
isBillingEnabled?: boolean;
isCRMMigrationEnabled?: boolean;
isServerlessFunctionSettingsEnabled?: boolean;
isSSOEnabled?: boolean;
isAdminPageEnabled?: boolean;
};
ehconitin marked this conversation as resolved.
Show resolved Hide resolved

export const SettingsRoutes = ({
isBillingEnabled,
isCRMMigrationEnabled,
isServerlessFunctionSettingsEnabled,
isSSOEnabled,
isAdminPageEnabled,
}: SettingsRoutesProps) => (
<Suspense fallback={<SettingsSkeletonLoader />}>
<Routes>
Expand Down Expand Up @@ -375,6 +383,9 @@ export const SettingsRoutes = ({
/>
</>
)}
{isAdminPageEnabled && (
<Route path={SettingsPath.Admin} element={<SettingsAdmin />} />
)}
</Routes>
</Suspense>
);
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const useCreateAppRouter = (
isCRMMigrationEnabled?: boolean,
isServerlessFunctionSettingsEnabled?: boolean,
isSSOEnabled?: boolean,
isAdminPageEnabled?: boolean,
) =>
createBrowserRouter(
createRoutesFromElements(
Expand Down Expand Up @@ -67,6 +68,7 @@ export const useCreateAppRouter = (
isServerlessFunctionSettingsEnabled
}
isSSOEnabled={isSSOEnabled}
isAdminPageEnabled={isAdminPageEnabled}
/>
}
/>
Expand Down
93 changes: 47 additions & 46 deletions packages/twenty-front/src/modules/auth/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,49 @@ export const useAuth = () => {

const setDateTimeFormat = useSetRecoilState(dateTimeFormatState);

const clearSession = useRecoilCallback(
FelixMalfait marked this conversation as resolved.
Show resolved Hide resolved
({ 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({
Expand Down Expand Up @@ -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]);
ehconitin marked this conversation as resolved.
Show resolved Hide resolved

const handleCredentialsSignUp = useCallback(
async (
Expand Down Expand Up @@ -340,7 +341,7 @@ export const useAuth = () => {
verify: handleVerify,

checkUserExists: { checkUserExistsData, checkUserExistsQuery },

clearSession,
signOut: handleSignOut,
signUpWithCredentials: handleCredentialsSignUp,
signInWithCredentials: handleCrendentialsSignIn,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
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 styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { H2Title, Section } from 'twenty-ui';

const StyledTable = styled(Table)`
margin-top: ${({ theme }) => theme.spacing(0.5)};
`;

const StyledTableHeaderRow = styled(Table)`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: StyledTableHeaderRow extends Table instead of TableRow, which could cause layout issues

margin-bottom: ${({ theme }) => theme.spacing(1.5)};
`;

const StyledTextContainerWithEllipsis = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;

export const SettingsAdminFeatureFlags = () => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: No loading or error state handling when fetching currentWorkspace


return (
<Section>
<H2Title title="Feature Flags" description="Manage feature flags." />

<Table>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Table structure creates new Table component for each row instead of reusing one table with multiple rows

<StyledTableHeaderRow>
<TableRow
gridAutoColumns="1fr 100px"
mobileGridAutoColumns="1fr 80px"
>
<TableHeader>Feature Flag</TableHeader>
<TableHeader>Value</TableHeader>
</TableRow>
</StyledTableHeaderRow>
{currentWorkspace?.featureFlags?.map((flag) => (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: No handling of empty/undefined featureFlags array - should show empty state message

<StyledTable key={flag.key}>
<TableRow
gridAutoColumns="1fr 100px"
mobileGridAutoColumns="1fr 80px"
>
<TableCell>
<StyledTextContainerWithEllipsis id={`hover-text-${flag.key}`}>
{flag.key}
</StyledTextContainerWithEllipsis>
</TableCell>
<TableCell>{flag.value ? 'Enabled' : 'Disabled'}</TableCell>
</TableRow>
</StyledTable>
))}
</Table>
</Section>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useImpersonate } from '@/settings/admin/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 an user." />
FelixMalfait marked this conversation as resolved.
Show resolved Hide resolved
<StyledContainer>
<StyledLinkContainer>
<TextInput
value={userId}
onChange={setUserId}
placeholder="User ID"
fullWidth
disabled={isLoading}
dataTestId="impersonate-input"
/>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider adding input validation or pattern matching for valid user ID formats

</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>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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);
FelixMalfait marked this conversation as resolved.
Show resolved Hide resolved
await sleep(0);
FelixMalfait marked this conversation as resolved.
Show resolved Hide resolved
window.location.href = AppPath.Index;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: setIsLoading(false) missing before redirect - loading state will remain true if navigation fails

} catch (error) {
console.error('Impersonation failed:', error);
setError('Failed to impersonate user. Please try again.');
setIsLoading(false);
}
};

return {
handleImpersonate,
isLoading,
error,
canImpersonate: currentUser?.canImpersonate,
};
};
Loading
Loading