-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Implement user auth with Cal Poly email verification #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
b0af9cf
feat: implement Cal Poly email authentication with verification
cole-hackman ccf1f30
docs: update environment variables documentation for auth
cole-hackman 1be11b1
fix: secure email verification with token validation
cole-hackman 71abd4a
refactor: switch from Password+SendGrid to OTP+Resend
cole-hackman 2a3c0a6
Merge branch 'dev' into feature/POLY-7-cal-poly-email-auth
jaydonkc 4f4ff70
fix: address CodeRabbit review suggestions for auth implementation
cole-hackman b7ff3ba
fix: configure ESM module resolution for backend typecheck
cole-hackman 6ccaee4
Normalize email to lowercase and trim whitespace
jaydonkc File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,55 +1,88 @@ | ||
| # PolyBuys Backend (Convex) | ||
| # Welcome to your Convex functions directory! | ||
|
|
||
| This directory contains the Convex serverless backend for PolyBuys. | ||
| Write your Convex functions here. | ||
| See https://docs.convex.dev/functions for more. | ||
|
|
||
| **Note**: We use self-hosted Convex deployed on Railway, not Convex cloud. | ||
| A query function that takes two arguments looks like: | ||
|
|
||
| ## Getting Started | ||
| ```ts | ||
| // convex/myFunctions.ts | ||
| import { query } from './_generated/server'; | ||
| import { v } from 'convex/values'; | ||
|
|
||
| First, create `backend/.env.local` with: | ||
| export const myQueryFunction = query({ | ||
| // Validators for arguments. | ||
| args: { | ||
| first: v.number(), | ||
| second: v.string(), | ||
| }, | ||
|
|
||
| ```bash | ||
| CONVEX_SELF_HOSTED_URL='https://api.polybuys.com' | ||
| CONVEX_SELF_HOSTED_ADMIN_KEY='<admin-key-from-team>' | ||
| ``` | ||
| // Function implementation. | ||
| handler: async (ctx, args) => { | ||
| // Read the database as many times as you need here. | ||
| // See https://docs.convex.dev/database/reading-data. | ||
| const documents = await ctx.db.query('tablename').collect(); | ||
|
|
||
| Then run the Convex development server: | ||
| // Arguments passed from the client are properties of the args object. | ||
| console.log(args.first, args.second); | ||
|
|
||
| ```bash | ||
| npm run dev:backend | ||
| // Write arbitrary JavaScript here: filter, aggregate, build derived data, | ||
| // remove non-public properties, or create new objects. | ||
| return documents; | ||
| }, | ||
| }); | ||
| ``` | ||
|
|
||
| This connects to our self-hosted Convex backend, watches for changes, and syncs your schema and functions to the deployment. | ||
|
|
||
| ## Project Structure | ||
|
|
||
| - **`schema.ts`** - Database schema definitions (tables, indexes) | ||
| - **`listings.ts`** - CRUD functions for marketplace listings | ||
| - **`_generated/`** - Auto-generated types (do not edit manually) | ||
| - **`__tests__/`** - Test files for backend functions | ||
| Using this query function in a React component looks like: | ||
|
|
||
| ## Adding New Functions | ||
| ```ts | ||
| const data = useQuery(api.myFunctions.myQueryFunction, { | ||
| first: 10, | ||
| second: 'hello', | ||
| }); | ||
| ``` | ||
|
|
||
| Create new `.ts` files in this directory and export query/mutation functions: | ||
| A mutation function looks like: | ||
|
|
||
| ```ts | ||
| import { query } from './_generated/server'; | ||
| // convex/myFunctions.ts | ||
| import { mutation } from './_generated/server'; | ||
| import { v } from 'convex/values'; | ||
|
|
||
| export const myQuery = query({ | ||
| args: { id: v.id('tableName') }, | ||
| export const myMutationFunction = mutation({ | ||
| // Validators for arguments. | ||
| args: { | ||
| first: v.string(), | ||
| second: v.string(), | ||
| }, | ||
|
|
||
| // Function implementation. | ||
| handler: async (ctx, args) => { | ||
| return await ctx.db.get(args.id); | ||
| // Insert or modify documents in the database here. | ||
| // Mutations can also read from the database like queries. | ||
| // See https://docs.convex.dev/database/writing-data. | ||
| const message = { body: args.first, author: args.second }; | ||
| const id = await ctx.db.insert('messages', message); | ||
|
|
||
| // Optionally, return a value from your mutation. | ||
| return await ctx.db.get('messages', id); | ||
| }, | ||
| }); | ||
| ``` | ||
|
|
||
| ## Updating the Schema | ||
|
|
||
| Edit `schema.ts` to add tables or modify fields. Convex will automatically migrate your data. | ||
| Using this mutation function in a React component looks like: | ||
|
|
||
| ## Resources | ||
| ```ts | ||
| const mutation = useMutation(api.myFunctions.myMutationFunction); | ||
| function handleButtonPress() { | ||
| // fire and forget, the most common way to use mutations | ||
| mutation({ first: 'Hello!', second: 'me' }); | ||
| // OR | ||
| // use the result once the mutation has completed | ||
| mutation({ first: 'Hello!', second: 'me' }).then((result) => console.log(result)); | ||
| } | ||
| ``` | ||
|
|
||
| - [Convex Docs](https://docs.convex.dev) | ||
| - [Functions Guide](https://docs.convex.dev/functions) | ||
| - [Database Guide](https://docs.convex.dev/database) | ||
| Use the Convex CLI to push your functions to a deployment. See everything | ||
| the Convex CLI can do by running `npx convex -h` in your project root | ||
| directory. To learn more, launch the docs with `npx convex docs`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import { Email } from '@convex-dev/auth/providers/Email'; | ||
| import { Resend as ResendAPI } from 'resend'; | ||
| import type { RandomReader } from '@oslojs/crypto/random'; | ||
| import { generateRandomString } from '@oslojs/crypto/random'; | ||
| import { isCalPolyEmail } from '@polybuys/shared'; | ||
| import { ConvexError } from 'convex/values'; | ||
|
|
||
| /** | ||
| * Resend OTP Email Provider for Cal Poly authentication | ||
| * Sends an 8-digit verification code that expires in 15 minutes | ||
| */ | ||
| export const ResendOTP = Email({ | ||
| id: 'resend-otp', | ||
| apiKey: process.env.AUTH_RESEND_KEY, | ||
| maxAge: 60 * 15, // 15 minutes | ||
|
|
||
| async generateVerificationToken() { | ||
| const random: RandomReader = { | ||
| read(bytes: Uint8Array) { | ||
| crypto.getRandomValues(bytes); | ||
| }, | ||
| }; | ||
| const alphabet = '0123456789'; | ||
| const length = 8; | ||
| return generateRandomString(random, alphabet, length); | ||
| }, | ||
|
|
||
| async sendVerificationRequest({ identifier: email, provider, token }) { | ||
| // Validate API key is configured | ||
| if (!provider.apiKey) { | ||
| throw new ConvexError('Email service not configured. Please contact support.'); | ||
| } | ||
|
|
||
| // Validate Cal Poly email domain | ||
| if (!isCalPolyEmail(email)) { | ||
| throw new ConvexError('Email must be a @calpoly.edu address'); | ||
| } | ||
|
|
||
| const resend = new ResendAPI(provider.apiKey); | ||
|
|
||
| const { error } = await resend.emails.send({ | ||
| from: 'PolyBuys <onboarding@resend.dev>', | ||
| to: [email], | ||
| subject: 'Your PolyBuys verification code', | ||
| text: `Your verification code is: ${token} | ||
|
|
||
| This code will expire in 15 minutes. | ||
|
|
||
| If you didn't request this code, you can safely ignore this email.`, | ||
| html: ` | ||
| <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;"> | ||
| <h1 style="color: #154734; margin-bottom: 24px; font-size: 24px;">PolyBuys</h1> | ||
| <p style="color: #333; font-size: 16px; margin-bottom: 24px;"> | ||
| Your verification code is: | ||
| </p> | ||
| <div style="background: #f4f4f5; border-radius: 8px; padding: 20px; text-align: center; margin-bottom: 24px;"> | ||
| <span style="font-family: monospace; font-size: 32px; font-weight: bold; letter-spacing: 4px; color: #154734;"> | ||
| ${token} | ||
| </span> | ||
| </div> | ||
| <p style="color: #666; font-size: 14px; margin-bottom: 8px;"> | ||
| This code will expire in 15 minutes. | ||
| </p> | ||
| <p style="color: #999; font-size: 12px;"> | ||
| If you didn't request this code, you can safely ignore this email. | ||
| </p> | ||
| </div> | ||
| `, | ||
| }); | ||
|
|
||
| if (error) { | ||
| console.error('Resend error:', error); | ||
| throw new ConvexError('Failed to send verification email. Please try again.'); | ||
| } | ||
| }, | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { convexAuth } from '@convex-dev/auth/server'; | ||
| import { ResendOTP } from './ResendOTP'; | ||
|
|
||
| /** | ||
| * Convex Auth configuration with Resend OTP provider | ||
| * Users sign in with their @calpoly.edu email and receive a verification code | ||
| */ | ||
| export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ | ||
| providers: [ResendOTP], | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import { httpRouter } from 'convex/server'; | ||
| import { auth } from './auth'; | ||
|
|
||
| const http = httpRouter(); | ||
|
|
||
| // Add Convex Auth HTTP routes for email verification links, etc. | ||
| auth.addHttpRoutes(http); | ||
|
|
||
| export default http; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| import { getAuthUserId } from '@convex-dev/auth/server'; | ||
| import { query, mutation } from './_generated/server'; | ||
| import { v, ConvexError } from 'convex/values'; | ||
|
|
||
| /** | ||
| * Get the current authenticated user's profile from our users table | ||
| */ | ||
| export const getCurrentUser = query({ | ||
| args: {}, | ||
| handler: async (ctx) => { | ||
| const userId = await getAuthUserId(ctx); | ||
| if (userId === null) { | ||
| return null; | ||
| } | ||
|
|
||
| // Get the auth user record | ||
| const authUser = await ctx.db.get(userId); | ||
| if (!authUser || !authUser.email) { | ||
| return null; | ||
| } | ||
|
|
||
| // Find user profile by email | ||
| const email = authUser.email.toLowerCase().trim(); | ||
| const userProfile = await ctx.db | ||
| .query('users') | ||
| .withIndex('by_email', (q) => q.eq('email', email)) | ||
| .first(); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| return userProfile; | ||
| }, | ||
| }); | ||
|
|
||
| /** | ||
| * Check if an email already exists | ||
| */ | ||
| export const checkEmailExists = query({ | ||
| args: { email: v.string() }, | ||
| handler: async (ctx, args) => { | ||
| const existingUser = await ctx.db | ||
| .query('users') | ||
| .withIndex('by_email', (q) => q.eq('email', args.email.toLowerCase().trim())) | ||
| .first(); | ||
|
|
||
| return existingUser !== null; | ||
| }, | ||
| }); | ||
|
|
||
| /** | ||
| * Update user profile (name, etc.) | ||
| */ | ||
| export const updateUserProfile = mutation({ | ||
| args: { | ||
| name: v.union(v.string(), v.null()), | ||
| }, | ||
| handler: async (ctx, args) => { | ||
| const userId = await getAuthUserId(ctx); | ||
| if (userId === null) { | ||
| throw new ConvexError('Not authenticated'); | ||
| } | ||
|
|
||
| const authUser = await ctx.db.get(userId); | ||
| if (!authUser || !authUser.email) { | ||
| throw new ConvexError('User not found'); | ||
| } | ||
|
|
||
| const email = authUser.email.toLowerCase().trim(); | ||
| const userProfile = await ctx.db | ||
| .query('users') | ||
| .withIndex('by_email', (q) => q.eq('email', email)) | ||
| .first(); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| if (!userProfile) { | ||
| throw new ConvexError('User profile not found'); | ||
| } | ||
|
|
||
| await ctx.db.patch(userProfile._id, { | ||
| name: args.name, | ||
| }); | ||
|
|
||
| return await ctx.db.get(userProfile._id); | ||
| }, | ||
| }); | ||
|
|
||
| /** | ||
| * Get or create user profile after authentication | ||
| * Called when user successfully authenticates via OTP | ||
| */ | ||
| export const getOrCreateUser = mutation({ | ||
| args: {}, | ||
| handler: async (ctx) => { | ||
| const userId = await getAuthUserId(ctx); | ||
| if (userId === null) { | ||
| throw new ConvexError('Not authenticated'); | ||
| } | ||
|
|
||
| const authUser = await ctx.db.get(userId); | ||
| if (!authUser || !authUser.email) { | ||
| throw new ConvexError('Auth user not found'); | ||
| } | ||
|
|
||
| const email = authUser.email.toLowerCase().trim(); | ||
|
|
||
| // Check if user profile already exists | ||
| const existingProfile = await ctx.db | ||
| .query('users') | ||
| .withIndex('by_email', (q) => q.eq('email', email)) | ||
| .first(); | ||
|
|
||
| if (existingProfile) { | ||
| return existingProfile; | ||
| } | ||
|
|
||
| // Create new user profile | ||
| const profileId = await ctx.db.insert('users', { | ||
| email, | ||
| name: authUser.name || null, | ||
| createdAt: Date.now(), | ||
| }); | ||
|
|
||
| return await ctx.db.get(profileId); | ||
| }, | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.