Add admin avatars and app logos (#20001)

## Summary
- Add user avatars and workspace logos to the admin general table and
workspace member detail view
- Show app icons in the admin app registrations table and reuse the
shared application display component
- Expose the needed avatar and logo fields through admin GraphQL queries
and backend lookup/statistics services
- Keep workspace fallback behavior consistent when no logo is set and
clean up a few local table styling duplicates

## Testing
- `./node_modules/.bin/tsc -p packages/twenty-front/tsconfig.json
--noEmit --pretty false`
- Manual UI verification of the admin general, workspace detail, and
apps tables

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Thomas des Francs
2026-04-26 19:15:20 +02:00
committed by GitHub
parent 499067ae14
commit fb8b9cb86c
20 changed files with 643 additions and 284 deletions
@@ -1,3 +1,4 @@
import { ApplicationDisplay } from '@/applications/components/ApplicationDisplay';
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@@ -13,12 +14,7 @@ import { t } from '@lingui/core/macro';
import { type ReactNode, useContext, useState } from 'react';
import { assertUnreachable, getSettingsPath } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import {
H2Title,
IconChevronRight,
IconPinned,
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { H2Title, IconChevronRight, IconPinned } from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { MenuItemToggle } from 'twenty-ui/navigation';
@@ -41,7 +37,6 @@ export const SettingsAdminApps = () => {
const apolloAdminClient = useApolloAdminClient();
const [searchQuery, setSearchQuery] = useState('');
const [showPreInstalledOnly, setShowPreInstalledOnly] = useState(false);
const { theme } = useContext(ThemeContext);
const { data } = useQuery(FindAllApplicationRegistrationsDocument, {
client: apolloAdminClient,
@@ -136,37 +131,11 @@ export const SettingsAdminApps = () => {
</TableRow>
<TableBody>
{filtered.map((registration) => (
<TableRow
<SettingsAdminAppsTableRow
key={registration.id}
to={getSettingsPath(
SettingsPath.AdminPanelApplicationRegistrationDetail,
{ applicationRegistrationId: registration.id },
)}
gridAutoColumns={TABLE_GRID}
mobileGridAutoColumns={TABLE_GRID_MOBILE}
isClickable
>
<TableCell
color={themeCssVariables.font.color.primary}
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
<OverflowingTextWithTooltip text={registration.name} />
</TableCell>
<TableCell overflow="hidden" align="right">
{getFormattedSource(registration)}
</TableCell>
<TableCell align="right">
{registration.isListed ? t`Yes` : t`No`}
</TableCell>
<TableCell align="right">
<IconChevronRight
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
</TableCell>
</TableRow>
registration={registration}
getFormattedSource={getFormattedSource}
/>
))}
</TableBody>
</Table>
@@ -174,3 +143,50 @@ export const SettingsAdminApps = () => {
</Section>
);
};
type SettingsAdminAppsTableRowProps = {
registration: ApplicationRegistrationFragmentFragment;
getFormattedSource: (
registration: ApplicationRegistrationFragmentFragment,
) => string;
};
const SettingsAdminAppsTableRow = ({
registration,
getFormattedSource,
}: SettingsAdminAppsTableRowProps) => {
const { theme } = useContext(ThemeContext);
return (
<TableRow
to={getSettingsPath(
SettingsPath.AdminPanelApplicationRegistrationDetail,
{ applicationRegistrationId: registration.id },
)}
gridAutoColumns={TABLE_GRID}
mobileGridAutoColumns={TABLE_GRID_MOBILE}
isClickable
>
<TableCell
color={themeCssVariables.font.color.primary}
gap={themeCssVariables.spacing[2]}
minWidth="0"
overflow="hidden"
>
<ApplicationDisplay application={registration} />
</TableCell>
<TableCell overflow="hidden" align="right">
{getFormattedSource(registration)}
</TableCell>
<TableCell align="right">
{registration.isListed ? t`Yes` : t`No`}
</TableCell>
<TableCell align="right">
<IconChevronRight
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
</TableCell>
</TableRow>
);
};
@@ -2,9 +2,6 @@ import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeat
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
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 { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
@@ -15,38 +12,35 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { useContext, useState } from 'react';
import { useDebounce } from 'use-debounce';
import { SettingsPath } from 'twenty-shared/types';
import { getImageAbsoluteURI, getSettingsPath } from 'twenty-shared/utils';
import { AvatarOrIcon } from 'twenty-ui/components';
import { getSettingsPath } from 'twenty-shared/utils';
import { currentUserState } from '@/auth/states/currentUserState';
import { H2Title } from 'twenty-ui/display';
import {
Avatar,
H2Title,
IconChevronRight,
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import {
AdminPanelRecentUsersDocument,
AdminPanelTopWorkspacesDocument,
} from '~/generated-admin/graphql';
const StyledEmptyState = styled.div`
color: ${themeCssVariables.font.color.tertiary};
padding: ${themeCssVariables.spacing[4]} 0;
`;
const StyledWorkspaceCell = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
min-width: 0;
`;
const StyledWorkspaceName = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const RECENT_USERS_GRID_TEMPLATE_COLUMNS = '1fr 2fr 1fr 36px';
const TOP_WORKSPACES_GRID_TEMPLATE_COLUMNS = '2fr 1fr 36px';
export const SettingsAdminGeneral = () => {
const { theme } = useContext(ThemeContext);
const apolloAdminClient = useApolloAdminClient();
const [userSearchTerm, setUserSearchTerm] = useState('');
const [debouncedUserSearchTerm] = useDebounce(userSearchTerm, 300);
@@ -59,35 +53,23 @@ export const SettingsAdminGeneral = () => {
const canImpersonate = currentUser?.canImpersonate;
const canManageFeatureFlags = useAtomStateValue(canManageFeatureFlagsState);
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, {
client: apolloAdminClient,
variables: { searchTerm: debouncedUserSearchTerm },
skip: !canImpersonate,
});
const { data: recentUsersData, loading: isLoadingUsers } = useQuery(
AdminPanelRecentUsersDocument,
{
client: apolloAdminClient,
variables: { searchTerm: debouncedUserSearchTerm },
skip: !canImpersonate,
},
);
const { data: topWorkspacesData, loading: isLoadingWorkspaces } = useQuery<{
adminPanelTopWorkspaces: {
id: string;
name: string;
totalUsers: number;
subdomain: string;
logo: string | null;
}[];
}>(ADMIN_PANEL_TOP_WORKSPACES, {
client: apolloAdminClient,
variables: { searchTerm: debouncedWorkspaceSearchTerm },
skip: !canImpersonate,
});
const { data: topWorkspacesData, loading: isLoadingWorkspaces } = useQuery(
AdminPanelTopWorkspacesDocument,
{
client: apolloAdminClient,
variables: { searchTerm: debouncedWorkspaceSearchTerm },
skip: !canImpersonate,
},
);
const recentUsers = recentUsersData?.adminPanelRecentUsers ?? [];
const topWorkspaces = topWorkspacesData?.adminPanelTopWorkspaces ?? [];
@@ -131,26 +113,71 @@ export const SettingsAdminGeneral = () => {
) : (
<Table>
<TableBody>
<TableRow gridTemplateColumns="1fr 2fr 1fr">
<TableRow
gridTemplateColumns={RECENT_USERS_GRID_TEMPLATE_COLUMNS}
>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Email`}</TableHeader>
<TableHeader align="right">{t`Workspace`}</TableHeader>
<TableHeader>{t`Workspace`}</TableHeader>
<TableHeader />
</TableRow>
{recentUsers.map((user) => (
<TableRow
key={user.id}
gridTemplateColumns="1fr 2fr 1fr"
gridTemplateColumns={RECENT_USERS_GRID_TEMPLATE_COLUMNS}
to={getSettingsPath(SettingsPath.AdminPanelUserDetail, {
userId: user.id,
})}
>
<TableCell color={themeCssVariables.font.color.primary}>
{`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
'\u2014'}
<TableCell
color={themeCssVariables.font.color.primary}
gap={themeCssVariables.spacing[2]}
overflow="hidden"
>
<Avatar
avatarUrl={user.avatarUrl}
placeholder={
`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
user.email
}
placeholderColorSeed={user.id}
size="md"
type="rounded"
/>
<OverflowingTextWithTooltip
text={
`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
'\u2014'
}
/>
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell align="right">
{user.workspaceName || '\u2014'}
<TableCell
gap={themeCssVariables.spacing[2]}
overflow="hidden"
>
{user.workspaceId ? (
<>
<Avatar
avatarUrl={user.workspaceLogo}
placeholder={user.workspaceName || ''}
placeholderColorSeed={user.workspaceId}
size="sm"
/>
<OverflowingTextWithTooltip
text={user.workspaceName || '\u2014'}
/>
</>
) : (
'\u2014'
)}
</TableCell>
<TableCell align="center">
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.tertiary}
/>
</TableCell>
</TableRow>
))}
@@ -180,41 +207,47 @@ export const SettingsAdminGeneral = () => {
) : (
<Table>
<TableBody>
<TableRow gridTemplateColumns="2fr 1fr">
<TableRow
gridTemplateColumns={TOP_WORKSPACES_GRID_TEMPLATE_COLUMNS}
>
<TableHeader>{t`Workspace`}</TableHeader>
<TableHeader align="right">{t`Users`}</TableHeader>
<TableHeader />
</TableRow>
{topWorkspaces.map((workspace) => (
<TableRow
key={workspace.id}
gridTemplateColumns="2fr 1fr"
gridTemplateColumns={TOP_WORKSPACES_GRID_TEMPLATE_COLUMNS}
to={getSettingsPath(
SettingsPath.AdminPanelWorkspaceDetail,
{ workspaceId: workspace.id },
)}
>
<TableCell color={themeCssVariables.font.color.primary}>
<StyledWorkspaceCell>
<AvatarOrIcon
avatarUrl={
getImageAbsoluteURI({
imageUrl: isNonEmptyString(workspace.logo)
? workspace.logo
: DEFAULT_WORKSPACE_LOGO,
baseUrl: REACT_APP_SERVER_BASE_URL,
}) ?? ''
}
placeholder={workspace.name}
avatarType="squared"
/>
<StyledWorkspaceName>
{workspace.name || '\u2014'}
</StyledWorkspaceName>
</StyledWorkspaceCell>
<TableCell
color={themeCssVariables.font.color.primary}
gap={themeCssVariables.spacing[2]}
overflow="hidden"
>
<Avatar
avatarUrl={workspace.logoUrl}
placeholder={workspace.name || ''}
placeholderColorSeed={workspace.id}
size="md"
/>
<OverflowingTextWithTooltip
text={workspace.name || '\u2014'}
/>
</TableCell>
<TableCell align="right">
{workspace.totalUsers}
</TableCell>
<TableCell align="center">
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.tertiary}
/>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -7,9 +7,11 @@ export const ADMIN_PANEL_RECENT_USERS = gql`
email
firstName
lastName
avatarUrl
createdAt
workspaceName
workspaceId
workspaceLogo
}
}
`;
@@ -4,10 +4,10 @@ export const ADMIN_PANEL_TOP_WORKSPACES = gql`
query AdminPanelTopWorkspaces($searchTerm: String) {
adminPanelTopWorkspaces(searchTerm: $searchTerm) {
id
logoUrl
name
totalUsers
subdomain
logo
}
}
`;
@@ -26,6 +26,7 @@ export const WORKSPACE_LOOKUP_ADMIN_PANEL = gql`
email
firstName
lastName
avatarUrl
}
featureFlags {
key
@@ -5,6 +5,7 @@ export const APPLICATION_REGISTRATION_FRAGMENT = gql`
id
universalIdentifier
name
logoUrl
oAuthClientId
oAuthRedirectUris
oAuthScopes