From 316fcbf2f02444d3d1ded8872fc2f5742ff7b14c Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Sat, 3 Jan 2026 16:59:05 +0700 Subject: [PATCH] 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. --- admin-spa/src/App.tsx | 2 + admin-spa/src/routes/ResetPassword.tsx | 255 +++++++++++++++++++ includes/Api/AuthController.php | 96 +++++++ includes/Api/Routes.php | 14 + includes/Core/Notifications/EmailManager.php | 10 +- 5 files changed, 372 insertions(+), 5 deletions(-) create mode 100644 admin-spa/src/routes/ResetPassword.tsx diff --git a/admin-spa/src/App.tsx b/admin-spa/src/App.tsx index 4a2805b..6e19f8d 100644 --- a/admin-spa/src/App.tsx +++ b/admin-spa/src/App.tsx @@ -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() { {/* Dashboard */} } /> + } /> } /> } /> } /> diff --git a/admin-spa/src/routes/ResetPassword.tsx b/admin-spa/src/routes/ResetPassword.tsx new file mode 100644 index 0000000..c234c2a --- /dev/null +++ b/admin-spa/src/routes/ResetPassword.tsx @@ -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 ( +
+ + + +

{__('Validating reset link...')}

+
+
+
+ ); + } + + // Success state + if (success) { + return ( +
+ + + +

{__('Password Reset Successful')}

+

+ {__('Your password has been updated. You can now log in with your new password.')} +

+ +
+
+
+ ); + } + + // Error state (invalid key) + if (!isValid && error) { + return ( +
+ + + +

{__('Invalid Reset Link')}

+

{error}

+ +
+
+
+ ); + } + + // Reset form + return ( +
+ + +
+
+ +
+
+ {__('Reset Your Password')} + + {__('Enter your new password below')} + +
+ +
+ {error && ( + + + {error} + + )} + +
+ +
+ setPassword(e.target.value)} + placeholder={__('Enter new password')} + required + className="pr-10" + /> + +
+ {password && ( +

+ {__('Strength')}: {passwordStrength.label} +

+ )} +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder={__('Confirm new password')} + required + /> + {confirmPassword && password !== confirmPassword && ( +

{__('Passwords do not match')}

+ )} +
+ + +
+
+
+
+ ); +} diff --git a/includes/Api/AuthController.php b/includes/Api/AuthController.php index 18699a8..7f70817 100644 --- a/includes/Api/AuthController.php +++ b/includes/Api/AuthController.php @@ -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 ); + } } diff --git a/includes/Api/Routes.php b/includes/Api/Routes.php index ce120d8..968272a 100644 --- a/includes/Api/Routes.php +++ b/includes/Api/Routes.php @@ -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(); diff --git a/includes/Core/Notifications/EmailManager.php b/includes/Core/Notifications/EmailManager.php index 586f074..aef88be 100644 --- a/includes/Core/Notifications/EmailManager.php +++ b/includes/Core/Notifications/EmailManager.php @@ -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;