feat: add password reset (forgot password) flow #4

Closed
opened 2026-03-11 08:26:41 +00:00 by zias · 2 comments
Owner

Problem

There is currently no way for a user to reset their password. The /login page has no "Forgot password?" link, no /forgot-password route exists, and no /reset-password route exists. If a user loses access to their account, they are fully locked out with no self-service recovery path.

Root cause

The Better Auth server config (packages/auth/src/index.ts) does not include the built-in forgotPassword / resetPassword plugin, and no corresponding UI routes have been built.


What needs to be implemented

1. Enable Better Auth's password reset plugin

In packages/auth/src/index.ts, add the forgotPassword plugin from better-auth/plugins. This plugin exposes two new API endpoints automatically via the existing /api/auth/$ catch-all handler:

  • POST /api/auth/forget-password — accepts an email, generates a signed reset token, and calls a sendResetPassword callback
  • POST /api/auth/reset-password — accepts the token + new password and updates the credential
import { forgotPassword } from "better-auth/plugins";

export const auth = betterAuth({
  // ...existing config
  plugins: [
    tanstackStartCookies(),
    forgotPassword({
      sendResetPassword: async ({ user, url }) => {
        await sendPasswordResetEmail(user.email, url);
      },
    }),
  ],
});

2. Add a password reset email template

In packages/api/src/email.ts, add a new sendPasswordResetEmail(to: string, resetUrl: string) function following the existing Nodemailer + inline HTML pattern used by sendConfirmationEmail. The email should:

  • Be in Dutch (consistent with the other templates)
  • Use Kunstenkamp branding
  • Contain a clear CTA button linking to resetUrl
  • Include a short expiry notice (tokens expire after 1 hour by default in Better Auth)

3. Add a /forgot-password route

New file: apps/web/src/routes/forgot-password.tsx

A simple form that accepts an email address and calls authClient.forgetPassword({ email, redirectTo: "/reset-password" }). Should show a confirmation message after submission (do not reveal whether the email exists — always show "if this email is registered, a link has been sent").

4. Add a /reset-password route

New file: apps/web/src/routes/reset-password.tsx

Reads the ?token= query param from the URL (Better Auth appends this automatically to the redirectTo URL). Renders a form with two fields (new password + confirm password). On submit calls authClient.resetPassword({ token, newPassword }). On success redirect to /login.

In apps/web/src/routes/login.tsx, add a small "Wachtwoord vergeten?" link below the password field that navigates to /forgot-password.


Files to change

File Change
packages/auth/src/index.ts Add forgotPassword plugin with sendResetPassword callback
packages/api/src/email.ts Add sendPasswordResetEmail() function
apps/web/src/routes/forgot-password.tsx New route — email input form
apps/web/src/routes/reset-password.tsx New route — new password form, reads ?token=
apps/web/src/routes/login.tsx Add "Wachtwoord vergeten?" link

No database migrations are needed — Better Auth reuses the existing verification table for reset tokens.


Notes

  • The SMTP setup (SMTP_HOST, SMTP_USER, SMTP_PASS) is already in place via packages/env/src/server.ts, so email delivery requires no new infrastructure.
  • Better Auth reset tokens expire after 1 hour by default. This can be configured via expiresIn in the plugin options.
  • The authClient in apps/web/src/lib/auth-client.ts will need to include forgotPasswordClient() from better-auth/client/plugins so authClient.forgetPassword() and authClient.resetPassword() are typed correctly.
## Problem There is currently no way for a user to reset their password. The `/login` page has no "Forgot password?" link, no `/forgot-password` route exists, and no `/reset-password` route exists. If a user loses access to their account, they are fully locked out with no self-service recovery path. ## Root cause The Better Auth server config (`packages/auth/src/index.ts`) does not include the built-in `forgotPassword` / `resetPassword` plugin, and no corresponding UI routes have been built. --- ## What needs to be implemented ### 1. Enable Better Auth's password reset plugin In `packages/auth/src/index.ts`, add the `forgotPassword` plugin from `better-auth/plugins`. This plugin exposes two new API endpoints automatically via the existing `/api/auth/$` catch-all handler: - `POST /api/auth/forget-password` — accepts an email, generates a signed reset token, and calls a `sendResetPassword` callback - `POST /api/auth/reset-password` — accepts the token + new password and updates the credential ```ts import { forgotPassword } from "better-auth/plugins"; export const auth = betterAuth({ // ...existing config plugins: [ tanstackStartCookies(), forgotPassword({ sendResetPassword: async ({ user, url }) => { await sendPasswordResetEmail(user.email, url); }, }), ], }); ``` ### 2. Add a password reset email template In `packages/api/src/email.ts`, add a new `sendPasswordResetEmail(to: string, resetUrl: string)` function following the existing Nodemailer + inline HTML pattern used by `sendConfirmationEmail`. The email should: - Be in Dutch (consistent with the other templates) - Use Kunstenkamp branding - Contain a clear CTA button linking to `resetUrl` - Include a short expiry notice (tokens expire after 1 hour by default in Better Auth) ### 3. Add a `/forgot-password` route New file: `apps/web/src/routes/forgot-password.tsx` A simple form that accepts an email address and calls `authClient.forgetPassword({ email, redirectTo: "/reset-password" })`. Should show a confirmation message after submission (do not reveal whether the email exists — always show "if this email is registered, a link has been sent"). ### 4. Add a `/reset-password` route New file: `apps/web/src/routes/reset-password.tsx` Reads the `?token=` query param from the URL (Better Auth appends this automatically to the `redirectTo` URL). Renders a form with two fields (new password + confirm password). On submit calls `authClient.resetPassword({ token, newPassword })`. On success redirect to `/login`. ### 5. Add a "Forgot password?" link to the login page In `apps/web/src/routes/login.tsx`, add a small "Wachtwoord vergeten?" link below the password field that navigates to `/forgot-password`. --- ## Files to change | File | Change | |---|---| | `packages/auth/src/index.ts` | Add `forgotPassword` plugin with `sendResetPassword` callback | | `packages/api/src/email.ts` | Add `sendPasswordResetEmail()` function | | `apps/web/src/routes/forgot-password.tsx` | New route — email input form | | `apps/web/src/routes/reset-password.tsx` | New route — new password form, reads `?token=` | | `apps/web/src/routes/login.tsx` | Add "Wachtwoord vergeten?" link | No database migrations are needed — Better Auth reuses the existing `verification` table for reset tokens. --- ## Notes - The SMTP setup (`SMTP_HOST`, `SMTP_USER`, `SMTP_PASS`) is already in place via `packages/env/src/server.ts`, so email delivery requires no new infrastructure. - Better Auth reset tokens expire after **1 hour** by default. This can be configured via `expiresIn` in the plugin options. - The `authClient` in `apps/web/src/lib/auth-client.ts` will need to include `forgotPasswordClient()` from `better-auth/client/plugins` so `authClient.forgetPassword()` and `authClient.resetPassword()` are typed correctly.
Author
Owner

Geïmplementeerd

De wachtwoord-reset flow is volledig geïmplementeerd en gecommit (dfc8ace).

Wat er gedaan is

Server (packages/auth/src/index.ts)

  • sendResetPassword callback toegevoegd aan de emailAndPassword optie in de Better Auth config (dit is de correcte manier — er bestaat geen forgotPassword plugin)
  • Nodemailer transport inline geïmplementeerd, gebruikt SMTP_HOST/USER/PASS/PORT/FROM env vars
  • Gestileerde HTML-e-mail in het Nederlands met de Kunstenkamp huisstijl (donkergroene achtergrond), link 1 uur geldig

Client (apps/web/src/lib/auth-client.ts)

  • Overbodige (niet-bestaande) plugin verwijderd — requestPasswordReset en resetPassword zijn standaard beschikbaar op de auth client

Nieuwe routes

  • /forgot-password — e-mailadres invullen, privacy-veilige bevestigingsboodschap
  • /reset-password?token=... — nieuw wachtwoord instellen via token uit de e-mail
  • "Wachtwoord vergeten?" link toegevoegd aan de loginpagina

Route tree (routeTree.gen.ts) handmatig bijgewerkt zodat TypeScript de nieuwe routes kent.

Flow

  1. Gebruiker klikt "Wachtwoord vergeten?" op de loginpagina
  2. Vult e-mailadres in op /forgot-password
  3. Better Auth stuurt e-mail met link naar /api/auth/reset-password/<token>?callbackURL=/reset-password
  4. Die redirect leidt de gebruiker naar /reset-password?token=<token>
  5. Gebruiker stelt nieuw wachtwoord in — redirect naar /login
## Geïmplementeerd ✅ De wachtwoord-reset flow is volledig geïmplementeerd en gecommit (`dfc8ace`). ### Wat er gedaan is **Server (`packages/auth/src/index.ts`)** - `sendResetPassword` callback toegevoegd aan de `emailAndPassword` optie in de Better Auth config (dit is de correcte manier — er bestaat geen `forgotPassword` plugin) - Nodemailer transport inline geïmplementeerd, gebruikt `SMTP_HOST/USER/PASS/PORT/FROM` env vars - Gestileerde HTML-e-mail in het Nederlands met de Kunstenkamp huisstijl (donkergroene achtergrond), link 1 uur geldig **Client (`apps/web/src/lib/auth-client.ts`)** - Overbodige (niet-bestaande) plugin verwijderd — `requestPasswordReset` en `resetPassword` zijn standaard beschikbaar op de auth client **Nieuwe routes** - `/forgot-password` — e-mailadres invullen, privacy-veilige bevestigingsboodschap - `/reset-password?token=...` — nieuw wachtwoord instellen via token uit de e-mail - "Wachtwoord vergeten?" link toegevoegd aan de loginpagina **Route tree** (`routeTree.gen.ts`) handmatig bijgewerkt zodat TypeScript de nieuwe routes kent. ### Flow 1. Gebruiker klikt "Wachtwoord vergeten?" op de loginpagina 2. Vult e-mailadres in op `/forgot-password` 3. Better Auth stuurt e-mail met link naar `/api/auth/reset-password/<token>?callbackURL=/reset-password` 4. Die redirect leidt de gebruiker naar `/reset-password?token=<token>` 5. Gebruiker stelt nieuw wachtwoord in — redirect naar `/login`
zias closed this issue 2026-03-11 08:42:49 +00:00
zias reopened this issue 2026-03-11 09:29:27 +00:00
Author
Owner

Nieuw probleem: /reset-password geeft 404 op productie

Wat er mis gaat

De reset-link in de e-mail stuurt de gebruiker naar:

https://kunstenkamp.be/reset-password?token=ZOhWlVALb5PzSDqBnz9Wxg2d

De server antwoordt met de HTML van de app, maar de route wordt niet herkend — de pagina toont "Not Found". In de SSR-manifest die mee is gerenderd zijn /reset-password en /forgot-password volledig afwezig.

Oorzaak

De code is geïmplementeerd in branch opencode/eager-wizard (commit dfc8ace) maar is nog niet gedeployed naar productie. De live build stamt van vóór deze wijzigingen. Zodra de branch gemerged wordt in main (of welke branch de deploy triggert) en er een nieuwe deploy gedraaid wordt, zouden de routes beschikbaar moeten zijn.

Wat te doen

  1. Merge branch opencode/eager-wizard in main (of de deploy-branch)
  2. Voer een nieuwe deploy uit: bun run deploy (via @kk/infra / Alchemy)
  3. Verifieer daarna dat:
    • /forgot-password laadt correct
    • /reset-password?token=... laadt de formulierpagina (niet "Not Found")
    • De reset-flow end-to-end werkt

Opmerking over routeTree.gen.ts

Het bestand apps/web/src/routeTree.gen.ts is handmatig bijgewerkt in de commit zodat TypeScript tevreden is. Bij de volgende bun dev of bun build wordt dit bestand automatisch overschreven door de TanStack Router Vite-plugin — dat is normaal en gewenst. De plugin herkent de nieuwe routebestanden (forgot-password.tsx, reset-password.tsx) en genereert het bestand opnieuw met de juiste inhoud.

## Nieuw probleem: `/reset-password` geeft 404 op productie ### Wat er mis gaat De reset-link in de e-mail stuurt de gebruiker naar: ``` https://kunstenkamp.be/reset-password?token=ZOhWlVALb5PzSDqBnz9Wxg2d ``` De server antwoordt met de HTML van de app, maar de route wordt niet herkend — de pagina toont **"Not Found"**. In de SSR-manifest die mee is gerenderd zijn `/reset-password` en `/forgot-password` volledig afwezig. ### Oorzaak De code is geïmplementeerd in branch `opencode/eager-wizard` (commit `dfc8ace`) maar is **nog niet gedeployed naar productie**. De live build stamt van vóór deze wijzigingen. Zodra de branch gemerged wordt in `main` (of welke branch de deploy triggert) en er een nieuwe deploy gedraaid wordt, zouden de routes beschikbaar moeten zijn. ### Wat te doen 1. **Merge branch `opencode/eager-wizard` in `main`** (of de deploy-branch) 2. **Voer een nieuwe deploy uit**: `bun run deploy` (via `@kk/infra` / Alchemy) 3. Verifieer daarna dat: - `/forgot-password` laadt correct - `/reset-password?token=...` laadt de formulierpagina (niet "Not Found") - De reset-flow end-to-end werkt ### Opmerking over `routeTree.gen.ts` Het bestand `apps/web/src/routeTree.gen.ts` is handmatig bijgewerkt in de commit zodat TypeScript tevreden is. Bij de volgende `bun dev` of `bun build` wordt dit bestand **automatisch overschreven** door de TanStack Router Vite-plugin — dat is normaal en gewenst. De plugin herkent de nieuwe routebestanden (`forgot-password.tsx`, `reset-password.tsx`) en genereert het bestand opnieuw met de juiste inhoud.
zias closed this issue 2026-03-11 09:30:16 +00:00
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: zias/kunstenkamp#4