Jake Vanderwerf
2026-06-29 4089ba01e0881c89a72332e13bc3a80b6bddec2a
inc/managers/MagicLinkManager.php
@@ -1,6 +1,10 @@
<?php
namespace JVBase\managers;
use JetBrains\PhpStorm\NoReturn;
use JVBase\base\Site;
use JVBase\meta\Form;
use JVBase\ui\Tabs;
use WP_User;
use WP_Error;
@@ -16,8 +20,8 @@
 */
class MagicLinkManager
{
   protected CacheManager $cache;
   protected EmailManager $email;
   protected Cache $cache;
   protected Cache $referral_cache;
   // Token settings
   protected int $token_expiry = 900; // 15 minutes in seconds
@@ -32,15 +36,13 @@
   public function __construct()
   {
      $this->cache = CacheManager::for('magic_links', $this->token_expiry);
      $this->email = new EmailManager();
      $this->cache = Cache::for('magic_links', $this->token_expiry);
      $this->referral_cache = Cache::for('referral_magic_links', 14 * DAY_IN_SECONDS);
      // Hook into WordPress auth flow
      add_action('template_redirect', [$this, 'handleMagicLinkClick']);
      add_action('wp_login_failed', [$this, 'handleFailedLogin']);
      // NOTE: LoginManager now handles the login form UI
      // If magic_link integration is enabled, LoginManager will call addMagicLinkOption()
      add_action('jvb_process_login_tokens', [$this, 'processRegistrationToken'], 10, 3);
   }
   /**
@@ -56,32 +58,25 @@
      // Validate email
      $email = sanitize_email($email);
      if (!is_email($email)) {
         error_log('Invalid email for Magic Link: '.$email);
         return new WP_Error('invalid_email', 'Invalid email address');
      }
      // Check rate limiting
      $rate_check = $this->checkRateLimit($email);
      if (is_wp_error($rate_check)) {
         error_log('Rate limit reached for Magic Link: '.$email);
         return $rate_check;
      }
      // Handle different link types
      switch ($type) {
         case self::TYPE_LOGIN:
            return $this->sendLoginLink($email, $context);
         case self::TYPE_SIGNUP:
            return $this->sendSignupLink($email, $context);
         case self::TYPE_REFERRAL:
            return $this->sendReferralLink($email, $context);
         case self::TYPE_RESET:
            return $this->sendResetLink($email, $context);
         default:
            return new WP_Error('invalid_type', 'Invalid magic link type');
      }
      return match ($type) {
         self::TYPE_LOGIN => $this->sendLoginLink($email, $context),
         self::TYPE_SIGNUP => $this->sendSignupLink($email, $context),
         self::TYPE_REFERRAL => $this->sendReferralLink($email, $context),
         self::TYPE_RESET => $this->sendResetLink($email, $context),
         default => new WP_Error('invalid_type', 'Invalid magic link type'),
      };
   }
   /**
@@ -97,7 +92,12 @@
         'created' => time()
      ], $data);
      $this->cache->set($token, $token_data);
      // Use longer expiry for referral tokens
      if ($type === self::TYPE_REFERRAL) {
         $this->referral_cache->set($token, $token_data);
      } else {
         $this->cache->set($token, $token_data);
      }
      return $token;
   }
@@ -105,11 +105,17 @@
   /**
    * Verify a token
    */
   protected function verifyToken(string $token, string $email): array|WP_Error
   public function verifyToken(string $token, string $email): array|WP_Error
   {
      // Try regular cache first, then referral cache
      $token_data = $this->cache->get($token);
      if (!$token_data) {
         $token_data = $this->referral_cache->get($token);
      }
      if (!$token_data) {
         error_log('Token not found. Checking cache stats...');
         return new WP_Error('invalid_token', 'Invalid or expired token');
      }
@@ -118,7 +124,12 @@
      }
      // Delete token after verification (single use)
      $this->cache->delete($token);
      // Check which cache it's in and delete from the correct one
      if ($token_data['type'] === 'referral') {
         $this->referral_cache->forget($token);
      } else {
         $this->cache->forget($token);
      }
      return $token_data;
   }
