All Sessions

Plan: Email OTP Login for Non-Illinois Users

February 2, 20264:51 PM7:21 PM(2h 31m)4 turns
Model: claude-opus-4-5-20251101
TaskUpdate10Read7Bash7TaskCreate5Edit5Task2Glob2Write2ExitPlanMode1
4:51 PM

Implement the following plan:

Plan: Email OTP Login for Non-Illinois Users

Goal

Add email OTP (one-time password) as a second login method alongside Microsoft/Azure AD, so non-Illinois users can authenticate.

Current State

  • Better Auth v1.4.18 with Microsoft as the only social provider
  • Login page auto-redirects to Microsoft — no provider choice
  • Mail relay already configured (Illinois outbound relay, nodemailer in apps/mail)
  • verifications table already exists in the DB schema (used by Better Auth)
  • MAIL_* env vars already in turbo.json globalPassThroughEnv

Approach

Better Auth has a first-party emailOTP plugin that handles OTP generation, storage (in the verifications table), and sign-in. We add this plugin server-side with a sendVerificationOTP callback that sends via the existing relay, register the client plugin, and redesign the login page to offer both methods.

No database migrations needed — the plugin uses the existing verifications table.

Steps

Step 1: Add nodemailer to packages/auth

File: packages/auth/package.json

  • Add "nodemailer": "7.0.13" to dependencies
  • Add "@types/nodemailer": "7.0.9" to devDependencies
  • Run bun install

Step 2: Add emailOTP plugin to auth config

File: packages/auth/src/config.ts

  • Import emailOTP from "better-auth/plugins" and nodemailer
  • Create a relay transport (same pattern as apps/mail/src/sender.ts): host from MAIL_RELAY_HOST, port from MAIL_RELAY_PORT, no TLS
  • Derive MAIL_DOMAIN from env for the from address (noreply@{MAIL_DOMAIN})
  • Add plugins: [emailOTP({ ... })] with:
    • sendVerificationOTP callback that sends via the relay transport
    • otpLength: 6, expiresIn: 300 (5 min)
    • disableSignUp: false (allow new accounts for non-Illinois users)
  • Add "email-otp" to account.accountLinking.trustedProviders for bidirectional linking

Step 3: Register client plugin

File: apps/web/lib/auth-client.ts

  • Import emailOTPClient from "better-auth/client/plugins"
  • Add plugins: [emailOTPClient()] to the createAuthClient call

Step 4: Redesign login page

File: apps/web/app/login/page.tsx

Replace auto-redirect with a three-state UI:

  1. Choose — two buttons: "Sign in with Illinois (Microsoft)" and "Sign in with email code", separated by an "or" divider
  2. OTP: email — email input + "Send sign-in code" button + back link
  3. OTP: code — 6-digit numeric input + "Verify and sign in" button + "Change email" / "Resend code" links

Uses existing Button, Input, Card components. All interactive elements get data-testid attributes.

Files Modified

  1. packages/auth/package.json — add nodemailer deps
  2. packages/auth/src/config.ts — emailOTP plugin + relay transport
  3. apps/web/lib/auth-client.ts — emailOTPClient plugin
  4. apps/web/app/login/page.tsx — redesigned login UI

What Does NOT Change

  • Database schema (no migrations)
  • API route handler (apps/web/app/api/auth/[...all]/route.ts) — plugin auto-registers endpoints
  • turbo.jsonMAIL_* already covered
  • Environment files — MAIL_RELAY_HOST, MAIL_RELAY_PORT, MAIL_DOMAIN already set

Verification

  1. bun install succeeds
  2. bunx turbo tsc --filter=@repo/web --filter=@repo/auth passes
  3. Visit /login — see both sign-in options
  4. Enter an email → receive OTP email → enter code → signed in
  5. Illinois users can still sign in via Microsoft button
  6. Check account linking: Illinois user signing in via email with their @illinois.edu address links to existing account

If you need specific details from before exiting plan mode (like exact code snippets, error messages, or content you generated), read the full transcript at: ~/.claude/projects/-Users-challen-www-usingandunderstanding-ai/c7022a9f-d659-463f-a413-011e07a97fe8.jsonl

I'll start by reading the key files I need to modify, then implement the changes.

Let me also check the existing mail sender pattern and the UI components available.

Good, I have all the context I need. Let me also quickly check the Input component and the existing nodemailer versions to ensure exact matches.

Now let me create the task list and start implementing.

Step 1: Add nodemailer dependencies to packages/auth

Already resolved from the lockfile (used elsewhere in the monorepo).

Step 2: Add emailOTP plugin to auth config

Now add the relay transport and the plugin configuration.

Now add the plugins array and update trustedProviders.

Step 3: Register emailOTPClient plugin in auth-client

Step 4: Redesign login page

Let me check what UI components are available (Label, etc.) before writing the login page.

No Label component — I'll use plain labels or skip them. Now let me write the redesigned login page.

Step 5: Verify TypeScript compilation

Both packages compile cleanly.

All done. Here's a summary of the changes:

4 files modified:

  1. packages/auth/package.json — Added nodemailer (7.0.13) and @types/nodemailer (7.0.9)

  2. packages/auth/src/config.ts — Added the emailOTP plugin with:

    • A nodemailer relay transport using MAIL_RELAY_HOST/MAIL_RELAY_PORT env vars
    • sendVerificationOTP callback that sends a code via the relay
    • 6-digit codes, 5-minute expiry, sign-up enabled
    • "email-otp" added to trustedProviders for account linking
  3. apps/web/lib/auth-client.ts — Registered emailOTPClient() plugin

  4. apps/web/app/login/page.tsx — Replaced auto-redirect with three-step UI:

    • Choose — "Sign in with Illinois (Microsoft)" and "Sign in with email code" buttons
    • Email — email input + send code button
    • Code — 6-digit OTP input + verify button, with change email and resend links
    • All interactive elements have data-testid attributes

No database migrations needed — the plugin uses the existing verifications table.

TaskUpdate10Read6TaskCreate5Edit5Glob2Bash2Task1Write1
7:17 PM

Your task

First, run git status, git diff, and git log --oneline -10 to understand the current state.

Then add and commit all modified files with a descriptive message. Break the changes into multiple commits if appropriate. If there are untracked files, ask if they should be added or added to the appropriate .gitignore file. The user may also want to take over at this point, so provide an abort option as well. Always check changes for unnecessary debugging statements or logging that might have been accidentally included. Prompt the user if you find any, and offer to remove each one by one. Do not use interactive git staging (git add -p) as it does not work.

No untracked files, no debugging statements or unnecessary logging in the diff. The changes are clean — all 5 modified files are part of a single coherent feature. This fits well as a single commit.

Committed as 9ba0033. Working tree is clean. The branch is 1 commit ahead of origin — let me know if you'd like to push.

Bash5
7:18 PM

OK. So, to be clear, this sends an email with a code which the person has to type in. Why can't we use a magic link? Is that an option? That would be my preference.

(no content)

Magic link is a better UX and Better Auth has a first-party magicLink plugin that works the same way — uses the verifications table, sends via the same mail relay. The user just clicks a link in their email instead of copying a code.

The only tradeoff: magic links require the user to open the link in the same browser (or at least the same device) where they initiated login. OTP is device-agnostic — you can read the code on your phone and type it on your laptop. In practice, magic links are fine for most users and feel more seamless.

Want me to switch from emailOTP to magicLink? The changes would be in the same 4 files — swap the plugin, simplify the login page (removes the code-entry step entirely), and update the email to contain a link instead of a code.

7:19 PM

Yeah, I think magic links are fine.

(no content)

Let me check the Better Auth magicLink plugin API and then plan the swap.

I have all the details. The swap is straightforward. Let me read the existing plan and update it.

Task1Read1Write1ExitPlanMode1