Password Management 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: Fix the password-reset e-mail flow so users actually reach the set-new-password page, and add an in-app “Change password” dialog.

Architecture: Spec: docs/specs/2026-06-11-password-management-design.md. All changes are in Plings-Web (React/Vite/Supabase). A shared PasswordFields component carries validation UI for both the existing /reset-password page and a new ChangePasswordDialog opened from the UserProfile dropdown. AuthContext gets an origin-relative redirectTo and a PASSWORD_RECOVERY safety-net redirect.

Tech Stack: React 18, TypeScript, supabase-js v2, shadcn/ui (Dialog, DropdownMenu), sonner toasts, lucide-react icons.

Verification: Plings-Web has no test runner. Each task is verified with npm run lint and npm run build plus manual checks in the dev server (port 8080). Working directory for all commands: Plings-Web/.


Task 1: Shared PasswordFields component

Files:

  • Create: src/components/auth/PasswordFields.tsx

  • Step 1: Create the component

Full content of src/components/auth/PasswordFields.tsx:

import React from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Check, X } from 'lucide-react';

const REQUIREMENTS = [
  'At least 8 characters',
  'At least one uppercase letter',
  'At least one lowercase letter',
  'At least one number',
  'At least one special character',
] as const;

export function validatePassword(password: string): { valid: boolean; errors: string[] } {
  const errors: string[] = [];

  if (password.length < 8) errors.push('At least 8 characters');
  if (!/[A-Z]/.test(password)) errors.push('At least one uppercase letter');
  if (!/[a-z]/.test(password)) errors.push('At least one lowercase letter');
  if (!/[0-9]/.test(password)) errors.push('At least one number');
  if (!/[^A-Za-z0-9]/.test(password)) errors.push('At least one special character');

  return { valid: errors.length === 0, errors };
}

interface PasswordFieldsProps {
  password: string;
  confirmPassword: string;
  onPasswordChange: (value: string) => void;
  onConfirmPasswordChange: (value: string) => void;
}

const PasswordFields: React.FC<PasswordFieldsProps> = ({
  password,
  confirmPassword,
  onPasswordChange,
  onConfirmPasswordChange,
}) => {
  const validation = validatePassword(password);

  return (
    <>
      <div className="space-y-2">
        <Label htmlFor="new-password">New Password</Label>
        <Input
          id="new-password"
          type="password"
          placeholder="Enter new password"
          value={password}
          onChange={(e) => onPasswordChange(e.target.value)}
          required
          className={password && !validation.valid ? 'border-destructive' : ''}
        />
      </div>

      {password && (
        <div className="space-y-2">
          <p className="text-sm font-medium">Password requirements:</p>
          <div className="space-y-1">
            {REQUIREMENTS.map((requirement) => {
              const isMet = !validation.errors.includes(requirement);
              return (
                <div key={requirement} className="flex items-center gap-2 text-sm">
                  {isMet ? (
                    <Check className="w-4 h-4 text-green-600" />
                  ) : (
                    <X className="w-4 h-4 text-destructive" />
                  )}
                  <span className={isMet ? 'text-green-600' : 'text-muted-foreground'}>
                    {requirement}
                  </span>
                </div>
              );
            })}
          </div>
        </div>
      )}

      <div className="space-y-2">
        <Label htmlFor="confirm-password">Confirm Password</Label>
        <Input
          id="confirm-password"
          type="password"
          placeholder="Confirm new password"
          value={confirmPassword}
          onChange={(e) => onConfirmPasswordChange(e.target.value)}
          required
          className={confirmPassword && password !== confirmPassword ? 'border-destructive' : ''}
        />
        {confirmPassword && password !== confirmPassword && (
          <p className="text-sm text-destructive">Passwords do not match</p>
        )}
      </div>
    </>
  );
};

export default PasswordFields;
  • Step 2: Verify it compiles

Run: npm run lint && npm run build Expected: no new lint errors, build succeeds.

  • Step 3: Commit
git add src/components/auth/PasswordFields.tsx
git commit -m "feat: shared PasswordFields component with validation checklist"

Task 2: Refactor ResetPassword page to use PasswordFields and stay signed in

Files:

  • Modify: src/pages/ResetPassword.tsx (replace whole file)

  • Step 1: Replace the file

Full new content of src/pages/ResetPassword.tsx:

