Merge pull request #1 from ianalloway/devin/1771109978-fix-github-username-sync

fix: sync GitHub profile on login to handle username renames (#303)
This commit is contained in:
Ian Alloway
2026-02-14 18:34:49 -05:00
committed by GitHub
3 changed files with 105 additions and 3 deletions
+11 -1
View File
@@ -2,6 +2,7 @@ import GitHub from '@auth/core/providers/github'
import { convexAuth } from '@convex-dev/auth/server'
import type { GenericMutationCtx } from 'convex/server'
import { ConvexError } from 'convex/values'
import { internal } from './_generated/api'
import type { DataModel, Id } from './_generated/dataModel'
export const BANNED_REAUTH_MESSAGE =
@@ -69,14 +70,23 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
],
callbacks: {
/**
* Block sign-in for deleted/deactivated users.
* Block sign-in for deleted/deactivated users and sync GitHub profile.
*
* Performance note: This callback runs on every OAuth sign-in, but the
* audit log query ONLY executes when a legacy deleted user attempts to sign
* in (user.deletedAt is set). For active users, this is a single field check.
*
* The GitHub profile sync is scheduled as a background action to handle
* the case where a user renames their GitHub account (fixes #303).
*/
async afterUserCreatedOrUpdated(ctx, args) {
await handleDeletedUserSignIn(ctx, args)
// Schedule GitHub profile sync to handle username renames (fixes #303)
// This runs as a background action so it doesn't block sign-in
await ctx.scheduler.runAfter(0, internal.users.syncGitHubProfileAction, {
userId: args.userId,
})
},
},
})
+42
View File
@@ -7,6 +7,8 @@ const GITHUB_API = 'https://api.github.com'
const MIN_ACCOUNT_AGE_MS = 7 * 24 * 60 * 60 * 1000
type GitHubUser = {
login?: string
avatar_url?: string
created_at?: string
}
@@ -78,3 +80,43 @@ export async function requireGitHubAccountAge(ctx: ActionCtx, userId: Id<'users'
)
}
}
/**
* Sync the user's GitHub profile (username, avatar) from the GitHub API.
* This handles the case where a user renames their GitHub account.
* Uses the immutable GitHub numeric ID to fetch the current profile.
*/
export async function syncGitHubProfile(ctx: ActionCtx, userId: Id<'users'>) {
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId })
if (!user || user.deletedAt || user.deactivatedAt) return
const providerAccountId = await ctx.runQuery(
internal.githubIdentity.getGitHubProviderAccountIdInternal,
{ userId },
)
if (!providerAccountId) return
assertGitHubNumericId(providerAccountId)
const response = await fetch(`${GITHUB_API}/user/${providerAccountId}`, {
headers: buildGitHubHeaders(),
})
if (!response.ok) {
// Silently fail - this is a best-effort sync, not critical path
console.warn(`[syncGitHubProfile] GitHub API error for user ${userId}: ${response.status}`)
return
}
const payload = (await response.json()) as GitHubUser
const newLogin = payload.login?.trim()
const newImage = payload.avatar_url?.trim()
// Only update if the username has changed
if (newLogin && newLogin !== user.name) {
await ctx.runMutation(internal.users.syncGitHubProfileInternal, {
userId,
name: newLogin,
image: newImage,
})
}
}
+52 -2
View File
@@ -2,9 +2,10 @@ import { getAuthUserId } from '@convex-dev/auth/server'
import { v } from 'convex/values'
import { internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import type { MutationCtx } from './_generated/server'
import { internalMutation, internalQuery, mutation, query } from './_generated/server'
import type { ActionCtx, MutationCtx } from './_generated/server'
import { internalAction, internalMutation, internalQuery, mutation, query } from './_generated/server'
import { assertAdmin, assertModerator, requireUser } from './lib/access'
import { syncGitHubProfile } from './lib/githubAccount'
import { toPublicUser } from './lib/public'
import { buildUserSearchResults } from './lib/userSearch'
@@ -59,6 +60,55 @@ export const setGitHubCreatedAtInternal = internalMutation({
},
})
/**
* Sync the user's GitHub profile (username, avatar) when it changes.
* This handles the case where a user renames their GitHub account.
*/
export const syncGitHubProfileInternal = internalMutation({
args: {
userId: v.id('users'),
name: v.string(),
image: v.optional(v.string()),
},
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId)
if (!user || user.deletedAt || user.deactivatedAt) return
const updates: Record<string, unknown> = {
name: args.name,
updatedAt: Date.now(),
}
// Update handle if it was derived from the old username
if (user.handle === user.name) {
updates.handle = args.name
}
// Update displayName if it was derived from the old username
if (user.displayName === user.name || user.displayName === user.handle) {
updates.displayName = args.name
}
// Update avatar if provided
if (args.image) {
updates.image = args.image
}
await ctx.db.patch(args.userId, updates)
},
})
/**
* Internal action to sync GitHub profile from the GitHub API.
* This is called after login to ensure the username is up-to-date.
*/
export const syncGitHubProfileAction = internalAction({
args: { userId: v.id('users') },
handler: async (ctx: ActionCtx, args) => {
await syncGitHubProfile(ctx, args.userId)
},
})
export const me = query({
args: {},
handler: async (ctx) => {