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}
+
+{: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
-
+
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 @@
-

+