Finally have all email verification / password reset auth flows working!

This commit is contained in:
2025-09-24 13:52:11 -05:00
parent 914c45dca4
commit ab278c2ae8
21 changed files with 1013 additions and 267 deletions

View File

@@ -1,4 +1,3 @@
export { validatePassword } from './password/validate';
export { Entra } from './providers/entra';
export { Password } from './providers/password';
export { Usesend } from './providers/usesend';
export { Password, validatePassword } from './providers/password';
export { UseSendOTP, UseSendOTPPasswordReset } from './providers/usesend';

View File

@@ -1,30 +0,0 @@
import { Usesend } from '..';
import { UseSend } from 'usesend-js';
import { generateRandomString, RandomReader } from '@oslojs/crypto/random';
export const UsesendOTPPasswordReset = Usesend({
id: 'unsend-otp',
apiKey: process.env.AUTH_USESEND_API_KEY,
async generateVerificationToken() {
const random: RandomReader = {
read(bytes) {
crypto.getRandomValues(bytes);
},
};
const alphabet = '0123456789';
const length = 8;
return generateRandomString(random, alphabet, length);
},
async sendVerificationRequest({ identifier: email, provider, token }) {
const useSend = new UseSend(provider.apiKey, 'https://usesend.gbrown.org');
const { error } = await useSend.emails.send({
to: [email],
from:
provider.from ??
'TechTracker Admin <admin@mail.techtracker.gbrown.org>',
subject: `Reset your password - TechTracker`,
text: `Your password reset code is ${token}`,
});
if (error) throw new Error('Usesend error: ' + error.message);
},
});

View File

@@ -1,12 +0,0 @@
export const validatePassword = (password: string): boolean => {
if (
password.length < 8 ||
password.length > 100 ||
!/\d/.test(password) ||
!/[a-z]/.test(password) ||
!/[A-Z]/.test(password)
) {
return false;
}
return true;
};

View File

@@ -1,8 +1,7 @@
import { ConvexError } from 'convex/values';
import { Password as DefaultPassword } from '@convex-dev/auth/providers/Password';
import { validatePassword } from '../password/validate';
import type { DataModel } from '../../../_generated/dataModel';
import { UsesendOTPPasswordReset } from '../password/reset';
import { DataModel } from '../../../_generated/dataModel';
import { UseSendOTP, UseSendOTPPasswordReset } from '..';
import { ConvexError } from 'convex/values';
export const Password = DefaultPassword<DataModel>({
profile(params, ctx) {
@@ -16,5 +15,19 @@ export const Password = DefaultPassword<DataModel>({
throw new ConvexError('Invalid password.');
}
},
reset: UsesendOTPPasswordReset,
reset: UseSendOTPPasswordReset,
verify: UseSendOTP,
});
export const validatePassword = (password: string): boolean => {
if (
password.length < 8 ||
password.length > 100 ||
!/\d/.test(password) ||
!/[a-z]/.test(password) ||
!/[A-Z]/.test(password)
) {
return false;
}
return true;
};

View File

@@ -1,94 +1,90 @@
import type { EmailConfig, EmailUserConfig } from '@auth/core/providers/email';
import { alphabet } from 'oslo/crypto';
import { generateRandomString, RandomReader } from '@oslojs/crypto/random';
import { UseSend } from 'usesend-js';
/** @todo Document this */
export const Usesend = (config: EmailUserConfig): EmailConfig => {
export default function UseSendProvider(config: EmailUserConfig): EmailConfig {
return {
id: 'usesend',
type: 'email',
name: 'Usesend',
from: 'TechTracker Admin <admin@mail.techtracker.gbrown.org>',
maxAge: 24 * 60 * 60,
name: 'UseSend',
from: 'TechTracker <admin@techtracker.gbrown.org>',
maxAge: 24 * 60 * 60, // 24 hours
async generateVerificationToken() {
const random: RandomReader = {
read(bytes) {
crypto.getRandomValues(bytes);
},
};
return generateRandomString(random, alphabet('0-9'), 6);
},
async sendVerificationRequest(params) {
const { identifier: to, provider, url, theme } = params;
const { host } = new URL(url);
const { identifier: to, provider, url, theme, token } = params;
//const { host } = new URL(url);
const host = 'TechTracker';
const useSend = new UseSend(
provider.apiKey,
process.env.AUTH_USESEND_API_KEY!,
'https://usesend.gbrown.org',
);
const { error } = await useSend.emails.send({
to,
from:
provider.from ??
'TechTracker Admin <admin@mail.techtracker.gbrown.org>',
subject: `Sign in to ${host}`,
html: html({ url, host, theme }),
text: text({ url, host }),
// For password reset, we want to send the code, not the magic link
const isPasswordReset =
url.includes('reset') || provider.id?.includes('reset');
const result = await useSend.emails.send({
from: provider.from!,
to: [to],
subject: isPasswordReset
? `Reset your password - ${host}`
: `Sign in to ${host}`,
text: isPasswordReset
? `Your password reset code is ${token}`
: `Your sign in code is ${token}`,
html: isPasswordReset
? `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2>Password Reset Request</h2>
<p>You requested a password reset. Your reset code is:</p>
<div style="font-size: 32px; font-weight: bold; text-align: center; padding: 20px; background: #f5f5f5; margin: 20px 0; border-radius: 8px;">
${token}
</div>
<p>This code expires in 1 hour.</p>
<p>If you didn't request this, please ignore this email.</p>
</div>
`
: `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2>Your Sign In Code</h2>
<p>Your verification code is:</p>
<div style="font-size: 32px; font-weight: bold; text-align: center; padding: 20px; background: #f5f5f5; margin: 20px 0; border-radius: 8px;">
${token}
</div>
<p>This code expires in 24 hours.</p>
</div>
`,
});
if (error) throw new Error('Usesend error: ' + error.message);
if (result.error) {
throw new Error('UseSend error: ' + JSON.stringify(result.error));
}
},
options: config,
};
};
}
type Theme = {
colorScheme?: 'auto' | 'dark' | 'light';
logo?: string;
brandColor?: string;
buttonText?: string;
};
// Create specific instances for password reset and email verification
export const UseSendOTPPasswordReset = UseSendProvider({
id: 'usesend-otp-password-reset',
apiKey: process.env.AUTH_USESEND_API_KEY,
maxAge: 60 * 60, // 1 hour
});
const text = ({ url, host }: { url: string; host: string }) => {
return `Sign in to ${host}\n${url}\n\n`;
};
const html = (params: { url: string; host: string; theme: Theme }) => {
const { url, host, theme } = params;
const escapedHost = host.replace(/\./g, '&#8203;.');
const brandColor = theme.brandColor || '#346df1';
const buttonText = theme.buttonText || '#fff';
const color = {
background: '#f9f9f9',
text: '#444',
mainBackground: '#fff',
buttonBackground: brandColor,
buttonBorder: brandColor,
buttonText,
};
return `
<body style="background: ${color.background};">
<table width="100%" border="0" cellspacing="20" cellpadding="0"
style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
<tr>
<td align="center"
style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
Sign in to <strong>${escapedHost}</strong>
</td>
</tr>
<tr>
<td align="center" style="padding: 20px 0;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}"><a href="${url}"
target="_blank"
style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">Sign
in</a></td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center"
style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
If you did not request this email you can safely ignore it.
</td>
</tr>
</table>
</body>
`;
};
export const UseSendOTP = UseSendProvider({
id: 'usesend-otp',
apiKey: process.env.AUTH_USESEND_API_KEY,
maxAge: 60 * 20, // 20 minutes
});