feat(users): add libravatar / gravatar support (#4277)

Co-authored-by: HeapReaper <kelivn@heapreaper.nl>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
HeapReaper
2026-01-09 13:10:52 +01:00
committed by GitHub
parent 717e17c9f8
commit a2a34124ae
27 changed files with 125 additions and 29 deletions

View File

@@ -22,6 +22,7 @@ export const apiKeysRouter = createTRPCRouter({
id: true,
name: true,
image: true,
email: true,
},
},
},

View File

@@ -155,6 +155,7 @@ export const boardRouter = createTRPCRouter({
id: true,
name: true,
image: true,
email: true,
},
},
userPermissions: {
@@ -1195,6 +1196,7 @@ export const boardRouter = createTRPCRouter({
id: true,
name: true,
image: true,
email: true,
},
},
},
@@ -1537,6 +1539,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
id: true,
name: true,
image: true,
email: true,
},
},
sections: {

View File

@@ -476,6 +476,7 @@ export const integrationRouter = createTRPCRouter({
id: true,
name: true,
image: true,
email: true,
},
},
},

View File

@@ -39,6 +39,7 @@ export const mediaRouter = createTRPCRouter({
id: true,
name: true,
image: true,
email: true,
},
},
},

View File

@@ -1220,11 +1220,11 @@ describe("getBoardPermissions should return board permissions", () => {
expect(result.users).toEqual(
expect.arrayContaining([
{
user: { id: user1, name: null, image: null },
user: { id: user1, name: null, image: null, email: null },
permission: "view",
},
{
user: { id: user2, name: null, image: null },
user: { id: user2, name: null, image: null, email: null },
permission: "modify",
},
]),

View File

@@ -174,7 +174,7 @@ export const userRouter = createTRPCRouter({
// Is protected because also used in board access / integration access forms
selectable: protectedProcedure
.input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional())
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: true })))
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: true, email: true })))
.meta({ openapi: { method: "GET", path: "/api/users/selectable", tags: ["users"], protect: true } })
.query(({ ctx, input }) => {
return ctx.db.query.users.findMany({
@@ -182,6 +182,7 @@ export const userRouter = createTRPCRouter({
id: true,
name: true,
image: true,
email: true,
},
where: input?.excludeExternalProviders ? eq(users.provider, "credentials") : undefined,
});
@@ -194,7 +195,7 @@ export const userRouter = createTRPCRouter({
limit: z.number().min(1).max(100).default(10),
}),
)
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: true })))
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: true, email: true })))
.meta({ openapi: { method: "POST", path: "/api/users/search", tags: ["users"], protect: true } })
.query(async ({ input, ctx }) => {
const dbUsers = await ctx.db.query.users.findMany({
@@ -202,6 +203,7 @@ export const userRouter = createTRPCRouter({
id: true,
name: true,
image: true,
email: true,
},
where: like(users.name, `%${input.query}%`),
limit: input.limit,
@@ -210,6 +212,7 @@ export const userRouter = createTRPCRouter({
id: user.id,
name: user.name ?? "",
image: user.image,
email: user.email,
}));
}),
getById: protectedProcedure

View File

@@ -57,12 +57,6 @@ export const createRequestIntegrationJobHandler = <
reduceWidgetOptionsWithDefaultValues(
itemForIntegration.kind,
{
defaultSearchEngineId: serverSettings.search.defaultSearchEngineId,
openSearchInNewTab: true,
firstDayOfWeek: 1,
homeBoardId: serverSettings.board.homeBoardId,
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
pingIconsEnabled: true,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
},

View File

@@ -5,6 +5,7 @@ export const defaultServerSettingsKeys = [
"analytics",
"crawlingAndIndexing",
"board",
"user",
"appearance",
"culture",
"search",
@@ -31,6 +32,9 @@ export const defaultServerSettings = {
enableStatusByDefault: true,
forceDisableStatus: false,
},
user: {
enableGravatar: true,
},
appearance: {
defaultColorScheme: "light" as ColorScheme,
},

View File

@@ -23,7 +23,6 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@mantine/dates": "^8.3.10",

View File

@@ -10,7 +10,8 @@ export type SettingsContextProps = Pick<
| "openSearchInNewTab"
| "pingIconsEnabled"
> &
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus">;
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> &
Pick<ServerSettings["user"], "enableGravatar">;
export interface PublicServerSettings {
search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
@@ -18,6 +19,7 @@ export interface PublicServerSettings {
ServerSettings["board"],
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
>;
user: Pick<ServerSettings["user"], "enableGravatar">;
}
export type UserSettings = Pick<
@@ -45,4 +47,5 @@ export const createSettings = ({
pingIconsEnabled: user?.pingIconsEnabled ?? false,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
enableGravatar: serverSettings.user.enableGravatar,
});

View File

@@ -11,7 +11,7 @@ import { interaction } from "../../lib/interaction";
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type User = { id: string; name: string; image: string | null };
type User = { id: string; name: string; image: string | null; email: string | null };
const userChildrenOptions = createChildrenOptions<User>({
useActions: () => [

View File

@@ -3294,6 +3294,13 @@
}
}
},
"user": {
"title": "Users",
"enableGravatar": {
"label": "Enable Gravatar",
"description": "Falls back to user avatars from Libravatar/Gravatar when no custom avatar is set and an email is configured"
}
},
"search": {
"title": "Search",
"defaultSearchEngine": {

View File

@@ -27,12 +27,14 @@
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.3.10",
"@mantine/dates": "^8.3.10",
"@mantine/hooks": "^8.3.10",
"@tabler/icons-react": "^3.36.1",
"crypto-js": "^4.2.0",
"mantine-react-table": "2.0.0-beta.9",
"next": "16.1.1",
"react": "19.2.3",
@@ -43,6 +45,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/crypto-js": "^4.2.2",
"@types/css-modules": "^1.0.5",
"eslint": "^9.39.2",
"typescript": "^5.9.3"

View File

@@ -1,9 +1,15 @@
"use client";
import type { AvatarProps } from "@mantine/core";
import { Avatar } from "@mantine/core";
import { enc, MD5 } from "crypto-js";
import { useSettings } from "@homarr/settings";
export interface UserProps {
name: string | null;
image: string | null;
email: string | null;
}
interface UserAvatarProps {
@@ -12,10 +18,18 @@ interface UserAvatarProps {
}
export const UserAvatar = ({ user, size }: UserAvatarProps) => {
const { enableGravatar } = useSettings();
if (!user?.name) return <Avatar size={size} />;
if (user.image) {
return <Avatar src={user.image} alt={user.name} size={size} />;
}
if (user.email && enableGravatar) {
const emailHash = MD5(user.email.trim().toLowerCase()).toString(enc.Hex);
return <Avatar src={`https://seccdn.libravatar.org/avatar/${emailHash}?d=blank`} alt={user.name} size={size} />;
}
return <Avatar name={user.name} color="initials" size={size}></Avatar>;
};

View File

@@ -42,7 +42,9 @@ export interface WidgetDefinition {
icon: TablerIcon;
supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean;
createOptions: (settings: SettingsContextProps) => WidgetOptionsRecord;
createOptions: (
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
) => WidgetOptionsRecord;
errors?: Partial<
Record<
DefaultErrorData["code"],

View File

@@ -115,7 +115,7 @@ export type inferSupportedIntegrationsStrict<TKind extends WidgetKind> = (Widget
export const reduceWidgetOptionsWithDefaultValues = (
kind: WidgetKind,
settings: SettingsContextProps,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
currentValue: Record<string, unknown> = {},
) => {
const definition = widgetImports[kind].definition;