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: