fix(auth): sync profile email verification with auth user (#726)

* fix(auth): sync profile email verification with auth user

Repair stale profile rows where user.email_verified is true but
profile.is_email_verified is false, which blocked the LMS with the
verify-email modal while send-verification-email returned
EMAIL_IS_ALREADY_VERIFIED.

Sync on session login, account fetch, and user update; update the
modal to respect session emailVerified and self-heal on that error.

Co-authored-by: Rotimi Best <[email protected]>

* chore(db): add backfill script for stale profile email verification

Co-authored-by: Rotimi Best <[email protected]>

* docs(db): document profile email verification backfill command

Co-authored-by: Rotimi Best <[email protected]>

* fix(auth): never demote profile email verification on sync

Greptile: self-hosted first signup can verify profile before
user.email_verified is set. Sync now only promotes verification.

Co-authored-by: Rotimi Best <[email protected]>

* fix(auth): promote user.email_verified when profile is verified

Self-hosted bootstrap and other flows can set profile.is_email_verified
before user.email_verified. Sync and createProfileHook now update both
tables when promoting verification.

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Rotimi Best
2026-07-11 06:38:28 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent 2fb6b1c477
commit c0f82d9573
10 changed files with 289 additions and 34 deletions
+86 -1
View File
@@ -2,7 +2,92 @@ import * as schema from '@db/schema';
import type { TProfile } from '@db/types';
import { db, type DbOrTxClient } from '@db/drizzle';
import { eq, inArray } from 'drizzle-orm';
import { and, eq, inArray, ne } from 'drizzle-orm';
/**
* True when the auth user has any non-credential account (OAuth/SSO). Those providers verify email.
*/
export async function hasVerifiedEmailProvider(userId: string, dbClient: DbOrTxClient = db): Promise<boolean> {
const accounts = await dbClient
.select({ providerId: schema.account.providerId })
.from(schema.account)
.where(and(eq(schema.account.userId, userId), ne(schema.account.providerId, 'credential')))
.limit(1);
return accounts.length > 0;
}
async function resolveAuthUserEmailVerified(
userId: string,
emailVerified: boolean,
dbClient: DbOrTxClient = db
): Promise<boolean> {
if (emailVerified) {
return true;
}
return hasVerifiedEmailProvider(userId, dbClient);
}
/**
* Keeps profile.is_email_verified in sync with auth user + linked OAuth/SSO accounts.
* Repairs legacy rows where user.email_verified is true but profile.is_email_verified is false,
* and promotes user.email_verified when profile is already verified (e.g. self-hosted bootstrap).
* Never demotes profile.is_email_verified.
*/
export async function syncProfileEmailVerificationFromAuthUser(userId: string, dbClient: DbOrTxClient = db) {
try {
const [user] = await dbClient.select().from(schema.user).where(eq(schema.user.id, userId)).limit(1);
if (!user) {
return null;
}
const [profile] = await dbClient.select().from(schema.profile).where(eq(schema.profile.id, userId)).limit(1);
if (!profile) {
return null;
}
const authSaysVerified = await resolveAuthUserEmailVerified(userId, user.emailVerified, dbClient);
// Only promote verification — never demote. Profile can be verified via
// self-hosted first signup, invites, or other flows before user.email_verified catches up.
const isEmailVerified = profile.isEmailVerified || authSaysVerified;
const emailChanged = profile.email !== user.email;
const profileNeedsVerification = isEmailVerified && !profile.isEmailVerified;
const userNeedsVerification = isEmailVerified && !user.emailVerified;
if (!emailChanged && !profileNeedsVerification && !userNeedsVerification) {
return profile;
}
if (userNeedsVerification) {
await dbClient.update(schema.user).set({ emailVerified: true }).where(eq(schema.user.id, userId));
}
const patch: Partial<TProfile> = {
email: user.email,
isEmailVerified
};
if (profileNeedsVerification) {
patch.verifiedAt = new Date().toISOString();
}
if (!emailChanged && !profileNeedsVerification) {
return { ...profile, isEmailVerified: true };
}
const [updatedProfile] = await dbClient
.update(schema.profile)
.set(patch)
.where(eq(schema.profile.id, userId))
.returning();
return updatedProfile ?? { ...profile, isEmailVerified: true };
} catch (error) {
console.error('syncProfileEmailVerificationFromAuthUser error:', error);
throw new Error('Failed to sync profile email verification');
}
}
export const getProfileById = async (id: string) => {
const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.id, id)).limit(1);