Skip to content

Commit 4cdb948

Browse files
authored
fix: handle missing picture claim in google oauth login (#567)
1 parent 2cc322b commit 4cdb948

9 files changed

Lines changed: 75 additions & 10 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "users" ALTER COLUMN "avatar_url" DROP NOT NULL;

prisma/schema.prisma

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ model User {
1616
name String @map("name") @db.VarChar(255)
1717
email String @unique @map("email")
1818
googleProviderId String @unique @map("google_provider_id")
19-
avatarURL String @map("avatar_url")
19+
avatarURL String? @map("avatar_url")
2020
2121
// Relations.
2222
userProfile UserProfile?
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<script lang="ts">
2+
export interface Props {
3+
src: string | null;
4+
name: string;
5+
}
6+
7+
const { src, name }: Props = $props();
8+
const initial = $derived(name.trim().charAt(0).toUpperCase());
9+
</script>
10+
11+
{#if src}
12+
<img {src} alt="profile" class="h-full w-full object-cover" />
13+
{:else}
14+
<span
15+
aria-label="profile"
16+
class="flex h-full w-full items-center justify-center bg-slate-700 text-sm font-semibold text-white"
17+
>
18+
{initial}
19+
</span>
20+
{/if}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { render, screen } from '@testing-library/svelte';
2+
import { describe, expect, test } from 'vitest';
3+
4+
import { Avatar } from './index.js';
5+
6+
describe('Avatar', () => {
7+
test('renders img with the given src when src is provided', () => {
8+
const props = { src: 'data:image/png;base64,abc', name: 'Alice' };
9+
10+
render(Avatar, { props });
11+
12+
const img = screen.getByRole('img');
13+
expect(img).toHaveAttribute('src', 'data:image/png;base64,abc');
14+
});
15+
16+
test('renders initials chip with first letter of name when src is null', () => {
17+
const props = { src: null, name: 'Alice' };
18+
19+
render(Avatar, { props });
20+
21+
expect(screen.queryByRole('img')).not.toBeInTheDocument();
22+
expect(screen.getByText('A')).toBeInTheDocument();
23+
});
24+
25+
test('uppercases the initial', () => {
26+
const props = { src: null, name: 'alice' };
27+
28+
render(Avatar, { props });
29+
30+
expect(screen.getByText('A')).toBeInTheDocument();
31+
});
32+
33+
test('trims leading whitespace before taking the initial', () => {
34+
const props = { src: null, name: ' alice' };
35+
36+
render(Avatar, { props });
37+
38+
expect(screen.getByText('A')).toBeInTheDocument();
39+
});
40+
});

src/lib/components/Avatar/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as Avatar, type Props as AvatarProps } from './Avatar.svelte';

src/lib/server/auth/google.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ export interface GoogleProfile {
1818
*/
1919
name: string;
2020
/**
21-
* A URL to the picture of the Google profile.
21+
* A URL to the picture of the Google profile, or null if Google did not provide a `picture` claim.
2222
*/
23-
picture: string;
23+
picture: string | null;
2424
}
2525

2626
/**
@@ -148,8 +148,8 @@ export async function verifyIdToken(idToken: string): Promise<GoogleProfile> {
148148
throw new InvalidIdTokenError('Google ID token payload missing');
149149
}
150150
const { sub, email, name, picture } = payload;
151-
if (!sub || !email || !name || !picture) {
152-
const missing = !sub ? 'sub' : !email ? 'email' : !name ? 'name' : 'picture';
151+
if (!sub || !email || !name) {
152+
const missing = !sub ? 'sub' : !email ? 'email' : 'name';
153153
throw new InvalidIdTokenError(`Google ID token missing claim: ${missing}`);
154154
}
155155

@@ -159,7 +159,7 @@ export async function verifyIdToken(idToken: string): Promise<GoogleProfile> {
159159
);
160160
}
161161

162-
return { id: sub, email, name, picture };
162+
return { id: sub, email, name, picture: picture ?? null };
163163
}
164164

165165
/**

src/lib/server/cache/avatar.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const AVATAR_TTL = 24 * 60 * 60;
1818
* for direct use as the `src` of an `<img>` element.
1919
*
2020
* @param userId - The ID of the user whose avatar should be retrieved.
21-
* @returns The base64-encoded avatar, or `null` if the user is not found.
21+
* @returns The base64-encoded avatar, or `null` if the user has no avatar URL or is not found.
2222
*/
2323
export async function getBase64EncodedAvatar(userId: string): Promise<string | null> {
2424
let avatar = await valkey.get(`${AVATAR_NAMESPACE}:${userId}`);
@@ -34,7 +34,7 @@ export async function getBase64EncodedAvatar(userId: string): Promise<string | n
3434
id: userId,
3535
},
3636
});
37-
if (!user) {
37+
if (!user || user.avatarURL === null) {
3838
return null;
3939
}
4040

src/routes/(main)/(protected)/(core)/+layout.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import type { MouseEventHandler } from 'svelte/elements';
44
55
import { page } from '$app/state';
6+
import { Avatar } from '$lib/components/Avatar/index.js';
67
import { trackProfileClick } from '$lib/helpers/analytics.js';
78
import { HOME_PATH, IsWithinViewport } from '$lib/helpers/index.js';
89
@@ -42,7 +43,7 @@
4243
class="h-10 w-10 cursor-pointer overflow-hidden rounded-full"
4344
onclick={handleProfileClick}
4445
>
45-
<img src={data.avatar} alt="profile" />
46+
<Avatar src={data.avatar} name={data.username} />
4647
</a>
4748
</div>
4849

src/routes/(main)/(protected)/profile/+page.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { ArrowLeft, BookOpenCheck, Lightbulb } from '@lucide/svelte';
33
44
import { afterNavigate } from '$app/navigation';
5+
import { Avatar } from '$lib/components/Avatar/index.js';
56
import { HOME_PATH, IsWithinViewport } from '$lib/helpers/index.js';
67
78
const { data } = $props();
@@ -81,7 +82,7 @@
8182
<main class="relative mx-auto flex min-h-svh max-w-5xl flex-col gap-y-4 px-4 py-3 pt-23">
8283
<div class="flex items-center gap-x-6 rounded-3xl bg-white p-4">
8384
<div class="h-10 w-10 overflow-hidden rounded-full">
84-
<img src={data.avatar} alt="profile" />
85+
<Avatar src={data.avatar} name={data.name} />
8586
</div>
8687

8788
<div class="flex flex-col">

0 commit comments

Comments
 (0)