@@ -162,7 +173,8 @@
   {
      $user = get_user_by('email', $email);
      if (!$user) {
         return new WP_Error('user_not_found', 'No account found with this email');
         error_log('Attempted signin with no account, redirecting to signup link '.$email);
         return $this->sendSignupLink($email, $context);
      }
      $token = $this->generateToken($email, self::TYPE_LOGIN, [
@@ -171,7 +183,7 @@
      $magic_url = add_query_arg([
         'magic_token' => $token,
         'email' => urlencode($email),
         'email' => rawurlencode($email),
         'action' => 'magic_login'
      ], home_url('/'));
@@ -182,7 +194,7 @@
      $subject = 'Sign in to ' . get_bloginfo('name');
      $message = $this->getLoginEmailTemplate($user->display_name, $magic_url);
      $sent = $this->email->sendEmail($email, $subject, $message, 'Log in to '. get_bloginfo('name'));
      $sent = JVB()->email()->sendEmail($email, $subject, $message, 'Log in to '. get_bloginfo('name'));
      return $sent ? true : new WP_Error('email_failed', 'Failed to send magic link');
   }
@@ -199,7 +211,7 @@
      $token_data = [
         'name' => $context['name'] ?? '',
         'role' => $context['role'] ?? 'subscriber',
         'role' => $context['role'] ?? Site::getDefaultRole(),
         'meta' => $context['meta'] ?? []
      ];
@@ -207,14 +219,14 @@
      $magic_url = add_query_arg([
         'magic_token' => $token,
         'email' => urlencode($email),
         'email' => rawurlencode($email),
         'action' => 'magic_signup'
      ], home_url('/'));
      $subject = 'Complete your ' . get_bloginfo('name') . ' registration';
      $message = $this->getSignupEmailTemplate($context['name'] ?? '', $magic_url);
      $sent = $this->email->sendEmail($email, $subject, $message, 'Complete Registration');
      $sent = JVB()->email()->sendEmail($email, $subject, $message, 'Complete Registration');
      return $sent ? true : new WP_Error('email_failed', 'Failed to send signup link');
   }
@@ -231,24 +243,25 @@
      $token_data = [
         'referral_code' => $context['referral_code'],
         'name' => $context['name'] ?? '',
         'role' => $context['role'] ?? 'subscriber'
         'role' => $context['role'] ?? 'subscriber',
         'email'  => $email
      ];
      $token = $this->generateToken($email, self::TYPE_REFERRAL, $token_data);
      $magic_url = add_query_arg([
         'magic_token' => $token,
         'email' => urlencode($email),
         'email' => rawurlencode($email),
         'action' => 'magic_referral'
      ], home_url('/'));
      $referrer_name = $context['referrer_name'] ?? 'A friend';
      $reward_text = $context['reward_text'] ?? '';
      $subject = $referrer_name . ' invited you to join ' . get_bloginfo('name');
      $message = $this->getReferralEmailTemplate($context['name'] ?? '', $referrer_name, $magic_url, $reward_text);
      $subject = (array_key_exists('subject', $context) && $context['subject'] !== '') ? $context['subject'] : $referrer_name . ' invited you to join ' . get_bloginfo('name');
      $message = $this->getReferralEmailTemplate($context['name'] ?? '', $referrer_name, $magic_url, $reward_text, $context);
      $sent = $this->email->sendEmail($email, $subject, $message, 'Accept Invitation');
      $sent = JVB()->email()->sendEmail($email, $subject, $message, 'Accept Invitation');
      return $sent ? true : new WP_Error('email_failed', 'Failed to send referral link');
   }
@@ -269,14 +282,14 @@
      $magic_url = add_query_arg([
         'magic_token' => $token,
         'email' => urlencode($email),
         'email' => rawurlencode($email),
         'action' => 'magic_reset'
      ], home_url('/'));
      $subject = 'Reset your password';
      $message = $this->getResetEmailTemplate($user->display_name, $magic_url);
      $sent = $this->email->sendEmail($email, $subject, $message, 'Reset Password');
      $sent = JVB()->email()->sendEmail($email, $subject, $message, 'Reset Password');
      return $sent ? true : new WP_Error('email_failed', 'Failed to send reset link');
   }
