feat: SPA-based password reset page

- Created ResetPassword.tsx with:
  - Password reset form with strength indicator
  - Key validation on load
  - Show/hide password toggle
  - Success/error states
  - Redirect to login on success

- Updated EmailManager.php:
  - Changed reset_link from wp-login.php to SPA route
  - Format: /wp-admin/admin.php?page=woonoow#/reset-password?key=KEY&login=LOGIN

- Added AuthController API methods:
  - validate_reset_key: Validates reset key before showing form
  - reset_password: Performs actual password reset

- Registered new REST routes in Routes.php:
  - POST /auth/validate-reset-key
  - POST /auth/reset-password

Password reset emails now link to the SPA instead of native WordPress.
This commit is contained in:
Dwindi Ramadhana
2026-01-03 16:59:05 +07:00
parent 3f8d15de61
commit 316fcbf2f0
5 changed files with 372 additions and 5 deletions

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { HashRouter, Routes, Route, NavLink, useLocation, useParams, Navigate, Link } from 'react-router-dom';
import { Login } from './routes/Login';
import ResetPassword from './routes/ResetPassword';
import Dashboard from '@/routes/Dashboard';
import DashboardRevenue from '@/routes/Dashboard/Revenue';
import DashboardOrders from '@/routes/Dashboard/Orders';
@@ -501,6 +502,7 @@ function AppRoutes() {
<Routes>
{/* Dashboard */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/dashboard/revenue" element={<DashboardRevenue />} />
<Route path="/dashboard/orders" element={<DashboardOrders />} />

View File

@@ -0,0 +1,255 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Loader2, CheckCircle, AlertCircle, Eye, EyeOff, Lock } from 'lucide-react';
import { __ } from '@/lib/i18n';
export default function ResetPassword() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const key = searchParams.get('key') || '';
const login = searchParams.get('login') || '';
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isValidating, setIsValidating] = useState(true);
const [isValid, setIsValid] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
// Validate the reset key on mount
useEffect(() => {
const validateKey = async () => {
if (!key || !login) {
setError(__('Invalid password reset link. Please request a new one.'));
setIsValidating(false);
return;
}
try {
const response = await fetch(`${window.WNW_CONFIG?.restUrl || '/wp-json/'}woonoow/v1/auth/validate-reset-key`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key, login }),
});
const data = await response.json();
if (response.ok && data.valid) {
setIsValid(true);
} else {
setError(data.message || __('This password reset link has expired or is invalid. Please request a new one.'));
}
} catch (err) {
setError(__('Unable to validate reset link. Please try again later.'));
} finally {
setIsValidating(false);
}
};
validateKey();
}, [key, login]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// Validate passwords match
if (password !== confirmPassword) {
setError(__('Passwords do not match'));
return;
}
// Validate password strength
if (password.length < 8) {
setError(__('Password must be at least 8 characters long'));
return;
}
setIsLoading(true);
try {
const response = await fetch(`${window.WNW_CONFIG?.restUrl || '/wp-json/'}woonoow/v1/auth/reset-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key, login, password }),
});
const data = await response.json();
if (response.ok) {
setSuccess(true);
} else {
setError(data.message || __('Failed to reset password. Please try again.'));
}
} catch (err) {
setError(__('An error occurred. Please try again later.'));
} finally {
setIsLoading(false);
}
};
// Password strength indicator
const getPasswordStrength = (pwd: string) => {
if (pwd.length === 0) return { label: '', color: '' };
if (pwd.length < 8) return { label: __('Too short'), color: 'text-red-500' };
let strength = 0;
if (pwd.length >= 8) strength++;
if (pwd.length >= 12) strength++;
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) strength++;
if (/\d/.test(pwd)) strength++;
if (/[!@#$%^&*(),.?":{}|<>]/.test(pwd)) strength++;
if (strength <= 2) return { label: __('Weak'), color: 'text-orange-500' };
if (strength <= 3) return { label: __('Medium'), color: 'text-yellow-500' };
return { label: __('Strong'), color: 'text-green-500' };
};
const passwordStrength = getPasswordStrength(password);
// Loading state
if (isValidating) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary mb-4" />
<p className="text-muted-foreground">{__('Validating reset link...')}</p>
</CardContent>
</Card>
</div>
);
}
// Success state
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center py-8">
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
<h2 className="text-xl font-semibold mb-2">{__('Password Reset Successful')}</h2>
<p className="text-muted-foreground text-center mb-6">
{__('Your password has been updated. You can now log in with your new password.')}
</p>
<Button onClick={() => navigate('/login')}>
{__('Go to Login')}
</Button>
</CardContent>
</Card>
</div>
);
}
// Error state (invalid key)
if (!isValid && error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center py-8">
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
<h2 className="text-xl font-semibold mb-2">{__('Invalid Reset Link')}</h2>
<p className="text-muted-foreground text-center mb-6">{error}</p>
<Button variant="outline" onClick={() => window.location.href = window.WNW_CONFIG?.siteUrl + '/my-account/lost-password/'}>
{__('Request New Reset Link')}
</Button>
</CardContent>
</Card>
</div>
);
}
// Reset form
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<div className="flex justify-center mb-4">
<div className="rounded-full bg-primary/10 p-3">
<Lock className="h-6 w-6 text-primary" />
</div>
</div>
<CardTitle className="text-2xl text-center">{__('Reset Your Password')}</CardTitle>
<CardDescription className="text-center">
{__('Enter your new password below')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="password">{__('New Password')}</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={__('Enter new password')}
required
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{password && (
<p className={`text-sm ${passwordStrength.color}`}>
{__('Strength')}: {passwordStrength.label}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">{__('Confirm Password')}</Label>
<Input
id="confirmPassword"
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={__('Confirm new password')}
required
/>
{confirmPassword && password !== confirmPassword && (
<p className="text-sm text-red-500">{__('Passwords do not match')}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{__('Resetting...')}
</>
) : (
__('Reset Password')
)}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -230,4 +230,100 @@ class AuthController {
'message' => __( 'Password reset email sent! Please check your inbox.', 'woonoow' ),
], 200 );
}
/**
* Validate password reset key
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response Response object
*/
public static function validate_reset_key( WP_REST_Request $request ): WP_REST_Response {
$key = sanitize_text_field( $request->get_param( 'key' ) );
$login = sanitize_text_field( $request->get_param( 'login' ) );
if ( empty( $key ) || empty( $login ) ) {
return new WP_REST_Response( [
'valid' => false,
'message' => __( 'Invalid password reset link', 'woonoow' ),
], 400 );
}
// Check the reset key
$user = check_password_reset_key( $key, $login );
if ( is_wp_error( $user ) ) {
$error_code = $user->get_error_code();
$message = __( 'This password reset link has expired or is invalid.', 'woonoow' );
if ( $error_code === 'invalid_key' ) {
$message = __( 'This password reset link is invalid.', 'woonoow' );
} elseif ( $error_code === 'expired_key' ) {
$message = __( 'This password reset link has expired. Please request a new one.', 'woonoow' );
}
return new WP_REST_Response( [
'valid' => false,
'message' => $message,
], 400 );
}
return new WP_REST_Response( [
'valid' => true,
'user' => [
'login' => $user->user_login,
'email' => $user->user_email,
],
], 200 );
}
/**
* Reset password with key
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response Response object
*/
public static function reset_password( WP_REST_Request $request ): WP_REST_Response {
$key = sanitize_text_field( $request->get_param( 'key' ) );
$login = sanitize_text_field( $request->get_param( 'login' ) );
$password = $request->get_param( 'password' );
if ( empty( $key ) || empty( $login ) || empty( $password ) ) {
return new WP_REST_Response( [
'success' => false,
'message' => __( 'Missing required fields', 'woonoow' ),
], 400 );
}
// Validate password strength
if ( strlen( $password ) < 8 ) {
return new WP_REST_Response( [
'success' => false,
'message' => __( 'Password must be at least 8 characters long', 'woonoow' ),
], 400 );
}
// Validate the reset key
$user = check_password_reset_key( $key, $login );
if ( is_wp_error( $user ) ) {
return new WP_REST_Response( [
'success' => false,
'message' => __( 'This password reset link has expired or is invalid. Please request a new one.', 'woonoow' ),
], 400 );
}
// Reset the password
reset_password( $user, $password );
// Delete the password reset key so it can't be reused
delete_user_meta( $user->ID, 'default_password_nag' );
// Trigger password changed action
do_action( 'password_reset', $user, $password );
return new WP_REST_Response( [
'success' => true,
'message' => __( 'Password reset successfully. You can now log in with your new password.', 'woonoow' ),
], 200 );
}
}

View File

@@ -79,6 +79,20 @@ class Routes {
'permission_callback' => '__return_true',
] );
// Validate password reset key (public)
register_rest_route( $namespace, '/auth/validate-reset-key', [
'methods' => 'POST',
'callback' => [ AuthController::class, 'validate_reset_key' ],
'permission_callback' => '__return_true',
] );
// Reset password with key (public)
register_rest_route( $namespace, '/auth/reset-password', [
'methods' => 'POST',
'callback' => [ AuthController::class, 'reset_password' ],
'permission_callback' => '__return_true',
] );
// Defer to controllers to register their endpoints
CheckoutController::register();
OrdersController::register();

View File

@@ -327,12 +327,12 @@ class EmailManager {
return $message; // Use WordPress default
}
// Build reset URL - use SPA route if available, otherwise WordPress default
$site_url = get_site_url();
// Build reset URL - use SPA route
$admin_url = admin_url('admin.php?page=woonoow');
// Check if this is a WooCommerce customer - use SPA reset page
// Otherwise fall back to WordPress default reset page
$reset_link = network_site_url("wp-login.php?action=rp&key=$key&login=" . rawurlencode($user_login), 'login');
// Build SPA reset password URL with hash router format
// Format: /wp-admin/admin.php?page=woonoow#/reset-password?key=KEY&login=LOGIN
$reset_link = $admin_url . '#/reset-password?key=' . $key . '&login=' . rawurlencode($user_login);
// Create a pseudo WC_Customer for template rendering
$customer = null;