import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { supabase } from '@/lib/supabase';
import { toast } from 'sonner';
import { Lock } from 'lucide-react';
import PasswordFields, { validatePassword } from '@/components/auth/PasswordFields';

const ResetPassword = () => {
  const [searchParams] = useSearchParams();
  const navigate = useNavigate();
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');
  const [loading, setLoading] = useState(false);
  const [isValidToken, setIsValidToken] = useState(false);
  const [checkingToken, setCheckingToken] = useState(true);

  useEffect(() => {
    // Check if we have the necessary auth parameters
    const checkAuthParams = async () => {
      const error = searchParams.get('error');
      const errorDescription = searchParams.get('error_description');

      if (error) {
        toast.error(errorDescription || 'Invalid or expired reset link');
        navigate('/');
        return;
      }

      // Check if there's a session (which means the token was valid)
      const { data: { session } } = await supabase.auth.getSession();

      if (session) {
        setIsValidToken(true);
      } else {
        toast.error('Invalid or expired reset link. Please request a new one.');
        navigate('/');
      }

      setCheckingToken(false);
    };

    checkAuthParams();
  }, [searchParams, navigate]);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (password !== confirmPassword) {
      toast.error('Passwords do not match');
      return;
    }

    const validation = validatePassword(password);
    if (!validation.valid) {
      toast.error('Password does not meet security requirements');
      return;
    }

    setLoading(true);

    try {
      const { error } = await supabase.auth.updateUser({
        password: password
      });

      if (error) throw error;

      toast.success('Password updated successfully!');

      // The recovery session is already a full session — keep the user signed in
      navigate('/dashboard');
    } catch (error) {
      toast.error(error instanceof Error ? error.message : 'Failed to update password');
    } finally {
      setLoading(false);
    }
  };

  if (checkingToken) {
    return (
      <div className="min-h-screen flex items-center justify-center p-4">
        <Card className="max-w-md w-full">
          <CardContent className="pt-6">
            <p className="text-center text-muted-foreground">Verifying reset link...</p>
          </CardContent>
        </Card>
      </div>
    );
  }

  if (!isValidToken) {
    return null; // Will redirect
  }

  return (
    <div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-b from-background to-secondary/20">
      <Card className="max-w-md w-full">
        <CardHeader className="text-center">
          <div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-4">
            <Lock className="w-6 h-6 text-primary" />
          </div>
          <CardTitle>Reset Your Password</CardTitle>
          <CardDescription>
            Enter your new password below
          </CardDescription>
        </CardHeader>
        <CardContent>
          <form onSubmit={handleSubmit} className="space-y-4">
            <PasswordFields
              password={password}
              confirmPassword={confirmPassword}
              onPasswordChange={setPassword}
              onConfirmPasswordChange={setConfirmPassword}
            />

            <Button type="submit" className="w-full" disabled={loading}>
              {loading ? 'Updating...' : 'Update Password'}
            </Button>
          </form>
        </CardContent>
      </Card>
    </div>
  );
};

export default ResetPassword;
  • Step 2: Verify

Run: npm run lint && npm run build Expected: success. Then open http://localhost:8080/reset-password while signed out — should toast “Invalid or expired reset link” and redirect home.

  • Step 3: Commit
git add src/pages/ResetPassword.tsx
git commit -m "refactor: ResetPassword uses shared PasswordFields, stays signed in after reset"

Task 3: AuthContext — origin-relative redirect + PASSWORD_RECOVERY safety net

Files:

  • Modify: src/contexts/AuthContext.tsx

  • Step 1: Make redirectTo origin-relative

In resetPassword (currently ~line 170), change:

const resetPassword = async (email: string) => {
  const { error } = await supabase.auth.resetPasswordForEmail(email, {
    redirectTo: 'https://plings.io/reset-password'
  });

to:

const resetPassword = async (email: string) => {
  const { error } = await supabase.auth.resetPasswordForEmail(email, {
    redirectTo: `${window.location.origin}/reset-password`
  });
  • Step 2: Handle the PASSWORD_RECOVERY event

In the onAuthStateChange callback (currently ~line 100), change:

const {
  data: { subscription },
} = supabase.auth.onAuthStateChange(async (event, session) => {
  if (event === 'SIGNED_OUT' || !session) {
    setUser(null);
  } else if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
    setUser(session.user);
  }
  setLoading(false);
});

to:

const {
  data: { subscription },
} = supabase.auth.onAuthStateChange(async (event, session) => {
  if (event === 'PASSWORD_RECOVERY') {
    // Recovery link clicked — force the set-new-password page no matter
    // where Supabase landed us (safety net for redirect fallbacks).
    setUser(session?.user ?? null);
    if (window.location.pathname !== '/reset-password') {
      window.location.assign('/reset-password');
    }
  } else if (event === 'SIGNED_OUT' || !session) {
    setUser(null);
  } else if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
    setUser(session.user);
  }
  setLoading(false);
});

