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
+20
View File
@@ -66,6 +66,26 @@ Requires `DATABASE_URL` or `PRIVATE_DATABASE_URL` in the environment. The script
cp .env.example .env # then set DATABASE_URL for your DB
```
#### db:backfill-profile-email-verification
Repairs stale `profile.is_email_verified` rows where the auth user is already verified (`user.email_verified = true`) or has a linked OAuth/SSO account, but the profile flag was never synced. Use after the auth/profile verification mismatch fix, or when users are blocked by the verify-email modal while `send-verification-email` returns `EMAIL_IS_ALREADY_VERIFIED`.
Dry run (lists affected count and a sample; makes no changes):
```bash
pnpm db:backfill-profile-email-verification
# or from repo root:
pnpm --filter @cio/db db:backfill-profile-email-verification
```
Apply updates:
```bash
pnpm db:backfill-profile-email-verification -- --execute
```
The script prints how many profiles were updated and verifies zero mismatches remain.
## Seeding
The seed script populates the database with demo data for local development. It is idempotent — running it multiple times is safe; existing records are skipped.
+1
View File
@@ -67,6 +67,7 @@
"db:rewrite-media-host": "tsx src/scripts/rewrite-media-host.ts",
"db:login-link": "tsx src/scripts/generate-login-link.ts",
"db:sync-orphaned-profiles": "tsx src/scripts/sync-orphaned-profiles.ts",
"db:backfill-profile-email-verification": "tsx src/scripts/backfill-profile-email-verification.ts",
"move-users": "tsx src/scripts/supabase-to-betterauth.ts",
"pull": "drizzle-kit pull --config drizzle.config.ts",
"generate-types": "tsx scripts/generate-types.ts && pnpm format",
+3
View File
@@ -18,6 +18,7 @@ import { sso } from '@better-auth/sso';
import { syncUserWithProfile } from './auth/hooks/sync-user';
import { tokenExchange } from './auth/plugins/token-exchange';
import { trackLoginHook } from './auth/hooks/track-login';
import { syncProfileEmailVerificationFromAuthUser } from './queries/auth/profile';
export { mintLoginLinkToken } from './auth/login-link';
@@ -112,11 +113,13 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
create: {
after: async (session) => {
await trackLoginHook(session);
await syncProfileEmailVerificationFromAuthUser(session.userId);
}
},
update: {
after: async (session) => {
await trackLoginHook(session);
await syncProfileEmailVerificationFromAuthUser(session.userId);
}
}
}
+10 -13
View File
@@ -1,24 +1,13 @@
import * as schema from '@db/schema';
import { and, eq, ne } from 'drizzle-orm';
import { eq } from 'drizzle-orm';
import { User } from 'better-auth';
import { db } from '@db/drizzle';
import { hasVerifiedEmailProvider } from '@db/queries/auth/profile';
import { getOrganizationCount } from '@db/queries/organization';
import { ssoProvisioningHook } from './sso-provisioning';
/**
* True if the user has any non-credential account (OAuth/SSO). Those providers verify email.
*/
export async function hasVerifiedEmailProvider(userId: string): Promise<boolean> {
const accounts = await db
.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;
}
/**
* True when this is a first-time signup on a self-hosted instance (no orgs exist yet).
* The first signup creates the only org; we auto-verify their email.
@@ -59,6 +48,14 @@ export const createProfileHook = async (user: User, _request?: Request) => {
}
if (existingProfile.length) {
if (!existingProfile[0].isEmailVerified && isEmailVerified) {
await db.update(schema.user).set({ emailVerified: true }).where(eq(schema.user.id, user.id));
await db
.update(schema.profile)
.set({ isEmailVerified: true, verifiedAt: new Date().toISOString() })
.where(eq(schema.profile.id, user.id));
}
return;
}
+3 -14
View File
@@ -1,9 +1,5 @@
import * as schema from '@db/schema';
import { User } from 'better-auth';
import { db } from '@db/drizzle';
import { eq } from 'drizzle-orm';
import { hasVerifiedEmailProvider } from './create-profile';
import { syncProfileEmailVerificationFromAuthUser } from '@db/queries/auth/profile';
export const syncUserWithProfile = async (user: User) => {
console.log('[auth] syncUserWithProfile: running', { userId: user?.id });
@@ -13,15 +9,8 @@ export const syncUserWithProfile = async (user: User) => {
}
try {
const isEmailVerified = user.emailVerified || (await hasVerifiedEmailProvider(user.id));
await db
.update(schema.profile)
.set({
email: user.email,
isEmailVerified
})
.where(eq(schema.profile.id, user.id));
await syncProfileEmailVerificationFromAuthUser(user.id);
} catch (error) {
console.error('Error marking profile as verified:', error);
console.error('Error syncing profile email verification:', error);
}
};
+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);
@@ -0,0 +1,105 @@
/**
* Backfills profile.is_email_verified for users already verified in auth
* (user.email_verified or a linked OAuth/SSO account) but stale on profile.
*
* Usage:
* pnpm db:backfill-profile-email-verification # dry run (default)
* pnpm db:backfill-profile-email-verification -- --execute # apply updates
*/
import 'dotenv/config';
import postgres from 'postgres';
const connectionString = process.env.DATABASE_URL ?? process.env.PRIVATE_DATABASE_URL ?? '';
const shouldExecute = process.argv.includes('--execute');
if (!connectionString) {
console.error('DATABASE_URL or PRIVATE_DATABASE_URL environment variable is required');
process.exit(1);
}
const AFFECTED_PROFILES_SQL = `
SELECT
p.id,
u.email,
u.email_verified AS user_email_verified,
p.is_email_verified AS profile_is_email_verified
FROM profile p
JOIN "user" u ON u.id = p.id
WHERE COALESCE(p.is_email_verified, false) = false
AND (
u.email_verified = true
OR EXISTS (
SELECT 1
FROM account a
WHERE a.user_id = u.id
AND a.provider_id <> 'credential'
)
)
`;
async function main() {
const sql = postgres(connectionString);
try {
const affected = await sql.unsafe(AFFECTED_PROFILES_SQL);
console.log(`Found ${affected.length} profile(s) to backfill.`);
if (affected.length === 0) {
console.log('Nothing to do.');
return;
}
if (!shouldExecute) {
console.log('Dry run only. Re-run with --execute to apply updates.');
console.log('Sample (up to 10):');
for (const row of affected.slice(0, 10)) {
console.log(
` ${row.id} ${row.email} user=${row.user_email_verified} profile=${row.profile_is_email_verified}`
);
}
return;
}
const updated = await sql`
UPDATE profile p
SET
is_email_verified = true,
verified_at = COALESCE(p.verified_at, NOW()),
updated_at = NOW()
FROM "user" u
WHERE p.id = u.id
AND COALESCE(p.is_email_verified, false) = false
AND (
u.email_verified = true
OR EXISTS (
SELECT 1
FROM account a
WHERE a.user_id = u.id
AND a.provider_id <> 'credential'
)
)
RETURNING p.id, p.email
`;
const remaining = await sql.unsafe(`SELECT COUNT(*)::int AS count FROM (${AFFECTED_PROFILES_SQL}) AS affected`);
const remainingCount = remaining[0]?.count ?? 0;
console.log(`Updated ${updated.length} profile(s).`);
console.log(`Remaining mismatches: ${remainingCount}`);
if (remainingCount > 0) {
console.warn('Some rows were not updated — inspect manually before retrying.');
process.exit(1);
}
console.log('Backfill complete.');
} catch (error) {
console.error('backfill-profile-email-verification error:', error);
throw error;
} finally {
await sql.end();
}
}
main();