Finally have all email verification / password reset auth flows working!
This commit is contained in:
36
packages/backend/convex/_generated/api.d.ts
vendored
36
packages/backend/convex/_generated/api.d.ts
vendored
@@ -12,18 +12,16 @@ import type {
|
||||
ApiFromModules,
|
||||
FilterApi,
|
||||
FunctionReference,
|
||||
} from 'convex/server';
|
||||
import type * as auth from '../auth.js';
|
||||
import type * as crons from '../crons.js';
|
||||
import type * as custom_auth_index from '../custom/auth/index.js';
|
||||
import type * as custom_auth_password_reset from '../custom/auth/password/reset.js';
|
||||
import type * as custom_auth_password_validate from '../custom/auth/password/validate.js';
|
||||
import type * as custom_auth_providers_entra from '../custom/auth/providers/entra.js';
|
||||
import type * as custom_auth_providers_password from '../custom/auth/providers/password.js';
|
||||
import type * as custom_auth_providers_usesend from '../custom/auth/providers/usesend.js';
|
||||
import type * as files from '../files.js';
|
||||
import type * as http from '../http.js';
|
||||
import type * as statuses from '../statuses.js';
|
||||
} from "convex/server";
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as custom_auth_index from "../custom/auth/index.js";
|
||||
import type * as custom_auth_providers_entra from "../custom/auth/providers/entra.js";
|
||||
import type * as custom_auth_providers_password from "../custom/auth/providers/password.js";
|
||||
import type * as custom_auth_providers_usesend from "../custom/auth/providers/usesend.js";
|
||||
import type * as files from "../files.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as statuses from "../statuses.js";
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's API.
|
||||
@@ -36,21 +34,19 @@ import type * as statuses from '../statuses.js';
|
||||
declare const fullApi: ApiFromModules<{
|
||||
auth: typeof auth;
|
||||
crons: typeof crons;
|
||||
'custom/auth/index': typeof custom_auth_index;
|
||||
'custom/auth/password/reset': typeof custom_auth_password_reset;
|
||||
'custom/auth/password/validate': typeof custom_auth_password_validate;
|
||||
'custom/auth/providers/entra': typeof custom_auth_providers_entra;
|
||||
'custom/auth/providers/password': typeof custom_auth_providers_password;
|
||||
'custom/auth/providers/usesend': typeof custom_auth_providers_usesend;
|
||||
"custom/auth/index": typeof custom_auth_index;
|
||||
"custom/auth/providers/entra": typeof custom_auth_providers_entra;
|
||||
"custom/auth/providers/password": typeof custom_auth_providers_password;
|
||||
"custom/auth/providers/usesend": typeof custom_auth_providers_usesend;
|
||||
files: typeof files;
|
||||
http: typeof http;
|
||||
statuses: typeof statuses;
|
||||
}>;
|
||||
export declare const api: FilterApi<
|
||||
typeof fullApi,
|
||||
FunctionReference<any, 'public'>
|
||||
FunctionReference<any, "public">
|
||||
>;
|
||||
export declare const internal: FilterApi<
|
||||
typeof fullApi,
|
||||
FunctionReference<any, 'internal'>
|
||||
FunctionReference<any, "internal">
|
||||
>;
|
||||
|
@@ -8,7 +8,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { anyApi } from 'convex/server';
|
||||
import { anyApi } from "convex/server";
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's API.
|
||||
|
@@ -13,9 +13,9 @@ import type {
|
||||
DocumentByName,
|
||||
TableNamesInDataModel,
|
||||
SystemTableNames,
|
||||
} from 'convex/server';
|
||||
import type { GenericId } from 'convex/values';
|
||||
import schema from '../schema.js';
|
||||
} from "convex/server";
|
||||
import type { GenericId } from "convex/values";
|
||||
import schema from "../schema.js";
|
||||
|
||||
/**
|
||||
* The names of all of your Convex tables.
|
||||
|
16
packages/backend/convex/_generated/server.d.ts
vendored
16
packages/backend/convex/_generated/server.d.ts
vendored
@@ -18,8 +18,8 @@ import {
|
||||
GenericQueryCtx,
|
||||
GenericDatabaseReader,
|
||||
GenericDatabaseWriter,
|
||||
} from 'convex/server';
|
||||
import type { DataModel } from './dataModel.js';
|
||||
} from "convex/server";
|
||||
import type { DataModel } from "./dataModel.js";
|
||||
|
||||
/**
|
||||
* Define a query in this Convex app's public API.
|
||||
@@ -29,7 +29,7 @@ import type { DataModel } from './dataModel.js';
|
||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const query: QueryBuilder<DataModel, 'public'>;
|
||||
export declare const query: QueryBuilder<DataModel, "public">;
|
||||
|
||||
/**
|
||||
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||
@@ -39,7 +39,7 @@ export declare const query: QueryBuilder<DataModel, 'public'>;
|
||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const internalQuery: QueryBuilder<DataModel, 'internal'>;
|
||||
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
|
||||
|
||||
/**
|
||||
* Define a mutation in this Convex app's public API.
|
||||
@@ -49,7 +49,7 @@ export declare const internalQuery: QueryBuilder<DataModel, 'internal'>;
|
||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const mutation: MutationBuilder<DataModel, 'public'>;
|
||||
export declare const mutation: MutationBuilder<DataModel, "public">;
|
||||
|
||||
/**
|
||||
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||
@@ -59,7 +59,7 @@ export declare const mutation: MutationBuilder<DataModel, 'public'>;
|
||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const internalMutation: MutationBuilder<DataModel, 'internal'>;
|
||||
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
|
||||
|
||||
/**
|
||||
* Define an action in this Convex app's public API.
|
||||
@@ -72,7 +72,7 @@ export declare const internalMutation: MutationBuilder<DataModel, 'internal'>;
|
||||
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const action: ActionBuilder<DataModel, 'public'>;
|
||||
export declare const action: ActionBuilder<DataModel, "public">;
|
||||
|
||||
/**
|
||||
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||
@@ -80,7 +80,7 @@ export declare const action: ActionBuilder<DataModel, 'public'>;
|
||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const internalAction: ActionBuilder<DataModel, 'internal'>;
|
||||
export declare const internalAction: ActionBuilder<DataModel, "internal">;
|
||||
|
||||
/**
|
||||
* Define an HTTP action.
|
||||
|
@@ -16,7 +16,7 @@ import {
|
||||
internalActionGeneric,
|
||||
internalMutationGeneric,
|
||||
internalQueryGeneric,
|
||||
} from 'convex/server';
|
||||
} from "convex/server";
|
||||
|
||||
/**
|
||||
* Define a query in this Convex app's public API.
|
||||
|
@@ -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';
|
||||
|
@@ -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);
|
||||
},
|
||||
});
|
@@ -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;
|
||||
};
|
@@ -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;
|
||||
};
|
||||
|
@@ -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, '​.');
|
||||
|
||||
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
|
||||
});
|
||||
|
@@ -15,3 +15,5 @@ AUTH_MICROSOFT_ENTRA_ID_ID=
|
||||
AUTH_MICROSOFT_ENTRA_ID_SECRET=
|
||||
AUTH_MICROSOFT_ENTRA_ID_ISSUER=
|
||||
AUTH_MICROSOFT_ENTRA_ID_AUTH_URL=
|
||||
# UseSend
|
||||
AUTH_USESEND_API_KEY=
|
||||
|
@@ -12,10 +12,15 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@react-email/components": "0.5.4",
|
||||
"@react-email/render": "^1.3.0",
|
||||
"convex": "^1.27.3",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"usesend-js": "^1.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"react-email": "4.2.11",
|
||||
"typescript": "5.9.2"
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user