From 3a5daaa9871f4dfaa73d94d0dc1b318f97cc4a4f Mon Sep 17 00:00:00 2001 From: weeihan Date: Tue, 21 Jul 2026 22:27:02 +0800 Subject: [PATCH] docs: add forgot password implementation plan --- .../plans/2026-07-21-forgot-password.md | 412 ++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-21-forgot-password.md diff --git a/docs/superpowers/plans/2026-07-21-forgot-password.md b/docs/superpowers/plans/2026-07-21-forgot-password.md new file mode 100644 index 0000000..1ff38d2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-forgot-password.md @@ -0,0 +1,412 @@ +# Forgot Password Flow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add self-service password reset so users can recover access via email link without admin help. + +**Architecture:** Three UI changes — a link on the login form, a forgot-password page (sends reset email via Supabase), and a reset-password page (sets new password using the recovery session). The existing `/api/auth/callback` route handles the email link's code exchange without modification. + +**Tech Stack:** Next.js 15 App Router, Supabase Auth (`@supabase/ssr`), Tailwind CSS + +## Global Constraints + +- `basePath: '/ims'` — all internal links use paths without `/ims` prefix; Next.js prepends it automatically +- `NEXT_PUBLIC_APP_URL` = `http://64.176.82.100/ims` in production (`.env.production`) — no trailing slash +- Supabase client in client components: `createClient` from `@/lib/supabase/client` +- Styling must match existing login form: `border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500`, error in `bg-red-50 border border-red-200 text-red-700 text-sm rounded px-3 py-2` +- Password minimum: 8 characters (matches `change-password-form.tsx`) +- No new API routes — all Supabase calls are client-side + +--- + +### Task 1: Add "Forgot password?" link to login form + +**Files:** +- Modify: `components/auth/login-form.tsx` + +**Interfaces:** +- Produces: link to `/forgot-password` visible below the Sign in button + +- [ ] **Step 1: Add the link** + +In `components/auth/login-form.tsx`, add after the ` +

+ + Forgot password? + +

+ +``` + +- [ ] **Step 2: Verify visually** + +Run `npm run dev` and open `http://localhost:3000/ims/login`. Confirm "Forgot password?" link appears below the Sign in button and clicking it navigates to `/forgot-password` (404 is expected — page doesn't exist yet). + +- [ ] **Step 3: Commit** + +```bash +git add components/auth/login-form.tsx +git commit -m "feat(auth): add forgot password link to login form" +``` + +--- + +### Task 2: Create forgot-password page + +**Files:** +- Create: `app/(auth)/forgot-password/page.tsx` + +**Interfaces:** +- Consumes: `createClient` from `@/lib/supabase/client`, `NEXT_PUBLIC_APP_URL` env var +- Produces: page at `/forgot-password` that sends a reset email and shows success state + +- [ ] **Step 1: Create the page file** + +Create `app/(auth)/forgot-password/page.tsx`: + +```tsx +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { createClient } from '@/lib/supabase/client' + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState('') + const [loading, setLoading] = useState(false) + const [submitted, setSubmitted] = useState(false) + const [error, setError] = useState(null) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!email) return + setLoading(true) + setError(null) + + const supabase = createClient() + const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? '' + const redirectTo = `${appUrl}/api/auth/callback?next=/auth/reset-password` + + const { error: resetError } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo, + }) + + setLoading(false) + + if (resetError) { + setError('Something went wrong. Please try again.') + return + } + + // Always show success — never reveal whether email exists + setSubmitted(true) + } + + if (submitted) { + return ( +
+
+

Check your email

+

+ If an account exists for {email}, a password reset link has been sent. +

+ + Back to sign in + +
+
+ ) + } + + return ( +
+
+
+

Reset password

+

+ Enter your email and we'll send a reset link. +

+
+ + {error && ( +

+ {error} +

+ )} + + setEmail(e.target.value)} + required + autoComplete="email" + className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + + +

+ + Back to sign in + +

