fix(dashboard): enroll verify flow and org-aware verification email (#718)
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { type BetterAuthOptions } from 'better-auth';
|
||||
import { sendEmail } from '@cio/email';
|
||||
import { buildEmailBranding, buildEmailFromName, sendEmail } from '@cio/email';
|
||||
|
||||
import { resolveVerificationOrg } from './resolve-verification-org';
|
||||
|
||||
type EmailVerificationOptions = Parameters<
|
||||
NonNullable<NonNullable<BetterAuthOptions['emailVerification']>['sendVerificationEmail']>
|
||||
@@ -21,6 +23,45 @@ type ChangeEmailConfirmationOptions = Parameters<
|
||||
* 7. we mark [email protected] as verified. [better-auth].
|
||||
*/
|
||||
|
||||
function stripTriggerParam(url: string): string {
|
||||
return url.replace('trigger=app', '').replace('trigger%3Dapp', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Brands verification mail with the org from `resolveVerificationOrg` (callback URL
|
||||
* host/?org=, self-hosted first org, then the user's first membership). When no
|
||||
* org applies — e.g. dashboard signup before onboarding creates an org — copy
|
||||
* and masthead fall back to ClassroomIO defaults.
|
||||
*/
|
||||
async function sendOrgAwareVerifyEmail(options: {
|
||||
to: string;
|
||||
verificationUrl: string;
|
||||
userId: string;
|
||||
userName?: string | null;
|
||||
newEmail?: string;
|
||||
}) {
|
||||
const org = await resolveVerificationOrg({
|
||||
verificationUrl: options.verificationUrl,
|
||||
userId: options.userId
|
||||
});
|
||||
|
||||
const orgName = org?.name?.trim() || 'ClassroomIO';
|
||||
const branding = buildEmailBranding(org ? { name: org.name, avatarUrl: org.avatarUrl, theme: org.theme } : undefined);
|
||||
|
||||
await sendEmail('verifyEmail', {
|
||||
to: options.to,
|
||||
from: org?.name ? buildEmailFromName(`${org.name} (via ClassroomIO.com)`) : undefined,
|
||||
subject: org?.name ? `Confirm your email for ${org.name}` : undefined,
|
||||
fields: {
|
||||
link: stripTriggerParam(options.verificationUrl),
|
||||
newEmail: options.newEmail,
|
||||
userName: options.userName || undefined,
|
||||
orgName,
|
||||
branding
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a verification email to the user when they sign up or change their email address.
|
||||
* @param options - The options for sending the verification email.
|
||||
@@ -35,15 +76,11 @@ export const sendVerificationEmail = async (options: EmailVerificationOptions) =
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove trigger=app or trigger%3Dapp from the URL
|
||||
const link = url.replace('trigger=app', '').replace('trigger%3Dapp', '');
|
||||
|
||||
await sendEmail('verifyEmail', {
|
||||
await sendOrgAwareVerifyEmail({
|
||||
to: user.email,
|
||||
fields: {
|
||||
link,
|
||||
userName: user.name || undefined
|
||||
}
|
||||
verificationUrl: url,
|
||||
userId: user.id,
|
||||
userName: user.name
|
||||
});
|
||||
};
|
||||
|
||||
@@ -56,12 +93,11 @@ export const sendChangeEmailConfirmation = async (options: ChangeEmailConfirmati
|
||||
const { user, newEmail, url } = options;
|
||||
console.log('\nsendChangeEmailConfirmation', options);
|
||||
|
||||
await sendEmail('verifyEmail', {
|
||||
await sendOrgAwareVerifyEmail({
|
||||
to: user.email,
|
||||
fields: {
|
||||
link: url,
|
||||
newEmail,
|
||||
userName: user.name || undefined
|
||||
}
|
||||
verificationUrl: url,
|
||||
userId: user.id,
|
||||
userName: user.name,
|
||||
newEmail
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { TENANT_ROOT_DOMAIN, BRAND_ROOT_DOMAIN } from '@cio/utils/constants/domains';
|
||||
|
||||
import type { TOrganization } from '../types';
|
||||
import {
|
||||
getFirstOrganization,
|
||||
getOrganizationByCustomDomain,
|
||||
getOrganizationByProfileId,
|
||||
getOrganizationBySiteName
|
||||
} from '../queries/organization/organization';
|
||||
|
||||
function extractCallbackUrl(verificationUrl: string): URL | null {
|
||||
try {
|
||||
const parsed = new URL(verificationUrl);
|
||||
const callback = parsed.searchParams.get('callbackURL');
|
||||
if (!callback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new URL(callback);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isTenantSubdomainHost(hostname: string): boolean {
|
||||
const host = hostname.replace(/^www\./, '').toLowerCase();
|
||||
const suffix = `.${TENANT_ROOT_DOMAIN}`;
|
||||
|
||||
return host.endsWith(suffix) && host.length > suffix.length;
|
||||
}
|
||||
|
||||
function getTenantSiteName(hostname: string): string | null {
|
||||
const host = hostname.replace(/^www\./, '').toLowerCase();
|
||||
const suffix = `.${TENANT_ROOT_DOMAIN}`;
|
||||
if (!host.endsWith(suffix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const siteName = host.slice(0, -suffix.length);
|
||||
if (!siteName || siteName.includes('.')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return siteName;
|
||||
}
|
||||
|
||||
function isCustomDomainHost(hostname: string): boolean {
|
||||
const host = hostname.replace(/^www\./, '').toLowerCase();
|
||||
if (host.includes('localhost')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (host === BRAND_ROOT_DOMAIN || host === `app.${BRAND_ROOT_DOMAIN}`) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isTenantSubdomainHost(host)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !host.endsWith(`.${BRAND_ROOT_DOMAIN}`);
|
||||
}
|
||||
|
||||
async function resolveOrgFromCallbackUrl(callbackUrl: URL): Promise<TOrganization | null> {
|
||||
const orgSiteName = callbackUrl.searchParams.get('org')?.trim();
|
||||
if (orgSiteName) {
|
||||
const org = await getOrganizationBySiteName(orgSiteName);
|
||||
if (org) {
|
||||
return org;
|
||||
}
|
||||
}
|
||||
|
||||
const hostname = callbackUrl.hostname.toLowerCase();
|
||||
|
||||
if (isCustomDomainHost(hostname)) {
|
||||
const org = await getOrganizationByCustomDomain(hostname);
|
||||
if (org) {
|
||||
return org;
|
||||
}
|
||||
}
|
||||
|
||||
const siteName = getTenantSiteName(hostname);
|
||||
if (siteName) {
|
||||
return getOrganizationBySiteName(siteName);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the tenant org that should brand a verification email from the
|
||||
* Better Auth verification URL's `callbackURL` (where the user started the
|
||||
* flow), with fallbacks for self-hosted and existing memberships.
|
||||
*/
|
||||
export async function resolveVerificationOrg(options: {
|
||||
verificationUrl: string;
|
||||
userId?: string;
|
||||
}): Promise<TOrganization | null> {
|
||||
const callbackUrl = extractCallbackUrl(options.verificationUrl);
|
||||
if (callbackUrl) {
|
||||
const org = await resolveOrgFromCallbackUrl(callbackUrl);
|
||||
if (org) {
|
||||
return org;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.PUBLIC_IS_SELFHOSTED === 'true') {
|
||||
const org = await getFirstOrganization();
|
||||
if (org) {
|
||||
return org;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.userId) {
|
||||
const memberships = await getOrganizationByProfileId(options.userId);
|
||||
const org = memberships[0];
|
||||
if (org) {
|
||||
return org;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export async function sendTemplateEmail<TSchema extends z.ZodType>(
|
||||
return deliverEmail([
|
||||
{
|
||||
to: config.to,
|
||||
subject: config.template.subject,
|
||||
subject: config.subject ?? config.template.subject,
|
||||
content,
|
||||
from: config.from ?? config.template.from,
|
||||
replyTo: config.replyTo ?? config.template.replyTo,
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface SendConfig<TSchema extends z.ZodType = z.ZodType> {
|
||||
fields: z.infer<TSchema>;
|
||||
from?: string;
|
||||
replyTo?: string;
|
||||
/** Override the template's default subject (e.g. org-scoped transactional mail). */
|
||||
subject?: string;
|
||||
/** Optional iCalendar (.ics) body delivered as a text/calendar part. */
|
||||
ics?: string;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
import { ZEmailBranding } from '../core/branding';
|
||||
|
||||
export const verifyEmailEmail = defineEmail({
|
||||
id: 'verifyEmail',
|
||||
@@ -9,11 +10,17 @@ export const verifyEmailEmail = defineEmail({
|
||||
schema: z.object({
|
||||
link: z.url(),
|
||||
newEmail: z.email().optional(),
|
||||
userName: z.string().optional()
|
||||
userName: z.string().optional(),
|
||||
orgName: z.string().min(1),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const isEmailChange = !!fields.newEmail;
|
||||
const userName = fields.userName || 'there';
|
||||
const hasOrgBranding = Boolean(fields.branding?.orgName);
|
||||
const welcomeLine = hasOrgBranding
|
||||
? `Welcome to ${fields.orgName}! In order to get your account ready for usage, we need to verify your email.`
|
||||
: 'Welcome! In order to get your account ready for usage, we need to verify your email.';
|
||||
|
||||
const content = isEmailChange
|
||||
? `
|
||||
@@ -27,14 +34,13 @@ export const verifyEmailEmail = defineEmail({
|
||||
`
|
||||
: `
|
||||
<p><strong>Hey ${userName} 👋</strong></p>
|
||||
<p>Welcome to ClassroomIO! In order to get your account ready for usage, we need to verify your email. </p>
|
||||
<p>We do this to make sure we don't get fake user emails in our signup. To get the best out of our product, we'll need you to verify your email by clicking the <strong>Verify</strong> button below
|
||||
</p>
|
||||
<p>${welcomeLine}</p>
|
||||
<p>We do this to make sure we don't get fake user emails in our signup. To get the best out of our product, we'll need you to verify your email by clicking the <strong>Verify</strong> button below.</p>
|
||||
<div>
|
||||
<a class="button" href="${fields.link}">Verify</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content);
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -95,6 +95,7 @@ export async function sendEmail<TEmailId extends EmailId>(
|
||||
fields: validatedFields,
|
||||
from: config.from ?? template.from,
|
||||
replyTo: config.replyTo ?? template.replyTo,
|
||||
subject: config.subject,
|
||||
ics: config.ics
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getDefaultTemplate } from '@cio/email/templates';
|
||||
|
||||
const VERIFY_EMAIL_CONTENT = `
|
||||
<p><strong>Hey there 👋</strong></p>
|
||||
<p>Welcome to ClassroomIO! In order to get your account ready for usage, we need to verify your email.</p>
|
||||
<p>Welcome to Acme Coding School! In order to get your account ready for usage, we need to verify your email.</p>
|
||||
<p>We do this to make sure we don't get fake user emails in our signup. To get the best out of our product, we'll need you to verify your email by clicking the <strong>Verify</strong> button below.</p>
|
||||
<div><a class="button" href="#">Verify</a></div>
|
||||
`;
|
||||
@@ -59,7 +59,11 @@ const STUDENT_JOINED_CONTENT = `
|
||||
|
||||
// ─── Exported email HTML strings ──────────────────────────────────────────
|
||||
|
||||
export const verifyEmailHtml = getDefaultTemplate(VERIFY_EMAIL_CONTENT);
|
||||
export const verifyEmailHtml = getDefaultTemplate(VERIFY_EMAIL_CONTENT, {
|
||||
orgName: 'Acme Coding School',
|
||||
logoUrl: 'https://placehold.co/120x40/png?text=Acme',
|
||||
themeColor: '#1d4ed8'
|
||||
});
|
||||
export const forgotPasswordHtml = getDefaultTemplate(FORGOT_PASSWORD_CONTENT);
|
||||
export const welcomeHtml = getDefaultTemplate(WELCOME_CONTENT);
|
||||
export const courseWelcomeHtml = getDefaultTemplate(COURSE_WELCOME_CONTENT);
|
||||
|
||||
Reference in New Issue
Block a user