diff --git a/apps/api/src/services/account/profile.ts b/apps/api/src/services/account/profile.ts index 0130a8c8..216b8983 100644 --- a/apps/api/src/services/account/profile.ts +++ b/apps/api/src/services/account/profile.ts @@ -5,7 +5,12 @@ import { getOrganizationByProfileId, hasOrgMemberByProfileIdOrEmail } from '@cio/db/queries/organization'; -import { getProfileByEmail, getProfileById, updateProfile } from '@cio/db/queries/auth'; +import { + getProfileByEmail, + getProfileById, + syncProfileEmailVerificationFromAuthUser, + updateProfile +} from '@cio/db/queries/auth'; import type { OrganizationWithMemberAndPlans } from '@cio/db/queries/organization/types'; import { ROLE } from '@cio/utils/constants'; @@ -38,6 +43,8 @@ export async function getAccountData(userId: string): Promise | undefined; + async function syncProfileFromAccount() { + const response = await classroomio.account.$get(); + if (!response.ok) { + return; + } + + const data = await response.json(); + if (data.success && data.profile) { + profile.set(data.profile); + } + } + + function isAlreadyVerifiedError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + + const maybeError = error as { code?: string; message?: string }; + return maybeError.code === 'EMAIL_IS_ALREADY_VERIFIED' || maybeError.message === 'Email is already verified'; + } + const sendVerificationCode = async () => { if (!$profile.email) { snackbar.error('verify_email_modal.snackbar.error'); @@ -45,18 +70,32 @@ callbackURL.searchParams.set('welcomePopup', 'true'); } - await authClient.sendVerificationEmail({ + const result = await authClient.sendVerificationEmail({ email: $profile.email, callbackURL: callbackURL.toString() }); + if (result?.error && isAlreadyVerifiedError(result.error)) { + await syncProfileFromAccount(); + return; + } + isSent = true; } catch (error) { + if (isAlreadyVerifiedError(error)) { + await syncProfileFromAccount(); + return; + } + snackbar.error('verify_email_modal.snackbar.error'); } finally { loading = false; } + if (!isSent) { + return; + } + interval = setInterval(() => { countDown = countDown - 1; }, 1000); @@ -70,7 +109,7 @@ onMount(() => { if (browser && $profile.id) { - domProtectionCleanup = setupDOMProtection(() => $profile.isEmailVerified ?? false); + domProtectionCleanup = setupDOMProtection(() => isEmailVerified); } }); @@ -82,9 +121,9 @@ }); $effect(() => { - if (browser) updateBodyClass($profile.isEmailVerified ?? false); + if (browser) updateBodyClass(isEmailVerified); - if (browser && !$profile.isEmailVerified && $profile.id) { + if (browser && !isEmailVerified && $profile.id) { console.warn( '%c🔒 SECURITY WARNING: Email verification required', 'color: red; font-weight: bold; font-size: 16px;', @@ -94,6 +133,14 @@ } }); + $effect(() => { + if (!browser || !$profile.id || $profile.isEmailVerified || !sessionUser?.emailVerified) { + return; + } + + void syncProfileFromAccount(); + }); + $effect(() => { if (!open || isSent || loading || autoTriggered) return; diff --git a/package.json b/package.json index 805c5470..41c185ec 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "release": "standard-version", "db:reset": "pnpm --filter @cio/db db:reset", "db:studio": "pnpm --filter @cio/db db:studio", + "db:backfill-profile-email-verification": "pnpm --filter @cio/db db:backfill-profile-email-verification", "dashboard:dev": "pnpm concurrently -n dashboard,ui -c blue,yellow \"pnpm --filter=@cio/dashboard run dev\" \"pnpm --filter=@cio/ui run dev\"", "api:dev": "pnpm concurrently -n api,jobs -c blue,yellow \"pnpm --filter=@cio/api run dev\" \"pnpm --filter=@cio/jobs-worker run dev\"", "jobs:dev": "pnpm --filter=@cio/jobs-worker run dev", diff --git a/packages/db/README.md b/packages/db/README.md index 52ea6061..5a3a5a8f 100644 --- a/packages/db/README.md +++ b/packages/db/README.md @@ -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. diff --git a/packages/db/package.json b/packages/db/package.json index d92283cd..8e252823 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -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", diff --git a/packages/db/src/auth.ts b/packages/db/src/auth.ts index c9c14a4f..b9c78385 100644 --- a/packages/db/src/auth.ts +++ b/packages/db/src/auth.ts @@ -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 = betterAuth({ create: { after: async (session) => { await trackLoginHook(session); + await syncProfileEmailVerificationFromAuthUser(session.userId); } }, update: { after: async (session) => { await trackLoginHook(session); + await syncProfileEmailVerificationFromAuthUser(session.userId); } } } diff --git a/packages/db/src/auth/hooks/create-profile.ts b/packages/db/src/auth/hooks/create-profile.ts index 675eee19..b0f85db6 100644 --- a/packages/db/src/auth/hooks/create-profile.ts +++ b/packages/db/src/auth/hooks/create-profile.ts @@ -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 { - 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; } diff --git a/packages/db/src/auth/hooks/sync-user.ts b/packages/db/src/auth/hooks/sync-user.ts index d9f9ba59..aad56997 100644 --- a/packages/db/src/auth/hooks/sync-user.ts +++ b/packages/db/src/auth/hooks/sync-user.ts @@ -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); } }; diff --git a/packages/db/src/queries/auth/profile.ts b/packages/db/src/queries/auth/profile.ts index 471b980e..d7ec2665 100644 --- a/packages/db/src/queries/auth/profile.ts +++ b/packages/db/src/queries/auth/profile.ts @@ -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 { + 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 { + 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 = { + 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); diff --git a/packages/db/src/scripts/backfill-profile-email-verification.ts b/packages/db/src/scripts/backfill-profile-email-verification.ts new file mode 100644 index 00000000..c5d603a8 --- /dev/null +++ b/packages/db/src/scripts/backfill-profile-email-verification.ts @@ -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();