diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx
index 3ee6daf89..708699bf8 100644
--- a/apps/nextjs/src/app/[locale]/layout.tsx
+++ b/apps/nextjs/src/app/[locale]/layout.tsx
@@ -106,6 +106,7 @@ export default async function Layout(props: {
forceDisableStatus: serverSettings.board.forceDisableStatus,
},
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
+ user: { enableGravatar: serverSettings.user.enableGravatar },
}}
{...innerProps}
/>
diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/user-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/user-settings-form.tsx
new file mode 100644
index 000000000..49b8f7219
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/user-settings-form.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { Switch } from "@mantine/core";
+
+import type { ServerSettings } from "@homarr/server-settings";
+import { useScopedI18n } from "@homarr/translation/client";
+
+import { CommonSettingsForm } from "./common-form";
+
+export const UserSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["user"] }) => {
+ const tUser = useScopedI18n("management.page.settings.section.user");
+
+ return (
+
+ {(form) => (
+ <>
+
+ >
+ )}
+
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/manage/settings/page.tsx b/apps/nextjs/src/app/[locale]/manage/settings/page.tsx
index 101a80d55..4a7ea9bc0 100644
--- a/apps/nextjs/src/app/[locale]/manage/settings/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/settings/page.tsx
@@ -12,6 +12,7 @@ import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
import { BoardSettingsForm } from "./_components/board-settings-form";
import { CultureSettingsForm } from "./_components/culture-settings-form";
import { SearchSettingsForm } from "./_components/search-settings-form";
+import { UserSettingsForm } from "./_components/user-settings-form";
export async function generateMetadata() {
const t = await getScopedI18n("management");
@@ -42,6 +43,10 @@ export default async function SettingsPage() {
{tSettings("section.board.title")}
+
+ {tSettings("section.user.title")}
+
+
{tSettings("section.search.title")}
diff --git a/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx
index e91cd3dfe..fbaae76c7 100644
--- a/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx
@@ -200,7 +200,10 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
-
+
{generalForm.values.username}
diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/page.tsx
index 9a99aebc7..42f9f4365 100644
--- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/page.tsx
@@ -44,7 +44,7 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
{group.owner ? (
-
+
{group.owner.name}
{group.owner.email}
diff --git a/apps/nextjs/src/components/access/access-settings.tsx b/apps/nextjs/src/components/access/access-settings.tsx
index c90b8a0d8..d9cb764d5 100644
--- a/apps/nextjs/src/components/access/access-settings.tsx
+++ b/apps/nextjs/src/components/access/access-settings.tsx
@@ -26,6 +26,7 @@ interface UserAccessPermission {
user: {
name: string | null;
image: string | null;
+ email: string | null;
id: string;
};
}
@@ -66,6 +67,7 @@ interface Props {
id: string;
name: string | null;
image: string | null;
+ email: string | null;
} | null;
};
translate: (key: TPermission) => string;
diff --git a/apps/nextjs/src/components/access/user-access-form.tsx b/apps/nextjs/src/components/access/user-access-form.tsx
index 1af278a5e..cefbe3528 100644
--- a/apps/nextjs/src/components/access/user-access-form.tsx
+++ b/apps/nextjs/src/components/access/user-access-form.tsx
@@ -21,6 +21,7 @@ export interface FormProps {
id: string;
name: string | null;
image: string | null;
+ email: string | null;
} | null;
};
accessQueryData: AccessQueryData;
@@ -118,6 +119,7 @@ interface UserItemContentProps {
id: string;
name: string | null;
image: string | null;
+ email: string | null;
};
}
diff --git a/apps/nextjs/src/components/access/user-select-modal.tsx b/apps/nextjs/src/components/access/user-select-modal.tsx
index 22f22a1e1..099054747 100644
--- a/apps/nextjs/src/components/access/user-select-modal.tsx
+++ b/apps/nextjs/src/components/access/user-select-modal.tsx
@@ -13,7 +13,7 @@ import { UserAvatar } from "@homarr/ui";
interface InnerProps {
presentUserIds: string[];
excludeExternalProviders?: boolean;
- onSelect: (props: { id: string; name: string; image: string }) => void | Promise;
+ onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise;
confirmLabel?: string;
}
@@ -36,6 +36,7 @@ export const UserSelectModal = createModal(({ actions, innerProps })
id: currentUser.id,
name: currentUser.name ?? "",
image: currentUser.image ?? "",
+ email: currentUser.email ?? null,
});
setLoading(false);
diff --git a/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts b/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts
index fa4f0f43d..9a687c2e4 100644
--- a/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts
+++ b/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts
@@ -34,6 +34,7 @@ export class BoardMockBuilder {
id: createId(),
image: null,
name: "User",
+ email: null,
},
groupPermissions: [],
userPermissions: [],
diff --git a/apps/nextjs/src/components/user-avatar.tsx b/apps/nextjs/src/components/user-avatar.tsx
index ce0cdb77b..5c4a5e68d 100644
--- a/apps/nextjs/src/components/user-avatar.tsx
+++ b/apps/nextjs/src/components/user-avatar.tsx
@@ -3,17 +3,21 @@ import type { MantineSize } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { UserAvatar } from "@homarr/ui";
-interface UserAvatarProps {
+interface CurrentUserAvatarProps {
size: MantineSize;
}
-export const CurrentUserAvatar = async ({ size }: UserAvatarProps) => {
+export const CurrentUserAvatar = async ({ size }: CurrentUserAvatarProps) => {
const currentSession = await auth();
- const user = {
- name: currentSession?.user.name ?? null,
- image: currentSession?.user.image ?? null,
- };
-
- return ;
+ return (
+
+ );
};
diff --git a/packages/api/src/router/apiKeys.ts b/packages/api/src/router/apiKeys.ts
index cdf8b5bea..9c0117085 100644
--- a/packages/api/src/router/apiKeys.ts
+++ b/packages/api/src/router/apiKeys.ts
@@ -22,6 +22,7 @@ export const apiKeysRouter = createTRPCRouter({
id: true,
name: true,
image: true,
+ email: true,
},
},
},
diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts
index 8d118018b..bbb7c0c66 100644
--- a/packages/api/src/router/board.ts
+++ b/packages/api/src/router/board.ts
@@ -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, use
id: true,
name: true,
image: true,
+ email: true,
},
},
sections: {
diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts
index 4a12e062e..77a98515b 100644
--- a/packages/api/src/router/integration/integration-router.ts
+++ b/packages/api/src/router/integration/integration-router.ts
@@ -476,6 +476,7 @@ export const integrationRouter = createTRPCRouter({
id: true,
name: true,
image: true,
+ email: true,
},
},
},
diff --git a/packages/api/src/router/medias/media-router.ts b/packages/api/src/router/medias/media-router.ts
index a3f958ff5..7f12bb042 100644
--- a/packages/api/src/router/medias/media-router.ts
+++ b/packages/api/src/router/medias/media-router.ts
@@ -39,6 +39,7 @@ export const mediaRouter = createTRPCRouter({
id: true,
name: true,
image: true,
+ email: true,
},
},
},
diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts
index 8bd21e556..44cc97adc 100644
--- a/packages/api/src/router/test/board.spec.ts
+++ b/packages/api/src/router/test/board.spec.ts
@@ -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",
},
]),
diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts
index 875ffe80b..f7ed6deb6 100644
--- a/packages/api/src/router/user.ts
+++ b/packages/api/src/router/user.ts
@@ -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
diff --git a/packages/request-handler/src/lib/cached-request-integration-job-handler.ts b/packages/request-handler/src/lib/cached-request-integration-job-handler.ts
index dd5249a24..63e7682c2 100644
--- a/packages/request-handler/src/lib/cached-request-integration-job-handler.ts
+++ b/packages/request-handler/src/lib/cached-request-integration-job-handler.ts
@@ -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,
},
diff --git a/packages/server-settings/src/index.ts b/packages/server-settings/src/index.ts
index 12c859165..5c4470f6a 100644
--- a/packages/server-settings/src/index.ts
+++ b/packages/server-settings/src/index.ts
@@ -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,
},
diff --git a/packages/settings/package.json b/packages/settings/package.json
index 3d304aee2..a827335c0 100644
--- a/packages/settings/package.json
+++ b/packages/settings/package.json
@@ -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",
diff --git a/packages/settings/src/creator.ts b/packages/settings/src/creator.ts
index 5eb348930..a23906948 100644
--- a/packages/settings/src/creator.ts
+++ b/packages/settings/src/creator.ts
@@ -10,7 +10,8 @@ export type SettingsContextProps = Pick<
| "openSearchInNewTab"
| "pingIconsEnabled"
> &
- Pick;
+ Pick &
+ Pick;
export interface PublicServerSettings {
search: Pick;
@@ -18,6 +19,7 @@ export interface PublicServerSettings {
ServerSettings["board"],
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
>;
+ user: Pick;
}
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,
});
diff --git a/packages/spotlight/src/modes/user-group/users-search-group.tsx b/packages/spotlight/src/modes/user-group/users-search-group.tsx
index b27dfbc37..d0eacd04a 100644
--- a/packages/spotlight/src/modes/user-group/users-search-group.tsx
+++ b/packages/spotlight/src/modes/user-group/users-search-group.tsx
@@ -11,7 +11,7 @@ import { interaction } from "../../lib/interaction";
// This has to be type so it can be interpreted as Record.
// 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({
useActions: () => [
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 517ecfea1..2d9d69546 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -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": {
diff --git a/packages/ui/package.json b/packages/ui/package.json
index c0a3e2051..582c45cff 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -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"
diff --git a/packages/ui/src/components/user-avatar.tsx b/packages/ui/src/components/user-avatar.tsx
index 87ffca28e..8c1e3f5a1 100644
--- a/packages/ui/src/components/user-avatar.tsx
+++ b/packages/ui/src/components/user-avatar.tsx
@@ -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 ;
+
if (user.image) {
return ;
}
+ if (user.email && enableGravatar) {
+ const emailHash = MD5(user.email.trim().toLowerCase()).toString(enc.Hex);
+ return ;
+ }
+
return ;
};
diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts
index f7df8c95d..6a6d026fb 100644
--- a/packages/widgets/src/definition.ts
+++ b/packages/widgets/src/definition.ts
@@ -42,7 +42,9 @@ export interface WidgetDefinition {
icon: TablerIcon;
supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean;
- createOptions: (settings: SettingsContextProps) => WidgetOptionsRecord;
+ createOptions: (
+ settings: Pick,
+ ) => WidgetOptionsRecord;
errors?: Partial<
Record<
DefaultErrorData["code"],
diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx
index a4e741649..677b801f2 100644
--- a/packages/widgets/src/index.tsx
+++ b/packages/widgets/src/index.tsx
@@ -115,7 +115,7 @@ export type inferSupportedIntegrationsStrict = (Widget
export const reduceWidgetOptionsWithDefaultValues = (
kind: WidgetKind,
- settings: SettingsContextProps,
+ settings: Pick,
currentValue: Record = {},
) => {
const definition = widgetImports[kind].definition;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 47d2ad1f6..c45856ab1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1898,9 +1898,6 @@ importers:
packages/settings:
dependencies:
- '@homarr/api':
- specifier: workspace:^0.1.0
- version: link:../api
'@homarr/db':
specifier: workspace:^0.1.0
version: link:../db
@@ -2066,6 +2063,9 @@ importers:
'@homarr/definitions':
specifier: workspace:^0.1.0
version: link:../definitions
+ '@homarr/settings':
+ specifier: workspace:^0.1.0
+ version: link:../settings
'@homarr/translation':
specifier: workspace:^0.1.0
version: link:../translation
@@ -2084,6 +2084,9 @@ importers:
'@tabler/icons-react':
specifier: ^3.36.1
version: 3.36.1(react@19.2.3)
+ crypto-js:
+ specifier: ^4.2.0
+ version: 4.2.0
mantine-react-table:
specifier: 2.0.0-beta.9
version: 2.0.0-beta.9(@mantine/core@8.3.10(@mantine/hooks@8.3.10(react@19.2.3))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@mantine/dates@8.3.10(@mantine/core@8.3.10(@mantine/hooks@8.3.10(react@19.2.3))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@mantine/hooks@8.3.10(react@19.2.3))(dayjs@1.11.19)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@mantine/hooks@8.3.10(react@19.2.3))(@tabler/icons-react@3.36.1(react@19.2.3))(clsx@2.1.1)(dayjs@1.11.19)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -2109,6 +2112,9 @@ importers:
'@homarr/tsconfig':
specifier: workspace:^0.1.0
version: link:../../tooling/typescript
+ '@types/crypto-js':
+ specifier: ^4.2.2
+ version: 4.2.2
'@types/css-modules':
specifier: ^1.0.5
version: 1.0.5
@@ -4802,6 +4808,9 @@ packages:
'@types/cors@2.8.17':
resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==}
+ '@types/crypto-js@4.2.2':
+ resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
+
'@types/css-font-loading-module@0.0.7':
resolution: {integrity: sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==}
@@ -5980,6 +5989,9 @@ packages:
crossws@0.3.5:
resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==}
+ crypto-js@4.2.0:
+ resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
+
crypto-random-string@2.0.0:
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
engines: {node: '>=8'}
@@ -13787,6 +13799,8 @@ snapshots:
dependencies:
'@types/node': 24.10.4
+ '@types/crypto-js@4.2.2': {}
+
'@types/css-font-loading-module@0.0.7': {}
'@types/css-modules@1.0.5': {}
@@ -15148,6 +15162,8 @@ snapshots:
dependencies:
uncrypto: 0.1.3
+ crypto-js@4.2.0: {}
+
crypto-random-string@2.0.0: {}
crypto-random-string@4.0.0: