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,
|
||||
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<GetAccountDataResu
|
||||
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.
|
||||
// Skip if they already have membership (by profileId) or a pending invite (by email).
|
||||
if (env.PUBLIC_IS_SELFHOSTED === 'true' && organizations.length === 0) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { page } from '$app/state';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
@@ -22,10 +23,34 @@
|
||||
let interval;
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user