+
+
+ ) +} +``` + +- [ ] **Step 2: Verify page loads** + +With dev server running, open `http://localhost:3000/ims/forgot-password`. Confirm the form renders with email input and "Send reset link" button. Confirm "Back to sign in" navigates to `/ims/login`. + +- [ ] **Step 3: Commit** + +```bash +git add app/\(auth\)/forgot-password/page.tsx +git commit -m "feat(auth): add forgot password page" +``` + +--- + +### Task 3: Create reset-password page + +**Files:** +- Create: `app/(auth)/reset-password/page.tsx` + +**Interfaces:** +- Consumes: `createClient` from `@/lib/supabase/client`, valid Supabase recovery session in browser cookie +- Produces: page at `/auth/reset-password` that sets a new password and redirects to `/login` + +Note: The path is `/auth/reset-password` (not `/reset-password`) because the auth callback `next` param is `/auth/reset-password` which resolves to `app/(auth)/reset-password/page.tsx` — the `(auth)` group folder matches the `/auth/` URL segment? No — route groups like `(auth)` do NOT add a URL segment. So `app/(auth)/reset-password/page.tsx` maps to `/reset-password`. + +**Correction:** The callback redirects to `/auth/reset-password` but the page lives at `app/(auth)/reset-password/page.tsx` which maps to `/reset-password`. These must match. Use `/reset-password` as the `next` param in the callback URL (update Task 2's `redirectTo`). + +Update `app/(auth)/forgot-password/page.tsx` line: +```tsx +const redirectTo = `${appUrl}/api/auth/callback?next=/reset-password` +``` + +- [ ] **Step 1: Fix redirectTo in forgot-password page** + +In `app/(auth)/forgot-password/page.tsx`, change: +```tsx +const redirectTo = `${appUrl}/api/auth/callback?next=/auth/reset-password` +``` +to: +```tsx +const redirectTo = `${appUrl}/api/auth/callback?next=/reset-password` +``` + +- [ ] **Step 2: Create the reset-password page** + +Create `app/(auth)/reset-password/page.tsx`: + +```tsx +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { createClient } from '@/lib/supabase/client' + +export default function ResetPasswordPage() { + const router = useRouter() + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + + if (password.length < 8) { + setError('Password must be at least 8 characters.') + return + } + if (password !== confirm) { + setError('Passwords do not match.') + return + } + + setLoading(true) + const supabase = createClient() + const { error: updateError } = await supabase.auth.updateUser({ password }) + setLoading(false) + + if (updateError) { + if (updateError.message.toLowerCase().includes('session') || + updateError.message.toLowerCase().includes('expired')) { + setError('Reset link has expired. Request a new one.') + } else { + setError(updateError.message) + } + return + } + + router.push('/login?message=password_reset') + } + + return ( +
+
+
+

Set new password

+

Choose a strong password.

+
+ + {error && ( +

+ {error}{' '} + {error.includes('expired') && ( + + Request new link + + )} +

+ )} + + setPassword(e.target.value)} + required + minLength={8} + autoComplete="new-password" + className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + setConfirm(e.target.value)} + required + autoComplete="new-password" + className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + +
+
+ ) +} +``` + +- [ ] **Step 3: Show success message on login page when redirected from reset** + +In `app/(auth)/login/page.tsx` (or wherever the login page renders), check for `?message=password_reset` and show a banner. Check what the login page currently looks like first: + +```bash +cat app/\(auth\)/login/page.tsx +``` + +If it just renders ``, update it to a server component that reads the search param and passes a success message: + +```tsx +import { LoginForm } from '@/components/auth/login-form' + +interface Props { + searchParams: Promise<{ message?: string }> +} + +export default async function LoginPage({ searchParams }: Props) { + const { message } = await searchParams + return ( +
+ {message === 'password_reset' && ( +
+ Password updated — sign in with your new password. +
+ )} + +
+ ) +} +``` + +If the login page already has a wrapping layout, add the banner inside the existing structure instead. + +- [ ] **Step 4: Verify reset-password page loads** + +Open `http://localhost:3000/ims/reset-password`. Confirm the two password inputs and "Set password" button render. Without a recovery session, the Supabase call will fail — the error message should show. + +- [ ] **Step 5: Commit** + +```bash +git add app/\(auth\)/forgot-password/page.tsx app/\(auth\)/reset-password/page.tsx app/\(auth\)/login/page.tsx +git commit -m "feat(auth): add reset-password page and login success banner" +``` + +--- + +### Task 4: Verify Supabase redirect URL and deploy + +**Files:** None — configuration + deploy + +- [ ] **Step 1: Check Supabase redirect URL allowlist** + +Go to [https://supabase.com](https://supabase.com) → project `nkcfjbgappslicotwopl` → Authentication → URL Configuration → Redirect URLs. + +Ensure this URL is listed: +``` +http://64.176.82.100/ims/api/auth/callback +``` + +If missing, click "Add URL" and add it. Without this, Supabase will reject the reset email's redirect as unauthorised. + +- [ ] **Step 2: Deploy to VPS** + +```bash +bash deploy.sh +``` + +Expected output ends with: +``` +==> Done. http://64.176.82.100/ims/ +``` + +- [ ] **Step 3: End-to-end test on production** + +1. Go to `http://64.176.82.100/ims/login` — confirm "Forgot password?" link visible +2. Click it → confirm redirects to `http://64.176.82.100/ims/forgot-password` +3. Enter a real user's email → click "Send reset link" +4. Confirm success screen: "Check your email" +5. Open email → click the reset link +6. Confirm landing on `http://64.176.82.100/ims/reset-password` +7. Enter a new password (≥8 chars) + confirm → click "Set password" +8. Confirm redirect to `/login` with green success banner +9. Sign in with the new password — confirm it works