(window.location.assign is used because AuthProvider mounts outside BrowserRouter, so useNavigate is unavailable.)

  • Step 3: Verify

Run: npm run lint && npm run build Expected: success.

  • Step 4: Commit
git add src/contexts/AuthContext.tsx
git commit -m "fix: origin-relative reset redirect + PASSWORD_RECOVERY safety-net routing"

Task 4: ChangePasswordDialog + menu item in UserProfile

Files:

  • Create: src/components/auth/ChangePasswordDialog.tsx
  • Modify: src/components/auth/UserProfile.tsx

  • Step 1: Create the dialog

Full content of src/components/auth/ChangePasswordDialog.tsx:

import React, { useState } from 'react';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { supabase } from '@/lib/supabase';
import { toast } from 'sonner';
import PasswordFields, { validatePassword } from '@/components/auth/PasswordFields';

interface ChangePasswordDialogProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
}

const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = ({ open, onOpenChange }) => {
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const resetState = () => {
    setPassword('');
    setConfirmPassword('');
    setError(null);
    setLoading(false);
  };

  const handleOpenChange = (nextOpen: boolean) => {
    if (!nextOpen) resetState();
    onOpenChange(nextOpen);
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError(null);

    if (password !== confirmPassword) {
      setError('Passwords do not match');
      return;
    }

    const validation = validatePassword(password);
    if (!validation.valid) {
      setError('Password does not meet security requirements');
      return;
    }

    setLoading(true);

    const { error: updateError } = await supabase.auth.updateUser({ password });

    setLoading(false);

    if (updateError) {
      setError(updateError.message);
      return;
    }

    toast.success('Password updated successfully!');
    handleOpenChange(false);
  };

  return (
    <Dialog open={open} onOpenChange={handleOpenChange}>
      <DialogContent className="sm:max-w-md">
        <DialogHeader>
          <DialogTitle>Change Password</DialogTitle>
          <DialogDescription>
            Choose a new password for your account.
          </DialogDescription>
        </DialogHeader>
        <form onSubmit={handleSubmit} className="space-y-4">
          <PasswordFields
            password={password}
            confirmPassword={confirmPassword}
            onPasswordChange={setPassword}
            onConfirmPasswordChange={setConfirmPassword}
          />

          {error && (
            <Alert variant="destructive">
              <AlertDescription>{error}</AlertDescription>
            </Alert>
          )}

          <Button type="submit" className="w-full" disabled={loading}>
            {loading ? 'Updating...' : 'Update Password'}
          </Button>
        </form>
      </DialogContent>
    </Dialog>
  );
};

export default ChangePasswordDialog;
  • Step 2: Add the menu item

Full new content of src/components/auth/UserProfile.tsx:

import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useAuth } from '@/contexts/AuthContext';
import { User, LogOut, KeyRound } from 'lucide-react';
import { toast } from 'sonner';
import { useNavigate } from 'react-router-dom';
import ChangePasswordDialog from '@/components/auth/ChangePasswordDialog';

