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 React, { useEffect, useState } from 'react';
|
||||||
import { HashRouter, Routes, Route, NavLink, useLocation, useParams, Navigate, Link } from 'react-router-dom';
|
import { HashRouter, Routes, Route, NavLink, useLocation, useParams, Navigate, Link } from 'react-router-dom';
|
||||||
import { Login } from './routes/Login';
|
import { Login } from './routes/Login';
|
||||||
|
import ResetPassword from './routes/ResetPassword';
|
||||||
import Dashboard from '@/routes/Dashboard';
|
import Dashboard from '@/routes/Dashboard';
|
||||||
import DashboardRevenue from '@/routes/Dashboard/Revenue';
|
import DashboardRevenue from '@/routes/Dashboard/Revenue';
|
||||||
import DashboardOrders from '@/routes/Dashboard/Orders';
|
import DashboardOrders from '@/routes/Dashboard/Orders';
|
||||||
@@ -501,6 +502,7 @@ function AppRoutes() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
{/* Dashboard */}
|
{/* Dashboard */}
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/dashboard/revenue" element={<DashboardRevenue />} />
|
<Route path="/dashboard/revenue" element={<DashboardRevenue />} />
|
||||||
<Route path="/dashboard/orders" element={<DashboardOrders />} />
|
<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' ),
|
'message' => __( 'Password reset email sent! Please check your inbox.', 'woonoow' ),
|
||||||
], 200 );
|
], 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',
|
'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
|
// Defer to controllers to register their endpoints
|
||||||
CheckoutController::register();
|
CheckoutController::register();
|
||||||
OrdersController::register();
|
OrdersController::register();
|
||||||
|
|||||||
@@ -327,12 +327,12 @@ class EmailManager {
|
|||||||
return $message; // Use WordPress default
|
return $message; // Use WordPress default
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build reset URL - use SPA route if available, otherwise WordPress default
|
// Build reset URL - use SPA route
|
||||||
$site_url = get_site_url();
|
$admin_url = admin_url('admin.php?page=woonoow');
|
||||||
|
|
||||||
// Check if this is a WooCommerce customer - use SPA reset page
|
// Build SPA reset password URL with hash router format
|
||||||
// Otherwise fall back to WordPress default reset page
|
// Format: /wp-admin/admin.php?page=woonoow#/reset-password?key=KEY&login=LOGIN
|
||||||
$reset_link = network_site_url("wp-login.php?action=rp&key=$key&login=" . rawurlencode($user_login), 'login');
|
$reset_link = $admin_url . '#/reset-password?key=' . $key . '&login=' . rawurlencode($user_login);
|
||||||
|
|
||||||
// Create a pseudo WC_Customer for template rendering
|
// Create a pseudo WC_Customer for template rendering
|
||||||
$customer = null;
|
$customer = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user