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 <rotimi-best@users.noreply.github.com> * chore(db): add backfill script for stale profile email verification Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * docs(db): document profile email verification backfill command Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * 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 <rotimi-best@users.noreply.github.com> * 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 <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
co-authored by
Rotimi Best
Cursor Agent
parent
2fb6b1c477
commit
c0f82d9573
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user