@frank-auth/react
Version:
Flexible and customizable React UI components for Frank Authentication
640 lines (566 loc) • 19.1 kB
text/typescript
/**
* @frank-auth/react - useMagicLink Hook
*
* Magic link authentication hook that provides passwordless email-based
* authentication with customizable email templates and verification flow.
*/
import {useCallback, useEffect, useMemo, useState} from 'react';
import type {MagicLinkRequest,} from '@frank-auth/client';
import {useAuth} from './use-auth';
import {useConfig} from '../provider/config-provider';
import type {AuthError} from '../provider/types';
// ============================================================================
// Magic Link Hook Interface
// ============================================================================
export interface UseMagicLinkReturn {
// Magic link state
isLoading: boolean;
error: AuthError | null;
lastSentEmail: string | null;
lastSentAt: Date | null;
canResend: boolean;
timeUntilResend: number; // seconds
// Magic link operations
sendMagicLink: (email: string, options?: MagicLinkOptions) => Promise<MagicLinkSendResult>;
verifyMagicLink: (token: string) => Promise<MagicLinkVerifyResult>;
resendMagicLink: () => Promise<MagicLinkSendResult>;
// Magic link verification (for URL-based verification)
verifyFromUrl: (url?: string) => Promise<MagicLinkVerifyResult>;
extractTokenFromUrl: (url?: string) => string | null;
// Utility methods
isValidEmail: (email: string) => boolean;
clearState: () => void;
}
export interface MagicLinkOptions {
redirectUrl?: string;
organizationId?: string;
customData?: Record<string, any>;
template?: string;
expiresIn?: number; // seconds
locale?: string;
}
export interface MagicLinkSendResult {
success: boolean;
email: string;
message: string;
expiresAt: Date;
error?: string;
}
export interface MagicLinkVerifyResult {
success: boolean;
user?: any;
session?: any;
error?: string;
requiresAdditionalVerification?: boolean;
mfaToken?: string;
}
// ============================================================================
// Magic Link Configurations
// ============================================================================
export const MAGIC_LINK_CONFIG = {
// Default expiration time (15 minutes)
DEFAULT_EXPIRES_IN: 15 * 60,
// Minimum time between sends (60 seconds)
RESEND_COOLDOWN: 60,
// Email validation regex
EMAIL_REGEX: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
// Default templates
TEMPLATES: {
SIGN_IN: 'magic-link-sign-in',
SIGN_UP: 'magic-link-sign-up',
VERIFY_EMAIL: 'magic-link-verify-email',
PASSWORD_RESET: 'magic-link-password-reset',
},
// URL parameter names
URL_PARAMS: {
TOKEN: 'token',
EMAIL: 'email',
TYPE: 'type',
REDIRECT: 'redirect_to',
},
} as const;
// ============================================================================
// Main useMagicLink Hook
// ============================================================================
/**
* Magic link authentication hook for passwordless email authentication
*
* @example Basic magic link sign-in
* ```tsx
* import { useMagicLink } from '@frank-auth/react';
*
* function MagicLinkSignIn() {
* const {
* sendMagicLink,
* isLoading,
* error,
* lastSentEmail,
* canResend,
* resendMagicLink,
* isValidEmail
* } = useMagicLink();
*
* const [email, setEmail] = useState('');
* const [sent, setSent] = useState(false);
*
* const handleSend = async () => {
* if (!isValidEmail(email)) {
* alert('Please enter a valid email address');
* return;
* }
*
* try {
* const result = await sendMagicLink(email, {
* redirectUrl: '/dashboard',
* template: 'sign-in'
* });
*
* if (result.success) {
* setSent(true);
* }
* } catch (error) {
* console.error('Failed to send magic link:', error);
* }
* };
*
* const handleResend = async () => {
* try {
* await resendMagicLink();
* } catch (error) {
* console.error('Failed to resend magic link:', error);
* }
* };
*
* if (sent) {
* return (
* <div>
* <h3>Check your email</h3>
* <p>We sent a magic link to {lastSentEmail}</p>
* <p>Click the link in your email to sign in.</p>
*
* {canResend ? (
* <button onClick={handleResend} disabled={isLoading}>
* Resend magic link
* </button>
* ) : (
* <p>You can resend the link in a few seconds</p>
* )}
*
* {error && <p style={{color: 'red'}}>{error.message}</p>}
* </div>
* );
* }
*
* return (
* <div>
* <h3>Sign in with magic link</h3>
* <input
* type="email"
* value={email}
* onChange={(e) => setEmail(e.target.value)}
* placeholder="Enter your email address"
* disabled={isLoading}
* />
* <button onClick={handleSend} disabled={isLoading || !email}>
* {isLoading ? 'Sending...' : 'Send magic link'}
* </button>
*
* {error && <p style={{color: 'red'}}>{error.message}</p>}
* </div>
* );
* }
* ```
*
* @example Magic link verification page
* ```tsx
* import { useEffect, useState } from 'react';
* import { useMagicLink } from '@frank-auth/react';
* import { useSearchParams, useNavigate } from 'react-router-dom';
*
* function MagicLinkVerify() {
* const { verifyFromUrl, isLoading } = useMagicLink();
* const [searchParams] = useSearchParams();
* const navigate = useNavigate();
* const [status, setStatus] = useState('verifying');
*
* useEffect(() => {
* const verify = async () => {
* try {
* const result = await verifyFromUrl();
*
* if (result.success) {
* setStatus('success');
*
* // Check for MFA requirement
* if (result.requiresAdditionalVerification) {
* navigate('/mfa', { state: { mfaToken: result.mfaToken } });
* } else {
* // Redirect to dashboard or intended page
* const redirectTo = searchParams.get('redirect_to') || '/dashboard';
* navigate(redirectTo);
* }
* } else {
* setStatus('error');
* }
* } catch (error) {
* console.error('Magic link verification failed:', error);
* setStatus('error');
* }
* };
*
* verify();
* }, [verifyFromUrl, navigate, searchParams]);
*
* if (isLoading || status === 'verifying') {
* return <div>Verifying magic link...</div>;
* }
*
* if (status === 'success') {
* return <div>Success! Redirecting...</div>;
* }
*
* return (
* <div>
* <h3>Invalid or expired magic link</h3>
* <p>The magic link you clicked is invalid or has expired.</p>
* <button onClick={() => navigate('/sign-in')}>
* Try again
* </button>
* </div>
* );
* }
* ```
*
* @example Magic link with organization invitation
* ```tsx
* function OrganizationInvite({ invitationToken, organizationId }) {
* const { sendMagicLink } = useMagicLink();
* const [email, setEmail] = useState('');
*
* const handleAcceptInvite = async () => {
* try {
* await sendMagicLink(email, {
* organizationId,
* customData: {
* invitationToken,
* action: 'accept_invitation'
* },
* template: 'organization-invite',
* redirectUrl: `/organizations/${organizationId}/welcome`
* });
* } catch (error) {
* console.error('Failed to send invitation magic link:', error);
* }
* };
*
* return (
* <div>
* <h3>Join Organization</h3>
* <p>Enter your email to receive a secure link to join the organization.</p>
* <input
* type="email"
* value={email}
* onChange={(e) => setEmail(e.target.value)}
* placeholder="Enter your email address"
* />
* <button onClick={handleAcceptInvite}>
* Send invitation link
* </button>
* </div>
* );
* }
* ```
*/
export function useMagicLink(): UseMagicLinkReturn {
const {activeOrganization, reload, userType, sdk} = useAuth();
const {apiUrl, publishableKey, features, linksPath, frontendUrl} = useConfig();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<AuthError | null>(null);
const [lastSentEmail, setLastSentEmail] = useState<string | null>(null);
const [lastSentAt, setLastSentAt] = useState<Date | null>(null);
// Check if magic links are available
const isMagicLinkAvailable = useMemo(() => features.magicLink, [features.magicLink]);
// Calculate resend availability
const canResend = useMemo(() => {
if (!lastSentAt) return false;
const timeSinceLastSend = (Date.now() - lastSentAt.getTime()) / 1000;
return timeSinceLastSend >= MAGIC_LINK_CONFIG.RESEND_COOLDOWN;
}, [lastSentAt]);
const timeUntilResend = useMemo(() => {
if (!lastSentAt || canResend) return 0;
const timeSinceLastSend = (Date.now() - lastSentAt.getTime()) / 1000;
return Math.max(0, MAGIC_LINK_CONFIG.RESEND_COOLDOWN - timeSinceLastSend);
}, [lastSentAt, canResend]);
// Update countdown timer
useEffect(() => {
if (!lastSentAt || canResend) return;
const interval = setInterval(() => {
// This will trigger the useMemo recalculation
}, 1000);
return () => clearInterval(interval);
}, [lastSentAt, canResend]);
// Error handler
const handleError = useCallback((err: any) => {
const authError: AuthError = {
code: err.code || 'UNKNOWN_ERROR',
message: err.message || 'An unknown error occurred',
details: err.details,
field: err.field,
};
setError(authError);
throw authError;
}, []);
// Email validation
const isValidEmail = useCallback((email: string): boolean => {
return MAGIC_LINK_CONFIG.EMAIL_REGEX.test(email);
}, []);
// Extract token from URL
const extractTokenFromUrl = useCallback((url?: string): string | null => {
const urlToCheck = url || window.location.href;
try {
const urlObj = new URL(urlToCheck);
return urlObj.searchParams.get(MAGIC_LINK_CONFIG.URL_PARAMS.TOKEN);
} catch {
return null;
}
}, []);
// Send magic link
const sendMagicLink = useCallback(async (
email: string,
options?: MagicLinkOptions
): Promise<MagicLinkSendResult> => {
if (!isMagicLinkAvailable) throw new Error('Magic links not available');
if (!isValidEmail(email)) throw new Error('Invalid email address');
try {
setIsLoading(true);
setError(null);
const magicLinkRequest: MagicLinkRequest = {
email,
redirectUrl: options?.redirectUrl || `${frontendUrl ?? window.location.origin}${linksPath?.magicLink}`,
// organizationId: options?.organizationId || activeOrganization?.id,
// customData: options?.customData,
expiresIn: options?.expiresIn || MAGIC_LINK_CONFIG.DEFAULT_EXPIRES_IN,
// locale: options?.locale,
};
const response = await sdk.auth.sendMagicLink(magicLinkRequest);
// Update state
setLastSentEmail(email);
setLastSentAt(new Date());
return {
success: response.success,
email,
message: response.message,
expiresAt: new Date(Date.now() + (magicLinkRequest.expiresIn! * 1000)),
};
} catch (err) {
return {
success: false,
email,
expiresAt: new Date(),
error: err.message || 'Failed to send magic link',
};
} finally {
setIsLoading(false);
}
}, [sdk.auth, isMagicLinkAvailable, isValidEmail, activeOrganization]);
// Verify magic link
const verifyMagicLink = useCallback(async (token: string): Promise<MagicLinkVerifyResult> => {
if (!isMagicLinkAvailable) throw new Error('Magic links not available');
if (!token) throw new Error('Verification token is required');
try {
setIsLoading(true);
setError(null);
const response = await sdk.auth.verifyMagicLink(token);
if (response.session) {
// Reload auth state with new user session
await reload();
return {
success: true,
user: response.user,
session: response.session,
requiresAdditionalVerification: response.mfaRequired,
mfaToken: response.mfaToken,
};
} else {
return {
success: false,
error: response.error || 'Magic link verification failed',
};
}
} catch (err) {
return {
success: false,
error: err.message || 'Magic link verification failed',
};
} finally {
setIsLoading(false);
}
}, [sdk.auth, isMagicLinkAvailable, reload]);
// Verify magic link from URL
const verifyFromUrl = useCallback(async (url?: string): Promise<MagicLinkVerifyResult> => {
const token = extractTokenFromUrl(url);
if (!token) {
return {
success: false,
error: 'No verification token found in URL',
};
}
return verifyMagicLink(token);
}, [extractTokenFromUrl, verifyMagicLink]);
// Resend magic link
const resendMagicLink = useCallback(async (): Promise<MagicLinkSendResult> => {
if (!lastSentEmail) {
throw new Error('No previous magic link to resend');
}
if (!canResend) {
throw new Error(`Please wait ${Math.ceil(timeUntilResend)} seconds before resending`);
}
return sendMagicLink(lastSentEmail);
}, [lastSentEmail, canResend, timeUntilResend, sendMagicLink]);
// Clear state
const clearState = useCallback(() => {
setError(null);
setLastSentEmail(null);
setLastSentAt(null);
}, []);
return {
// Magic link state
isLoading,
error,
lastSentEmail,
lastSentAt,
canResend,
timeUntilResend,
// Magic link operations
sendMagicLink,
verifyMagicLink,
resendMagicLink,
// Magic link verification
verifyFromUrl,
extractTokenFromUrl,
// Utility methods
isValidEmail,
clearState,
};
}
// ============================================================================
// Specialized Magic Link Hooks
// ============================================================================
/**
* Hook for magic link sign-in flow
*/
export function useMagicLinkSignIn() {
const {
sendMagicLink,
isLoading,
error,
lastSentEmail,
canResend,
resendMagicLink,
isValidEmail,
clearState,
} = useMagicLink();
const [signInState, setSignInState] = useState<'idle' | 'email_sent' | 'verified'>('idle');
const signIn = useCallback(async (email: string, redirectUrl?: string) => {
if (!isValidEmail(email)) {
throw new Error('Please enter a valid email address');
}
try {
const result = await sendMagicLink(email, {
redirectUrl: redirectUrl || '/dashboard',
template: MAGIC_LINK_CONFIG.TEMPLATES.SIGN_IN,
});
if (result.success) {
setSignInState('email_sent');
}
return result;
} catch (error) {
setSignInState('idle');
throw error;
}
}, [sendMagicLink, isValidEmail]);
const reset = useCallback(() => {
setSignInState('idle');
clearState();
}, [clearState]);
return {
signIn,
resend: resendMagicLink,
reset,
state: signInState,
sentTo: lastSentEmail,
canResend,
isLoading,
error,
isValidEmail,
};
}
/**
* Hook for magic link verification flow
*/
export function useMagicLinkVerification() {
const {
verifyFromUrl,
verifyMagicLink,
extractTokenFromUrl,
isLoading,
error,
} = useMagicLink();
const [verificationState, setVerificationState] = useState<'idle' | 'verifying' | 'success' | 'error'>('idle');
const [verificationResult, setVerificationResult] = useState<MagicLinkVerifyResult | null>(null);
// Auto-verify if token is in URL
useEffect(() => {
const token = extractTokenFromUrl();
if (token && verificationState === 'idle') {
verify(token);
}
}, [extractTokenFromUrl, verificationState]);
const verify = useCallback(async (token?: string) => {
try {
setVerificationState('verifying');
const result = token
? await verifyMagicLink(token)
: await verifyFromUrl();
setVerificationResult(result);
setVerificationState(result.success ? 'success' : 'error');
return result;
} catch (error) {
setVerificationState('error');
setVerificationResult({
success: false,
error: error.message,
});
throw error;
}
}, [verifyMagicLink, verifyFromUrl]);
return {
verify,
state: verificationState,
result: verificationResult,
isVerifying: verificationState === 'verifying' || isLoading,
isSuccess: verificationState === 'success',
isError: verificationState === 'error',
error: error || verificationResult?.error,
requiresMFA: verificationResult?.requiresAdditionalVerification,
mfaToken: verificationResult?.mfaToken,
};
}
/**
* Hook for magic link password reset flow
*/
export function useMagicLinkPasswordReset() {
const {sendMagicLink, isValidEmail} = useMagicLink();
const sendResetLink = useCallback(async (email: string) => {
if (!isValidEmail(email)) {
throw new Error('Please enter a valid email address');
}
return sendMagicLink(email, {
template: MAGIC_LINK_CONFIG.TEMPLATES.PASSWORD_RESET,
redirectUrl: `${window.location.origin}/auth/reset-password`,
});
}, [sendMagicLink, isValidEmail]);
return {
sendResetLink,
isValidEmail,
};
}