| | |
| | | <?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; |
| | | |
| | |
| | | */ |
| | | class MagicLinkManager |
| | | { |
| | | protected CacheManager $cache; |
| | | protected CacheManager $referral_cache; |
| | | protected Cache $cache; |
| | | protected Cache $referral_cache; |
| | | |
| | | // Token settings |
| | | protected int $token_expiry = 900; // 15 minutes in seconds |
| | |
| | | |
| | | public function __construct() |
| | | { |
| | | $this->cache = CacheManager::for('magic_links', $this->token_expiry); |
| | | $this->referral_cache = CacheManager::for('referral_magic_links', 14 * DAY_IN_SECONDS); |
| | | $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']); |
| | |
| | | // 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'), |
| | | }; |
| | | } |
| | | |
| | | /** |
| | |
| | | // Delete token after verification (single use) |
| | | // Check which cache it's in and delete from the correct one |
| | | if ($token_data['type'] === 'referral') { |
| | | $this->referral_cache->delete($token); |
| | | $this->referral_cache->forget($token); |
| | | } else { |
| | | $this->cache->delete($token); |
| | | $this->cache->forget($token); |
| | | } |
| | | |
| | | return $token_data; |
| | |
| | | { |
| | | $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, [ |
| | |
| | | |
| | | $token_data = [ |
| | | 'name' => $context['name'] ?? '', |
| | | 'role' => $context['role'] ?? 'subscriber', |
| | | 'role' => $context['role'] ?? Site::getDefaultRole(), |
| | | 'meta' => $context['meta'] ?? [] |
| | | ]; |
| | | |
| | |
| | | */ |
| | | 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; |
| | | } |
| | | |
| | |
| | | $token_data = $this->verifyToken($token, $email); |
| | | |
| | | if (is_wp_error($token_data)) { |
| | | $this->handleInvalidToken($token_data); |
| | | $this->cleanURL(); |
| | | return; |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | #[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 |
| | | */ |
| | |
| | | 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'); |
| | | return; |
| | | JVB()->error()->log('MagicLinkManager', 'Could not process Signup'); |
| | | $this->cleanURL(); |
| | | } |
| | | $user_id = wp_create_user( |
| | | $token_data['email'], |
| | |
| | | ); |
| | | |
| | | 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); |
| | |
| | | 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(); |
| | | } |
| | | |
| | | /** |
| | |
| | | |
| | | $email = sanitize_email($token_data['email']); |
| | | if (email_exists($email)) { |
| | | wp_die('Looks like you already have an account!'); |
| | | $this->processSignup($token_data); |
| | | } |
| | | |
| | | $role = JVB()->referrals()->getRole(); |
| | | $pass = wp_generate_password(20, true, true); |
| | | $name = sanitize_text_field($token_data['name']); |
| | |
| | | $user_id->get_error_message(), |
| | | $token_data |
| | | ); |
| | | $this->cleanURL(); |
| | | } |
| | | } |
| | | |
| | |
| | | $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 |
| | |
| | | |
| | | 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 .= JVB()->email()->button($magic_url, 'Sign In'); |
| | | $content .= '<p>Or copy and paste this link into your browser of choice:</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 .= '<p>If you didn\'t request this, you can safely ignore this email. The link will expire in 15 minutes.</p>'; |
| | | $content .= JVB()->email()->signature(); |
| | | |
| | | $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 = 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 into your browser of choice:</p>'; |
| | | $content .= '<p>Or copy and paste this link:</p>'; |
| | | $content .= JVB()->email()->link($magic_url); |
| | | $content .= '<p>This link expires in 15 minutes.</p>'; |
| | | $content .= JVB()->email()->signature(); |
| | | |
| | | $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, 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 (array_key_exists('message', $context) && $context['message']!== '') { |
| | | $content .= wpautop($context['message']); |
| | | if (!empty($context['message'])) { |
| | | $content .= JVB()->email()->callout(nl2br(esc_html($context['message']))); |
| | | } |
| | | |
| | | if ($reward_text) { |
| | | $content .= '<p>' . esc_html($reward_text) . '</p>'; |
| | | $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 into your browser of choice:</p>'; |
| | | $content .= '<p>Or copy and paste this link:</p>'; |
| | | $content .= JVB()->email()->link($magic_url); |
| | | $content .= '<p>This link expires in 14 days.</p>'; |
| | | $content .= JVB()->email()->signature(); |
| | | $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 .= JVB()->email()->button($magic_url, 'Reset Password'); |
| | | $content .= '<p>Or copy and paste this link into your browser of choice:</p>'; |
| | | $content .= JVB()->email()->link($magic_url); |
| | | $content .= '<p>If you didn\'t request this, you can safely ignore this email. This link expires in 15 minutes.</p>'; |
| | | $content .= JVB()->email()->signature(); |
| | | return $content; |
| | | ob_start(); |
| | | JVB()->connect('cloudflare')->renderTurnstile(); |
| | | $turnstile = ob_get_clean(); |
| | | global $wp; |
| | | |
| | | $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(); |
| | | } |
| | | } |