diff --git a/prisma/migrations/20260512024728_make_avatar_url_nullable/migration.sql b/prisma/migrations/20260512024728_make_avatar_url_nullable/migration.sql new file mode 100644 index 00000000..190610a0 --- /dev/null +++ b/prisma/migrations/20260512024728_make_avatar_url_nullable/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ALTER COLUMN "avatar_url" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 30c00bcd..4b526976 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,7 +16,7 @@ model User { name String @map("name") @db.VarChar(255) email String @unique @map("email") googleProviderId String @unique @map("google_provider_id") - avatarURL String @map("avatar_url") + avatarURL String? @map("avatar_url") // Relations. userProfile UserProfile? diff --git a/src/lib/components/Avatar/Avatar.svelte b/src/lib/components/Avatar/Avatar.svelte new file mode 100644 index 00000000..613a10fc --- /dev/null +++ b/src/lib/components/Avatar/Avatar.svelte @@ -0,0 +1,20 @@ + + +{#if src} + profile +{:else} + + {initial} + +{/if} diff --git a/src/lib/components/Avatar/Avatar.test.ts b/src/lib/components/Avatar/Avatar.test.ts new file mode 100644 index 00000000..8d2e1829 --- /dev/null +++ b/src/lib/components/Avatar/Avatar.test.ts @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/svelte'; +import { describe, expect, test } from 'vitest'; + +import { Avatar } from './index.js'; + +describe('Avatar', () => { + test('renders img with the given src when src is provided', () => { + const props = { src: 'data:image/png;base64,abc', name: 'Alice' }; + + render(Avatar, { props }); + + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', 'data:image/png;base64,abc'); + }); + + test('renders initials chip with first letter of name when src is null', () => { + const props = { src: null, name: 'Alice' }; + + render(Avatar, { props }); + + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); + }); + + test('uppercases the initial', () => { + const props = { src: null, name: 'alice' }; + + render(Avatar, { props }); + + expect(screen.getByText('A')).toBeInTheDocument(); + }); + + test('trims leading whitespace before taking the initial', () => { + const props = { src: null, name: ' alice' }; + + render(Avatar, { props }); + + expect(screen.getByText('A')).toBeInTheDocument(); + }); +}); diff --git a/src/lib/components/Avatar/index.ts b/src/lib/components/Avatar/index.ts new file mode 100644 index 00000000..a1cfa5ef --- /dev/null +++ b/src/lib/components/Avatar/index.ts @@ -0,0 +1 @@ +export { default as Avatar, type Props as AvatarProps } from './Avatar.svelte'; diff --git a/src/lib/server/auth/google.ts b/src/lib/server/auth/google.ts index afb9cee3..e0fda22c 100644 --- a/src/lib/server/auth/google.ts +++ b/src/lib/server/auth/google.ts @@ -18,9 +18,9 @@ export interface GoogleProfile { */ name: string; /** - * A URL to the picture of the Google profile. + * A URL to the picture of the Google profile, or null if Google did not provide a `picture` claim. */ - picture: string; + picture: string | null; } /** @@ -148,8 +148,8 @@ export async function verifyIdToken(idToken: string): Promise { throw new InvalidIdTokenError('Google ID token payload missing'); } const { sub, email, name, picture } = payload; - if (!sub || !email || !name || !picture) { - const missing = !sub ? 'sub' : !email ? 'email' : !name ? 'name' : 'picture'; + if (!sub || !email || !name) { + const missing = !sub ? 'sub' : !email ? 'email' : 'name'; throw new InvalidIdTokenError(`Google ID token missing claim: ${missing}`); } @@ -159,7 +159,7 @@ export async function verifyIdToken(idToken: string): Promise { ); } - return { id: sub, email, name, picture }; + return { id: sub, email, name, picture: picture ?? null }; } /** diff --git a/src/lib/server/cache/avatar.ts b/src/lib/server/cache/avatar.ts index 2f4a9062..af2a518f 100644 --- a/src/lib/server/cache/avatar.ts +++ b/src/lib/server/cache/avatar.ts @@ -18,7 +18,7 @@ const AVATAR_TTL = 24 * 60 * 60; * for direct use as the `src` of an `` element. * * @param userId - The ID of the user whose avatar should be retrieved. - * @returns The base64-encoded avatar, or `null` if the user is not found. + * @returns The base64-encoded avatar, or `null` if the user has no avatar URL or is not found. */ export async function getBase64EncodedAvatar(userId: string): Promise { let avatar = await valkey.get(`${AVATAR_NAMESPACE}:${userId}`); @@ -34,7 +34,7 @@ export async function getBase64EncodedAvatar(userId: string): Promise - profile + diff --git a/src/routes/(main)/(protected)/profile/+page.svelte b/src/routes/(main)/(protected)/profile/+page.svelte index 01f03c2b..d0608208 100644 --- a/src/routes/(main)/(protected)/profile/+page.svelte +++ b/src/routes/(main)/(protected)/profile/+page.svelte @@ -2,6 +2,7 @@ import { ArrowLeft, BookOpenCheck, Lightbulb } from '@lucide/svelte'; import { afterNavigate } from '$app/navigation'; + import { Avatar } from '$lib/components/Avatar/index.js'; import { HOME_PATH, IsWithinViewport } from '$lib/helpers/index.js'; const { data } = $props(); @@ -81,7 +82,7 @@
- profile +