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
@@ -33,6 +33,7 @@ import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-clie
import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module';
import { UsageModule } from 'src/engine/core-modules/usage/usage.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -71,6 +72,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
UsageModule,
KeyValuePairModule,
UserVarsModule,
UserModule,
],
providers: [
AdminPanelResolver,
@@ -10,4 +10,7 @@ export class AdminPanelRecentUserDTO extends UserInfoDTO {
@Field(() => UUIDScalarType, { nullable: true })
workspaceId?: string | null;
@Field(() => String, { nullable: true })
workspaceLogo?: string | null;
}
@@ -7,6 +7,9 @@ export class AdminPanelTopWorkspaceDTO {
@Field(() => UUIDScalarType)
id: string;
@Field(() => String, { nullable: true })
logoUrl?: string | null;
@Field(() => String)
name: string;
@@ -15,7 +18,4 @@ export class AdminPanelTopWorkspaceDTO {
@Field(() => String)
subdomain: string;
@Field(() => String, { nullable: true })
logo: string | null;
}
@@ -20,6 +20,9 @@ export class UserInfoDTO {
@Field(() => String, { nullable: true })
lastName?: string;
@Field(() => String, { nullable: true })
avatarUrl?: string | null;
@Field(() => Date)
createdAt: Date;
}
@@ -1,16 +1,24 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { Brackets, ILike, IsNull, Repository } from 'typeorm';
import { type AdminPanelRecentUserDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-recent-user.dto';
import { type AdminPanelTopWorkspaceDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-top-workspace.dto';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
const RECENT_USERS_LIMIT = 10;
const TOP_WORKSPACES_LIMIT = 10;
@Injectable()
export class AdminPanelStatisticsService {
constructor(
private readonly fileUrlService: FileUrlService,
private readonly userService: UserService,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
@InjectRepository(WorkspaceEntity)
@@ -20,90 +28,173 @@ export class AdminPanelStatisticsService {
async getRecentUsers(
searchTerm?: string,
): Promise<AdminPanelRecentUserDTO[]> {
let whereClause = 'u."deletedAt" IS NULL';
const params: unknown[] = [];
const trimmedSearch = searchTerm?.trim();
if (searchTerm && searchTerm.trim().length > 0) {
const term = `%${searchTerm.trim()}%`;
const queryBuilder = this.userRepository
.createQueryBuilder('user')
.leftJoinAndSelect(
'user.userWorkspaces',
'userWorkspace',
'"userWorkspace"."deletedAt" IS NULL',
)
.leftJoinAndSelect(
'userWorkspace.workspace',
'workspace',
'"workspace"."deletedAt" IS NULL',
)
.where({ deletedAt: IsNull() })
.orderBy('user.createdAt', 'DESC')
.addOrderBy('userWorkspace.createdAt', 'DESC')
.take(RECENT_USERS_LIMIT);
whereClause += ` AND (u.email ILIKE $1 OR CONCAT(u."firstName", ' ', u."lastName") ILIKE $1 OR u.id::text ILIKE $1)`;
params.push(term);
if (trimmedSearch && trimmedSearch.length > 0) {
const like = `%${trimmedSearch}%`;
queryBuilder.andWhere(
new Brackets((qb) => {
qb.where({ email: ILike(like) })
.orWhere(
`CONCAT("user"."firstName", ' ', "user"."lastName") ILIKE :like`,
{ like },
)
.orWhere('"user"."id"::text ILIKE :like', { like });
}),
);
}
const results = await this.userRepository.manager.query(
`SELECT * FROM (
SELECT DISTINCT ON (u.id) u.id, u.email, u."firstName", u."lastName", u."createdAt",
w."displayName" AS "workspaceName", w.id AS "workspaceId"
FROM core."user" u
LEFT JOIN core."userWorkspace" uw ON uw."userId" = u.id AND uw."deletedAt" IS NULL
LEFT JOIN core.workspace w ON w.id = uw."workspaceId" AND w."deletedAt" IS NULL
WHERE ${whereClause}
ORDER BY u.id, u."createdAt" DESC
) sub
ORDER BY sub."createdAt" DESC
LIMIT 10`,
params,
);
const users = await queryBuilder.getMany();
return results.map(
(row: {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
createdAt: Date;
workspaceName: string | null;
workspaceId: string | null;
}) => ({
id: row.id,
email: row.email,
firstName: row.firstName || null,
lastName: row.lastName || null,
createdAt: row.createdAt,
workspaceName: row.workspaceName ?? null,
workspaceId: row.workspaceId ?? null,
}),
);
const signedAvatarUrlByUserId =
await this.buildSignedAvatarUrlByUserId(users);
return users.map((user) => {
const displayWorkspace = user.userWorkspaces[0]?.workspace;
return {
id: user.id,
email: user.email,
firstName: user.firstName ?? undefined,
lastName: user.lastName ?? undefined,
createdAt: user.createdAt,
avatarUrl: signedAvatarUrlByUserId.get(user.id) ?? null,
workspaceName: displayWorkspace?.displayName ?? null,
workspaceId: displayWorkspace?.id ?? null,
workspaceLogo: displayWorkspace
? this.fileUrlService.signWorkspaceLogoUrl(displayWorkspace)
: null,
};
});
}
async getTopWorkspaces(
searchTerm?: string,
): Promise<AdminPanelTopWorkspaceDTO[]> {
let whereClause = 'w."deletedAt" IS NULL';
const params: unknown[] = [];
const trimmedSearch = searchTerm?.trim();
if (searchTerm && searchTerm.trim().length > 0) {
const term = `%${searchTerm.trim()}%`;
const queryBuilder = this.workspaceRepository
.createQueryBuilder('workspace')
.leftJoin(
'workspace.workspaceUsers',
'userWorkspace',
'"userWorkspace"."deletedAt" IS NULL',
)
.select('workspace.id', 'id')
.addSelect('workspace.displayName', 'name')
.addSelect('workspace.subdomain', 'subdomain')
.addSelect('workspace.logoFileId', 'logoFileId')
.addSelect('COUNT("userWorkspace"."id")::int', 'totalUsers')
.where({ deletedAt: IsNull() })
.groupBy('workspace.id')
.orderBy('"totalUsers"', 'DESC')
.limit(TOP_WORKSPACES_LIMIT);
whereClause += ` AND (w."displayName" ILIKE $1 OR w.subdomain ILIKE $1 OR w.id::text ILIKE $1)`;
params.push(term);
if (trimmedSearch && trimmedSearch.length > 0) {
const like = `%${trimmedSearch}%`;
queryBuilder.andWhere(
new Brackets((qb) => {
qb.where('"workspace"."displayName" ILIKE :like', { like })
.orWhere('"workspace"."subdomain" ILIKE :like', { like })
.orWhere('"workspace"."id"::text ILIKE :like', { like });
}),
);
}
const results = await this.workspaceRepository.manager.query(
`SELECT w.id, w."displayName" AS name, w.subdomain, w.logo, COUNT(uw.id)::int AS "totalUsers"
FROM core.workspace w
LEFT JOIN core."userWorkspace" uw ON uw."workspaceId" = w.id AND uw."deletedAt" IS NULL
WHERE ${whereClause}
GROUP BY w.id
ORDER BY "totalUsers" DESC
LIMIT 10`,
params,
const rows: Array<{
id: string;
name: string | null;
subdomain: string | null;
logoFileId: string | null;
totalUsers: number;
}> = await queryBuilder.getRawMany();
return rows.map((row) => ({
id: row.id,
logoUrl: this.fileUrlService.signWorkspaceLogoUrl({
id: row.id,
logoFileId: row.logoFileId,
}),
name: row.name ?? '',
subdomain: row.subdomain ?? '',
totalUsers: row.totalUsers,
}));
}
private async buildSignedAvatarUrlByUserId(
users: UserEntity[],
): Promise<Map<string, string | null>> {
const signedAvatarUrlByUserId = new Map<string, string | null>();
const contextsByWorkspaceId = new Map<
string,
{
workspace: WorkspaceEntity;
fallbackAvatarUrlsByUserId: Map<string, string | null>;
}
>();
for (const user of users) {
signedAvatarUrlByUserId.set(user.id, user.defaultAvatarUrl ?? null);
for (const userWorkspace of user.userWorkspaces) {
const workspace = userWorkspace.workspace;
if (!workspace) {
continue;
}
const entry = contextsByWorkspaceId.get(workspace.id) ?? {
workspace,
fallbackAvatarUrlsByUserId: new Map(),
};
entry.fallbackAvatarUrlsByUserId.set(
user.id,
userWorkspace.defaultAvatarUrl ?? user.defaultAvatarUrl ?? null,
);
contextsByWorkspaceId.set(workspace.id, entry);
}
}
await Promise.all(
Array.from(contextsByWorkspaceId.values()).map(
async ({ workspace, fallbackAvatarUrlsByUserId }) => {
const perWorkspaceSigned =
await this.userService.loadSignedAvatarUrlsByUserId({
workspace,
fallbackAvatarUrlsByUserId,
});
for (const [userId, signedUrl] of perWorkspaceSigned.entries()) {
const existing = signedAvatarUrlByUserId.get(userId);
if (!isNonEmptyString(existing) && isNonEmptyString(signedUrl)) {
signedAvatarUrlByUserId.set(userId, signedUrl);
}
}
},
),
);
return results.map(
(row: {
id: string;
name: string;
subdomain: string;
logo: string | null;
totalUsers: number;
}) => ({
id: row.id,
name: row.name ?? '',
subdomain: row.subdomain ?? '',
logo: row.logo ?? null,
totalUsers: row.totalUsers,
}),
);
return signedAvatarUrlByUserId;
}
}
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { FeatureFlagKey, FileFolder } from 'twenty-shared/types';
import { FeatureFlagKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
@@ -16,6 +16,7 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -24,6 +25,7 @@ export class AdminPanelUserLookupService {
constructor(
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly fileUrlService: FileUrlService,
private readonly userService: UserService,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
@InjectRepository(WorkspaceEntity)
@@ -34,6 +36,21 @@ export class AdminPanelUserLookupService {
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
) {}
private buildFallbackAvatarUrlsByUserId(
workspaceUsers: UserWorkspaceEntity[],
): Map<string, string | null> {
return new Map(
workspaceUsers
.filter((workspaceUser) => isDefined(workspaceUser.user))
.map((workspaceUser) => [
workspaceUser.user.id,
workspaceUser.defaultAvatarUrl ??
workspaceUser.user.defaultAvatarUrl ??
null,
]),
);
}
async userLookup(userIdentifier: string): Promise<UserLookup> {
const isEmail = userIdentifier.includes('@');
const normalizedIdentifier = isEmail
@@ -65,6 +82,53 @@ export class AdminPanelUserLookupService {
const allFeatureFlagKeys = Object.values(FeatureFlagKey);
const workspaces = await Promise.all(
targetUser.userWorkspaces.map(async (userWorkspace) => {
const workspaceUsers = userWorkspace.workspace.workspaceUsers.filter(
(workspaceUser) => isDefined(workspaceUser.user),
);
const avatarUrlsByUserId =
await this.userService.loadSignedAvatarUrlsByUserId({
workspace: userWorkspace.workspace,
fallbackAvatarUrlsByUserId:
this.buildFallbackAvatarUrlsByUserId(workspaceUsers),
});
return {
id: userWorkspace.workspace.id,
name: userWorkspace.workspace.displayName ?? '',
totalUsers: workspaceUsers.length,
activationStatus: userWorkspace.workspace.activationStatus,
createdAt: userWorkspace.workspace.createdAt,
logo:
this.fileUrlService.signWorkspaceLogoUrl(userWorkspace.workspace) ??
undefined,
allowImpersonation: userWorkspace.workspace.allowImpersonation,
workspaceUrls: this.workspaceDomainsService.getWorkspaceUrls({
subdomain: userWorkspace.workspace.subdomain,
customDomain: userWorkspace.workspace.customDomain,
isCustomDomainEnabled:
userWorkspace.workspace.isCustomDomainEnabled,
}),
users: workspaceUsers.map((workspaceUser) => ({
id: workspaceUser.user.id,
email: workspaceUser.user.email,
firstName: workspaceUser.user.firstName,
lastName: workspaceUser.user.lastName,
avatarUrl: avatarUrlsByUserId.get(workspaceUser.user.id) ?? null,
createdAt: workspaceUser.user.createdAt,
})),
featureFlags: allFeatureFlagKeys.map((key) => ({
key,
value:
userWorkspace.workspace.featureFlags?.find(
(flag) => flag.key === key,
)?.value ?? false,
})),
};
}),
);
return {
user: {
id: targetUser.id,
@@ -73,42 +137,7 @@ export class AdminPanelUserLookupService {
lastName: targetUser.lastName,
createdAt: targetUser.createdAt,
},
workspaces: targetUser.userWorkspaces.map((userWorkspace) => ({
id: userWorkspace.workspace.id,
name: userWorkspace.workspace.displayName ?? '',
totalUsers: userWorkspace.workspace.workspaceUsers.length,
activationStatus: userWorkspace.workspace.activationStatus,
createdAt: userWorkspace.workspace.createdAt,
logo: isDefined(userWorkspace.workspace.logoFileId)
? this.fileUrlService.signFileByIdUrl({
fileId: userWorkspace.workspace.logoFileId,
workspaceId: userWorkspace.workspace.id,
fileFolder: FileFolder.CorePicture,
})
: undefined,
allowImpersonation: userWorkspace.workspace.allowImpersonation,
workspaceUrls: this.workspaceDomainsService.getWorkspaceUrls({
subdomain: userWorkspace.workspace.subdomain,
customDomain: userWorkspace.workspace.customDomain,
isCustomDomainEnabled: userWorkspace.workspace.isCustomDomainEnabled,
}),
users: userWorkspace.workspace.workspaceUsers
.filter((workspaceUser) => isDefined(workspaceUser.user))
.map((workspaceUser) => ({
id: workspaceUser.user.id,
email: workspaceUser.user.email,
firstName: workspaceUser.user.firstName,
lastName: workspaceUser.user.lastName,
createdAt: workspaceUser.user.createdAt,
})),
featureFlags: allFeatureFlagKeys.map((key) => ({
key,
value:
userWorkspace.workspace.featureFlags?.find(
(flag) => flag.key === key,
)?.value ?? false,
})),
})),
workspaces,
};
}
@@ -138,6 +167,16 @@ export class AdminPanelUserLookupService {
]);
const allFeatureFlagKeys = Object.values(FeatureFlagKey);
const definedWorkspaceUsers = workspaceUsers.filter((wu) =>
isDefined(wu.user),
);
const avatarUrlsByUserId =
await this.userService.loadSignedAvatarUrlsByUserId({
workspace,
fallbackAvatarUrlsByUserId: this.buildFallbackAvatarUrlsByUserId(
definedWorkspaceUsers,
),
});
const workspaceInfo = {
id: workspace.id,
@@ -145,28 +184,21 @@ export class AdminPanelUserLookupService {
totalUsers: workspaceUsers.length,
activationStatus: workspace.activationStatus,
createdAt: workspace.createdAt,
logo: isDefined(workspace.logoFileId)
? this.fileUrlService.signFileByIdUrl({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
fileFolder: FileFolder.CorePicture,
})
: undefined,
logo: this.fileUrlService.signWorkspaceLogoUrl(workspace) ?? undefined,
allowImpersonation: workspace.allowImpersonation,
workspaceUrls: this.workspaceDomainsService.getWorkspaceUrls({
subdomain: workspace.subdomain,
customDomain: workspace.customDomain,
isCustomDomainEnabled: workspace.isCustomDomainEnabled,
}),
users: workspaceUsers
.filter((wu) => isDefined(wu.user))
.map((wu) => ({
id: wu.user.id,
email: wu.user.email,
firstName: wu.user.firstName,
lastName: wu.user.lastName,
createdAt: wu.user.createdAt,
})),
users: definedWorkspaceUsers.map((wu) => ({
id: wu.user.id,
email: wu.user.email,
firstName: wu.user.firstName,
lastName: wu.user.lastName,
avatarUrl: avatarUrlsByUserId.get(wu.user.id) ?? null,
createdAt: wu.user.createdAt,
})),
featureFlags: allFeatureFlagKeys.map((key) => ({
key,
value: featureFlags.find((flag) => flag.key === key)?.value ?? false,
@@ -181,6 +213,9 @@ export class AdminPanelUserLookupService {
email: firstUser?.email ?? '',
firstName: firstUser?.firstName,
lastName: firstUser?.lastName,
avatarUrl: firstUser
? (avatarUrlsByUserId.get(firstUser.id) ?? null)
: null,
createdAt: firstUser?.createdAt ?? new Date(),
},
workspaces: [workspaceInfo],