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
@@ -5,7 +5,12 @@ import {
|
|||||||
getOrganizationByProfileId,
|
getOrganizationByProfileId,
|
||||||
hasOrgMemberByProfileIdOrEmail
|
hasOrgMemberByProfileIdOrEmail
|
||||||
} from '@cio/db/queries/organization';
|
} 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 type { OrganizationWithMemberAndPlans } from '@cio/db/queries/organization/types';
|
||||||
import { ROLE } from '@cio/utils/constants';
|
import { ROLE } from '@cio/utils/constants';
|
||||||
@@ -38,6 +43,8 @@ export async function getAccountData(userId: string): Promise<GetAccountDataResu
|
|||||||
throw new AppError(`Account not found for user ID: ${userId}`, ErrorCodes.ACCOUNT_NOT_FOUND, 404);
|
throw new AppError(`Account not found for user ID: ${userId}`, ErrorCodes.ACCOUNT_NOT_FOUND, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
profile = (await syncProfileEmailVerificationFromAuthUser(userId)) ?? profile;
|
||||||
|
|
||||||
// Self-hosted: auto-add user as student to the single org if they are not a member.
|
// Self-hosted: auto-add user as student to the single org if they are not a member.
|
||||||
// Skip if they already have membership (by profileId) or a pending invite (by email).
|
// Skip if they already have membership (by profileId) or a pending invite (by email).
|
||||||
if (env.PUBLIC_IS_SELFHOSTED === 'true' && organizations.length === 0) {
|
if (env.PUBLIC_IS_SELFHOSTED === 'true' && organizations.length === 0) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { currentOrg } from '$lib/utils/store/org';
|
import { currentOrg } from '$lib/utils/store/org';
|
||||||
import { profile } from '$lib/utils/store/user';
|
import { profile } from '$lib/utils/store/user';
|
||||||
import { authClient } from '$lib/utils/services/auth/client';
|
import { authClient } from '$lib/utils/services/auth/client';
|
||||||
|
import { classroomio } from '$lib/utils/services/api';
|
||||||
import { globalStore } from '$lib/utils/store/app';
|
import { globalStore } from '$lib/utils/store/app';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
@@ -22,10 +23,34 @@
|
|||||||
let interval;
|
let interval;
|
||||||
let countDown = $state(WAIT_SEC);
|
let countDown = $state(WAIT_SEC);
|
||||||
|
|
||||||
const open = $derived(Boolean(!$profile.isEmailVerified && !!$profile.id && !!$currentOrg.id));
|
const session = authClient.useSession();
|
||||||
|
const sessionUser = $derived($session.data?.user ?? null);
|
||||||
|
const isEmailVerified = $derived(Boolean($profile.isEmailVerified || sessionUser?.emailVerified));
|
||||||
|
const open = $derived(Boolean(!isEmailVerified && !!$profile.id && !!$currentOrg.id));
|
||||||
|
|
||||||
let domProtectionCleanup: ReturnType<typeof setupDOMProtection> | undefined;
|
let domProtectionCleanup: ReturnType<typeof setupDOMProtection> | 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 () => {
|
const sendVerificationCode = async () => {
|
||||||
if (!$profile.email) {
|
if (!$profile.email) {
|
||||||
snackbar.error('verify_email_modal.snackbar.error');
|
snackbar.error('verify_email_modal.snackbar.error');
|
||||||
@@ -45,18 +70,32 @@
|
|||||||
callbackURL.searchParams.set('welcomePopup', 'true');
|
callbackURL.searchParams.set('welcomePopup', 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
await authClient.sendVerificationEmail({
|
const result = await authClient.sendVerificationEmail({
|
||||||
email: $profile.email,
|
email: $profile.email,
|
||||||
callbackURL: callbackURL.toString()
|
callbackURL: callbackURL.toString()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result?.error && isAlreadyVerifiedError(result.error)) {
|
||||||
|
await syncProfileFromAccount();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
isSent = true;
|
isSent = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isAlreadyVerifiedError(error)) {
|
||||||
|
await syncProfileFromAccount();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
snackbar.error('verify_email_modal.snackbar.error');
|
snackbar.error('verify_email_modal.snackbar.error');
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isSent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
interval = setInterval(() => {
|
interval = setInterval(() => {
|
||||||
countDown = countDown - 1;
|
countDown = countDown - 1;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -70,7 +109,7 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (browser && $profile.id) {
|
if (browser && $profile.id) {
|
||||||
domProtectionCleanup = setupDOMProtection(() => $profile.isEmailVerified ?? false);
|
domProtectionCleanup = setupDOMProtection(() => isEmailVerified);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,9 +121,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (browser) updateBodyClass($profile.isEmailVerified ?? false);
|
if (browser) updateBodyClass(isEmailVerified);
|
||||||
|
|
||||||
if (browser && !$profile.isEmailVerified && $profile.id) {
|
if (browser && !isEmailVerified && $profile.id) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'%c🔒 SECURITY WARNING: Email verification required',
|
'%c🔒 SECURITY WARNING: Email verification required',
|
||||||
'color: red; font-weight: bold; font-size: 16px;',
|
'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(() => {
|
$effect(() => {
|
||||||
if (!open || isSent || loading || autoTriggered) return;
|
if (!open || isSent || loading || autoTriggered) return;
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"release": "standard-version",
|
"release": "standard-version",
|
||||||
"db:reset": "pnpm --filter @cio/db db:reset",
|
"db:reset": "pnpm --filter @cio/db db:reset",
|
||||||
"db:studio": "pnpm --filter @cio/db db:studio",
|
"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\"",
|
"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\"",
|
"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",
|
"jobs:dev": "pnpm --filter=@cio/jobs-worker run dev",
|
||||||
|
|||||||
@@ -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
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
"db:rewrite-media-host": "tsx src/scripts/rewrite-media-host.ts",
|
"db:rewrite-media-host": "tsx src/scripts/rewrite-media-host.ts",
|
||||||
"db:login-link": "tsx src/scripts/generate-login-link.ts",
|
"db:login-link": "tsx src/scripts/generate-login-link.ts",
|
||||||
"db:sync-orphaned-profiles": "tsx src/scripts/sync-orphaned-profiles.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",
|
"move-users": "tsx src/scripts/supabase-to-betterauth.ts",
|
||||||
"pull": "drizzle-kit pull --config drizzle.config.ts",
|
"pull": "drizzle-kit pull --config drizzle.config.ts",
|
||||||
"generate-types": "tsx scripts/generate-types.ts && pnpm format",
|
"generate-types": "tsx scripts/generate-types.ts && pnpm format",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { sso } from '@better-auth/sso';
|
|||||||
import { syncUserWithProfile } from './auth/hooks/sync-user';
|
import { syncUserWithProfile } from './auth/hooks/sync-user';
|
||||||
import { tokenExchange } from './auth/plugins/token-exchange';
|
import { tokenExchange } from './auth/plugins/token-exchange';
|
||||||
import { trackLoginHook } from './auth/hooks/track-login';
|
import { trackLoginHook } from './auth/hooks/track-login';
|
||||||
|
import { syncProfileEmailVerificationFromAuthUser } from './queries/auth/profile';
|
||||||
|
|
||||||
export { mintLoginLinkToken } from './auth/login-link';
|
export { mintLoginLinkToken } from './auth/login-link';
|
||||||
|
|
||||||
@@ -112,11 +113,13 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
|
|||||||
create: {
|
create: {
|
||||||
after: async (session) => {
|
after: async (session) => {
|
||||||
await trackLoginHook(session);
|
await trackLoginHook(session);
|
||||||
|
await syncProfileEmailVerificationFromAuthUser(session.userId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
after: async (session) => {
|
after: async (session) => {
|
||||||
await trackLoginHook(session);
|
await trackLoginHook(session);
|
||||||
|
await syncProfileEmailVerificationFromAuthUser(session.userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
import * as schema from '@db/schema';
|
import * as schema from '@db/schema';
|
||||||
|
|
||||||
import { and, eq, ne } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
import { User } from 'better-auth';
|
import { User } from 'better-auth';
|
||||||
import { db } from '@db/drizzle';
|
import { db } from '@db/drizzle';
|
||||||
|
import { hasVerifiedEmailProvider } from '@db/queries/auth/profile';
|
||||||
import { getOrganizationCount } from '@db/queries/organization';
|
import { getOrganizationCount } from '@db/queries/organization';
|
||||||
import { ssoProvisioningHook } from './sso-provisioning';
|
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).
|
* 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.
|
* 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.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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import * as schema from '@db/schema';
|
|
||||||
|
|
||||||
import { User } from 'better-auth';
|
import { User } from 'better-auth';
|
||||||
import { db } from '@db/drizzle';
|
import { syncProfileEmailVerificationFromAuthUser } from '@db/queries/auth/profile';
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { hasVerifiedEmailProvider } from './create-profile';
|
|
||||||
|
|
||||||
export const syncUserWithProfile = async (user: User) => {
|
export const syncUserWithProfile = async (user: User) => {
|
||||||
console.log('[auth] syncUserWithProfile: running', { userId: user?.id });
|
console.log('[auth] syncUserWithProfile: running', { userId: user?.id });
|
||||||
@@ -13,15 +9,8 @@ export const syncUserWithProfile = async (user: User) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isEmailVerified = user.emailVerified || (await hasVerifiedEmailProvider(user.id));
|
await syncProfileEmailVerificationFromAuthUser(user.id);
|
||||||
await db
|
|
||||||
.update(schema.profile)
|
|
||||||
.set({
|
|
||||||
email: user.email,
|
|
||||||
isEmailVerified
|
|
||||||
})
|
|
||||||
.where(eq(schema.profile.id, user.id));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error marking profile as verified:', error);
|
console.error('Error syncing profile email verification:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,92 @@ import * as schema from '@db/schema';
|
|||||||
|
|
||||||
import type { TProfile } from '@db/types';
|
import type { TProfile } from '@db/types';
|
||||||
import { db, type DbOrTxClient } from '@db/drizzle';
|
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) => {
|
export const getProfileById = async (id: string) => {
|
||||||
const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.id, id)).limit(1);
|
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();
|
||||||
Reference in New Issue
Block a user