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:
@@ -22,6 +22,7 @@ export const apiKeysRouter = createTRPCRouter({
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -476,6 +476,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -39,6 +39,7 @@ export const mediaRouter = createTRPCRouter({
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: () => [
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user