Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions src/lib/auth/oauth-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,113 @@ describe('extractOAuthDisplayName', () => {
});
expect(extractOAuthDisplayName(user)).toBe('José García 🚀');
});

// Issue #29: GitHub puts the user's @handle in user_name, not name. Without
// this tier in the cascade, GitHub users with no display name set would
// fall through to email prefix even though the @handle is a meaningful
// identifier provided by the OAuth flow.
describe('issue #29 — extended cascade for GitHub / OIDC providers', () => {
it('returns user_name when full_name and name are absent (GitHub @handle)', () => {
const user = createMockUser({
user_metadata: { user_name: 'octocat' },
});
expect(extractOAuthDisplayName(user)).toBe('octocat');
});

it('prefers name over user_name (GitHub user with display name set)', () => {
const user = createMockUser({
user_metadata: { name: 'The Octocat', user_name: 'octocat' },
});
expect(extractOAuthDisplayName(user)).toBe('The Octocat');
});

it('returns preferred_username when nothing higher is set (OIDC)', () => {
const user = createMockUser({
user_metadata: { preferred_username: 'jsmith' },
});
expect(extractOAuthDisplayName(user)).toBe('jsmith');
});

it('prefers user_name over preferred_username when both present', () => {
const user = createMockUser({
user_metadata: {
user_name: 'octocat',
preferred_username: 'fallback',
},
});
expect(extractOAuthDisplayName(user)).toBe('octocat');
});

it('skips whitespace-only metadata fields and falls through', () => {
const user = createMockUser({
email: 'jsmith@example.com',
user_metadata: {
full_name: ' ',
name: '',
user_name: ' ',
},
});
expect(extractOAuthDisplayName(user)).toBe('jsmith');
});

it('trims surrounding whitespace from a populated tier', () => {
const user = createMockUser({
user_metadata: { full_name: ' Jon Pohlner ' },
});
expect(extractOAuthDisplayName(user)).toBe('Jon Pohlner');
});

// Realistic Google fixture: full_name + name + avatar_url, no user_name
it('handles Google OAuth metadata shape', () => {
const user = createMockUser({
email: 'jpohlner@gmail.com',
user_metadata: {
full_name: 'Jon Pohlner',
name: 'Jon Pohlner',
avatar_url: 'https://lh3.googleusercontent.com/a/abc',
email_verified: true,
},
});
expect(extractOAuthDisplayName(user)).toBe('Jon Pohlner');
});

// Realistic GitHub fixture: name set to display name, user_name to handle
it('handles GitHub OAuth metadata shape with display name', () => {
const user = createMockUser({
email: 'octocat@users.noreply.github.com',
user_metadata: {
name: 'The Octocat',
user_name: 'octocat',
avatar_url: 'https://avatars.githubusercontent.com/u/583231',
},
});
expect(extractOAuthDisplayName(user)).toBe('The Octocat');
});

// Realistic GitHub fixture: handle only (user has no display name set on GitHub)
it('handles GitHub OAuth metadata shape with handle only', () => {
const user = createMockUser({
email: 'octocat@users.noreply.github.com',
user_metadata: {
user_name: 'octocat',
avatar_url: 'https://avatars.githubusercontent.com/u/583231',
},
});
expect(extractOAuthDisplayName(user)).toBe('octocat');
});

it('ignores non-string metadata values without throwing', () => {
const user = createMockUser({
email: 'jsmith@example.com',
user_metadata: {
full_name: 42 as unknown as string,
name: null as unknown as string,
user_name: undefined as unknown as string,
},
});
expect(extractOAuthDisplayName(user)).toBe('jsmith');
});
});
});

describe('extractOAuthAvatarUrl', () => {
Expand Down
33 changes: 25 additions & 8 deletions src/lib/auth/oauth-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,38 @@ import { createClient } from '@/lib/supabase/client';
import { createLogger } from '@/lib/logger';

/**
* Extract display name from OAuth user metadata using fallback cascade
* Priority: full_name > name > email prefix > "Anonymous User"
* Extract display name from OAuth user metadata using fallback cascade.
* Priority: full_name > name > user_name > preferred_username > email prefix > "Anonymous User"
*
* Provider-specific notes:
* - Google sets `full_name` and `name`
* - GitHub sets `name` (the user's display name) and `user_name` (the GitHub
* handle). The handle is preferred over email prefix because users with
* no display name set on GitHub still have a meaningful identifier.
* - Other providers may use `preferred_username` (OIDC standard claim).
*
* Trims whitespace at each tier so a metadata field of " " falls through
* to the next tier instead of producing a whitespace-only display name.
*
* @param user - Supabase User object
* @returns Display name string, never null
*/
export function extractOAuthDisplayName(user: User | null): string {
if (!user) return 'Anonymous User';

// Fallback cascade per FR-005
const fullName = user.user_metadata?.full_name;
if (fullName) return fullName;

const name = user.user_metadata?.name;
if (name) return name;
const meta = user.user_metadata ?? {};
const tiers = [
meta.full_name,
meta.name,
meta.user_name,
meta.preferred_username,
];
for (const tier of tiers) {
if (typeof tier === 'string') {
const trimmed = tier.trim();
if (trimmed.length > 0) return trimmed;
}
}

// Email prefix fallback
const email = user.email;
Expand Down
32 changes: 26 additions & 6 deletions supabase/migrations/20251006_complete_monolithic_setup.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2290,16 +2290,36 @@ END
$seed_admin_profile$;

-- ============================================================================
-- Feature 004: Populate OAuth user profiles (one-time migration)
-- Only updates NULL display_name for OAuth users
-- Idempotent: Safe to run multiple times (FR-006)
-- Feature 004 / Issue #29: Populate OAuth user profiles (one-time backfill)
-- Only updates NULL display_name for OAuth users.
-- Idempotent: Safe to run multiple times (FR-006).
--
-- Cascade mirrors src/lib/auth/oauth-utils.ts extractOAuthDisplayName():
-- full_name > name > user_name > preferred_username > email prefix > 'Anonymous User'
--
-- Provider notes:
-- - Google sets full_name AND name
-- - GitHub sets name (display name) AND user_name (the @handle) — without
-- user_name in the cascade, GitHub users with no GitHub display name
-- would fall through to email prefix even though the @handle is a
-- better identifier
-- - Other OIDC providers may set preferred_username
--
-- Note on runtime behavior: create_user_profile() (the on_auth_user_created
-- trigger) does NOT set display_name — it inserts only (id, created_at,
-- updated_at). So at signup display_name is NULL, and the runtime
-- populateOAuthProfile() in src/lib/auth/oauth-utils.ts is the sole
-- authoritative populator going forward. This UPDATE handles only the
-- one-time bootstrap for users who existed before that runtime path landed.
-- ============================================================================
UPDATE public.user_profiles p
SET
display_name = COALESCE(
u.raw_user_meta_data->>'full_name',
u.raw_user_meta_data->>'name',
split_part(u.email, '@', 1),
NULLIF(TRIM(u.raw_user_meta_data->>'full_name'), ''),
NULLIF(TRIM(u.raw_user_meta_data->>'name'), ''),
NULLIF(TRIM(u.raw_user_meta_data->>'user_name'), ''),
NULLIF(TRIM(u.raw_user_meta_data->>'preferred_username'), ''),
NULLIF(split_part(u.email, '@', 1), ''),
'Anonymous User'
),
avatar_url = COALESCE(p.avatar_url, u.raw_user_meta_data->>'avatar_url')
Expand Down
Loading