<?php
|
namespace JVBase\managers;
|
|
use JVBase\blocks\CustomBlocks;
|
use JVBase\forms\TaxonomySelector;
|
use JVBase\meta\MetaManager;
|
use JVBase\meta\MetaForm;
|
use JVBase\managers\AjaxRateLimiter;
|
use JVBase\utility\Features;
|
use WP_Error;
|
use WP_User;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
class LoginManager
|
{
|
protected Features $siteFeatures;
|
protected ?MagicLinkManager $magicLink = null;
|
protected ?MetaForm $metaForm = null;
|
protected EmailManager $emailManager;
|
protected AjaxRateLimiter $rateLimiter;
|
|
|
protected array $forms =[];
|
protected array $labels = [];
|
protected array $fields = [];
|
protected ?string $action = null;
|
protected string $title = '';
|
|
// Token handlers registry
|
protected array $tokenHandlers = [];
|
protected array $messageHandlers = [];
|
|
private array $allowed_file_types = [
|
'image/jpeg',
|
'image/png',
|
'image/gif',
|
'application/pdf'
|
];
|
private int $max_file_size = 5242880; // 5MB in bytes
|
|
public function __construct()
|
{
|
$this->siteFeatures = Features::forSite();
|
$this->metaForm = new MetaForm();
|
$this->emailManager = new EmailManager();
|
$this->rateLimiter = new AjaxRateLimiter();
|
|
// Register default token handlers
|
$this->registerDefaultHandlers();
|
|
// Initialize magic link support if enabled
|
if ($this->siteFeatures->has('magicLink')) {
|
$this->initMagicLinkSupport();
|
}
|
|
// Create login page if it doesn't exist
|
$this->ensureLoginPageExists();
|
|
|
// Redirect wp-login.php to custom page
|
add_action('login_init', [$this, 'redirectToCustomLogin']);
|
add_action('template_include', [$this, 'renderLoginPage']);
|
|
add_action('wp_enqueue_scripts', [$this, 'enqueueScripts'], 15);
|
|
// Handle form submissions via AJAX
|
add_action('wp_ajax_nopriv_jvb_login', [$this, 'handleAjaxLogin']);
|
add_action('wp_ajax_nopriv_jvb_register', [$this, 'handleAjaxRegister']);
|
add_action('wp_ajax_nopriv_jvb_lostpassword', [$this, 'handleAjaxLostPassword']);
|
add_action('wp_ajax_nopriv_jvb_resetpass', [$this, 'handleAjaxResetPassword']);
|
|
// Login success handling
|
add_action('wp_login', [$this, 'handleSuccessfulLogin'], 10, 2);
|
|
// Allow other features to register handlers
|
do_action('jvbLoginManagerInit', $this);
|
}
|
|
/**************************************************************************
|
* SETUP & CONFIGURATION
|
**************************************************************************/
|
|
/**
|
* Redirect wp-login.php to custom login page
|
*/
|
public function redirectToCustomLogin(): void
|
{
|
// Don't redirect if AJAX or REST
|
if ((defined('DOING_AJAX') && DOING_AJAX) || (defined('REST_REQUEST') && REST_REQUEST)) {
|
return;
|
}
|
// Build custom login URL with all query args
|
$custom_login_page = home_url('/login');
|
$query_args = $_GET;
|
|
// Remove WordPress internal args
|
unset($query_args['interim-login'], $query_args['wp-auth-check']);
|
|
if (!empty($query_args)) {
|
$custom_login_page = add_query_arg($query_args, $custom_login_page);
|
}
|
|
wp_safe_redirect($custom_login_page);
|
exit;
|
}
|
protected function getRegistrationFormFields():array
|
{
|
$form = get_option(BASE.'registration_form_fields');
|
if (!$form) {
|
$form = [];
|
|
$select = [];
|
//Basic fields, for any
|
$fields = [
|
'name' => [
|
'type' => 'text',
|
'required' => true,
|
'label' => 'Your Name',
|
'placeholder'=> 'Mister Meseeks'
|
],
|
'email' => [
|
'type' => 'email',
|
'required' => true,
|
'label' => 'Your Email',
|
'placeholder'=> 'look@me.com'
|
]
|
];
|
if (count(JVB_USER) > 1) {
|
foreach (JVB_USER as $slug => $config) {
|
if (!array_key_exists('can_register', $config) || !$config['can_register']) {
|
continue;
|
}
|
$icon = $config['icon'] ?? '';
|
$icon = ($icon !== '') ? jvbIcon($icon) : '';
|
$select[$slug] = '<span class="label">'.$icon.$config['label'].'</span><span class="text">'.$config['register']['text']??''.'</span>';
|
if (!empty($config['register']['fields']??[])){
|
foreach ($config['register']['fields'] as $field) {
|
$field['condition'] = [
|
'field' => 'user_select',
|
'value' => $slug,
|
'operator' => '=='
|
];
|
$fields[] = $field;
|
}
|
}
|
}
|
if (!empty($select)) {
|
$select = array_merge(
|
[
|
'subscriber' => 'Subscriber',
|
],
|
$select
|
);
|
$form = array_merge(
|
[
|
'user_select' => [
|
'type' => 'radio',
|
'label' => 'Register as',
|
'options' => $select,
|
'required' => true,
|
'default' => 'subscriber'
|
]
|
],
|
$fields
|
);
|
}
|
}else {
|
$form = $fields;
|
}
|
update_option(BASE.'registration_form_fields', $form);
|
}
|
return $form;
|
|
}
|
|
protected function setupFields():void
|
{
|
$fields = [];
|
switch($this->action) {
|
case 'register':
|
$fields = $this->getRegistrationFormFields();
|
break;
|
case 'lostpassword':
|
$fields = [
|
'user_email' => [
|
'type' => 'email',
|
'label' => __('Email Address', 'jvb'),
|
'required' => true,
|
'placeholder' => 'look@me.com',
|
],
|
];
|
break;
|
case 'rp':
|
case 'resetpass':
|
$fields = [
|
'pass1' => [
|
'type' => 'text',
|
'subtype' => 'password',
|
'label' => __('New Password', 'jvb'),
|
'required' => true,
|
],
|
'pass2' => [
|
'type' => 'text',
|
'subtype' => 'password',
|
'label' => __('Confirm Password', 'jvb'),
|
'required' => true,
|
],
|
];
|
break;
|
case 'login':
|
$fields = [
|
'user_email' => [
|
'type' => 'email',
|
'label' => __('Email Address', 'jvb'),
|
'required' => true,
|
'placeholder' => 'look@me.com',
|
],
|
'user_password' => [
|
'type' => 'text',
|
'subtype'=> 'password',
|
'label' => __('Password', 'jvb'),
|
'required' => true,
|
],
|
'remember_me' => [
|
'type' => 'true_false',
|
'label' => __('Remember Me', 'jvb'),
|
'default' => true
|
]
|
];
|
break;
|
case 'postpass':
|
$fields = [
|
'post_password' => [
|
'type' => 'text',
|
'subtype' => 'password',
|
'label' => __('Password', 'jvb'),
|
'required' => true,
|
'hint' => 'This post is password protected. Please enter the password to view it.',
|
],
|
];
|
break;
|
case 'confirmaction':
|
|
break;
|
|
}
|
$this->fields = $fields;
|
}
|
|
/**
|
* Ensure login page exists
|
*/
|
protected function ensureLoginPageExists(): void
|
{
|
$login_page = $this->getLoginPage();
|
|
if (!$login_page || !is_int($login_page)) {
|
$page_id = get_page_by_path('login');
|
if (!$page_id) {
|
$page_id = wp_insert_post([
|
'post_title' => 'Login',
|
'post_name' => 'login',
|
'post_content' => '[jvb_login_form]',
|
'post_status' => 'publish',
|
'post_type' => 'page',
|
'post_author' => 1
|
]);
|
}
|
|
if ($page_id && !is_wp_error($page_id)) {
|
if (is_object($page_id)) {
|
$page_id = (int)$page_id->ID;
|
}
|
update_option(BASE.'login_page', $page_id);
|
// Hide from menus/search
|
update_post_meta($page_id, '_wp_page_template', 'default');
|
update_post_meta($page_id, BASE . 'exclude_from_search', true);
|
}
|
}
|
}
|
public function getLoginPage():int|false
|
{
|
return (int)get_option(BASE.'login_page');
|
}
|
|
public function isLoginPage():bool
|
{
|
return is_page($this->getLoginPage());
|
}
|
|
public static function isLogin():bool
|
{
|
$self = new self;
|
return $self->isLoginPage();
|
}
|
|
/**************************************************************************
|
TOKEN & MESSAGE HANDLERS
|
Extensible by other classes
|
**************************************************************************/
|
public function registerTokenHandler(string $token_key, callable $handler, int $priority = 10): void
|
{
|
if (!isset($this->tokenHandlers[$priority])) {
|
$this->tokenHandlers[$priority] = [];
|
}
|
|
$this->tokenHandlers[$priority][$token_key] = $handler;
|
ksort($this->tokenHandlers);
|
}
|
|
public function registerMessageHandler(string $type, callable $handler, ?callable $condition = null): void
|
{
|
$this->messageHandlers[$type] = [
|
'handler' => $handler,
|
'condition' => $condition
|
];
|
}
|
|
protected function registerDefaultHandlers(): void
|
{
|
// Invitation handler
|
if ($this->siteFeatures->has('invitations')) {
|
$this->registerTokenHandler('invite', function($token, $email, $user_id) {
|
if (isset($_POST['invite_token'])) {
|
JVB()->routes('invites')->acceptInvitation(
|
sanitize_text_field($_POST['invite_token']),
|
sanitize_email($_POST['invite_email']),
|
$user_id
|
);
|
}
|
});
|
|
$this->registerMessageHandler('invitation',
|
function() {
|
$data = JVB()->routes('invites')->verifyInvitation(
|
sanitize_text_field($_GET['invite']),
|
sanitize_email($_GET['email'])
|
);
|
$name = $data->name;
|
$inviters = json_decode($data->inviters, true);
|
$names = [];
|
|
foreach ($inviters as $inviter) {
|
$artist = jvbContentFromUser((int)$inviter['user_id']);
|
$names[] = ($artist['name'] === '') ? $artist['display_name'] : $artist['name'];
|
}
|
|
$message = (count($names) > 1)
|
? 'are already here, and have invited you to join in!'
|
: ' is already here, and invited you to join in!';
|
|
return '<h2>Join the Scene, '.$name.'</h2>
|
<p style="text-align:center;">'.jvbCommaList($names).$message.'</p>';
|
},
|
function() {
|
return isset($_GET['invite']) && isset($_GET['email']);
|
}
|
);
|
}
|
|
// List sharing handler (Favourites)
|
if ($this->siteFeatures->has('favourites')) {
|
$this->registerTokenHandler('list_token', function($token, $email, $user_id) {
|
if (!empty($_GET['list_token']) && !empty($_GET['email'])) {
|
JVB()->routes('favourites')->acceptListInvitation(
|
sanitize_text_field($_GET['list_token']),
|
sanitize_email($_GET['email']),
|
$user_id
|
);
|
}
|
});
|
|
$this->registerMessageHandler('favourites',
|
function() {
|
return '<h2>'.(JVB_LOGIN['login_from_favourite_header'] ?? 'Save your Favourites').'</h2>';
|
},
|
function() {
|
return isset($_GET['type']) && $_GET['type'] === 'favourites';
|
}
|
);
|
}
|
|
// Referral handler - FIXED VERSION
|
$this->registerTokenHandler('referral_code', function($code, $email, $user_id) {
|
// $code is already sanitized from processTokenHandlers
|
if (session_status() === PHP_SESSION_NONE) {
|
session_start();
|
}
|
$_SESSION[BASE . 'referral_code'] = $code;
|
setcookie(
|
BASE . 'referral_code',
|
$code,
|
time() + (86400 * 30),
|
'/'
|
);
|
}, 5);
|
}
|
|
protected function initMagicLinkSupport(): void
|
{
|
if (!Features::forSite()->has('magicLink')) {
|
return;
|
}
|
$this->magicLink = new MagicLinkManager();
|
}
|
|
|
|
/*********************************************************************
|
RENDERING
|
*********************************************************************/
|
public function renderLoginPage(string $template):string
|
{
|
if (!$this->isLoginPage()) {
|
return $template;
|
}
|
$this->setup();
|
ob_start();
|
jvbInlineStyles('nav');
|
jvbInlineStyles('dash');
|
jvbInlineStyles('forms');
|
$this->customStyles();
|
|
$this->renderHeader();
|
$this->renderForms();
|
$this->renderFooter();
|
|
echo ob_get_clean();
|
return '';
|
}
|
|
protected function setup():void
|
{
|
if (array_key_exists('action', $_GET)) {
|
switch ($_GET['action']){
|
case 'lostpassword':
|
case 'retrievepassword': // Alias
|
$action = 'lostpassword';
|
break;
|
case 'rp':
|
case 'resetpass':
|
$action = 'resetpass';
|
break;
|
default:
|
$action = $_GET['action'];
|
}
|
} else {
|
$action = 'login';
|
}
|
|
$this->action = $action;
|
$this->setupLabels();
|
$this->setupFields();
|
$this->setupTitle();
|
}
|
|
protected function setupTitle():void
|
{
|
switch ($this->action) {
|
case 'lostpassword':
|
$title = 'Lost Your Password?';
|
break;
|
case 'resetpass':
|
$title = 'Reset Your Password';
|
break;
|
case 'register':
|
$title = 'Create Your Account';
|
break;
|
default:
|
$title = 'Log In To Your Account';
|
}
|
$this->title = $title;
|
}
|
|
protected function customStyles():void
|
{
|
$logo = get_theme_mod('custom_logo');
|
$small = $large = '';
|
if ($logo) {
|
$small = wp_get_attachment_image_src($logo, 'medium')[0];
|
$large = wp_get_attachment_image_src($logo, 'large')[0];
|
|
}
|
echo '<style>
|
.login header,
|
.login footer {
|
display: none;
|
}
|
.login main {
|
display: flex;
|
flex-direction: column;
|
gap: 2rem;
|
justify-content: center;
|
position: relative;
|
}
|
.login main::before {
|
background-size: 20vw;
|
inset: 0;
|
z-index: 0;
|
content: "";
|
background-image: url("'.$small.'");
|
background-repeat: no-repeat;
|
position: absolute;
|
background-position: 40vw 1rem;
|
}
|
.login main .login-box {
|
--gap: .75rem;
|
padding: 1rem;
|
border-radius: var(--outerRadius);
|
background-color: var(--overlay-heavy);
|
box-shadow: var(--shadow-right), var(--shadow-down);
|
margin: 15vh auto 0!important;
|
}
|
.login main .login-box,
|
.login main .navigation {
|
z-index: 5;
|
max-width: 90vw!important;
|
}
|
.login main .navigation {
|
padding: 0 1rem;
|
margin: 0 auto!important;
|
font-size: var(--small);
|
}
|
.login-box .button {
|
--height: 2.5rem;
|
width: 100%;
|
}
|
.login-box .options {
|
padding: 0 .5rem;
|
}
|
label[for="user_select-subscriber"] {
|
position: absolute;
|
left: var(--offScreen);
|
}
|
|
@media (min-width:768px) {
|
.login main .navigation,
|
.login main .login-box {
|
max-width: 60vw!important;
|
margin: 0 2rem 0 auto!important;
|
}
|
.login main .login-box {
|
padding: 2rem;
|
--gap: 2rem;
|
}
|
.login main .navigation {
|
padding: 0 var(--offHeight);
|
}
|
|
.login-box .options {
|
padding: 0 4rem;
|
}
|
.login main::before {
|
background-size: 80vw;
|
inset: -5vw;
|
background-image: url("'.$large.'");
|
opacity: .25;
|
transform: rotate(-5deg);
|
background-position: -10vw center;
|
}
|
}
|
</style>';
|
}
|
|
protected function renderForms():void
|
{
|
|
$form = $this->action.'form';
|
|
?>
|
<section class="login-box col btw">
|
<h1><?=$this->labels['title']?></h1>
|
<?= $this->labels['description'] ?>
|
<form name="<?=$form?>" method="post" data-action="jvb_<?=$this->action?>">
|
<?php wp_nonce_field('jvb_'.$this->action, '_wpnonce'); ?>
|
<input type="hidden" name="action" value="jvb_<?=$this->action?>">
|
<input type="hidden" name="redirect_to" value="<?= esc_attr($_GET['redirect_to'] ?? '') ?>">
|
<input type="hidden" name="request_id" value="<?= wp_generate_password(16, false) ?>">
|
|
<?php
|
$this->addHiddenTokenFields();
|
|
foreach ($this->fields as $name => $config) {
|
$this->metaForm->render($name, '', $config);
|
}
|
|
$this->maybeTurnstile();
|
?>
|
<div class="row btw nowrap">
|
<button type="submit" class="button button-primary button-large">Log In</button>
|
<?php $this->maybeMagicLink(); ?>
|
</div>
|
</form>
|
|
<?php
|
if (is_array($this->labels['extra'])) {
|
echo '<div class="extra">';
|
foreach($this->labels['extra'] as $extra) {
|
echo '<p>'.$extra.'</p>';
|
}
|
echo '</div>';
|
} else if ($this->labels['extra']!=='') {
|
echo '<div class="extra">'.$this->labels['extra'].'</div>';
|
}
|
?>
|
|
<div class="options row btw">
|
<?php
|
switch ($this->action) {
|
case 'login': ?>
|
<a href="<?= add_query_arg('action', 'lostpassword', get_the_permalink()) ?>">Forgot Password?</a>
|
<a href="<?= add_query_arg('action', 'register', get_the_permalink()) ?>">Create Account</a>
|
<?php
|
break;
|
case 'register': ?>
|
<a href="<?= get_the_permalink() ?>">Or Login</a>
|
<a href="<?= add_query_arg('action', 'lostpassword', get_the_permalink()) ?>">Forgot Password?</a>
|
<?php
|
break;
|
case 'lostpassword': ?>
|
<a href="<?= get_the_permalink() ?>">Login Instead</a>
|
<a href="<?= add_query_arg('action', 'register', get_the_permalink()) ?>">Create Account</a>
|
<?php
|
break;
|
|
}
|
?>
|
|
</div>
|
</section>
|
<div class="navigation row btw">
|
<a href="<?= get_home_url() ?>">Home</a>
|
<?php
|
$privacy = get_privacy_policy_url();
|
if ($privacy !== '') { ?>
|
<a href="<?= $privacy ?>">Our Privacy Policy</a>
|
<?php } ?>
|
</div>
|
<?php
|
}
|
protected function renderHeader():void
|
{
|
?>
|
<!DOCTYPE html>
|
<html <?php language_attributes(); ?>>
|
<head>
|
<title><?= $this->title ?> | <?= get_bloginfo('name') ?></title>
|
<meta charset="<?php bloginfo('charset'); ?>">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<link rel="preconnect" href="<?= get_home_url()?>"/>
|
<?php wp_head(); ?>
|
</head>
|
<body class="login">
|
<?php jvbAccessibility();?>
|
<header>
|
<?php
|
$checked = (is_user_logged_in() && current_user_can('prefers_dark_theme', true)) ? ' checked' : '';
|
$title = ($checked == '') ? 'Toggle Dark Mode' : 'Toggle Light Mode';
|
echo '<label title="'.$title.'" id="theme-switch" class="toggle-switch" for="theme-switcher">
|
<input class="theme-switch row" id="theme-switcher" type="checkbox"'.$checked.' data-setting="theme" data-theme role="switch" name="dark-mode"><span class="slider">'.
|
jvbIcon('light', ['title'=> 'Light Mode']).
|
jvbIcon('dark', ['title'=>'Dark Mode']).
|
'</span></label>';
|
?>
|
<p class="title">
|
<a href="<?= get_home_url(); ?>" rel="home" title="Back to Site">
|
<?php
|
$icon = (int) get_option( 'site_icon' );
|
$out = '';
|
if ($icon > 0) {
|
$url = wp_get_attachment_image_url( $icon);
|
if ($url) {
|
$out = '<img src="'.$url.'">';
|
}
|
}
|
if ($out == '') {
|
$out =jvbIcon('home');
|
}
|
?><?= $out ?>
|
</a>
|
</p>
|
</header>
|
<main>
|
<?php
|
}
|
|
protected function renderFooter():void
|
{
|
?>
|
|
<footer class="col">
|
<?= $this->labels['footer'] ?>
|
<?= jvbLoadingScreen() ?>
|
<?= TaxonomySelector::outputSelectorModal() ?>
|
<?php
|
do_action('jvbLoginFooter');
|
?>
|
<p>Made with ♡ by <a href="https://jakevan.ca/">JakeVan</a></p>
|
</footer>
|
|
<?php wp_footer(); ?>
|
|
</body>
|
</html>
|
|
<?php
|
}
|
|
protected function addHiddenTokenFields(): void
|
{
|
foreach ($this->tokenHandlers as $priority => $handlers) {
|
foreach ($handlers as $token_key => $handler) {
|
if (isset($_GET[$token_key])) {
|
$value = sanitize_text_field($_GET[$token_key]);
|
echo '<input type="hidden" name="' . esc_attr($token_key) . '" value="' . esc_attr($value) . '">';
|
}
|
}
|
}
|
|
if (isset($_GET['email'])) {
|
echo '<input type="hidden" name="token_email" value="' . esc_attr(sanitize_email($_GET['email'])) . '">';
|
}
|
}
|
|
/*************************************************************************
|
AJAX HANDLERS
|
*************************************************************************/
|
public function handleAjaxLogin(): void
|
{
|
check_ajax_referer('jvb_login', '_wpnonce');
|
|
// Rate limiting
|
if (!$this->checkAjaxRateLimit('login')) {
|
wp_send_json_error([
|
'message' => 'Too many attempts. Please wait a moment.',
|
'code' => 'rate_limit'
|
], 429);
|
}
|
|
// Duplicate submission check
|
if (!$this->checkRequestId()) {
|
wp_send_json_error([
|
'message' => 'Duplicate request detected',
|
'code' => 'duplicate_request'
|
], 409);
|
}
|
|
$email = sanitize_email($_POST['user_email'] ?? '');
|
$password = $_POST['user_password'] ?? '';
|
$remember = !empty($_POST['remember_me']);
|
|
if (empty($email) || empty($password)) {
|
wp_send_json_error([
|
'message' => 'Please fill in all fields',
|
'field' => empty($email) ? 'user_email' : 'user_password',
|
'code' => 'missing_fields'
|
]);
|
}
|
|
// Verify Turnstile if enabled
|
if (!$this->verifyTurnstile()) {
|
wp_send_json_error([
|
'message' => 'Security verification failed',
|
'code' => 'turnstile_failed'
|
]);
|
}
|
|
$user = get_user_by('email', $email);
|
if (!$user) {
|
wp_send_json_error([
|
'message' => 'Unknown email address',
|
'field' => 'user_email',
|
'code' => 'invalid_email'
|
]);
|
}
|
|
$user = wp_authenticate($user->user_login, $password);
|
|
if (is_wp_error($user)) {
|
wp_send_json_error([
|
'message' => $user->get_error_message(),
|
'field' => 'user_password',
|
'code' => $user->get_error_code()
|
]);
|
}
|
|
wp_clear_auth_cookie();
|
wp_set_current_user($user->ID);
|
wp_set_auth_cookie($user->ID, $remember);
|
|
do_action('wp_login', $user->user_login, $user);
|
|
$redirect = $_POST['redirect_to'] ?? home_url('/dash');
|
wp_send_json_success(['redirect' => $redirect]);
|
}
|
|
public function handleAjaxRegister(): void
|
{
|
check_ajax_referer('jvb_register', '_wpnonce');
|
|
// Rate limiting
|
if (!$this->checkAjaxRateLimit('register')) {
|
wp_send_json_error([
|
'message' => 'Too many attempts. Please wait a moment.',
|
'code' => 'rate_limit'
|
], 429);
|
}
|
|
// Duplicate submission check
|
if (!$this->checkRequestId()) {
|
wp_send_json_error([
|
'message' => 'Duplicate request detected',
|
'code' => 'duplicate_request'
|
], 409);
|
}
|
|
// Verify Turnstile
|
if (!$this->verifyTurnstile()) {
|
wp_send_json_error([
|
'message' => 'Security verification failed',
|
'code' => 'turnstile_failed'
|
]);
|
}
|
|
$name = sanitize_text_field($_POST['name'] ?? '');
|
$email = sanitize_email($_POST['email'] ?? '');
|
$user_type = sanitize_text_field($_POST['user_select'] ?? 'subscriber');
|
|
// Spam prevention - if subscriber is selected and there are other options
|
if ($user_type === 'subscriber' && count(JVB_USER) > 0) {
|
$registerable = array_filter(JVB_USER, fn($config) => $config['can_register'] ?? false);
|
if (!empty($registerable)) {
|
wp_send_json_error([
|
'message' => 'Please select a valid account type',
|
'field' => 'user_select',
|
'code' => 'invalid_user_type'
|
]);
|
}
|
}
|
|
// Validate fields
|
if (empty($name)) {
|
wp_send_json_error([
|
'message' => 'Name is required',
|
'field' => 'name',
|
'code' => 'missing_name'
|
]);
|
}
|
|
if (empty($email)) {
|
wp_send_json_error([
|
'message' => 'Email is required',
|
'field' => 'email',
|
'code' => 'missing_email'
|
]);
|
}
|
|
// Check if role can register
|
if ($user_type !== 'subscriber') {
|
if (!isset(JVB_USER[$user_type]) || empty(JVB_USER[$user_type]['can_register'])) {
|
wp_send_json_error([
|
'message' => 'Invalid account type',
|
'field' => 'user_select',
|
'code' => 'invalid_user_type'
|
]);
|
}
|
}
|
|
// Check if email exists
|
if (email_exists($email)) {
|
wp_send_json_error([
|
'message' => 'Email already registered',
|
'field' => 'email',
|
'code' => 'duplicate_email'
|
]);
|
}
|
|
// Create user
|
$user_id = wp_create_user($email, wp_generate_password(), $email);
|
|
if (is_wp_error($user_id)) {
|
wp_send_json_error([
|
'message' => $user_id->get_error_message(),
|
'code' => 'user_creation_failed'
|
]);
|
}
|
|
// Update user data
|
wp_update_user([
|
'ID' => $user_id,
|
'display_name' => $name,
|
'first_name' => $name
|
]);
|
|
// Set role
|
$user = new WP_User($user_id);
|
if ($user_type === 'subscriber') {
|
$user->set_role('subscriber');
|
} else {
|
$role = JVB_USER[$user_type]['role'] ?? 'subscriber';
|
$user->set_role($role);
|
|
// Check if needs approval
|
if (Features::forMembership()->has('memberVerified') &&
|
in_array($role, JVB_MEMBERSHIP['memberVerified'] ?? [])) {
|
$user->add_cap('skip_moderation', false);
|
update_user_meta($user_id, BASE . 'pending_approval', true);
|
}
|
}
|
|
// Save additional fields
|
update_user_meta($user_id, BASE . 'user_type', $user_type);
|
|
// Process additional fields from form
|
foreach ($_POST as $key => $value) {
|
if (in_array($key, ['name', 'email', 'action', '_wpnonce', 'request_id', 'user_select'])) {
|
continue;
|
}
|
update_user_meta($user_id, BASE . $key, sanitize_text_field($value));
|
}
|
|
// Handle token handlers
|
$this->processTokenHandlers($user_id, $email);
|
|
// Send welcome email with password setup link
|
$this->sendWelcomeEmail($user_id);
|
|
// Trigger registration action for other systems
|
do_action('jvbAfterUserRegistration', $user_id, $user_type, $_POST);
|
|
wp_send_json_success([
|
'message' => 'Registration successful! Check your email.',
|
'title' => $this->labels['successTitle'] ?? 'Success!',
|
'description' => $this->labels['successDescription'] ?? 'Check your email for next steps',
|
'user_id' => $user_id // Important for file upload dependencies!
|
]);
|
}
|
|
public function handleAjaxLostPassword(): void
|
{
|
check_ajax_referer('jvb_lostpassword', '_wpnonce');
|
|
// Rate limiting
|
if (!$this->checkAjaxRateLimit('lostpassword')) {
|
wp_send_json_error([
|
'message' => 'Too many attempts. Please wait a moment.',
|
'code' => 'rate_limit'
|
], 429);
|
}
|
|
$email = sanitize_email($_POST['user_email'] ?? '');
|
|
if (empty($email)) {
|
wp_send_json_error([
|
'message' => 'Email required',
|
'field' => 'user_email',
|
'code' => 'missing_email'
|
]);
|
}
|
|
// Verify Turnstile
|
if (!$this->verifyTurnstile()) {
|
wp_send_json_error([
|
'message' => 'Security verification failed',
|
'code' => 'turnstile_failed'
|
]);
|
}
|
|
// Use WordPress's built-in function
|
$result = retrieve_password($email);
|
|
if (is_wp_error($result)) {
|
wp_send_json_error([
|
'message' => $result->get_error_message(),
|
'code' => $result->get_error_code()
|
]);
|
}
|
|
wp_send_json_success(['message' => 'Check your email for reset link']);
|
}
|
|
public function handleAjaxResetPassword(): void
|
{
|
check_ajax_referer('jvb_resetpass', '_wpnonce');
|
|
// Rate limiting
|
if (!$this->checkAjaxRateLimit('resetpass')) {
|
wp_send_json_error([
|
'message' => 'Too many attempts. Please wait a moment.',
|
'code' => 'rate_limit'
|
], 429);
|
}
|
|
$key = sanitize_text_field($_POST['key'] ?? $_GET['key'] ?? '');
|
$login = sanitize_text_field($_POST['login'] ?? $_GET['login'] ?? '');
|
$pass1 = $_POST['pass1'] ?? '';
|
$pass2 = $_POST['pass2'] ?? '';
|
|
if (empty($key) || empty($login)) {
|
wp_send_json_error([
|
'message' => 'Invalid reset link',
|
'code' => 'invalid_key'
|
]);
|
}
|
|
if (empty($pass1) || empty($pass2)) {
|
wp_send_json_error([
|
'message' => 'Please enter a password',
|
'field' => empty($pass1) ? 'pass1' : 'pass2',
|
'code' => 'missing_password'
|
]);
|
}
|
|
if ($pass1 !== $pass2) {
|
wp_send_json_error([
|
'message' => 'Passwords do not match',
|
'field' => 'pass2',
|
'code' => 'password_mismatch'
|
]);
|
}
|
|
// Verify reset key
|
$user = check_password_reset_key($key, $login);
|
|
if (is_wp_error($user)) {
|
wp_send_json_error([
|
'message' => 'Invalid or expired reset link',
|
'code' => 'invalid_key'
|
]);
|
}
|
|
// Reset password
|
reset_password($user, $pass1);
|
|
wp_send_json_success([
|
'message' => 'Password reset successfully',
|
'redirect' => home_url('/login')
|
]);
|
}
|
|
|
/**********************************************************************
|
TOKEN PROCESSING
|
**********************************************************************/
|
protected function processTokenHandlers(int $user_id, string $email): void
|
{
|
foreach ($this->tokenHandlers as $priority => $handlers) {
|
foreach ($handlers as $token_key => $handler) {
|
if (isset($_POST[$token_key]) || isset($_GET[$token_key])) {
|
$token_value = $_POST[$token_key] ?? $_GET[$token_key];
|
call_user_func($handler, sanitize_text_field($token_value), $email, $user_id);
|
}
|
}
|
}
|
}
|
|
|
/***********************************************************************
|
EMAIL SENDING
|
***********************************************************************/
|
protected function sendWelcomeEmail(int $user_id): void
|
{
|
$user = get_userdata($user_id);
|
if (!$user) {
|
return;
|
}
|
|
// Generate password reset key
|
$key = get_password_reset_key($user);
|
if (is_wp_error($key)) {
|
error_log('Failed to generate password reset key: ' . $key->get_error_message());
|
return;
|
}
|
|
$reset_url = add_query_arg([
|
'action' => 'rp',
|
'key' => $key,
|
'login' => rawurlencode($user->user_login)
|
], home_url('/login'));
|
|
$subject = $this->labels['email'] ?? 'Welcome to ' . get_bloginfo('name');
|
|
$message = '<h2>Welcome, ' . esc_html($user->display_name) . '!</h2>';
|
$message .= '<p>Your account has been created. Click the button below to set your password and get started:</p>';
|
$message .= jvbMailButton($reset_url, 'Set Your Password');
|
$message .= '<p>This link expires in 24 hours.</p>';
|
|
$this->emailManager->sendEmail($user->user_email, $subject, $message);
|
}
|
|
/*************************************************************************
|
* SECURITY & VALIDATION
|
*************************************************************************/
|
protected function checkAjaxRateLimit(string $action): bool
|
{
|
return $this->rateLimiter->checkLimit($action);
|
}
|
protected function checkRequestId(): bool
|
{
|
$request_id = $_POST['request_id'] ?? '';
|
if (empty($request_id)) {
|
return true; // No request_id provided, allow (for backward compat)
|
}
|
|
$cache_key = 'request_' . $request_id;
|
if (get_transient($cache_key)) {
|
return false; // Duplicate request
|
}
|
|
// Store request ID for 1 minute to prevent duplicates
|
set_transient($cache_key, true, 60);
|
return true;
|
}
|
|
protected function maybeTurnstile(): void
|
{
|
if (!Features::hasIntegration('cloudflare')) {
|
return;
|
}
|
JVB()->connect('cloudflare')->renderTurnstile();
|
}
|
|
protected function maybeTurnstileScripts(): void
|
{
|
if (!Features::hasIntegration('cloudflare')) {
|
return;
|
}
|
JVB()->connect('cloudflare')->enqueueTurnstileScripts();
|
}
|
|
protected function verifyTurnstile(): bool
|
{
|
if (!Features::hasIntegration('cloudflare')) {
|
return true; // Not enabled, pass verification
|
}
|
|
$token = $_POST['cf-turnstile-response'] ?? '';
|
if (empty($token)) {
|
return false;
|
}
|
|
return JVB()->connect('cloudflare')->verifyTurnstile($token);
|
}
|
|
/************************************************************************
|
LABELS & UI
|
************************************************************************/
|
protected function setupLabels(): void
|
{
|
$default = $this->getDefaultLabels();
|
$this->labels = apply_filters('jvbLoginLabels', $default, $_GET);
|
|
foreach (['description', 'footer', 'extra'] as $location) {
|
$text = (!is_array($this->labels[$location])) ? [$this->labels[$location]] : $this->labels[$location];
|
if (!empty($text)) {
|
$this->labels[$location] = '<div class="'.$location.'">';
|
foreach ($text as $d) {
|
$this->labels[$location] .= '<p>'.$d.'</p>';
|
}
|
$this->labels[$location] .= '</div>';
|
}
|
}
|
}
|
|
protected function getDefaultLabels(): array
|
{
|
switch ($this->action) {
|
case 'register':
|
return [
|
'title' => JVB_LOGIN['register']['title'] ?? 'Create Your Account',
|
'description' => JVB_LOGIN['register']['description'] ?? [],
|
'extra' => JVB_LOGIN['register']['extra'] ?? [],
|
'footer' => JVB_LOGIN['register']['footer'] ?? '',
|
'email' => JVB_LOGIN['register']['email']['subject'] ?? '['.get_bloginfo('name').'] Finish Creating Your Account',
|
'submit' => JVB_LOGIN['register']['submit'] ?? 'Create Account',
|
'successTitle' => JVB_LOGIN['register']['success']['title'] ?? 'Success!',
|
'successDescription' => JVB_LOGIN['register']['success']['description'] ?? ['See your email for next steps','(Check your spam folder if you cannot find it after a couple minutes.)'],
|
];
|
case 'lostpassword':
|
return [
|
'title' => JVB_LOGIN['forgot_password']['title'] ?? 'Reset Password',
|
'description' => JVB_LOGIN['forgot_password']['description'] ?? [],
|
'extra' => JVB_LOGIN['forgot_password']['extra'] ?? [],
|
'footer' => JVB_LOGIN['forgot_password']['footer'] ?? '',
|
'submit' => JVB_LOGIN['forgot_password']['submit'] ?? 'Send Reset Link',
|
'successTitle' => JVB_LOGIN['forgot_password']['success']['title'] ?? 'Success!',
|
'successDescription' => JVB_LOGIN['forgot_password']['success']['description'] ?? ['Check your email for reset instructions'],
|
];
|
case 'resetpass':
|
return [
|
'title' => JVB_LOGIN['reset_pass']['title'] ?? 'Reset Your Password',
|
'description' => JVB_LOGIN['reset_pass']['description'] ?? [],
|
'extra' => JVB_LOGIN['reset_pass']['extra'] ?? [],
|
'footer' => JVB_LOGIN['reset_pass']['footer'] ?? '',
|
'submit' => JVB_LOGIN['reset_pass']['submit'] ?? 'Reset Password',
|
];
|
case 'login':
|
default:
|
return [
|
'title' => JVB_LOGIN['login']['title'] ?? 'Sign in',
|
'description' => JVB_LOGIN['login']['description'] ?? [],
|
'extra' => JVB_LOGIN['login']['extra'] ?? [],
|
'footer' => JVB_LOGIN['login']['footer'] ?? '',
|
'submit' => JVB_LOGIN['login']['submit'] ?? 'Sign In',
|
];
|
}
|
}
|
|
protected function maybeMagicLink(): void
|
{
|
if (!$this->magicLink || !in_array($this->action, ['login', 'lostpassword'])) {
|
return;
|
}
|
?>
|
<button type="button" id="magic-link-btn" class="button button-secondary button-large">
|
<?= jvbIcon('email', ['size' => 20]); ?>
|
Get Login Link
|
</button>
|
<script type="text/javascript">
|
document.getElementById('magic-link-btn')?.addEventListener('click', function(e) {
|
e.preventDefault();
|
const email = document.querySelector('input[name="user_email"]')?.value;
|
if (!email) {
|
alert('Please enter your email address first');
|
return;
|
}
|
|
fetch('<?= rest_url('jvb/v1/magic-link'); ?>', {
|
method: 'POST',
|
headers: {
|
'Content-Type': 'application/json',
|
'X-WP-Nonce': '<?= wp_create_nonce('wp_rest') ?>'
|
},
|
body: JSON.stringify({ email: email, type: 'login' })
|
})
|
.then(r => r.json())
|
.then(data => {
|
alert(data.success ? 'Check your email!' : (data.message || 'Failed to send link'));
|
});
|
});
|
</script>
|
<?php
|
}
|
|
|
/************************************************************************
|
SCRIPTS
|
************************************************************************/
|
public function enqueueScripts(): void
|
{
|
if (!$this->isLoginPage()) {
|
return;
|
}
|
|
$this->maybeTurnstileScripts();
|
wp_enqueue_script('jvb-form');
|
|
$script = "
|
document.addEventListener('DOMContentLoaded', () => {
|
const form = document.querySelector('.login form');
|
if (form && window.jvbForm) {
|
let controller = new window.jvbForm();
|
controller.registerForm(form, {
|
autosave: false,
|
endpoint: false
|
});
|
} else if (form && !window.jvbForm) {
|
console.error('jvbForm not loaded');
|
}
|
});";
|
|
wp_add_inline_script('jvb-form', $script);
|
}
|
|
/*************************************************************************
|
SUCCESS HANDLING
|
*************************************************************************/
|
public function handleSuccessfulLogin(string $username, WP_User $user): void
|
{
|
if (isOurPeople() && !user_can($user, 'manage_options')) {
|
wp_redirect(get_home_url(null, '/dash'));
|
exit;
|
}
|
}
|
|
|
/**
|
* Handle login errors
|
*/
|
protected function handleLoginError(WP_Error $error): void
|
{
|
$login_url = wp_login_url();
|
$login_url = add_query_arg('login_error', urlencode($error->get_error_code()), $login_url);
|
|
if (isset($_REQUEST['redirect_to'])) {
|
$login_url = add_query_arg('redirect_to', urlencode($_REQUEST['redirect_to']), $login_url);
|
}
|
|
wp_safe_redirect($login_url);
|
exit;
|
}
|
}
|
|
// Initialize the login manager
|
new LoginManager();
|