@@ -286,13 +299,13 @@
    */
   public function handleMagicLinkClick(): void
   {
      if (!isset($_GET['action']) || !isset($_GET['magic_token']) || !isset($_GET['email'])) {
      if (!isset($_GET['magic_token']) || !isset($_GET['action']) || !isset($_GET['email'])) {
         return;
      }
      $action = sanitize_text_field($_GET['action']);
      $token = sanitize_text_field($_GET['magic_token']);
      $email = sanitize_email($_GET['email']);
      $email = sanitize_email(rawurldecode($_GET['email']));
      if (!in_array($action, ['magic_login', 'magic_signup', 'magic_referral', 'magic_reset'])) {
         return;
@@ -301,7 +314,7 @@
      $token_data = $this->verifyToken($token, $email);
      if (is_wp_error($token_data)) {
         $this->handleInvalidToken($token_data);
         $this->cleanURL();
         return;
      }
@@ -324,6 +337,19 @@
      }
   }
   #[NoReturn] protected function cleanURL():void
   {
      global $wp;
      $redirect = isset($_GET['redirect_to']) ? esc_url_raw($_GET['redirect_to']) : get_home_url(null, '/'.$wp->request);
      if (isset($_GET['magic_token'])) {
         error_log('============= Redirecting and removing magic token stuffs =============');
         wp_safe_redirect(remove_query_arg(['magic_token', 'action', 'email'], esc_url($redirect)));
         exit;
      }
      wp_safe_redirect(esc_url($redirect));
      exit;
   }
   /**
    * Process login via magic link
    */
@@ -340,18 +366,18 @@
      wp_set_auth_cookie($user->ID, true);
      do_action('wp_login', $user->user_login, $user);
      $redirect = isset($_GET['redirect_to']) ? esc_url_raw($_GET['redirect_to']) : home_url('/dash');
      wp_safe_redirect($redirect);
      exit;
      $this->cleanURL();
   }
   /**
    * Process signup via magic link
    */
   protected function processSignup(array $token_data): void
   #[NoReturn] protected function processSignup(array $token_data): void
   {
      if (!array_key_exists('email', $token_data) || !array_key_exists('name', $token_data)) {
         JVB()->error()->log('MagicLinkManager', 'Could not process Signup');
         $this->cleanURL();
      }
      $user_id = wp_create_user(
         $token_data['email'],
         wp_generate_password(20, true, true),
@@ -359,7 +385,7 @@
      );
      if (is_wp_error($user_id)) {
         wp_die('Failed to create account: ' . $user_id->get_error_message());
         $this->cleanURL();
      }
      $user = get_user_by('ID', $user_id);
@@ -382,57 +408,53 @@
      wp_set_current_user($user_id);
      wp_set_auth_cookie($user_id, true);
      do_action('user_register', $user_id);
      do_action('wp_login', $user->user_login, $user);
      wp_safe_redirect(home_url('/dash?welcome=1'));
      exit;
      $this->cleanURL();
   }
   /**
    * Process referral signup via magic link
    */
   /**
    * Process referral signup via magic link
    */
   protected function processReferralSignup(array $token_data): void
   {
      $user_id = wp_create_user(
         $token_data['email'],
         wp_generate_password(20, true, true),
         $token_data['email']
      );
      if (is_wp_error($user_id)) {
         wp_die('Failed to create account: ' . $user_id->get_error_message());
      if (!array_key_exists('email', $token_data) || !array_key_exists('name', $token_data)) {
         JVB()->error()->log('[MagicLinkManager]Could not process Referral Signup');
         return;
      }
      if (!empty($token_data['name'])) {
         wp_update_user([
            'ID' => $user_id,
            'display_name' => $token_data['name'],
            'first_name' => $token_data['name']
         ]);
      $email = sanitize_email($token_data['email']);
      if (email_exists($email)) {
         $this->processSignup($token_data);
      }
      // Store referral code for ReferralManager
      if (session_status() === PHP_SESSION_NONE) {
         session_start();
      $role = JVB()->referrals()->getRole();
      $pass = wp_generate_password(20, true, true);
      $name = sanitize_text_field($token_data['name']);
      $user_id = wp_insert_user([
         'user_login'   => $email,
         'user_email'   => $email,
         'user_pass'    => $pass,
         'display_name' => $name,
         'role'         => $role
      ]);
      if (!is_wp_error($user_id)) {
         $response = JVB()->routes('login')->login($email, $pass, true);
         if ($response) {
            wp_safe_redirect(home_url('/dash?welcome=1&referral=1'));
            exit;
         }
      } else {
         JVB()->error()->log(
            '[MagicLinkManager]',
            $user_id->get_error_message(),
            $token_data
         );
         $this->cleanURL();
      }
      $_SESSION[BASE . 'referral_code'] = $token_data['referral_code'];
      setcookie(
         BASE . 'referral_code',
         $token_data['referral_code'],
         time() + (86400 * 30),
         '/'
      );
      $user = get_user_by('ID', $user_id);
      wp_set_current_user($user_id);
      wp_set_auth_cookie($user_id, true);
      do_action('user_register', $user_id);
      do_action('wp_login', $user->user_login, $user);
      wp_safe_redirect(home_url('/dash?welcome=1&referral=1'));
      exit;
   }
   /**
@@ -443,26 +465,22 @@
      $user = get_user_by('ID', $token_data['user_id']);
      if (!$user) {
         wp_die('Invalid user');
         JVB()->error()->log(
            'MagicLinkManager',
            'Invalid user, could not reset password',
         );
         $this->cleanURL();
      }
      // Log user in and redirect to password change page
      wp_set_current_user($user->ID);
      wp_set_auth_cookie($user->ID, true);
      //TODO: hmmm
      wp_safe_redirect(admin_url('profile.php?password_reset=1'));
      exit;
   }
   /**
    * Handle invalid token
    */
   protected function handleInvalidToken(WP_Error $error): void
   {
      wp_die($error->get_error_message());
   }
   /**
    * Handle failed login - offer magic link option
    */
   public function handleFailedLogin(string $username): void
@@ -492,56 +510,103 @@
   protected function getLoginEmailTemplate(string $name, string $magic_url): string
   {
      $content = '<h2>Hey ' . esc_html($name) . '!</h2>';
      $content .= '<p>Click the button below to sign in to your account. This link expires in 15 minutes.</p>';
      $content .= '<p style="text-align: center; margin: 30px 0;">';
      $content .= '<a href="' . $magic_url . '" style="background: #2271b1; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">Sign In</a>';
      $content .= '</p>';
      $content .= '<p style="color: #666; font-size: 14px;">If you didn\'t request this, you can safely ignore this email.</p>';
      $content = JVB()->email()->h2('Hey ' . esc_html($name) . '!');
      $content .= '<p>Click the button below to sign in to your account instantly - no password needed!</p>';
      $content .= JVB()->email()->button($magic_url, 'Sign In Now');
      $content .= '<p>Or copy and paste this link into your browser:</p>';
      $content .= JVB()->email()->link($magic_url);
      $content .= JVB()->email()->divider();
      $content .= '<p>If you didn\'t request this, you can safely ignore this email. This link expires in 15 minutes.</p>';
      return $content;
   }
   protected function getSignupEmailTemplate(string $name, string $magic_url): string
   {
      $content = '<h2>Welcome' . ($name ? ', ' . esc_html($name) : '') . '!</h2>';
      $content .= '<p>You\'re almost there! Click the button below to complete your registration and access your account.</p>';
      $content .= '<p style="text-align: center; margin: 30px 0;">';
      $content .= '<a href="' . $magic_url . '" style="background: #2271b1; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">Complete Registration</a>';
      $content .= '</p>';
      $content .= '<p style="color: #666; font-size: 14px;">This link expires in 15 minutes.</p>';
      $content = JVB()->email()->h2('Welcome' . ($name ? ', ' . esc_html($name) : '') . '!');
      $content .= '<p>Click the button below to complete your registration and access your account!</p>';
      $content .= JVB()->email()->button($magic_url, 'Complete Registration');
      $content .= '<p>Or copy and paste this link:</p>';
      $content .= JVB()->email()->link($magic_url);
      $content .= JVB()->email()->spacer(10);
      $content .= '<p><small>This link expires in 24 hours.</small></p>';
      return $content;
   }
   protected function getReferralEmailTemplate(string $name, string $referrer_name, string $magic_url, string $reward_text): string
   protected function getReferralEmailTemplate(string $name, string $referrer_name, string $magic_url, string $reward_text, array $context): string
   {
      $content = '<h2>Hey' . ($name ? ' ' . esc_html($name) : '') . '!</h2>';
      $content .= '<p><strong>' . esc_html($referrer_name) . '</strong> thinks you\'d love ' . get_bloginfo('name') . '!</p>';
      $content = JVB()->email()->h2('Hey' . ($name ? ' ' . esc_html($name) : '') . '!');
      $content .= sprintf(
         '<p><strong>%s</strong> thinks you\'d love %s!</p>',
         esc_html($referrer_name),
         esc_html(get_bloginfo('name'))
      );
      if ($reward_text) {
         $content .= '<p>' . esc_html($reward_text) . '</p>';
      if (!empty($context['message'])) {
         $content .= JVB()->email()->callout(nl2br(esc_html($context['message'])));
      }
      $content .= '<p style="text-align: center; margin: 30px 0;">';
      $content .= '<a href="' . $magic_url . '" style="background: #2271b1; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">Join Now</a>';
      $content .= '</p>';
      $content .= '<p style="color: #666; font-size: 14px;">This link expires in 15 minutes.</p>';
      if ($reward_text) {
         $content .= JVB()->email()->alert(
            '<strong>Special Welcome Offer:</strong> ' . esc_html($reward_text),
            'success'
         );
      }
      $content .= JVB()->email()->button($magic_url, 'Join Now');
      $content .= '<p>Or copy and paste this link:</p>';
      $content .= JVB()->email()->link($magic_url);
      $content .= JVB()->email()->spacer(10);
      $content .= '<p><small>This invitation expires in 14 days.</small></p>';
      return $content;
   }
   protected function getResetEmailTemplate(string $name, string $magic_url): string
   public function renderForm():string
   {
      $content = '<h2>Hey ' . esc_html($name) . '!</h2>';
      $content .= '<p>We received a request to reset your password. Click the button below to sign in and update your password.</p>';
      $content .= '<p style="text-align: center; margin: 30px 0;">';
      $content .= '<a href="' . $magic_url . '" style="background: #2271b1; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">Reset Password</a>';
      $content .= '</p>';
      $content .= '<p style="color: #666; font-size: 14px;">If you didn\'t request this, you can safely ignore this email. This link expires in 15 minutes.</p>';
      ob_start();
      JVB()->connect('cloudflare')->renderTurnstile();
      $turnstile = ob_get_clean();
      global $wp;
      return $content;
       $magicLink = sprintf(
         '<form id="magic-link-form" data-save="auth/magic">
            %s%s
            <input type="hidden" name="redirect_to" value="%s">
            %s
            <button type="submit">%sLogin with Magic Link</button>
            <p class="hint"><a href="" data-tab="login">Prefer to use a password?</a></p>
         </form>
         <div class="success-content" hidden>
            <h3>Check Your Email!</h3>
            <p>We\'ve sent you a magic link to log in - no password required! Click the link in your email to log in.</p>
            <p class="hint">Can\'t find it? Check your spam folder.</p>
         </div>',
         jvbFormStatus(),
         Form::render('user_email', null, [
            'required'  => true,
            'type'      => 'email',
            'label'     => 'Your Email',
            'autocomplete'=>'email'
         ]),
         get_home_url(null,'/'.$wp->request),
         $turnstile,
         jvbIcon('magic-wand'),
//       wp_login_url()
      );
       $login = LoginManager::getInstance()->renderLoginForm('login', get_home_url(null,'/'.$wp->request));
       $tabs = new Tabs();
       $tabs->addTab('magic')
          ->title('With Magic Link')
          ->icon('magic-wand')
          ->content($magicLink);
       $tabs->addTab('login')
          ->title('With Password')
          ->icon('password')
          ->content($login);
       return $tabs->render();
   }
}
new MagicLinkManager();