@@ -116,7 +95,7 @@ const MagicLinkSent: React.FC = () => {
If an account exists for this address, we sent a secure sign-in link.
-
{identifier}
+ {identifier &&
{identifier}
}
Open the email and click the link to finish signing in.
diff --git a/src/context/InternalAuthContext.tsx b/src/context/InternalAuthContext.tsx
deleted file mode 100644
index 3eaf7c7..0000000
--- a/src/context/InternalAuthContext.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright © 2026 Fells Code, LLC
- * Licensed under the GNU Affero General Public License v3.0
- * See LICENSE file in the project root for full license information
- */
-
-import { createContext, Dispatch, ReactNode, SetStateAction, useContext } from 'react';
-
-interface InternalAuthContextType {
- validateToken: () => Promise;
- setLoading: Dispatch>;
-}
-
-const InternalAuthContext = createContext(undefined);
-
-export const InternalAuthProvider = ({
- value,
- children,
-}: {
- value: InternalAuthContextType;
- children: ReactNode;
-}) => {
- return (
- {children}
- );
-};
-
-// This is internal. Do NOT export from index.ts
-export const useInternalAuth = () => {
- const context = useContext(InternalAuthContext);
- if (!context) {
- throw new Error('useInternalAuth must be used within InternalAuthProvider');
- }
- return context;
-};
diff --git a/src/fetchWithAuth.ts b/src/fetchWithAuth.ts
index 62b1140..81e59df 100644
--- a/src/fetchWithAuth.ts
+++ b/src/fetchWithAuth.ts
@@ -33,9 +33,10 @@ export const createFetchWithAuth = (opts: FetchWithAuthOptions) => {
const response = await fetch(url, requestInit);
- if (response.ok) return response;
+ if (!response.ok) {
+ console.warn('Auth fetch failed:', response.status, url);
+ }
- console.warn('Auth fetch failed:', response.status, url);
- throw new Error(`Failed to make API call to ${url}`);
+ return response;
};
};
diff --git a/src/hooks/useAuthClient.ts b/src/hooks/useAuthClient.ts
new file mode 100644
index 0000000..4f4935b
--- /dev/null
+++ b/src/hooks/useAuthClient.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright © 2026 Fells Code, LLC
+ * Licensed under the GNU Affero General Public License v3.0
+ * See LICENSE file in the project root for full license information
+ */
+
+import { useMemo } from 'react';
+
+import { useAuth } from '@/AuthProvider';
+import { createSeamlessAuthClient } from '@/client/createSeamlessAuthClient';
+
+export const useAuthClient = () => {
+ const { apiHost, mode } = useAuth();
+
+ return useMemo(
+ () =>
+ createSeamlessAuthClient({
+ apiHost,
+ mode,
+ }),
+ [apiHost, mode]
+ );
+};
diff --git a/src/hooks/usePasskeySupport.ts b/src/hooks/usePasskeySupport.ts
new file mode 100644
index 0000000..c9b440b
--- /dev/null
+++ b/src/hooks/usePasskeySupport.ts
@@ -0,0 +1,43 @@
+/*
+ * Copyright © 2026 Fells Code, LLC
+ * Licensed under the GNU Affero General Public License v3.0
+ * See LICENSE file in the project root for full license information
+ */
+
+import { useEffect, useState } from 'react';
+
+import { isPasskeySupported } from '@/utils';
+
+export const usePasskeySupport = () => {
+ const [passkeySupported, setPasskeySupported] = useState(false);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ let active = true;
+
+ const checkSupport = async () => {
+ try {
+ const supported = await isPasskeySupported();
+ if (active) {
+ setPasskeySupported(supported);
+ }
+ } catch {
+ if (active) {
+ setPasskeySupported(false);
+ }
+ } finally {
+ if (active) {
+ setLoading(false);
+ }
+ }
+ };
+
+ void checkSupport();
+
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ return { passkeySupported, loading };
+};
diff --git a/src/index.ts b/src/index.ts
index bc07eaf..945d5d2 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,7 +4,43 @@
* See LICENSE file in the project root for full license information
*/
-import { AuthContextType, AuthProvider, useAuth, Credential, User } from '@/AuthProvider';
+import { AuthContextType, AuthProvider, useAuth } from '@/AuthProvider';
import { AuthRoutes } from '@/AuthRoutes';
-export { AuthProvider, AuthRoutes, useAuth };
-export type { AuthContextType, Credential, User };
+import {
+ createSeamlessAuthClient,
+ CurrentUserResult,
+ LoginInput,
+ PasskeyLoginResult,
+ PasskeyMetadata,
+ PasskeyRegistrationResult,
+ RegisterInput,
+ SeamlessAuthClient,
+ SeamlessAuthClientOptions,
+} from '@/client/createSeamlessAuthClient';
+import { AuthMode } from '@/fetchWithAuth';
+import { useAuthClient } from '@/hooks/useAuthClient';
+import { usePasskeySupport } from '@/hooks/usePasskeySupport';
+import { Credential, User } from '@/types';
+
+export {
+ AuthProvider,
+ AuthRoutes,
+ createSeamlessAuthClient,
+ useAuth,
+ useAuthClient,
+ usePasskeySupport,
+};
+export type {
+ AuthContextType,
+ AuthMode,
+ Credential,
+ CurrentUserResult,
+ LoginInput,
+ PasskeyLoginResult,
+ PasskeyMetadata,
+ PasskeyRegistrationResult,
+ RegisterInput,
+ SeamlessAuthClient,
+ SeamlessAuthClientOptions,
+ User,
+};
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..a16ce33
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright © 2026 Fells Code, LLC
+ * Licensed under the GNU Affero General Public License v3.0
+ * See LICENSE file in the project root for full license information
+ */
+
+import {
+ AuthenticatorTransportFuture,
+ CredentialDeviceType,
+} from '@simplewebauthn/browser';
+
+export interface User {
+ id: string;
+ email: string;
+ phone: string;
+ roles?: string[];
+}
+
+export interface Credential {
+ id: string;
+ counter: number;
+ transports?: AuthenticatorTransportFuture[];
+ deviceType: CredentialDeviceType;
+ backedup: boolean;
+ friendlyName: string | null;
+ lastUsedAt: Date | null;
+ platform: string | null;
+ browser: string | null;
+ deviceInfo: string | null;
+}
diff --git a/src/views/EmailRegistration.tsx b/src/views/EmailRegistration.tsx
index bfcd9aa..e31ee8c 100644
--- a/src/views/EmailRegistration.tsx
+++ b/src/views/EmailRegistration.tsx
@@ -6,39 +6,30 @@
import { useAuth } from '@/AuthProvider';
import React, { useEffect, useState } from 'react';
+import { useAuthClient } from '@/hooks/useAuthClient';
+import { usePasskeySupport } from '@/hooks/usePasskeySupport';
import { useNavigate } from 'react-router-dom';
import styles from '@/styles/verifyOTP.module.css';
-import { createFetchWithAuth } from '@/fetchWithAuth';
-import { isPasskeySupported } from '@/utils';
-import { useInternalAuth } from '@/context/InternalAuthContext';
import OtpInput from '@/components/OtpInput';
const EmailRegistration: React.FC = () => {
const navigate = useNavigate();
- const { apiHost, mode } = useAuth();
- const { validateToken } = useInternalAuth();
+ const { refreshSession } = useAuth();
+ const authClient = useAuthClient();
+ const { passkeySupported } = usePasskeySupport();
const [loading, setLoading] = useState(false);
const [emailOtp, setEmailOtp] = useState('');
const [emailTimeLeft, setEmailTimeLeft] = useState(300);
const [error, setError] = useState('');
const [resendMsg, setResendMsg] = useState('');
- const [passkeyAvailable, setPasskeyAvailable] = useState(false);
-
- const fetchWithAuth = createFetchWithAuth({
- authMode: mode,
- authHost: apiHost,
- });
const onResendEmail = async () => {
setError('');
setResendMsg('');
- const response = await fetchWithAuth(`/otp/generate-email-otp`, {
- method: 'GET',
- headers: { 'Content-Type': 'application/json' },
- });
+ const response = await authClient.requestEmailOtp();
if (!response.ok) {
setError(
@@ -62,24 +53,17 @@ const EmailRegistration: React.FC = () => {
setLoading(true);
try {
- const response = await fetchWithAuth(`/otp/verify-email-otp`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- verificationToken: emailOtp,
- }),
- credentials: 'include',
- });
+ const response = await authClient.verifyEmailOtp(emailOtp);
if (!response.ok) {
setError('Verification failed.');
return;
}
- if (passkeyAvailable) {
+ if (passkeySupported) {
navigate('/registerPasskey');
} else {
- await validateToken();
+ await refreshSession();
navigate('/');
}
} catch (err) {
@@ -105,16 +89,6 @@ const EmailRegistration: React.FC = () => {
return () => clearInterval(interval);
}, []);
-
- useEffect(() => {
- async function checkSupport() {
- const supported = await isPasskeySupported();
- setPasskeyAvailable(supported);
- }
-
- checkSupport();
- }, []);
-
return (
diff --git a/src/views/Login.tsx b/src/views/Login.tsx
index 180b108..5f5fc2a 100644
--- a/src/views/Login.tsx
+++ b/src/views/Login.tsx
@@ -7,21 +7,18 @@
import { useAuth } from '@/AuthProvider';
import PhoneInputWithCountryCode from '@/components/phoneInput';
import React, { useEffect, useState } from 'react';
+import { useAuthClient } from '@/hooks/useAuthClient';
+import { usePasskeySupport } from '@/hooks/usePasskeySupport';
import { useNavigate } from 'react-router-dom';
import styles from '@/styles/login.module.css';
-import { isPasskeySupported, isValidEmail, isValidPhoneNumber } from '../utils';
-import { createFetchWithAuth } from '@/fetchWithAuth';
+import { isValidEmail, isValidPhoneNumber } from '../utils';
import AuthFallbackOptions from '@/components/AuthFallbackOptions';
const Login: React.FC = () => {
const navigate = useNavigate();
- const {
- apiHost,
- hasSignedInBefore,
- mode: authMode,
- login,
- handlePasskeyLogin,
- } = useAuth();
+ const { hasSignedInBefore, login, handlePasskeyLogin } = useAuth();
+ const authClient = useAuthClient();
+ const { passkeySupported } = usePasskeySupport();
const [identifier, setIdentifier] = useState
('');
const [email, setEmail] = useState('');
const [mode, setMode] = useState<'login' | 'register'>('register');
@@ -30,23 +27,10 @@ const Login: React.FC = () => {
const [phoneError, setPhoneError] = useState('');
const [emailError, setEmailError] = useState('');
const [identifierError, setIdentifierError] = useState('');
- const [passkeyAvailable, setPasskeyAvailable] = useState(false);
const [showFallbackOptions, setShowFallbackOptions] = useState(false);
const [bootstrapToken, setBootstrapToken] = useState(null);
- const fetchWithAuth = createFetchWithAuth({
- authMode,
- authHost: apiHost,
- });
-
useEffect(() => {
- async function checkSupport() {
- const supported = await isPasskeySupported();
- setPasskeyAvailable(supported);
- }
-
- checkSupport();
-
if (hasSignedInBefore) {
setMode('login');
}
@@ -82,13 +66,10 @@ const Login: React.FC = () => {
setFormErrors('');
try {
- const response = await fetchWithAuth(`/registration/register`, {
- method: 'POST',
- body: JSON.stringify({
- email,
- phone,
- ...(bootstrapToken ? { bootstrapToken } : {}),
- }),
+ const response = await authClient.register({
+ email,
+ phone,
+ bootstrapToken,
});
if (!response.ok) {
@@ -100,30 +81,29 @@ const Login: React.FC = () => {
if (data.message === 'Success') {
navigate('/verifyPhoneOTP');
+ return;
}
setFormErrors(
- 'An unexpected error occured. Try again. If the problem persists, try resetting your password'
+ 'An unexpected error occurred. Try again. If the problem persists, try resetting your password.'
);
} catch (err) {
console.error('Unexpected login error', err);
setFormErrors(
- 'An unexpected error occured. Try again. If the problem persists, try resetting your password'
+ 'An unexpected error occurred. Try again. If the problem persists, try resetting your password.'
);
}
};
const sendMagicLink = async () => {
try {
- const response = await fetchWithAuth(`/magic-link`, {
- method: 'GET',
- });
+ const response = await authClient.requestMagicLink();
if (!response.ok) {
setFormErrors('Failed to send magic link.');
return;
}
- navigate('/magiclinks-sent');
+ navigate('/magiclinks-sent', { state: { identifier } });
} catch (err) {
console.error(err);
setFormErrors('Failed to send magic link.');
@@ -132,10 +112,7 @@ const Login: React.FC = () => {
const sendPhoneOtp = async () => {
try {
- const response = await fetchWithAuth(`/login/phone-otp`, {
- method: 'POST',
- body: JSON.stringify({ identifier }),
- });
+ const response = await authClient.requestPhoneOtp();
if (!response.ok) {
setFormErrors('Failed to send OTP.');
@@ -151,22 +128,43 @@ const Login: React.FC = () => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
+ setFormErrors('');
+ setShowFallbackOptions(false);
- if (mode === 'login') {
- const loginRes = await login(identifier, passkeyAvailable);
-
- if (loginRes && loginRes.ok && passkeyAvailable) {
- const passkeyResult = await handlePasskeyLogin();
+ try {
+ if (mode === 'login') {
+ const loginRes = await login(identifier, passkeySupported);
+
+ if (loginRes?.ok && passkeySupported) {
+ const passkeyResult = await handlePasskeyLogin();
+ if (passkeyResult) {
+ navigate('/');
+ return;
+ }
+
+ setShowFallbackOptions(true);
+ setFormErrors(
+ 'Passkey sign-in could not be completed. Choose another sign-in method.'
+ );
+ return;
+ }
- if (passkeyResult) {
- navigate('/');
+ if (loginRes?.ok) {
+ setShowFallbackOptions(true);
+ return;
}
- } else {
- setIdentifierError('An error occurred');
- setShowFallbackOptions(true);
+
+ setFormErrors('Failed to start sign-in. Please try again.');
+ return;
}
+
+ if (mode === 'register') {
+ await register();
+ }
+ } catch (err) {
+ console.error(err);
+ setFormErrors('Failed to continue sign-in. Please try again.');
}
- if (mode === 'register') register();
};
return (
diff --git a/src/views/PassKeyLogin.tsx b/src/views/PassKeyLogin.tsx
index 029d824..6e20fb6 100644
--- a/src/views/PassKeyLogin.tsx
+++ b/src/views/PassKeyLogin.tsx
@@ -4,76 +4,38 @@
* See LICENSE file in the project root for full license information
*/
-import { startAuthentication } from '@simplewebauthn/browser';
import { useAuth } from '@/AuthProvider';
-import { useInternalAuth } from '@/context/InternalAuthContext';
-import React from 'react';
+import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import styles from '@/styles/passKeyLogin.module.css';
-import { createFetchWithAuth } from '../fetchWithAuth';
const PassKeyLogin: React.FC = () => {
const navigate = useNavigate();
- const { apiHost, mode } = useAuth();
- const { validateToken } = useInternalAuth();
+ const { handlePasskeyLogin: runPasskeyLogin } = useAuth();
+ const [error, setError] = useState('');
- const fetchWithAuth = createFetchWithAuth({
- authMode: mode,
- authHost: apiHost,
- });
+ const handlePasskeyLoginClick = async () => {
+ setError('');
- const handlePasskeyLogin = async () => {
- try {
- const response = await fetchWithAuth(`/webAuthn/login/start`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
- });
+ const success = await runPasskeyLogin();
- if (!response.ok) {
- console.error('Something went wrong getting webauthn options');
- return;
- }
-
- const options = await response.json();
- const credential = await startAuthentication({ optionsJSON: options });
-
- const verificationResponse = await fetchWithAuth(`/webAuthn/login/finish`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ assertionResponse: credential }),
- credentials: 'include',
- });
-
- if (!verificationResponse.ok) {
- console.error('Failed to verify passkey');
- }
-
- const verificationResult = await verificationResponse.json();
-
- if (verificationResult.message === 'Success') {
- if (verificationResult.mfaLogin) {
- navigate('/mfaLogin');
- }
- await validateToken();
- navigate('/');
- return;
- } else {
- console.error(`Passkey login failed`);
- }
- } catch {
- console.error('Passkey login error');
+ if (success) {
+ navigate('/');
+ return;
}
+
+ setError('Passkey sign-in could not be completed. Try another sign-in method.');
};
return (
Login with Passkey
-
+
Use Passkey
+ {error && {error}
}
);
diff --git a/src/views/PassKeyRegistration.tsx b/src/views/PassKeyRegistration.tsx
index 465e9b8..0199716 100644
--- a/src/views/PassKeyRegistration.tsx
+++ b/src/views/PassKeyRegistration.tsx
@@ -4,29 +4,25 @@
* See LICENSE file in the project root for full license information
*/
-import {
- type RegistrationResponseJSON,
- startRegistration,
- WebAuthnError,
-} from '@simplewebauthn/browser';
import { useAuth } from '@/AuthProvider';
-import { useInternalAuth } from '@/context/InternalAuthContext';
-import React, { useEffect, useState } from 'react';
+import { PasskeyMetadata } from '@/client/createSeamlessAuthClient';
+import React, { useState } from 'react';
+import { useAuthClient } from '@/hooks/useAuthClient';
+import { usePasskeySupport } from '@/hooks/usePasskeySupport';
import { useNavigate } from 'react-router-dom';
import styles from '@/styles/registerPasskey.module.css';
-import { isPasskeySupported, parseUserAgent } from '@/utils';
-import { createFetchWithAuth } from '@/fetchWithAuth';
+import { parseUserAgent } from '@/utils';
import DeviceNameModal from '@/components/DeviceNameModal';
const PasskeyRegistration: React.FC = () => {
- const { apiHost, mode } = useAuth();
- const { validateToken } = useInternalAuth();
+ const { refreshSession } = useAuth();
+ const authClient = useAuthClient();
+ const { passkeySupported, loading: passkeySupportLoading } = usePasskeySupport();
const navigate = useNavigate();
const [status, setStatus] = useState<'idle' | 'success' | 'error' | 'loading'>('idle');
const [message, setMessage] = useState('');
- const [passkeyAvailable, setPasskeyAvailable] = useState(false);
const [showDeviceModal, setShowDeviceModal] = useState(false);
const [pendingMetadata, setPendingMetadata] = useState<{
@@ -35,20 +31,6 @@ const PasskeyRegistration: React.FC = () => {
deviceInfo: string;
} | null>(null);
- const fetchWithAuth = createFetchWithAuth({
- authMode: mode,
- authHost: apiHost,
- });
-
- useEffect(() => {
- async function checkSupport() {
- const supported = await isPasskeySupported();
- setPasskeyAvailable(supported);
- }
-
- checkSupport();
- }, []);
-
const openDeviceModal = () => {
const { platform, browser, deviceInfo } = parseUserAgent();
@@ -59,43 +41,23 @@ const PasskeyRegistration: React.FC = () => {
const continueRegistration = async (friendlyName: string) => {
if (!pendingMetadata) return;
- const { platform, browser, deviceInfo } = pendingMetadata;
+ const metadata: PasskeyMetadata = {
+ friendlyName,
+ ...pendingMetadata,
+ };
setStatus('loading');
try {
- const challengeRes = await fetchWithAuth(`/webAuthn/register/start`, {
- method: 'GET',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
- });
-
- if (!challengeRes.ok) {
- throw new Error('Failed to fetch challenge');
- }
-
- const options = await challengeRes.json();
-
- let attResp: RegistrationResponseJSON;
+ const result = await authClient.registerPasskey(metadata);
- try {
- attResp = await startRegistration({ optionsJSON: options });
- } catch (error) {
- if (error instanceof WebAuthnError) {
- throw new Error(error.name);
- }
- throw error;
+ if (!result.success) {
+ throw new Error(result.message);
}
- await verifyPassKey(attResp, {
- friendlyName,
- platform,
- browser,
- deviceInfo,
- });
-
+ await refreshSession();
setStatus('success');
- setMessage('Passkey registered successfully.');
+ setMessage(result.message);
navigate('/');
} catch (error) {
console.error(error);
@@ -107,40 +69,18 @@ const PasskeyRegistration: React.FC = () => {
}
};
- const verifyPassKey = async (
- attResp: RegistrationResponseJSON,
- metadata: {
- friendlyName: string;
- platform: string;
- browser: string;
- deviceInfo: string;
- }
- ) => {
- const verificationResp = await fetchWithAuth(`/webAuthn/register/finish`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- attestationResponse: attResp,
- metadata,
- }),
- credentials: 'include',
- });
-
- if (!verificationResp.ok) {
- throw new Error('Verification failed');
- }
-
- await validateToken();
- };
-
return (
<>
- {!passkeyAvailable ? (
+ {passkeySupportLoading || !passkeySupported ? (
-
Checking for Passkey Support...
+
+ {passkeySupportLoading
+ ? 'Checking for Passkey Support...'
+ : 'Passkeys are not supported on this device.'}
+
) : (
diff --git a/src/views/PhoneRegistration.tsx b/src/views/PhoneRegistration.tsx
index 3ac03fc..228138d 100644
--- a/src/views/PhoneRegistration.tsx
+++ b/src/views/PhoneRegistration.tsx
@@ -4,17 +4,15 @@
* See LICENSE file in the project root for full license information
*/
-import { useAuth } from '@/AuthProvider';
import React, { useEffect, useState } from 'react';
+import { useAuthClient } from '@/hooks/useAuthClient';
import { useNavigate } from 'react-router-dom';
import styles from '@/styles/verifyOTP.module.css';
-import { createFetchWithAuth } from '../fetchWithAuth';
import OtpInput from '@/components/OtpInput';
const PhoneRegistration: React.FC = () => {
const navigate = useNavigate();
- const { apiHost, mode } = useAuth();
const [phoneOtp, setPhoneOtp] = useState('');
const [phoneVerified, setPhoneVerified] = useState
(null);
@@ -23,10 +21,7 @@ const PhoneRegistration: React.FC = () => {
const [resendMsg, setResendMsg] = useState('');
const [phoneTimeLeft, setPhoneTimeLeft] = useState(300);
- const fetchWithAuth = createFetchWithAuth({
- authMode: mode,
- authHost: apiHost,
- });
+ const authClient = useAuthClient();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -39,7 +34,7 @@ const PhoneRegistration: React.FC = () => {
setLoading(true);
try {
- verifyPhoneOTP();
+ await verifyPhoneOTP();
} catch {
setError('Unexpected error occurred.');
} finally {
@@ -51,16 +46,7 @@ const PhoneRegistration: React.FC = () => {
setLoading(true);
try {
if (!phoneVerified) {
- const response = await fetchWithAuth(`/otp/verify-phone-otp`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- verificationToken: phoneOtp,
- }),
- credentials: 'include',
- });
+ const response = await authClient.verifyPhoneOtp(phoneOtp);
if (!response.ok) {
setError('Verification failed.');
@@ -78,12 +64,7 @@ const PhoneRegistration: React.FC = () => {
const onResendPhone = async () => {
setError('');
- const response = await fetchWithAuth(`/otp/generate-phone-otp`, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- });
+ const response = await authClient.requestPhoneOtp();
const data = await response.json();
@@ -131,12 +112,7 @@ const PhoneRegistration: React.FC = () => {
useEffect(() => {
const nextStep = async () => {
- const response = await fetchWithAuth(`/otp/generate-email-otp`, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- });
+ const response = await authClient.requestEmailOtp();
if (!response.ok) {
setError(
@@ -150,7 +126,7 @@ const PhoneRegistration: React.FC = () => {
if (phoneVerified) {
nextStep();
}
- }, [phoneVerified, navigate, fetchWithAuth]);
+ }, [phoneVerified, navigate, authClient]);
return (
diff --git a/src/views/VerifyMagicLink.tsx b/src/views/VerifyMagicLink.tsx
index 89faa49..06b4137 100644
--- a/src/views/VerifyMagicLink.tsx
+++ b/src/views/VerifyMagicLink.tsx
@@ -4,36 +4,32 @@
* See LICENSE file in the project root for full license information
*/
-import { useAuth } from '@/AuthProvider';
import React, { useEffect, useState } from 'react';
+import { useAuthClient } from '@/hooks/useAuthClient';
import { useNavigate, useSearchParams } from 'react-router-dom';
import styles from '@/styles/verifyMagiclink.module.css';
-import { createFetchWithAuth } from '@/fetchWithAuth';
const VerifyMagicLink: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get('token');
- const { apiHost, mode } = useAuth();
const [error, setError] = useState('');
const [successMsg, setSuccessMsg] = useState('');
- const fetchWithAuth = createFetchWithAuth({
- authMode: mode,
- authHost: apiHost,
- });
+ const authClient = useAuthClient();
useEffect(() => {
const verify = async () => {
+ if (!token) {
+ setError('Missing token for verification.');
+ console.error('No token found', token);
+ return;
+ }
+
try {
- const response = await fetchWithAuth(`/magic-link/verify/${token}`, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- });
+ const response = await authClient.verifyMagicLink(token);
if (!response.ok) {
console.error('Failed to verify token');
@@ -58,13 +54,8 @@ const VerifyMagicLink: React.FC = () => {
navigate('/');
}, 900);
};
- if (token) {
- verify();
- } else {
- setError('Missing token for verification.');
- console.error('No token found', token);
- }
- }, [token, fetchWithAuth, navigate]);
+ verify();
+ }, [token, authClient, navigate]);
return (
diff --git a/tests/EmailRegistration.test.tsx b/tests/EmailRegistration.test.tsx
index 1816839..2aab7f0 100644
--- a/tests/EmailRegistration.test.tsx
+++ b/tests/EmailRegistration.test.tsx
@@ -4,20 +4,18 @@
* See LICENSE file in the project root for full license information
*/
-import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
+import { render, screen, fireEvent, act } from '@testing-library/react';
import EmailRegistration from '@/views/EmailRegistration';
import { useAuth } from '@/AuthProvider';
-import { useInternalAuth } from '@/context/InternalAuthContext';
-import { createFetchWithAuth } from '@/fetchWithAuth';
-import { isPasskeySupported } from '@/utils';
+import { useAuthClient } from '@/hooks/useAuthClient';
+import { usePasskeySupport } from '@/hooks/usePasskeySupport';
import { useNavigate } from 'react-router-dom';
jest.mock('@/AuthProvider');
-jest.mock('@/context/InternalAuthContext');
-jest.mock('@/fetchWithAuth');
-jest.mock('@/utils');
+jest.mock('@/hooks/useAuthClient');
+jest.mock('@/hooks/usePasskeySupport');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
@@ -33,8 +31,11 @@ jest.mock('@/components/OtpInput', () => (props: any) => (
describe('EmailRegistration', () => {
const navigate = jest.fn();
- const validateToken = jest.fn();
- const mockFetch = jest.fn();
+ const refreshSession = jest.fn();
+ const mockAuthClient = {
+ requestEmailOtp: jest.fn(),
+ verifyEmailOtp: jest.fn(),
+ };
beforeEach(() => {
jest.useFakeTimers();
@@ -42,19 +43,17 @@ describe('EmailRegistration', () => {
(useNavigate as jest.Mock).mockReturnValue(navigate);
(useAuth as jest.Mock).mockReturnValue({
- apiHost: 'http://localhost',
- mode: 'web',
+ refreshSession,
});
- (useInternalAuth as jest.Mock).mockReturnValue({
- validateToken,
+ (useAuthClient as jest.Mock).mockReturnValue(mockAuthClient);
+ (usePasskeySupport as jest.Mock).mockReturnValue({
+ passkeySupported: false,
+ loading: false,
});
- (createFetchWithAuth as jest.Mock).mockReturnValue(mockFetch);
-
- (isPasskeySupported as jest.Mock).mockResolvedValue(false);
-
- mockFetch.mockResolvedValue({ ok: true });
+ mockAuthClient.requestEmailOtp.mockResolvedValue({ ok: true });
+ mockAuthClient.verifyEmailOtp.mockResolvedValue({ ok: true });
});
afterEach(() => {
@@ -80,7 +79,7 @@ describe('EmailRegistration', () => {
});
test('submits OTP and verifies email', async () => {
- mockFetch.mockResolvedValueOnce({ ok: true });
+ mockAuthClient.verifyEmailOtp.mockResolvedValueOnce({ ok: true });
render(
);
@@ -92,12 +91,7 @@ describe('EmailRegistration', () => {
fireEvent.submit(screen.getByRole('button', { name: /verify & continue/i }));
});
- expect(mockFetch).toHaveBeenCalledWith(
- '/otp/verify-email-otp',
- expect.objectContaining({
- method: 'POST',
- })
- );
+ expect(mockAuthClient.verifyEmailOtp).toHaveBeenCalledWith('ABCDEF');
});
test('resend email triggers API call', async () => {
@@ -107,12 +101,7 @@ describe('EmailRegistration', () => {
fireEvent.click(screen.getByRole('button', { name: /resend code to email/i }));
});
- expect(mockFetch).toHaveBeenCalledWith(
- '/otp/generate-email-otp',
- expect.objectContaining({
- method: 'GET',
- })
- );
+ expect(mockAuthClient.requestEmailOtp).toHaveBeenCalled();
});
test('timer counts down', () => {
@@ -128,15 +117,14 @@ describe('EmailRegistration', () => {
});
test('successful verification navigates to registerPasskey if supported', async () => {
- (isPasskeySupported as jest.Mock).mockResolvedValue(true);
- mockFetch.mockResolvedValue({ ok: true });
+ (usePasskeySupport as jest.Mock).mockReturnValue({
+ passkeySupported: true,
+ loading: false,
+ });
+ mockAuthClient.verifyEmailOtp.mockResolvedValue({ ok: true });
render(
);
- await waitFor(() => {
- expect(isPasskeySupported).toHaveBeenCalled();
- });
-
fireEvent.change(screen.getByTestId('otp-input'), {
target: { value: 'ABCDEF' },
});
@@ -145,16 +133,15 @@ describe('EmailRegistration', () => {
fireEvent.click(screen.getByRole('button', { name: /verify & continue/i }));
});
- await waitFor(() => {
- expect(isPasskeySupported).toHaveBeenCalled();
- });
-
expect(navigate).toHaveBeenCalledWith('/registerPasskey');
});
test('successful verification logs in if passkeys not supported', async () => {
- (isPasskeySupported as jest.Mock).mockResolvedValue(false);
- mockFetch.mockResolvedValue({ ok: true });
+ (usePasskeySupport as jest.Mock).mockReturnValue({
+ passkeySupported: false,
+ loading: false,
+ });
+ mockAuthClient.verifyEmailOtp.mockResolvedValue({ ok: true });
render(
);
@@ -168,7 +155,7 @@ describe('EmailRegistration', () => {
await act(async () => {});
- expect(validateToken).toHaveBeenCalled();
+ expect(refreshSession).toHaveBeenCalled();
expect(navigate).toHaveBeenCalledWith('/');
});
diff --git a/tests/InternalContext.test.tsx b/tests/InternalContext.test.tsx
deleted file mode 100644
index 9f06cb6..0000000
--- a/tests/InternalContext.test.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright © 2026 Fells Code, LLC
- * Licensed under the GNU Affero General Public License v3.0
- * See LICENSE file in the project root for full license information
- */
-
-import { render, screen } from '@testing-library/react';
-import {
- InternalAuthProvider,
- useInternalAuth,
-} from '../src/context/InternalAuthContext';
-
-const TestComponent = () => {
- const { validateToken } = useInternalAuth();
- return
Run Validate ;
-};
-
-describe('InternalAuthContext', () => {
- it('provides context values to consumers', () => {
- const mockValidateToken = jest.fn().mockResolvedValue(undefined);
- const mockSetLoading = jest.fn();
-
- render(
-
-
-
- );
-
- const button = screen.getByRole('button', { name: /Run Validate/i });
- button.click();
-
- expect(mockValidateToken).toHaveBeenCalledTimes(1);
- });
-
- it('throws error when used outside provider', () => {
- const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
-
- expect(() => render(
)).toThrowError(
- 'useInternalAuth must be used within InternalAuthProvider'
- );
-
- spy.mockRestore();
- });
-});
diff --git a/tests/MagicLinkSent.test.tsx b/tests/MagicLinkSent.test.tsx
index c7cec7a..de39e63 100644
--- a/tests/MagicLinkSent.test.tsx
+++ b/tests/MagicLinkSent.test.tsx
@@ -8,14 +8,12 @@ import { render, screen, fireEvent, act } from '@testing-library/react';
import MagicLinkSent from '@/components/MagicLinkSent';
import { useAuth } from '@/AuthProvider';
-import { useInternalAuth } from '@/context/InternalAuthContext';
-import { createFetchWithAuth } from '@/fetchWithAuth';
+import { useAuthClient } from '@/hooks/useAuthClient';
import { useNavigate, useLocation } from 'react-router-dom';
jest.mock('@/AuthProvider');
-jest.mock('@/context/InternalAuthContext');
-jest.mock('@/fetchWithAuth');
+jest.mock('@/hooks/useAuthClient');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
@@ -24,8 +22,11 @@ jest.mock('react-router-dom', () => ({
describe('MagicLinkSent', () => {
const navigate = jest.fn();
- const validateToken = jest.fn();
- const mockFetch = jest.fn();
+ const refreshSession = jest.fn();
+ const mockAuthClient = {
+ requestMagicLink: jest.fn(),
+ checkMagicLink: jest.fn(),
+ };
beforeEach(() => {
jest.useFakeTimers();
@@ -37,17 +38,13 @@ describe('MagicLinkSent', () => {
});
(useAuth as jest.Mock).mockReturnValue({
- apiHost: 'http://localhost',
- mode: 'web',
+ refreshSession,
});
- (useInternalAuth as jest.Mock).mockReturnValue({
- validateToken,
- });
-
- (createFetchWithAuth as jest.Mock).mockReturnValue(mockFetch);
+ (useAuthClient as jest.Mock).mockReturnValue(mockAuthClient);
- mockFetch.mockResolvedValue({ status: 200 });
+ mockAuthClient.requestMagicLink.mockResolvedValue({ ok: true });
+ mockAuthClient.checkMagicLink.mockResolvedValue({ status: 200 });
});
afterEach(() => {
@@ -94,7 +91,7 @@ describe('MagicLinkSent', () => {
fireEvent.click(button);
});
- expect(mockFetch).toHaveBeenCalledWith('/magic-link', expect.any(Object));
+ expect(mockAuthClient.requestMagicLink).toHaveBeenCalled();
});
test('change email button navigates to login', () => {
@@ -119,7 +116,7 @@ describe('MagicLinkSent', () => {
return createdChannel;
});
- mockFetch.mockResolvedValue({ status: 200 });
+ mockAuthClient.checkMagicLink.mockResolvedValue({ status: 200 });
render(
);
@@ -129,8 +126,8 @@ describe('MagicLinkSent', () => {
});
});
- expect(mockFetch).toHaveBeenCalledWith('/magic-link/check', expect.any(Object));
- expect(validateToken).toHaveBeenCalledTimes(1);
+ expect(mockAuthClient.checkMagicLink).toHaveBeenCalled();
+ expect(refreshSession).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith('/');
});
@@ -141,9 +138,9 @@ describe('MagicLinkSent', () => {
jest.advanceTimersByTime(5000);
});
- expect(mockFetch).toHaveBeenCalledWith('/magic-link/check', expect.any(Object));
+ expect(mockAuthClient.checkMagicLink).toHaveBeenCalled();
- expect(validateToken).toHaveBeenCalled();
+ expect(refreshSession).toHaveBeenCalled();
expect(navigate).toHaveBeenCalledWith('/');
});
});
diff --git a/tests/PassKeyLogin.test.tsx b/tests/PassKeyLogin.test.tsx
index ea167ac..6c75c16 100644
--- a/tests/PassKeyLogin.test.tsx
+++ b/tests/PassKeyLogin.test.tsx
@@ -4,28 +4,21 @@
* See LICENSE file in the project root for full license information
*/
-import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import PassKeyLogin from '../src/views/PassKeyLogin';
-// 🧠 Mock react-router navigate
const mockNavigate = jest.fn();
+const mockHandlePasskeyLogin = jest.fn();
+
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
-// 🧠 Mock AuthProvider + InternalAuthContext
-const mockValidateToken = jest.fn();
jest.mock('@/AuthProvider', () => ({
- useAuth: () => ({ apiHost: 'https://api.example.com' }),
-}));
-jest.mock('@/context/InternalAuthContext', () => ({
- useInternalAuth: () => ({ validateToken: mockValidateToken }),
-}));
-
-const mockStartAuthentication = jest.fn();
-jest.mock('@simplewebauthn/browser', () => ({
- startAuthentication: (...args: any[]) => mockStartAuthentication(...args),
+ useAuth: () => ({
+ handlePasskeyLogin: mockHandlePasskeyLogin,
+ }),
}));
jest.mock(
@@ -34,120 +27,37 @@ jest.mock(
);
beforeEach(() => {
- jest.resetAllMocks();
- global.fetch = jest.fn().mockResolvedValue({
- ok: true,
- json: async () => ({}),
- });
+ jest.clearAllMocks();
});
describe('PassKeyLogin', () => {
it('renders title and button', () => {
render(
);
- expect(screen.getByText(/Login with Passkey/i)).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /Use Passkey/i })).toBeInTheDocument();
- });
-
- it('handles successful passkey authentication flow', async () => {
- (global.fetch as jest.Mock)
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ challenge: 'abc' }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ message: 'Success', token: 'token123' }),
- });
- mockStartAuthentication.mockResolvedValueOnce({ credential: 'xyz' });
-
- render(
);
- fireEvent.click(screen.getByRole('button', { name: /Use Passkey/i }));
-
- await waitFor(() => {
- expect(global.fetch).toHaveBeenCalledWith(
- 'https://api.example.com/webAuthn/login/start',
- expect.objectContaining({ method: 'POST' })
- );
- expect(global.fetch).toHaveBeenCalledWith(
- 'https://api.example.com/webAuthn/login/finish',
- expect.objectContaining({ method: 'POST' })
- );
- expect(mockValidateToken).toHaveBeenCalled();
- expect(mockNavigate).toHaveBeenCalledWith('/');
- });
+ expect(screen.getByText(/login with passkey/i)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /use passkey/i })).toBeInTheDocument();
});
- it('navigates to /mfaLogin when no token returned', async () => {
- (global.fetch as jest.Mock)
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({}),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ message: 'Success' }), // no token field
- });
-
- mockStartAuthentication.mockResolvedValueOnce({ credential: 'xyz' });
+ it('navigates home when passkey login succeeds', async () => {
+ mockHandlePasskeyLogin.mockResolvedValueOnce(true);
render(
);
- fireEvent.click(screen.getByRole('button', { name: /Use Passkey/i }));
+ fireEvent.click(screen.getByRole('button', { name: /use passkey/i }));
await waitFor(() => {
+ expect(mockHandlePasskeyLogin).toHaveBeenCalled();
expect(mockNavigate).toHaveBeenCalledWith('/');
});
});
- it('logs error if initial request fails', async () => {
- const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
- (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false });
-
- render(
);
- fireEvent.click(screen.getByRole('button', { name: /Use Passkey/i }));
-
- await waitFor(() => {
- expect(consoleSpy).toHaveBeenCalledWith('Passkey login error');
- });
-
- consoleSpy.mockRestore();
- });
-
- it('logs error if verify-authentication fails', async () => {
- const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
- (global.fetch as jest.Mock)
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ challenge: 'abc' }),
- })
- .mockResolvedValueOnce({
- ok: false,
- json: async () => ({}),
- });
-
- mockStartAuthentication.mockResolvedValueOnce({ credential: 'xyz' });
+ it('shows an error when passkey login cannot be completed', async () => {
+ mockHandlePasskeyLogin.mockResolvedValueOnce(false);
render(
);
- fireEvent.click(screen.getByRole('button', { name: /Use Passkey/i }));
-
- await waitFor(() => {
- expect(consoleSpy).toHaveBeenCalledWith('Passkey login error');
- });
-
- consoleSpy.mockRestore();
- });
-
- it('handles caught exception in handlePasskeyLogin', async () => {
- const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
- mockStartAuthentication.mockRejectedValueOnce(new Error('webauthn failed'));
-
- render(
);
- fireEvent.click(screen.getByRole('button', { name: /Use Passkey/i }));
-
- await waitFor(() => {
- expect(consoleSpy).toHaveBeenCalledWith('Passkey login error');
- });
+ fireEvent.click(screen.getByRole('button', { name: /use passkey/i }));
- consoleSpy.mockRestore();
+ expect(
+ await screen.findByText(/passkey sign-in could not be completed/i)
+ ).toBeInTheDocument();
});
});
diff --git a/tests/PhoneRegistration.test.tsx b/tests/PhoneRegistration.test.tsx
index 06305fd..6dc67c2 100644
--- a/tests/PhoneRegistration.test.tsx
+++ b/tests/PhoneRegistration.test.tsx
@@ -7,12 +7,10 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import PhoneRegistration from '@/views/PhoneRegistration';
-import { useAuth } from '@/AuthProvider';
-import { createFetchWithAuth } from '@/fetchWithAuth';
+import { useAuthClient } from '@/hooks/useAuthClient';
import { useNavigate } from 'react-router-dom';
-jest.mock('@/AuthProvider');
-jest.mock('@/fetchWithAuth');
+jest.mock('@/hooks/useAuthClient');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
@@ -28,24 +26,24 @@ jest.mock('@/components/OtpInput', () => (props: any) => (
describe('PhoneRegistration', () => {
const navigate = jest.fn();
- const mockFetch = jest.fn();
+ const mockAuthClient = {
+ verifyPhoneOtp: jest.fn(),
+ requestPhoneOtp: jest.fn(),
+ requestEmailOtp: jest.fn(),
+ };
beforeEach(() => {
jest.useFakeTimers();
(useNavigate as jest.Mock).mockReturnValue(navigate);
+ (useAuthClient as jest.Mock).mockReturnValue(mockAuthClient);
- (useAuth as jest.Mock).mockReturnValue({
- apiHost: 'http://localhost',
- mode: 'web',
- });
-
- (createFetchWithAuth as jest.Mock).mockReturnValue(mockFetch);
-
- mockFetch.mockResolvedValue({
+ mockAuthClient.verifyPhoneOtp.mockResolvedValue({ ok: true });
+ mockAuthClient.requestPhoneOtp.mockResolvedValue({
ok: true,
json: async () => ({}),
});
+ mockAuthClient.requestEmailOtp.mockResolvedValue({ ok: true });
});
afterEach(() => {
@@ -83,12 +81,7 @@ describe('PhoneRegistration', () => {
fireEvent.submit(screen.getByRole('button', { name: /verify & continue/i }));
});
- expect(mockFetch).toHaveBeenCalledWith(
- '/otp/verify-phone-otp',
- expect.objectContaining({
- method: 'POST',
- })
- );
+ expect(mockAuthClient.verifyPhoneOtp).toHaveBeenCalledWith('123456');
});
test('resend SMS triggers API call', async () => {
@@ -98,16 +91,11 @@ describe('PhoneRegistration', () => {
fireEvent.click(screen.getByRole('button', { name: /resend code to phone/i }));
});
- expect(mockFetch).toHaveBeenCalledWith(
- '/otp/generate-phone-otp',
- expect.objectContaining({
- method: 'GET',
- })
- );
+ expect(mockAuthClient.requestPhoneOtp).toHaveBeenCalled();
});
test('resend stores token if returned', async () => {
- mockFetch.mockResolvedValueOnce({
+ mockAuthClient.requestPhoneOtp.mockResolvedValueOnce({
ok: true,
json: async () => ({ token: 'abc123' }),
});
@@ -134,9 +122,8 @@ describe('PhoneRegistration', () => {
});
test('successful phone verification triggers email OTP and navigation', async () => {
- mockFetch
- .mockResolvedValueOnce({ ok: true }) // verify phone
- .mockResolvedValueOnce({ ok: true }); // generate email otp
+ mockAuthClient.verifyPhoneOtp.mockResolvedValueOnce({ ok: true });
+ mockAuthClient.requestEmailOtp.mockResolvedValueOnce({ ok: true });
render(
);
@@ -150,7 +137,7 @@ describe('PhoneRegistration', () => {
await act(async () => {});
- expect(mockFetch).toHaveBeenCalledWith('/otp/generate-email-otp', expect.any(Object));
+ expect(mockAuthClient.requestEmailOtp).toHaveBeenCalled();
expect(navigate).toHaveBeenCalledWith('/verifyEmailOTP');
});
diff --git a/tests/RegisterPassKey.test.tsx b/tests/RegisterPassKey.test.tsx
index 6f0390a..be53599 100644
--- a/tests/RegisterPassKey.test.tsx
+++ b/tests/RegisterPassKey.test.tsx
@@ -6,11 +6,12 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import RegisterPasskey from '../src/views/PassKeyRegistration';
+import { useAuthClient } from '@/hooks/useAuthClient';
+import { usePasskeySupport } from '@/hooks/usePasskeySupport';
const mockNavigate = jest.fn();
-const mockValidateToken = jest.fn();
-const mockFetch = jest.fn();
-const mockStartRegistration = jest.fn();
+const mockRefreshSession = jest.fn();
+const mockRegisterPasskey = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@@ -19,23 +20,14 @@ jest.mock('react-router-dom', () => ({
jest.mock('@/AuthProvider', () => ({
useAuth: () => ({
- apiHost: 'https://api.example.com',
- mode: 'server',
+ refreshSession: mockRefreshSession,
}),
}));
-jest.mock('@/context/InternalAuthContext', () => ({
- useInternalAuth: () => ({
- validateToken: mockValidateToken,
- }),
-}));
-
-jest.mock('@/fetchWithAuth', () => ({
- createFetchWithAuth: () => mockFetch,
-}));
+jest.mock('@/hooks/useAuthClient');
+jest.mock('@/hooks/usePasskeySupport');
jest.mock('@/utils', () => ({
- isPasskeySupported: jest.fn().mockResolvedValue(true),
parseUserAgent: jest.fn().mockReturnValue({
platform: 'macOS',
browser: 'Chrome',
@@ -43,13 +35,6 @@ jest.mock('@/utils', () => ({
}),
}));
-jest.mock('@simplewebauthn/browser', () => ({
- startRegistration: (...args: any[]) => mockStartRegistration(...args),
- WebAuthnError: class WebAuthnError extends Error {
- name = 'WebAuthnError';
- },
-}));
-
// Mock modal so we control confirm manually
jest.mock('@/components/DeviceNameModal', () => (props: any) => {
if (!props.isOpen) return null;
@@ -63,6 +48,13 @@ jest.mock('@/components/DeviceNameModal', () => (props: any) => {
beforeEach(() => {
jest.clearAllMocks();
+ (useAuthClient as jest.Mock).mockReturnValue({
+ registerPasskey: mockRegisterPasskey,
+ });
+ (usePasskeySupport as jest.Mock).mockReturnValue({
+ passkeySupported: true,
+ loading: false,
+ });
});
describe('RegisterPasskey', () => {
@@ -81,17 +73,9 @@ describe('RegisterPasskey', () => {
});
it('handles successful registration flow', async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ challenge: 'xyz' }),
- })
- .mockResolvedValueOnce({
- ok: true,
- });
-
- mockStartRegistration.mockResolvedValueOnce({
- id: 'cred',
+ mockRegisterPasskey.mockResolvedValueOnce({
+ success: true,
+ message: 'Passkey registered successfully.',
});
render(
);
@@ -100,25 +84,23 @@ describe('RegisterPasskey', () => {
fireEvent.click(await screen.findByText('Confirm'));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- '/webAuthn/register/start',
- expect.any(Object)
- );
- });
-
- await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- '/webAuthn/register/finish',
- expect.any(Object)
- );
+ expect(mockRegisterPasskey).toHaveBeenCalledWith({
+ friendlyName: 'My Device',
+ platform: 'macOS',
+ browser: 'Chrome',
+ deviceInfo: 'MacBook Pro',
+ });
});
- expect(mockValidateToken).toHaveBeenCalled();
+ expect(mockRefreshSession).toHaveBeenCalled();
expect(mockNavigate).toHaveBeenCalledWith('/');
});
it('handles challenge failure', async () => {
- mockFetch.mockResolvedValueOnce({ ok: false });
+ mockRegisterPasskey.mockResolvedValueOnce({
+ success: false,
+ message: 'Failed to fetch passkey registration challenge.',
+ });
render(
);
@@ -131,15 +113,11 @@ describe('RegisterPasskey', () => {
});
it('handles WebAuthnError', async () => {
- mockFetch.mockResolvedValueOnce({
- ok: true,
- json: async () => ({ challenge: 'xyz' }),
+ mockRegisterPasskey.mockResolvedValueOnce({
+ success: false,
+ message: 'WebAuthnError',
});
- // eslint-disable-next-line @typescript-eslint/no-require-imports
- const { WebAuthnError } = require('@simplewebauthn/browser');
- mockStartRegistration.mockRejectedValueOnce(new WebAuthnError('Failure'));
-
render(
);
fireEvent.click(await screen.findByText(/Register Passkey/i));
@@ -151,16 +129,10 @@ describe('RegisterPasskey', () => {
});
it('handles verification failure', async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ challenge: 'xyz' }),
- })
- .mockResolvedValueOnce({
- ok: false,
- });
-
- mockStartRegistration.mockResolvedValueOnce({});
+ mockRegisterPasskey.mockResolvedValueOnce({
+ success: false,
+ message: 'Verification failed.',
+ });
render(
);
@@ -180,4 +152,15 @@ describe('RegisterPasskey', () => {
expect(screen.queryByText('Confirm')).not.toBeInTheDocument();
});
+
+ it('renders unsupported state when passkeys are unavailable', () => {
+ (usePasskeySupport as jest.Mock).mockReturnValue({
+ passkeySupported: false,
+ loading: false,
+ });
+
+ render(
);
+
+ expect(screen.getByText(/passkeys are not supported on this device/i)).toBeInTheDocument();
+ });
});
diff --git a/tests/VerifyMagicLink.test.tsx b/tests/VerifyMagicLink.test.tsx
index 4b83dee..eb53ba1 100644
--- a/tests/VerifyMagicLink.test.tsx
+++ b/tests/VerifyMagicLink.test.tsx
@@ -7,13 +7,11 @@
import { render, screen, act } from '@testing-library/react';
import VerifyMagicLink from '@/views/VerifyMagicLink';
-import { useAuth } from '@/AuthProvider';
-import { createFetchWithAuth } from '@/fetchWithAuth';
+import { useAuthClient } from '@/hooks/useAuthClient';
import { useNavigate, useSearchParams } from 'react-router-dom';
-jest.mock('@/AuthProvider');
-jest.mock('@/fetchWithAuth');
+jest.mock('@/hooks/useAuthClient');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@@ -23,20 +21,16 @@ jest.mock('react-router-dom', () => ({
describe('VerifyMagicLink', () => {
const navigate = jest.fn();
- const mockFetch = jest.fn();
+ const mockAuthClient = {
+ verifyMagicLink: jest.fn(),
+ };
const postMessage = jest.fn();
beforeEach(() => {
jest.useFakeTimers();
(useNavigate as jest.Mock).mockReturnValue(navigate);
-
- (useAuth as jest.Mock).mockReturnValue({
- apiHost: 'http://localhost',
- mode: 'web',
- });
-
- (createFetchWithAuth as jest.Mock).mockReturnValue(mockFetch);
+ (useAuthClient as jest.Mock).mockReturnValue(mockAuthClient);
global.BroadcastChannel = jest.fn(() => ({
postMessage,
@@ -65,7 +59,7 @@ describe('VerifyMagicLink', () => {
new URLSearchParams('?token=abc123'),
]);
- mockFetch.mockResolvedValue({
+ mockAuthClient.verifyMagicLink.mockResolvedValue({
ok: false,
});
@@ -79,7 +73,7 @@ describe('VerifyMagicLink', () => {
new URLSearchParams('?token=abc123'),
]);
- mockFetch.mockResolvedValue({
+ mockAuthClient.verifyMagicLink.mockResolvedValue({
ok: true,
});
@@ -93,7 +87,7 @@ describe('VerifyMagicLink', () => {
new URLSearchParams('?token=abc123'),
]);
- mockFetch.mockResolvedValue({
+ mockAuthClient.verifyMagicLink.mockResolvedValue({
ok: true,
});
@@ -111,7 +105,7 @@ describe('VerifyMagicLink', () => {
new URLSearchParams('?token=abc123'),
]);
- mockFetch.mockResolvedValue({
+ mockAuthClient.verifyMagicLink.mockResolvedValue({
ok: true,
});
@@ -131,7 +125,7 @@ describe('VerifyMagicLink', () => {
new URLSearchParams('?token=abc123'),
]);
- mockFetch.mockResolvedValue({
+ mockAuthClient.verifyMagicLink.mockResolvedValue({
ok: true,
});
diff --git a/tests/authProvider.test.tsx b/tests/authProvider.test.tsx
index 7cc21e9..c2d3345 100644
--- a/tests/authProvider.test.tsx
+++ b/tests/authProvider.test.tsx
@@ -9,9 +9,6 @@ import { AuthProvider, useAuth } from '../src/AuthProvider';
import { createFetchWithAuth } from '../src/fetchWithAuth';
jest.mock('../src/fetchWithAuth');
-jest.mock('@/context/InternalAuthContext', () => ({
- InternalAuthProvider: ({ children }: any) =>
{children}
,
-}));
// the mock returned fetch function
const mockFetchWithAuthImpl = jest.fn();
@@ -36,12 +33,12 @@ describe('AuthProvider', () => {
jest.clearAllMocks();
});
- it('loads user and token successfully', async () => {
+ it('loads user and credentials successfully', async () => {
mockFetchWithAuthImpl.mockResolvedValueOnce({
ok: true,
json: async () => ({
user: { id: '1', email: 'test@example.com', phone: '555-1234', roles: ['admin'] },
- token: { oneTimeToken: 'abc', expiresAt: '2025-01-01' },
+ credentials: [],
}),
} as any);
diff --git a/tests/createSeamlessAuthClient.test.ts b/tests/createSeamlessAuthClient.test.ts
new file mode 100644
index 0000000..8f5582e
--- /dev/null
+++ b/tests/createSeamlessAuthClient.test.ts
@@ -0,0 +1,106 @@
+/*
+ * Copyright © 2026 Fells Code, LLC
+ * Licensed under the GNU Affero General Public License v3.0
+ * See LICENSE file in the project root for full license information
+ */
+
+import { createSeamlessAuthClient } from '../src/client/createSeamlessAuthClient';
+import { createFetchWithAuth } from '../src/fetchWithAuth';
+import {
+ startAuthentication,
+ startRegistration,
+} from '@simplewebauthn/browser';
+
+jest.mock('../src/fetchWithAuth');
+jest.mock('@simplewebauthn/browser', () => ({
+ startAuthentication: jest.fn(),
+ startRegistration: jest.fn(),
+ WebAuthnError: class WebAuthnError extends Error {
+ name = 'WebAuthnError';
+ },
+}));
+
+const mockFetchWithAuth = jest.fn();
+
+(createFetchWithAuth as jest.Mock).mockReturnValue(mockFetchWithAuth);
+
+describe('createSeamlessAuthClient', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('forwards login requests through the shared auth fetch helper', async () => {
+ const response = { ok: true };
+ mockFetchWithAuth.mockResolvedValueOnce(response);
+
+ const client = createSeamlessAuthClient({
+ apiHost: 'https://api.example.com',
+ mode: 'server',
+ });
+
+ await expect(
+ client.login({ identifier: 'test@example.com', passkeyAvailable: true })
+ ).resolves.toBe(response);
+
+ expect(mockFetchWithAuth).toHaveBeenCalledWith('/login', {
+ method: 'POST',
+ body: JSON.stringify({
+ identifier: 'test@example.com',
+ passkeyAvailable: true,
+ }),
+ });
+ });
+
+ it('returns a successful passkey login result when both WebAuthn steps succeed', async () => {
+ mockFetchWithAuth
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ challenge: 'challenge' }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ message: 'Success', mfaLogin: false }),
+ });
+ (startAuthentication as jest.Mock).mockResolvedValueOnce({ credential: 'assertion' });
+
+ const client = createSeamlessAuthClient({
+ apiHost: 'https://api.example.com',
+ mode: 'web',
+ });
+
+ await expect(client.loginWithPasskey()).resolves.toEqual({
+ success: true,
+ mfaRequired: false,
+ message: 'Passkey login succeeded.',
+ });
+ });
+
+ it('returns a successful passkey registration result when registration completes', async () => {
+ mockFetchWithAuth
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ challenge: 'challenge' }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ });
+ (startRegistration as jest.Mock).mockResolvedValueOnce({ id: 'cred' });
+
+ const client = createSeamlessAuthClient({
+ apiHost: 'https://api.example.com',
+ mode: 'web',
+ });
+
+ await expect(
+ client.registerPasskey({
+ friendlyName: 'My Laptop',
+ platform: 'mac',
+ browser: 'chrome',
+ deviceInfo: 'mac • chrome',
+ })
+ ).resolves.toEqual({
+ success: true,
+ message: 'Passkey registered successfully.',
+ });
+ });
+});
diff --git a/tests/fetchWithAuth.test.tsx b/tests/fetchWithAuth.test.tsx
index 0048a07..21041b9 100644
--- a/tests/fetchWithAuth.test.tsx
+++ b/tests/fetchWithAuth.test.tsx
@@ -45,16 +45,15 @@ describe('createFetchWithAuth', () => {
expect(url).toBe('https://api.example.com/auth/auth/me');
});
- it('throws error when fetch response is not ok', async () => {
+ it('returns the raw response when fetch response is not ok', async () => {
const fetchWithAuth = createFetchWithAuth({
authMode: 'web',
authHost: 'https://auth.example.com',
});
- mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
+ const response = { ok: false, status: 500 };
+ mockFetch.mockResolvedValueOnce(response);
- await expect(fetchWithAuth('/login')).rejects.toThrow(
- 'Failed to make API call to https://auth.example.com/login'
- );
+ await expect(fetchWithAuth('/login')).resolves.toBe(response);
});
});
diff --git a/tests/login.test.tsx b/tests/login.test.tsx
index d924b25..352a225 100644
--- a/tests/login.test.tsx
+++ b/tests/login.test.tsx
@@ -8,22 +8,19 @@ import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
import Login from '@/views/Login';
import { useAuth } from '@/AuthProvider';
-import { useInternalAuth } from '@/context/InternalAuthContext';
-import { createFetchWithAuth } from '@/fetchWithAuth';
-import { isPasskeySupported, isValidEmail, isValidPhoneNumber } from '@/utils';
+import { useAuthClient } from '@/hooks/useAuthClient';
+import { usePasskeySupport } from '@/hooks/usePasskeySupport';
+import { isValidEmail, isValidPhoneNumber } from '@/utils';
import { useNavigate } from 'react-router-dom';
-import { startAuthentication } from '@simplewebauthn/browser';
jest.mock('@/AuthProvider');
-jest.mock('@/context/InternalAuthContext');
-jest.mock('@/fetchWithAuth');
+jest.mock('@/hooks/useAuthClient');
+jest.mock('@/hooks/usePasskeySupport');
jest.mock('@/utils', () => ({
- isPasskeySupported: jest.fn(),
isValidEmail: jest.fn(),
isValidPhoneNumber: jest.fn(),
}));
-jest.mock('@simplewebauthn/browser');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
@@ -47,8 +44,11 @@ jest.mock('@/components/AuthFallbackOptions', () => (props: any) => (
describe('Login', () => {
const navigate = jest.fn();
- const validateToken = jest.fn();
- const mockFetch = jest.fn();
+ const mockAuthClient = {
+ register: jest.fn(),
+ requestMagicLink: jest.fn(),
+ requestPhoneOtp: jest.fn(),
+ };
beforeEach(() => {
(useNavigate as jest.Mock).mockReturnValue(navigate);
@@ -57,26 +57,25 @@ describe('Login', () => {
apiHost: 'http://localhost',
hasSignedInBefore: true,
mode: 'web',
- login: () => jest.fn(),
+ login: jest.fn().mockResolvedValue({ ok: true }),
+ handlePasskeyLogin: jest.fn().mockResolvedValue(false),
});
- (useInternalAuth as jest.Mock).mockReturnValue({
- validateToken,
+ (useAuthClient as jest.Mock).mockReturnValue(mockAuthClient);
+ (usePasskeySupport as jest.Mock).mockReturnValue({
+ passkeySupported: false,
+ loading: false,
});
- (createFetchWithAuth as jest.Mock).mockReturnValue(mockFetch);
-
- (isPasskeySupported as jest.Mock).mockResolvedValue(false);
-
(isValidEmail as jest.Mock).mockReturnValue(true);
(isValidPhoneNumber as jest.Mock).mockReturnValue(false);
- mockFetch.mockResolvedValue({
+ mockAuthClient.register.mockResolvedValue({
ok: true,
json: async () => ({ message: 'Success', mfaLogin: false }),
});
-
- (startAuthentication as jest.Mock).mockResolvedValue({});
+ mockAuthClient.requestMagicLink.mockResolvedValue({ ok: true });
+ mockAuthClient.requestPhoneOtp.mockResolvedValue({ ok: true });
jest.clearAllMocks();
});
@@ -105,11 +104,13 @@ describe('Login', () => {
test('login triggers API request', async () => {
const mockLogin = jest.fn().mockResolvedValueOnce({ ok: true });
+ const mockHandlePasskeyLogin = jest.fn().mockResolvedValue(false);
(useAuth as jest.Mock).mockReturnValue({
apiHost: 'http://localhost',
hasSignedInBefore: true,
mode: 'web',
login: mockLogin,
+ handlePasskeyLogin: mockHandlePasskeyLogin,
});
render(
);
@@ -133,8 +134,6 @@ describe('Login', () => {
});
test('fallback options appear if passkeys unavailable', async () => {
- (isPasskeySupported as jest.Mock).mockResolvedValue(false);
-
render(
);
const input = screen.getByPlaceholderText(/email or phone number/i);
@@ -153,8 +152,6 @@ describe('Login', () => {
});
test('magic link option navigates to magic link sent page', async () => {
- (isPasskeySupported as jest.Mock).mockResolvedValue(false);
-
render(
);
fireEvent.change(screen.getByPlaceholderText(/email or phone number/i), {
@@ -173,13 +170,14 @@ describe('Login', () => {
fireEvent.click(magicLink);
});
- expect(navigate).toHaveBeenCalledWith('/magiclinks-sent');
+ expect(navigate).toHaveBeenCalledWith('/magiclinks-sent', {
+ state: { identifier: 'test@example.com' },
+ });
});
test('phone OTP option navigates to verify phone', async () => {
(isValidEmail as jest.Mock).mockReturnValue(false);
(isValidPhoneNumber as jest.Mock).mockReturnValue(true);
- (isPasskeySupported as jest.Mock).mockResolvedValue(false);
render(
);
@@ -199,6 +197,7 @@ describe('Login', () => {
fireEvent.click(phoneOtp);
});
+ expect(mockAuthClient.requestPhoneOtp).toHaveBeenCalled();
expect(navigate).toHaveBeenCalledWith('/verifyPhoneOTP');
});
@@ -206,7 +205,7 @@ describe('Login', () => {
(isValidEmail as jest.Mock).mockReturnValue(true);
(isValidPhoneNumber as jest.Mock).mockReturnValue(true);
- mockFetch.mockResolvedValueOnce({
+ mockAuthClient.register.mockResolvedValueOnce({
ok: true,
json: async () => ({ message: 'Success' }),
});
@@ -237,4 +236,5 @@ describe('Login', () => {
expect(navigate).toHaveBeenCalledWith('/verifyPhoneOTP');
});
+
});
diff --git a/tests/useAuthClient.test.tsx b/tests/useAuthClient.test.tsx
new file mode 100644
index 0000000..6b2dc4b
--- /dev/null
+++ b/tests/useAuthClient.test.tsx
@@ -0,0 +1,33 @@
+/*
+ * Copyright © 2026 Fells Code, LLC
+ * Licensed under the GNU Affero General Public License v3.0
+ * See LICENSE file in the project root for full license information
+ */
+
+import { renderHook } from '@testing-library/react';
+
+import { useAuth } from '@/AuthProvider';
+import { createSeamlessAuthClient } from '@/client/createSeamlessAuthClient';
+import { useAuthClient } from '@/hooks/useAuthClient';
+
+jest.mock('@/AuthProvider');
+jest.mock('@/client/createSeamlessAuthClient');
+
+describe('useAuthClient', () => {
+ it('creates a client from the current auth config', () => {
+ const client = { login: jest.fn() };
+ (useAuth as jest.Mock).mockReturnValue({
+ apiHost: 'https://api.example.com',
+ mode: 'server',
+ });
+ (createSeamlessAuthClient as jest.Mock).mockReturnValue(client);
+
+ const { result } = renderHook(() => useAuthClient());
+
+ expect(createSeamlessAuthClient).toHaveBeenCalledWith({
+ apiHost: 'https://api.example.com',
+ mode: 'server',
+ });
+ expect(result.current).toBe(client);
+ });
+});
diff --git a/tests/usePasskeySupport.test.tsx b/tests/usePasskeySupport.test.tsx
new file mode 100644
index 0000000..ea4c5da
--- /dev/null
+++ b/tests/usePasskeySupport.test.tsx
@@ -0,0 +1,44 @@
+/*
+ * Copyright © 2026 Fells Code, LLC
+ * Licensed under the GNU Affero General Public License v3.0
+ * See LICENSE file in the project root for full license information
+ */
+
+import { renderHook, waitFor } from '@testing-library/react';
+
+import { usePasskeySupport } from '@/hooks/usePasskeySupport';
+import { isPasskeySupported } from '@/utils';
+
+jest.mock('@/utils', () => ({
+ isPasskeySupported: jest.fn(),
+}));
+
+describe('usePasskeySupport', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('reports support when passkeys are available', async () => {
+ (isPasskeySupported as jest.Mock).mockResolvedValue(true);
+
+ const { result } = renderHook(() => usePasskeySupport());
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(result.current.passkeySupported).toBe(true);
+ });
+
+ it('reports unsupported when the capability check fails', async () => {
+ (isPasskeySupported as jest.Mock).mockRejectedValue(new Error('unsupported'));
+
+ const { result } = renderHook(() => usePasskeySupport());
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(result.current.passkeySupported).toBe(false);
+ });
+});