Add admin panel workspace detail page with chat viewer (#19579)
## Overview Adds comprehensive admin panel functionality for viewing workspace details and AI chat threads. ## Changes ### Frontend - **New Routes**: Added `AdminPanelWorkspaceDetail` and `AdminPanelWorkspaceChatThread` pages with lazy loading - **New Queries**: - `getAdminWorkspaceChatThreads` - fetch chat threads for a workspace - `getAdminChatThreadMessages` - fetch messages for a specific thread - `workspaceLookupAdminPanel` - lookup workspace info and users - **New Components**: - `SettingsAdminWorkspaceDetail` - displays workspace info and chat sessions tabs - `SettingsAdminWorkspaceChatThread` - renders chat conversation with message bubbles - **Navigation**: Updated AI admin panel to link to workspace detail pages - **Settings Paths**: Added `AdminPanelWorkspaceDetail` and `AdminPanelWorkspaceChatThread` paths ### Backend - **New DTOs**: - `AdminWorkspaceChatThreadDTO` - workspace chat thread data - `AdminChatThreadMessagesDTO` - thread with messages - `AdminChatMessageDTO` - individual message with parts - **New Resolvers**: Added three queries to `AdminPanelResolver` - **New Service Methods**: - `workspaceLookup()` - fetch workspace info - `getWorkspaceChatThreads()` - list chat threads - `getChatThreadMessages()` - fetch thread messages with validation - **Module Updates**: Added entity imports for workspace, user, AI chat, and feature flag data ### Security - Added `allowImpersonation` check before accessing chat data - Validates workspace ownership and access permissions --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+127
@@ -0,0 +1,127 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer';
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import { Card } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import {
|
||||
AgentMessageRole,
|
||||
type GetAdminChatThreadMessagesQuery,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type ChatMessage = NonNullable<
|
||||
GetAdminChatThreadMessagesQuery['getAdminChatThreadMessages']
|
||||
>['messages'][number];
|
||||
|
||||
type SettingsAdminChatThreadMessageListProps = {
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
|
||||
const StyledMessagesContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
|
||||
align-items: ${({ isUser }) => (isUser ? 'flex-end' : 'flex-start')};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledMessageContent = styled.div<{ isUser?: boolean }>`
|
||||
background: ${({ isUser }) =>
|
||||
isUser ? themeCssVariables.background.tertiary : 'transparent'};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
color: ${({ isUser }) =>
|
||||
isUser
|
||||
? themeCssVariables.font.color.secondary
|
||||
: themeCssVariables.font.color.primary};
|
||||
font-weight: ${({ isUser }) => (isUser ? 500 : 400)};
|
||||
line-height: 1.4em;
|
||||
max-width: 100%;
|
||||
overflow-wrap: break-word;
|
||||
padding: ${({ isUser }) =>
|
||||
isUser ? `0 ${themeCssVariables.spacing[2]}` : '0'};
|
||||
white-space: ${({ isUser }) => (isUser ? 'pre-wrap' : 'normal')};
|
||||
width: ${({ isUser }) => (isUser ? 'fit-content' : '100%')};
|
||||
`;
|
||||
|
||||
const StyledRoleLabel = styled.span`
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
margin-bottom: ${themeCssVariables.spacing[1]};
|
||||
text-transform: capitalize;
|
||||
`;
|
||||
|
||||
const StyledTimestamp = styled.span`
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
margin-top: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
export const SettingsAdminChatThreadMessageList = ({
|
||||
messages,
|
||||
}: SettingsAdminChatThreadMessageListProps) => {
|
||||
const visibleMessages = messages.filter(
|
||||
(message) => message.role !== AgentMessageRole.SYSTEM,
|
||||
);
|
||||
|
||||
if (visibleMessages.length === 0) {
|
||||
return (
|
||||
<Card rounded>
|
||||
<TableRow gridTemplateColumns="1fr">
|
||||
<TableCell
|
||||
color={themeCssVariables.font.color.tertiary}
|
||||
align="center"
|
||||
>
|
||||
{t`No messages found.`}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledMessagesContainer>
|
||||
{visibleMessages.map((message) => {
|
||||
const isUser = message.role === AgentMessageRole.USER;
|
||||
const textParts = message.parts
|
||||
.filter((part) => part.type === 'text' && part.textContent !== null)
|
||||
.map((part) => part.textContent)
|
||||
.join('\n');
|
||||
|
||||
const toolParts = message.parts.filter(
|
||||
(part) => part.type === 'tool-call' && part.toolName !== null,
|
||||
);
|
||||
|
||||
if (textParts.length === 0 && toolParts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledMessageBubble key={message.id} isUser={isUser}>
|
||||
<StyledRoleLabel>{message.role}</StyledRoleLabel>
|
||||
{textParts.length > 0 && (
|
||||
<StyledMessageContent isUser={isUser}>
|
||||
{isUser ? textParts : <LazyMarkdownRenderer text={textParts} />}
|
||||
</StyledMessageContent>
|
||||
)}
|
||||
{toolParts.map((part, index) => (
|
||||
<StyledMessageContent key={index} isUser={false}>
|
||||
{t`Tool call: ${part.toolName}`}
|
||||
</StyledMessageContent>
|
||||
))}
|
||||
<StyledTimestamp>
|
||||
{new Date(message.createdAt).toLocaleString()}
|
||||
</StyledTimestamp>
|
||||
</StyledMessageBubble>
|
||||
);
|
||||
})}
|
||||
</StyledMessagesContainer>
|
||||
);
|
||||
};
|
||||
+140
-158
@@ -1,134 +1,74 @@
|
||||
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
|
||||
import { SettingsAdminWorkspaceContent } from '@/settings/admin-panel/components/SettingsAdminWorkspaceContent';
|
||||
import { userLookupResultState } from '@/settings/admin-panel/states/userLookupResultState';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { SettingsAdminVersionContainer } from '@/settings/admin-panel/components/SettingsAdminVersionContainer';
|
||||
import { ADMIN_PANEL_RECENT_USERS } from '@/settings/admin-panel/graphql/queries/adminPanelRecentUsers';
|
||||
import { ADMIN_PANEL_TOP_WORKSPACES } from '@/settings/admin-panel/graphql/queries/adminPanelTopWorkspaces';
|
||||
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
|
||||
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
|
||||
import { TabList } from '@/ui/layout/tab-list/components/TabList';
|
||||
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
import { TableBody } from '@/ui/layout/table/components/TableBody';
|
||||
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 { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useState } from 'react';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { UserLookupAdminPanelDocument } from '~/generated-metadata/graphql';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
|
||||
import { SettingsAdminVersionContainer } from '@/settings/admin-panel/components/SettingsAdminVersionContainer';
|
||||
import { SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminUserLookupWorkspaceTabsId';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
|
||||
import { getImageAbsoluteURI, isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
H2Title,
|
||||
IconId,
|
||||
IconMail,
|
||||
IconSearch,
|
||||
IconUser,
|
||||
} from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
const StyledEmptyState = styled.div`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
padding: ${themeCssVariables.spacing[4]} 0;
|
||||
`;
|
||||
|
||||
export const SettingsAdminGeneral = () => {
|
||||
const [userIdentifier, setUserIdentifier] = useState('');
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const [userSearchTerm, setUserSearchTerm] = useState('');
|
||||
const [debouncedUserSearchTerm] = useDebounce(userSearchTerm, 300);
|
||||
|
||||
const [activeTabId, setActiveTabId] = useAtomComponentState(
|
||||
activeTabIdComponentState,
|
||||
SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID,
|
||||
);
|
||||
const [userLookupResult, setUserLookupResult] = useAtomState(
|
||||
userLookupResultState,
|
||||
);
|
||||
const [isUserLookupLoading, setIsUserLookupLoading] = useState(false);
|
||||
|
||||
const [userLookup] = useMutation(UserLookupAdminPanelDocument);
|
||||
const [workspaceSearchTerm, setWorkspaceSearchTerm] = useState('');
|
||||
const [debouncedWorkspaceSearchTerm] = useDebounce(workspaceSearchTerm, 300);
|
||||
|
||||
const currentUser = useAtomStateValue(currentUserState);
|
||||
|
||||
const canAccessFullAdminPanel = currentUser?.canAccessFullAdminPanel;
|
||||
|
||||
const canImpersonate = currentUser?.canImpersonate;
|
||||
|
||||
const canManageFeatureFlags = useAtomStateValue(canManageFeatureFlagsState);
|
||||
|
||||
const handleSearch = async () => {
|
||||
setActiveTabId('');
|
||||
setIsUserLookupLoading(true);
|
||||
setUserLookupResult(null);
|
||||
const { data: recentUsersData, loading: isLoadingUsers } = useQuery<{
|
||||
adminPanelRecentUsers: {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
createdAt: string;
|
||||
workspaceName?: string | null;
|
||||
workspaceId?: string | null;
|
||||
}[];
|
||||
}>(ADMIN_PANEL_RECENT_USERS, {
|
||||
variables: { searchTerm: debouncedUserSearchTerm },
|
||||
skip: !canImpersonate,
|
||||
});
|
||||
|
||||
const response = await userLookup({
|
||||
variables: { userIdentifier },
|
||||
onCompleted: (data) => {
|
||||
setIsUserLookupLoading(false);
|
||||
if (isDefined(data?.userLookupAdminPanel)) {
|
||||
setUserLookupResult(data.userLookupAdminPanel);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsUserLookupLoading(false);
|
||||
enqueueErrorSnackBar({
|
||||
apolloError: error,
|
||||
});
|
||||
},
|
||||
});
|
||||
const { data: topWorkspacesData, loading: isLoadingWorkspaces } = useQuery<{
|
||||
adminPanelTopWorkspaces: {
|
||||
id: string;
|
||||
name: string;
|
||||
totalUsers: number;
|
||||
subdomain: string;
|
||||
}[];
|
||||
}>(ADMIN_PANEL_TOP_WORKSPACES, {
|
||||
variables: { searchTerm: debouncedWorkspaceSearchTerm },
|
||||
skip: !canImpersonate,
|
||||
});
|
||||
|
||||
const result = response.data?.userLookupAdminPanel;
|
||||
|
||||
if (isDefined(result?.workspaces) && result.workspaces.length > 0) {
|
||||
setActiveTabId(result.workspaces[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
const activeWorkspace = userLookupResult?.workspaces.find(
|
||||
(workspace) => workspace.id === activeTabId,
|
||||
);
|
||||
|
||||
const tabs =
|
||||
userLookupResult?.workspaces.map((workspace) => ({
|
||||
id: workspace.id,
|
||||
title: workspace.name,
|
||||
logo:
|
||||
getImageAbsoluteURI({
|
||||
imageUrl: isNonEmptyString(workspace.logo)
|
||||
? workspace.logo
|
||||
: DEFAULT_WORKSPACE_LOGO,
|
||||
baseUrl: REACT_APP_SERVER_BASE_URL,
|
||||
}) ?? '',
|
||||
})) ?? [];
|
||||
|
||||
const userFullName = `${userLookupResult?.user.firstName || ''} ${
|
||||
userLookupResult?.user.lastName || ''
|
||||
}`.trim();
|
||||
|
||||
const userInfoItems = [
|
||||
{
|
||||
Icon: IconUser,
|
||||
label: t`Name`,
|
||||
value: userFullName,
|
||||
},
|
||||
{
|
||||
Icon: IconMail,
|
||||
label: t`Email`,
|
||||
value: userLookupResult?.user.email,
|
||||
},
|
||||
{
|
||||
Icon: IconId,
|
||||
label: t`ID`,
|
||||
value: userLookupResult?.user.id,
|
||||
},
|
||||
];
|
||||
const recentUsers = recentUsersData?.adminPanelRecentUsers ?? [];
|
||||
const topWorkspaces = topWorkspacesData?.adminPanelTopWorkspaces ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -143,63 +83,105 @@ export const SettingsAdminGeneral = () => {
|
||||
)}
|
||||
|
||||
{canImpersonate && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={
|
||||
canManageFeatureFlags
|
||||
? t`Feature Flags & Impersonation`
|
||||
: t`User Impersonation`
|
||||
}
|
||||
description={
|
||||
canManageFeatureFlags
|
||||
? t`Look up users and manage their workspace feature flags or impersonate them.`
|
||||
: t`Look up users to impersonate them.`
|
||||
}
|
||||
/>
|
||||
|
||||
<StyledContainer>
|
||||
<SettingsTextInput
|
||||
instanceId="admin-user-lookup"
|
||||
value={userIdentifier}
|
||||
onChange={setUserIdentifier}
|
||||
onInputEnter={handleSearch}
|
||||
placeholder={t`Enter user ID or email address`}
|
||||
fullWidth
|
||||
disabled={isUserLookupLoading}
|
||||
/>
|
||||
<Button
|
||||
Icon={IconSearch}
|
||||
variant="primary"
|
||||
accent="blue"
|
||||
title={t`Search`}
|
||||
onClick={handleSearch}
|
||||
disabled={!userIdentifier.trim() || isUserLookupLoading}
|
||||
/>
|
||||
</StyledContainer>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{isDefined(userLookupResult) && (
|
||||
<>
|
||||
<Section>
|
||||
<H2Title title={t`User Info`} description={t`About this user`} />
|
||||
<SettingsTableCard
|
||||
items={userInfoItems}
|
||||
rounded
|
||||
gridAutoColumns="1fr 4fr"
|
||||
<H2Title
|
||||
title={t`Recent Users`}
|
||||
description={
|
||||
canManageFeatureFlags
|
||||
? t`Last 10 users created. Click to manage feature flags or impersonate.`
|
||||
: t`Last 10 users created. Click to impersonate.`
|
||||
}
|
||||
/>
|
||||
<SettingsTextInput
|
||||
instanceId="admin-panel-user-search"
|
||||
value={userSearchTerm}
|
||||
onChange={setUserSearchTerm}
|
||||
placeholder={t`Search by name, email, or user ID...`}
|
||||
fullWidth
|
||||
/>
|
||||
{isLoadingUsers ? (
|
||||
<SettingsSkeletonLoader />
|
||||
) : recentUsers.length === 0 ? (
|
||||
<StyledEmptyState>
|
||||
{t`No users found matching your search criteria.`}
|
||||
</StyledEmptyState>
|
||||
) : (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow gridTemplateColumns="1fr 2fr 1fr">
|
||||
<TableHeader>{t`Name`}</TableHeader>
|
||||
<TableHeader>{t`Email`}</TableHeader>
|
||||
<TableHeader align="right">{t`Workspace`}</TableHeader>
|
||||
</TableRow>
|
||||
{recentUsers.map((user) => (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
gridTemplateColumns="1fr 2fr 1fr"
|
||||
to={getSettingsPath(SettingsPath.AdminPanelUserDetail, {
|
||||
userId: user.id,
|
||||
})}
|
||||
>
|
||||
<TableCell color={themeCssVariables.font.color.primary}>
|
||||
{`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
|
||||
'\u2014'}
|
||||
</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell align="right">
|
||||
{user.workspaceName || '\u2014'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Workspaces`}
|
||||
description={t`All workspaces this user is a member of`}
|
||||
title={t`Top Workspaces`}
|
||||
description={t`Top 10 workspaces by number of users`}
|
||||
/>
|
||||
<TabList
|
||||
tabs={tabs}
|
||||
behaveAsLinks={false}
|
||||
componentInstanceId={SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID}
|
||||
<SettingsTextInput
|
||||
instanceId="admin-panel-workspace-search"
|
||||
value={workspaceSearchTerm}
|
||||
onChange={setWorkspaceSearchTerm}
|
||||
placeholder={t`Search by workspace name, subdomain, or ID...`}
|
||||
fullWidth
|
||||
/>
|
||||
<SettingsAdminWorkspaceContent activeWorkspace={activeWorkspace} />
|
||||
{isLoadingWorkspaces ? (
|
||||
<SettingsSkeletonLoader />
|
||||
) : topWorkspaces.length === 0 ? (
|
||||
<StyledEmptyState>
|
||||
{t`No workspaces found matching your search criteria.`}
|
||||
</StyledEmptyState>
|
||||
) : (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow gridTemplateColumns="2fr 1fr">
|
||||
<TableHeader>{t`Workspace`}</TableHeader>
|
||||
<TableHeader align="right">{t`Users`}</TableHeader>
|
||||
</TableRow>
|
||||
{topWorkspaces.map((workspace) => (
|
||||
<TableRow
|
||||
key={workspace.id}
|
||||
gridTemplateColumns="2fr 1fr"
|
||||
to={getSettingsPath(
|
||||
SettingsPath.AdminPanelWorkspaceDetail,
|
||||
{ workspaceId: workspace.id },
|
||||
)}
|
||||
>
|
||||
<TableCell color={themeCssVariables.font.color.primary}>
|
||||
{workspace.name || '\u2014'}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{workspace.totalUsers}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
|
||||
+28
-165
@@ -1,46 +1,29 @@
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
|
||||
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
|
||||
import { useFeatureFlagState } from '@/settings/admin-panel/hooks/useFeatureFlagState';
|
||||
import { useImpersonationAuth } from '@/settings/admin-panel/hooks/useImpersonationAuth';
|
||||
import { useImpersonationRedirect } from '@/settings/admin-panel/hooks/useImpersonationRedirect';
|
||||
import { userLookupResultState } from '@/settings/admin-panel/states/userLookupResultState';
|
||||
import { type WorkspaceInfo } from '@/settings/admin-panel/types/WorkspaceInfo';
|
||||
import { getWorkspaceSchemaName } from '@/settings/admin-panel/utils/getWorkspaceSchemaName';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
import { TableBody } from '@/ui/layout/table/components/TableBody';
|
||||
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 { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useState } from 'react';
|
||||
import { getImageAbsoluteURI, isDefined } from 'twenty-shared/utils';
|
||||
import { AvatarOrIcon, Chip } from 'twenty-ui/components';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import {
|
||||
getImageAbsoluteURI,
|
||||
getSettingsPath,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
import { AvatarOrIcon, LinkChip } from 'twenty-ui/components';
|
||||
import {
|
||||
H2Title,
|
||||
IconEyeShare,
|
||||
IconCalendar,
|
||||
IconHome,
|
||||
IconId,
|
||||
IconLink,
|
||||
IconStatusChange,
|
||||
IconUser,
|
||||
} from 'twenty-ui/display';
|
||||
import { Button, Toggle } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import {
|
||||
type FeatureFlagKey,
|
||||
ImpersonateDocument,
|
||||
UpdateWorkspaceFeatureFlagDocument,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type SettingsAdminWorkspaceContentProps = {
|
||||
activeWorkspace: WorkspaceInfo | undefined;
|
||||
@@ -53,93 +36,11 @@ const StyledContainer = styled.div`
|
||||
margin-top: ${themeCssVariables.spacing[6]};
|
||||
`;
|
||||
|
||||
const StyledButtonContainer = styled.div`
|
||||
margin-top: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
export const SettingsAdminWorkspaceContent = ({
|
||||
activeWorkspace,
|
||||
}: SettingsAdminWorkspaceContentProps) => {
|
||||
const canManageFeatureFlags = useAtomStateValue(canManageFeatureFlagsState);
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const [currentUser] = useAtomState(currentUserState);
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
|
||||
const [updateFeatureFlag] = useMutation(UpdateWorkspaceFeatureFlagDocument);
|
||||
const [isImpersonateLoading, setIsImpersonationLoading] = useState(false);
|
||||
const { executeImpersonationAuth } = useImpersonationAuth();
|
||||
const { executeImpersonationRedirect } = useImpersonationRedirect();
|
||||
const [impersonate] = useMutation(ImpersonateDocument);
|
||||
|
||||
const { updateFeatureFlagState } = useFeatureFlagState();
|
||||
const userLookupResult = useAtomStateValue(userLookupResultState);
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
const handleImpersonate = async (workspaceId: string) => {
|
||||
if (!userLookupResult?.user.id) {
|
||||
enqueueErrorSnackBar({ message: t`Please search for a user first` });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsImpersonationLoading(true);
|
||||
|
||||
await impersonate({
|
||||
variables: { userId: userLookupResult.user.id, workspaceId },
|
||||
onCompleted: async (data) => {
|
||||
const { loginToken, workspace } = data.impersonate;
|
||||
const isCurrentWorkspace = workspace.id === currentWorkspace?.id;
|
||||
if (isCurrentWorkspace) {
|
||||
await executeImpersonationAuth(loginToken.token);
|
||||
return;
|
||||
}
|
||||
|
||||
return executeImpersonationRedirect(
|
||||
workspace.workspaceUrls,
|
||||
loginToken.token,
|
||||
'_blank',
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error.message;
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to impersonate user. ${errorMessage}`,
|
||||
});
|
||||
},
|
||||
}).finally(() => {
|
||||
setIsImpersonationLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFeatureFlagUpdate = async (
|
||||
workspaceId: string,
|
||||
featureFlag: FeatureFlagKey,
|
||||
value: boolean,
|
||||
) => {
|
||||
const previousValue = userLookupResult?.workspaces
|
||||
.find((workspace) => workspace.id === workspaceId)
|
||||
?.featureFlags.find((flag) => flag.key === featureFlag)?.value;
|
||||
|
||||
updateFeatureFlagState(workspaceId, featureFlag, value);
|
||||
await updateFeatureFlag({
|
||||
variables: {
|
||||
workspaceId,
|
||||
featureFlag,
|
||||
value,
|
||||
},
|
||||
|
||||
onError: (error) => {
|
||||
if (isDefined(previousValue)) {
|
||||
updateFeatureFlagState(workspaceId, featureFlag, previousValue);
|
||||
}
|
||||
const errorMessage = error.message;
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to update feature flag. ${errorMessage}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getWorkspaceUrl = (workspaceUrls: WorkspaceInfo['workspaceUrls']) => {
|
||||
return workspaceUrls.customUrl ?? workspaceUrls.subdomainUrl;
|
||||
};
|
||||
@@ -148,10 +49,13 @@ export const SettingsAdminWorkspaceContent = ({
|
||||
{
|
||||
Icon: IconHome,
|
||||
label: t`Name`,
|
||||
value: (
|
||||
<Chip
|
||||
value: activeWorkspace?.id ? (
|
||||
<LinkChip
|
||||
label={activeWorkspace?.name ?? ''}
|
||||
emptyLabel={t`Untitled`}
|
||||
to={getSettingsPath(SettingsPath.AdminPanelWorkspaceDetail, {
|
||||
workspaceId: activeWorkspace.id,
|
||||
})}
|
||||
leftComponent={
|
||||
<AvatarOrIcon
|
||||
avatarUrl={
|
||||
@@ -165,6 +69,8 @@ export const SettingsAdminWorkspaceContent = ({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
(activeWorkspace?.name ?? '')
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -191,6 +97,18 @@ export const SettingsAdminWorkspaceContent = ({
|
||||
label: t`Members`,
|
||||
value: activeWorkspace?.totalUsers,
|
||||
},
|
||||
{
|
||||
Icon: IconStatusChange,
|
||||
label: t`Status`,
|
||||
value: activeWorkspace?.activationStatus,
|
||||
},
|
||||
{
|
||||
Icon: IconCalendar,
|
||||
label: t`Created`,
|
||||
value: activeWorkspace?.createdAt
|
||||
? new Date(activeWorkspace.createdAt).toLocaleDateString()
|
||||
: '',
|
||||
},
|
||||
];
|
||||
|
||||
if (!activeWorkspace) return null;
|
||||
@@ -206,62 +124,7 @@ export const SettingsAdminWorkspaceContent = ({
|
||||
items={workspaceInfoItems}
|
||||
gridAutoColumns="1fr 4fr"
|
||||
/>
|
||||
<StyledButtonContainer>
|
||||
{currentUser?.canImpersonate && (
|
||||
<Button
|
||||
Icon={IconEyeShare}
|
||||
variant="primary"
|
||||
accent="default"
|
||||
title={
|
||||
activeWorkspace.allowImpersonation === false
|
||||
? t`Impersonation is disabled for this workspace`
|
||||
: t`Impersonate`
|
||||
}
|
||||
onClick={() => handleImpersonate(activeWorkspace.id)}
|
||||
disabled={
|
||||
isImpersonateLoading ||
|
||||
activeWorkspace.allowImpersonation === false
|
||||
}
|
||||
dataTestId="impersonate-button"
|
||||
/>
|
||||
)}
|
||||
</StyledButtonContainer>
|
||||
</Section>
|
||||
{canManageFeatureFlags && (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
gridAutoColumns="1fr 100px"
|
||||
mobileGridAutoColumns="1fr 80px"
|
||||
>
|
||||
<TableHeader>{t`Feature Flag`}</TableHeader>
|
||||
<TableHeader align="right">{t`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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user