From 34a24ce6c9d07be8821e7adb7a02b175143d353a Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Thu, 16 Apr 2026 20:51:53 -0300 Subject: [PATCH] feat: add user role field and implement Gravatar fallback for user avatars --- .../migration.sql | 2 + apps/backend/src/lib/auth.ts | 3 +- .../src/modules/user/services/user.service.ts | 8 +- apps/frontend/src/lib/auth.tsx | 77 ++++++++++++++++++- 4 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260416233831_add_role_to_user/migration.sql diff --git a/apps/backend/prisma/migrations/20260416233831_add_role_to_user/migration.sql b/apps/backend/prisma/migrations/20260416233831_add_role_to_user/migration.sql new file mode 100644 index 0000000..e30d4d8 --- /dev/null +++ b/apps/backend/prisma/migrations/20260416233831_add_role_to_user/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "role" TEXT NOT NULL DEFAULT 'member'; diff --git a/apps/backend/src/lib/auth.ts b/apps/backend/src/lib/auth.ts index 912f999..14ec802 100644 --- a/apps/backend/src/lib/auth.ts +++ b/apps/backend/src/lib/auth.ts @@ -1,6 +1,5 @@ import { betterAuth } from 'better-auth'; import { prismaAdapter } from 'better-auth/adapters/prisma'; -// If your Prisma file is located elsewhere, you can change the path import { openAPI } from 'better-auth/plugins'; import { db } from './prisma'; @@ -19,7 +18,7 @@ export const auth = betterAuth({ enabled: true, }, trustedOrigins: [process.env.APP_BASE_URL ?? 'http://localhost:5173'], - plugins: [], + plugins: [openAPI()], }); export type Session = typeof auth.$Infer.Session.session; diff --git a/apps/backend/src/modules/user/services/user.service.ts b/apps/backend/src/modules/user/services/user.service.ts index c015a7e..1a9d59e 100644 --- a/apps/backend/src/modules/user/services/user.service.ts +++ b/apps/backend/src/modules/user/services/user.service.ts @@ -1,13 +1,19 @@ +import { createHash } from 'node:crypto'; import type { User } from '@/lib/auth'; import type { UserProfile } from '@repo/api-contract'; +function getGravatarUrl(email: string): string { + const hash = createHash('md5').update(email.trim().toLowerCase()).digest('hex'); + return `https://www.gravatar.com/avatar/${hash}?d=identicon`; +} + export function getUserProfile(user: User): UserProfile { return { id: user.id, fullName: user.name, email: user.email, role: (user as any).role || 'member', - avatarUrl: user.image || null, + avatarUrl: user.image || getGravatarUrl(user.email), createdAt: user.createdAt, }; } diff --git a/apps/frontend/src/lib/auth.tsx b/apps/frontend/src/lib/auth.tsx index 51a3b76..4679ab3 100644 --- a/apps/frontend/src/lib/auth.tsx +++ b/apps/frontend/src/lib/auth.tsx @@ -40,6 +40,80 @@ export type AuthContextValue = { const AuthContext = createContext(null); +/** + * Standard MD5 implementation for Gravatar support + * (Self-contained to avoid dependency issues) + */ +function md5(string: string) { + const k = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32)); + let a = 0x67452301; + let b = 0xefcdab89; + let c = 0x98badcfe; + let d = 0x10325476; + const s = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, + 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, + 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, + ]; + + const str = unescape(encodeURIComponent(string)); + const msg = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) msg[i] = str.charCodeAt(i); + + const len = msg.length; + const wordLen = ((len + 8) >> 6) + 1; + const words = new Uint32Array(wordLen * 16); + for (let i = 0; i < len; i++) words[i >> 2] |= msg[i] << ((i % 4) * 8); + words[len >> 2] |= 0x80 << ((len % 4) * 8); + words[wordLen * 16 - 2] = len * 8; + + for (let i = 0; i < words.length; i += 16) { + let [aa, bb, cc, dd] = [a, b, c, d]; + for (let j = 0; j < 64; j++) { + let f: number; + let g: number; + if (j < 16) { + f = (b & c) | (~b & d); + g = j; + } else if (j < 32) { + f = (d & b) | (~d & c); + g = (5 * j + 1) % 16; + } else if (j < 48) { + f = b ^ c ^ d; + g = (3 * j + 5) % 16; + } else { + f = c ^ (b | ~d); + g = (7 * j) % 16; + } + const temp = d; + d = c; + c = b; + b = + (b + + ((a + f + k[j] + words[i + g]) << s[j] | (a + f + k[j] + words[i + g]) >>> (32 - s[j]))) | + 0; + a = temp; + } + a = (a + aa) | 0; + b = (b + bb) | 0; + c = (c + cc) | 0; + d = (d + dd) | 0; + } + + return [a, b, c, d] + .map((v) => + Array.from({ length: 4 }, (_, i) => ((v >> (i * 8)) & 0xff).toString(16).padStart(2, '0')).join( + '' + ) + ) + .join(''); +} + +function getGravatarUrl(email: string): string { + const hash = md5(email.trim().toLowerCase()); + return `https://www.gravatar.com/avatar/${hash}?d=identicon`; +} + function getDisplayName(user: User | null): string { if (!user) return 'Usuario'; if (user.name?.trim()) return user.name.trim(); @@ -53,7 +127,8 @@ function getInitials(name: string): string { } function getAvatarUrl(user: User | null): string | null { - return user?.image || null; + if (!user) return null; + return user.image || getGravatarUrl(user.email); } export function AuthProvider({ children }: PropsWithChildren) {