const UserProfile = () => {
  const { user, signOut } = useAuth();
  const navigate = useNavigate();
  const [changePasswordOpen, setChangePasswordOpen] = useState(false);

  const handleSignOut = async () => {
    try {
      await signOut();
      toast.success('Successfully signed out');
      navigate('/');
    } catch (error) {
      toast.error('Error signing out');
    }
  };

  if (!user) return null;

  const userInitials = user.email?.slice(0, 2).toUpperCase() || 'U';

  return (
    <>
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button variant="ghost" className="relative h-8 w-8 rounded-full">
            <Avatar className="h-8 w-8">
              <AvatarImage src={user.user_metadata?.avatar_url} />
              <AvatarFallback>{userInitials}</AvatarFallback>
            </Avatar>
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent className="w-56" align="end" forceMount>
          <DropdownMenuItem className="flex items-center">
            <User className="mr-2 h-4 w-4" />
            <span>{user.email}</span>
          </DropdownMenuItem>
          <DropdownMenuItem onClick={() => setChangePasswordOpen(true)} className="flex items-center">
            <KeyRound className="mr-2 h-4 w-4" />
            <span>Change password</span>
          </DropdownMenuItem>
          <DropdownMenuItem onClick={handleSignOut} className="flex items-center">
            <LogOut className="mr-2 h-4 w-4" />
            <span>Sign out</span>
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
      <ChangePasswordDialog open={changePasswordOpen} onOpenChange={setChangePasswordOpen} />
    </>
  );
};

export default UserProfile;
  • Step 3: Verify in browser

Run: npm run lint && npm run build Then in the dev server (signed in): profile avatar → “Change password” → dialog opens, requirements checklist updates live, submitting a valid password shows success toast and closes.

  • Step 4: Commit
git add src/components/auth/ChangePasswordDialog.tsx src/components/auth/UserProfile.tsx
git commit -m "feat: in-app Change password dialog in profile menu"

Task 5: Documentation

Files:

  • Modify: docs/api/authentication.md (add “Password recovery flow” section)
  • Modify: docs/frontend/routing.md (only if /reset-password description needs correcting)

  • Step 1: Add a “Password Recovery Flow” section to docs/api/authentication.md

Content to add (adjust placement to the document’s structure; add an **Updated**: timestamp line per CLAUDE.md standards, generated with date "+%a %d %b %Y %H:%M:%S %Z"):

## Password Recovery Flow (Plings-Web)

1. User requests a reset (AuthModal → "Forgot password?"). The app calls
   `supabase.auth.resetPasswordForEmail(email, { redirectTo: window.location.origin + '/reset-password' })`.
2. The e-mail link signs the user in with a recovery session and redirects to `/reset-password`,
   where a new password is set via `supabase.auth.updateUser({ password })`.
3. Safety net: `AuthContext` listens for the `PASSWORD_RECOVERY` auth event and forces
   navigation to `/reset-password`, even if Supabase falls back to the Site URL.
4. Signed-in users can change their password from the profile menu ("Change password").
   No current password is required (Supabase `updateUser` semantics).

### Required Supabase configuration

Auth → URL Configuration → Redirect URLs must include:

- `https://plings.io/reset-password`
- `http://localhost:8080/reset-password`

If the redirect URL is missing from this allowlist, Supabase silently falls back to the
Site URL and the user lands on the home page signed in, without a set-password step.
  • Step 2: Commit
git add docs/api/authentication.md docs/frontend/routing.md
git commit -m "docs: password recovery flow and Supabase redirect allowlist requirements"

Task 6: Supabase dashboard config + end-to-end verification (with Paul)

Files: none (dashboard configuration + manual testing)

  • Step 1: Update Supabase allowlist (manual — Paul)

In the Supabase dashboard (project gjzcqcxoacxkrchrzaen): Auth → URL Configuration → Redirect URLs, add:

  • https://plings.io/reset-password
  • http://localhost:8080/reset-password

  • Step 2: End-to-end test of recovery flow
  1. In dev server (signed out): AuthModal → “Forgot password?” → enter e-mail → submit.
  2. Open the e-mail, click the link.
  3. Expected: browser lands on http://localhost:8080/reset-password showing the form (NOT the home page).
  4. Set a new password meeting all requirements.
  5. Expected: success toast, redirect to /dashboard, still signed in.
  6. Sign out, sign in with the new password. Expected: success.
  • Step 3: End-to-end test of in-app change
  1. Signed in: profile menu → “Change password” → set another password.
  2. Sign out, sign in with the newest password. Expected: success.
  • Step 4: Test the safety net

Temporarily remove http://localhost:8080/reset-password from the allowlist, request a new reset link, click it. Expected: you land on the home page but are immediately redirected to /reset-password. Re-add the allowlist entry afterwards.


FINAL: Use quality-control-enforcer agent to validate implementation

Mandatory per CLAUDE.md (auth/security-sensitive code). Review scope: Tasks 1–5 diffs, focusing on auth state handling in AuthContext and the no-current-password decision documented in the spec.