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:
@@ -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 />} />
|
||||
|
||||
255
admin-spa/src/routes/ResetPassword.tsx
Normal file
255
admin-spa/src/routes/ResetPassword.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user