From 595761d8e0e9432cca29d72de03d294507b3a93a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 23 Apr 2026 15:24:00 -0400 Subject: [PATCH 1/3] feat: abstract all the seamless functions --- AGENTS.md | 233 +++++++++++++++++++ README.md | 214 +++++++++++++----- package.json | 2 +- scripts/clean-dist.mjs | 3 + src/AuthProvider.tsx | 176 +++++---------- src/client/createSeamlessAuthClient.ts | 300 +++++++++++++++++++++++++ src/components/MagicLinkSent.tsx | 45 +--- src/context/InternalAuthContext.tsx | 35 --- src/fetchWithAuth.ts | 7 +- src/hooks/useAuthClient.ts | 23 ++ src/hooks/usePasskeySupport.ts | 43 ++++ src/index.ts | 42 +++- src/types.ts | 30 +++ src/views/EmailRegistration.tsx | 44 +--- src/views/Login.tsx | 90 ++++---- src/views/PassKeyLogin.tsx | 64 ++---- src/views/PassKeyRegistration.tsx | 106 ++------- src/views/PhoneRegistration.tsx | 38 +--- src/views/VerifyMagicLink.tsx | 31 +-- tests/EmailRegistration.test.tsx | 75 +++---- tests/InternalContext.test.tsx | 49 ---- tests/MagicLinkSent.test.tsx | 37 ++- tests/PassKeyLogin.test.tsx | 128 ++--------- tests/PhoneRegistration.test.tsx | 47 ++-- tests/RegisterPassKey.test.tsx | 109 ++++----- tests/VerifyMagicLink.test.tsx | 28 +-- tests/authProvider.test.tsx | 7 +- tests/createSeamlessAuthClient.test.ts | 106 +++++++++ tests/fetchWithAuth.test.tsx | 9 +- tests/login.test.tsx | 54 ++--- tests/useAuthClient.test.tsx | 33 +++ tests/usePasskeySupport.test.tsx | 44 ++++ 32 files changed, 1371 insertions(+), 881 deletions(-) create mode 100644 AGENTS.md create mode 100644 scripts/clean-dist.mjs create mode 100644 src/client/createSeamlessAuthClient.ts delete mode 100644 src/context/InternalAuthContext.tsx create mode 100644 src/hooks/useAuthClient.ts create mode 100644 src/hooks/usePasskeySupport.ts create mode 100644 src/types.ts delete mode 100644 tests/InternalContext.test.tsx create mode 100644 tests/createSeamlessAuthClient.test.ts create mode 100644 tests/useAuthClient.test.tsx create mode 100644 tests/usePasskeySupport.test.tsx diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..73e4272 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,233 @@ +# AGENTS.md + +This file is for coding agents working in the `@seamless-auth/react` repository. + +Use it as the repo-level source of truth for what this package is today, how it fits into the wider Seamless Auth ecosystem, and what kinds of changes should be reinforced instead of reintroducing older patterns. + +## Purpose + +This repo publishes `@seamless-auth/react`. + +The package now supports both of these usage styles: + +- a fast-start path with `AuthProvider` and `AuthRoutes` +- a headless-capable SDK path with exported client helpers and React hooks for custom auth UIs + +When making changes, bias toward: + +- public, documented auth primitives +- optional built-in UI +- one shared implementation path used by both custom UIs and bundled screens + +Avoid pushing the repo back toward “private logic hidden in screens.” + +## Where This Package Sits + +This package is the frontend React SDK in a wider Seamless Auth stack. + +Useful sibling repos to inspect when behavior is unclear: + +- `../seamless-auth-starter-react` +- `../seamless-auth-admin-dashboard` +- `../seamless-auth-docs` +- `../seamless-auth-server` +- `../seamless-auth-api` + +Common usage patterns: + +- `seamless-auth-starter-react` is the best reference for the drop-in `AuthRoutes` path +- `seamless-auth-admin-dashboard` is a good reference for consuming provider state and helpers without using bundled routes +- `seamless-auth-docs` may lag behind this repo, so treat this repo as the source of truth when docs conflict with source + +## Runtime Model + +This package assumes cookie-based auth flows and a Seamless Auth-compatible backend. + +`createFetchWithAuth()` is the shared request helper: + +- it always sends `credentials: "include"` +- in `web` mode it targets `${authHost}/...` +- in `server` mode it targets `${authHost}/auth/...` + +The common deployment shape is `server` mode against a backend that mounts the Seamless Auth routes under `/auth`. + +Important implication: + +- frontend behavior here is tightly coupled to backend route names and cookie/session expectations +- if request paths or auth flow ordering seem questionable, inspect `seamless-auth-server` or `seamless-auth-api` before changing code or docs + +## Current Public API + +Exports from `src/index.ts` currently include: + +- `AuthProvider` +- `AuthRoutes` +- `createSeamlessAuthClient` +- `useAuth` +- `useAuthClient` +- `usePasskeySupport` + +Exported types currently include: + +- `AuthContextType` +- `AuthMode` +- `Credential` +- `CurrentUserResult` +- `LoginInput` +- `PasskeyLoginResult` +- `PasskeyMetadata` +- `PasskeyRegistrationResult` +- `RegisterInput` +- `SeamlessAuthClient` +- `SeamlessAuthClientOptions` +- `User` + +Public API changes should be treated deliberately: + +- if something is not exported from `src/index.ts`, it is not public +- once something is exported, it should be supportable and documented +- built-in UI should consume public primitives whenever practical instead of reaching into private helpers + +## Current Architecture + +The current package is organized around a shared SDK core with optional UI layered on top: + +- `src/AuthProvider.tsx` + - owns auth/session state + - exposes the main provider context + - validates the session with `/users/me` + - exposes refresh, login, logout, user deletion, and credential actions +- `src/client/createSeamlessAuthClient.ts` + - shared headless auth client + - contains the backend request choreography for login, registration, OTP, magic-link, passkey flows, and credential mutations +- `src/hooks/useAuthClient.ts` + - creates a memoized client from provider configuration +- `src/hooks/usePasskeySupport.ts` + - exposes browser support detection for passkeys +- `src/AuthRoutes.tsx` + - bundles the prebuilt auth route flow +- `src/views/*` + - bundled route screens that now consume the public provider/client layer +- `src/components/*` + - reusable UI pieces for those bundled screens +- `src/fetchWithAuth.ts` + - mode-aware request construction +- `src/types.ts` + - shared user and credential types +- `tests/*` + - Jest + Testing Library coverage for provider, client, hooks, and views + +Important architectural reality: + +- the internal-only auth context path is gone +- built-in screens now use public primitives instead of hidden refresh helpers +- remaining work is mostly docs, examples, and incremental polish rather than major extraction plumbing + +## Backend Endpoints Assumed By The SDK + +The built-in flows and exported client assume these route families exist: + +- `/login` +- `/logout` +- `/registration/register` +- `/webAuthn/login/start` +- `/webAuthn/login/finish` +- `/webAuthn/register/start` +- `/webAuthn/register/finish` +- `/otp/generate-phone-otp` +- `/otp/generate-email-otp` +- `/otp/verify-phone-otp` +- `/otp/verify-email-otp` +- `/magic-link` +- `/magic-link/check` +- `/magic-link/verify/:token` +- `/users/me` +- `/users/credentials` +- `/users/delete` + +Before documenting new flow behavior, verify the route contract in `seamless-auth-server` or `seamless-auth-api`. + +## Migration Status + +The SDK migration that moved this package from UI-first internals toward public primitives is effectively complete. + +Completed outcomes: + +- route and docs drift from the earlier transition period were cleaned up +- the shared client was extracted into `createSeamlessAuthClient()` +- public React hooks were added for custom UIs +- `useAuth()` now exposes `refreshSession()` +- bundled screens were rewritten to use public primitives +- the old internal auth context was removed + +That means future work should usually build on the current public surface rather than adding another parallel abstraction. + +## Preferred Change Patterns + +Bias toward these patterns: + +- add reusable behavior to the headless client first, then expose it through React hooks or provider helpers as needed +- keep `AuthProvider` as the source of truth for auth/session state +- use `refreshSession()` when custom flows need to synchronize provider state after a successful auth step +- export types intentionally from `src/index.ts` +- keep built-in views thin and aligned with public APIs +- update README and adjacent docs when the supported contract changes + +For docs work: + +- update this repo first +- then update `../seamless-auth-docs` if the public contract changed +- prefer examples that show custom UI usage, not just `AuthRoutes` + +## Current Rough Edges + +The biggest remaining gaps are no longer hidden internals. They are mostly polish and documentation-oriented: + +- custom UI examples can still be improved, especially full end-to-end examples for bespoke login and registration screens +- passkey login currently handles the no-MFA and unsupported-browser paths cleanly, but there is still no bundled MFA continuation route +- some client methods intentionally return raw `Response` objects, so callers are responsible for checking `response.ok` and handling body parsing consistently +- public docs in sibling repos may still reflect older package behavior + +If you tackle one of these, keep the solution aligned with the existing public surface instead of reintroducing private screen-only helpers. + +## Testing And Validation + +Primary local checks: + +- `npm run lint` +- `npm test -- --runInBand` +- `npm run build` +- `npm run check-npm-build` + +Use at least `npm test -- --runInBand` and `npm run build` for public API or docs-sensitive changes that affect exports, route assumptions, or package output. + +If you change exports or route behavior, also inspect: + +- `dist/index.d.ts` +- provider tests +- hook tests +- route/view tests +- sibling consumer repos if the change affects usage patterns + +## Safe Workflow For Agents + +When changing this repo: + +1. Identify whether the change affects provider state, headless client behavior, bundled UI, or docs. +2. Inspect sibling repos when behavior or expectations are unclear. +3. Verify backend route assumptions before changing request paths or examples. +4. Keep the export boundary explicit in `src/index.ts`. +5. Make built-in screens consume the same public primitive whenever possible. +6. Update README and related docs when supported behavior changes. +7. Run relevant validation before finishing. + +## What To Avoid + +Avoid these patterns unless the user explicitly asks for them: + +- adding new auth flow logic only inside route components +- introducing hidden helper contexts for bundled screens +- exporting unstable internals without documenting them +- changing endpoint assumptions without checking the server/api repos +- leaving README or repo guidance out of sync with the actual exports +- creating a second source of truth for session state outside `AuthProvider` diff --git a/README.md b/README.md index aeb4588..e3a35bf 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,77 @@ -# Seamless Auth React - # @seamless-auth/react [![npm version](https://img.shields.io/npm/v/@seamless-auth/react.svg?label=%40seamless-auth%2Freact)](https://www.npmjs.com/package/@seamless-auth/react) [![coverage](https://img.shields.io/codecov/c/github/fells-code/seamless-auth-react)](https://app.codecov.io/gh/fells-code/seamless-auth-react) [![license](https://img.shields.io/github/license/fells-code/seamless-auth-react)](./LICENSE) -A drop-in authentication provider for React applications, designed to handle login, multi-factor authentication, passkeys, and user session validation using your own backend. - -## Features +`@seamless-auth/react` is a React SDK for Seamless Auth. It gives you a provider for auth state, a headless client and hooks for custom auth UIs, and optional prebuilt auth routes when you want a faster drop-in flow. -- Provides `AuthProvider` context -- Includes `useAuth()` hook for access to auth state and actions -- Ships with pre-built login, MFA, and passkey routes -- Lets consumer apps handle routing via `react-router-dom` -- Supports automatic session validation on load +## What It Exports ---- +- `AuthProvider` +- `AuthRoutes` +- `useAuth()` +- `createSeamlessAuthClient()` +- `useAuthClient()` +- `usePasskeySupport()` +- types including `AuthMode`, `AuthContextType`, `Credential`, `User`, and the headless client input/result types ## Installation ```bash -npm install seamless-auth-react +npm install @seamless-auth/react ``` ---- +## Choose Your Integration Style + +You can use this package in three ways: + +1. `AuthProvider` + `useAuth()` for auth state and core auth actions +2. `createSeamlessAuthClient()` or `useAuthClient()` to build fully custom login and registration screens +3. `AuthRoutes` when you want the built-in login, OTP, magic-link, and passkey screens + +Most apps will use `AuthProvider` either way. -## Usage +## Quick Start -### 1. Wrap your app with `AuthProvider` +### Wrap your app with `AuthProvider` ```tsx -import { AuthProvider } from 'seamless-auth-react'; +import { AuthProvider } from '@seamless-auth/react'; import { BrowserRouter } from 'react-router-dom'; - + ; ``` -### 2. Use `useAuth()` to access auth state +### Read auth state with `useAuth()` ```tsx -import { useAuth } from 'seamless-auth-react'; +import { useAuth } from '@seamless-auth/react'; + +function Dashboard() { + const { user, logout, refreshSession } = useAuth(); -const Dashboard = () => { - const { user, logout } = useAuth(); return (
- Welcome, {user?.email} - +

Welcome, {user?.email}

+ +
); -}; +} ``` -### 3. Use `` for handling login/mfa/passkey screens +### Use built-in auth routes with `AuthRoutes` ```tsx -import { Routes, Route } from 'react-router-dom'; -import { useAuth, AuthRoutes } from 'seamless-auth-react'; +import { AuthRoutes, useAuth } from '@seamless-auth/react'; +import { Route, Routes } from 'react-router-dom'; -const AppRoutes = () => { +function AppRoutes() { const { isAuthenticated } = useAuth(); return ( @@ -75,59 +83,157 @@ const AppRoutes = () => { )} ); -}; +} ``` -> Note: You are responsible for handling route protection and redirects based on `isAuthenticated`. +You are still responsible for your app’s route protection and redirects. ---- +## `useAuth()` API -## AuthContext API - -### `useAuth()` returns: +`useAuth()` returns the current auth state plus the provider-backed helpers: ```ts { - user: { email: string, roles?: string[] } | null; + user: User | null; + credentials: Credential[]; isAuthenticated: boolean; + loading: boolean; + apiHost: string; + mode: AuthMode; + hasSignedInBefore: boolean; + markSignedIn(): void; + hasRole(role: string): boolean | undefined; + refreshSession(): Promise; logout(): Promise; deleteUser(): Promise; - hasRole(role: string): boolean | undefined; + login(identifier: string, passkeyAvailable: boolean): Promise; + handlePasskeyLogin(): Promise; + updateCredential(credential: Credential): Promise; + deleteCredential(credentialId: string): Promise; } ``` ---- +Use `refreshSession()` after completing a custom auth flow that should update provider state. + +## Headless Client + +For custom auth UIs, use the exported client directly: + +```ts +import { createSeamlessAuthClient } from '@seamless-auth/react'; + +const authClient = createSeamlessAuthClient({ + apiHost: 'https://your.api', + mode: 'server', +}); + +const response = await authClient.login({ + identifier: 'user@example.com', + passkeyAvailable: true, +}); + +if (response.ok) { + // Continue your custom flow +} +``` -## Auth Routes Included +The headless client exposes helpers for: + +- current-user/session lookup +- login and passkey login +- registration +- phone OTP and email OTP +- magic-link request, verify, and polling +- passkey registration +- logout and delete-user +- credential update and deletion + +Client methods return raw `Response` objects except for the passkey convenience helpers: + +- `loginWithPasskey(): Promise` +- `registerPasskey(metadata): Promise` + +## React Hooks For Custom UI + +If you want custom React screens but do not want to manually recreate the client, use the exported hooks: + +```tsx +import { useAuth, useAuthClient, usePasskeySupport } from '@seamless-auth/react'; + +function CustomLogin() { + const { refreshSession } = useAuth(); + const authClient = useAuthClient(); + const { passkeySupported, loading } = usePasskeySupport(); + + async function handleEmailLogin() { + const response = await authClient.login({ + identifier: 'user@example.com', + passkeyAvailable: passkeySupported, + }); + + if (response.ok) { + await refreshSession(); + } + } + + return ( + + ); +} +``` + +## Built-In Routes + +`AuthRoutes` currently includes: - `/login` -- `/mfaLogin` - `/passKeyLogin` +- `/verifyPhoneOTP` +- `/verifyEmailOTP` +- `/verify-magiclink` - `/registerPasskey` -- `/verifyOTP` +- `/magiclinks-sent` -Each route includes a pre-built UI and expects your backend to expose compatible endpoints. +These are optional UI wrappers over the same SDK primitives the package now exports for custom flows. ---- +## Backend Expectations -## Customization +This package assumes a Seamless Auth-compatible backend with cookie-based auth flows. -You can override the included UI screens by: +- In `web` mode, requests target `${apiHost}/...` +- In `server` mode, requests target `${apiHost}/auth/...` +- Requests are sent with `credentials: 'include'` +- `AuthProvider` validates the current session by calling `/users/me` on load -- Copying the component source from the package -- Creating your own version -- Replacing the component in your app +The built-in flows assume compatible endpoints for: ---- +- `/login` +- `/logout` +- `/registration/register` +- `/webAuthn/login/start` +- `/webAuthn/login/finish` +- `/webAuthn/register/start` +- `/webAuthn/register/finish` +- `/otp/generate-phone-otp` +- `/otp/generate-email-otp` +- `/otp/verify-phone-otp` +- `/otp/verify-email-otp` +- `/magic-link` +- `/magic-link/check` +- `/magic-link/verify/:token` +- `/users/me` +- `/users/credentials` +- `/users/delete` ## Notes -- This package **does not** create its own `` or ``. -- It is designed to be fully compatible with your existing routing tree. -- The `AuthProvider` automatically calls `/users/me` on load to validate session. - ---- +- This package does not create its own ``. +- It is designed to fit into your app’s existing routing tree. +- The quickest path is `AuthProvider` + `AuthRoutes`. +- The most flexible path is `AuthProvider` + custom UI using `useAuth()`, `useAuthClient()`, and `usePasskeySupport()`. ## License -MIT +AGPL-3.0-only diff --git a/package.json b/package.json index 1212ffe..1ff71ff 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "npm": ">=9.0.0 <13.0.0" }, "scripts": { - "build": "rollup -c", + "build": "node ./scripts/clean-dist.mjs && rollup -c", "test": "jest", "coverage": "npm test -- --coverage", "lint": "eslint ./src ./tests", diff --git a/scripts/clean-dist.mjs b/scripts/clean-dist.mjs new file mode 100644 index 0000000..a2d5d7e --- /dev/null +++ b/scripts/clean-dist.mjs @@ -0,0 +1,3 @@ +import { rm } from 'node:fs/promises'; + +await rm(new URL('../dist', import.meta.url), { force: true, recursive: true }); diff --git a/src/AuthProvider.tsx b/src/AuthProvider.tsx index 32a59a0..6fbc38b 100644 --- a/src/AuthProvider.tsx +++ b/src/AuthProvider.tsx @@ -4,44 +4,29 @@ * See LICENSE file in the project root for full license information */ -import { InternalAuthProvider } from '@/context/InternalAuthContext'; -import { startAuthentication } from '@simplewebauthn/browser'; +import { createSeamlessAuthClient } from '@/client/createSeamlessAuthClient'; +import { Credential, User } from '@/types'; import React, { createContext, ReactNode, useCallback, useContext, useEffect, + useMemo, useState, } from 'react'; -import { AuthMode, createFetchWithAuth } from './fetchWithAuth'; +import { AuthMode } from './fetchWithAuth'; import { usePreviousSignIn } from './hooks/usePreviousSignIn'; -import { - AuthenticatorTransportFuture, - CredentialDeviceType, -} from '@simplewebauthn/browser'; - -export interface User { - id: string; - email: string; - phone: string; - roles?: string[]; -} - -type AuthToken = { - oneTimeToken: string; - expiresAt: string; -}; export interface AuthContextType { user: User | null; - logout: () => void; - deleteUser: () => void; + logout: () => Promise; + deleteUser: () => Promise; + refreshSession: () => Promise; isAuthenticated: boolean; hasRole: (role: string) => boolean | undefined; apiHost: string; - token: AuthToken | null; markSignedIn: () => void; hasSignedInBefore: boolean; mode: AuthMode; @@ -53,19 +38,6 @@ export interface AuthContextType { loading: boolean; } -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; -} - const AuthContext = createContext(undefined); /** @@ -97,89 +69,64 @@ export const AuthProvider: React.FC = ({ const [credentials, setCredentials] = useState([]); const [isAuthenticated, setIsAuthenticated] = useState(false); const [loading, setLoading] = useState(true); - const [token, setToken] = useState(null); const { hasSignedInBefore, markSignedIn } = usePreviousSignIn(); - const [authMode] = useState(mode); + const authMode = mode; - const fetchWithAuth = createFetchWithAuth({ - authMode, - authHost: apiHost, - }); + const authClient = useMemo( + () => + createSeamlessAuthClient({ + mode: authMode, + apiHost, + }), + [authMode, apiHost] + ); const login = async ( identifier: string, passkeyAvailable: boolean ): Promise => { - const response = await fetchWithAuth(`/login`, { - method: 'POST', - body: JSON.stringify({ identifier, passkeyAvailable }), - }); - - return response; + return authClient.login({ identifier, passkeyAvailable }); }; const handlePasskeyLogin = async () => { - try { - const response = await fetchWithAuth(`/webAuthn/login/start`, { - method: 'POST', - }); - - const options = await response.json(); - const credential = await startAuthentication({ optionsJSON: options }); - - const verificationResponse = await fetchWithAuth(`/webAuthn/login/finish`, { - method: 'POST', - body: JSON.stringify({ assertionResponse: credential }), - }); - - if (!verificationResponse.ok) { - console.error('Failed to verify passkey'); - } + const result = await authClient.loginWithPasskey(); - const verificationResult = await verificationResponse.json(); - - if (verificationResult.message === 'Success') { - if (verificationResult.mfaLogin) { - return true; - } - await validateToken(); - return false; - } else { - console.error('Passkey login failed:', verificationResult.message); - return false; - } - } catch (error) { - console.error('Passkey login error:', error); + if (result.mfaRequired) { + console.warn( + 'Passkey login requested MFA, but the built-in MFA route is not currently available.' + ); return false; } + + if (result.success) { + await validateToken(); + return true; + } + + console.error('Passkey login failed:', result.message); + return false; }; const logout = useCallback(async () => { - if (user) { - try { - await fetchWithAuth(`/logout`, { - method: 'GET', - }); - } catch { - console.error('Error during logout'); - } finally { - setIsAuthenticated(false); - setUser(null); - setToken(null); - } + try { + await authClient.logout(); + } catch { + console.error('Error during logout'); + } finally { + setIsAuthenticated(false); + setUser(null); + setCredentials([]); } - }, [fetchWithAuth, user]); + }, [authClient]); const deleteUser = async () => { try { - const response = await fetchWithAuth(`/users/delete`, { - method: 'delete', - }); + const response = await authClient.deleteUser(); if (response.ok) { setUser(null); setIsAuthenticated(false); - setToken(null); + setCredentials([]); return; } else { throw new Error('Could not delete user.'); @@ -192,35 +139,31 @@ export const AuthProvider: React.FC = ({ const hasRole = (role: string) => user?.roles?.includes(role); - const validateToken = async () => { + const validateToken = useCallback(async () => { setLoading(true); try { - const response = await fetchWithAuth(`users/me`, { - method: 'GET', - }); + const response = await authClient.getCurrentUser(); if (response.ok) { const { user, credentials } = await response.json(); setUser(user); - setCredentials(credentials); + setCredentials(credentials ?? []); setIsAuthenticated(true); } else { - logout(); + await logout(); } } catch { - logout(); + await logout(); } finally { setLoading(false); } - }; + }, [authClient, logout]); const updateCredential = async (credential: Credential) => { - const response = await fetchWithAuth(`users/credentials`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ friendlyName: credential.friendlyName, id: credential.id }), + const response = await authClient.updateCredential({ + friendlyName: credential.friendlyName, + id: credential.id, }); if (response.ok) { @@ -231,12 +174,7 @@ export const AuthProvider: React.FC = ({ }; const deleteCredential = async (credentialId: string) => { - const response = await fetchWithAuth(`users/credentials`, { - method: 'DELETE', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: credentialId }), - }); + const response = await authClient.deleteCredential(credentialId); if (response.ok) { return response.json(); @@ -246,8 +184,8 @@ export const AuthProvider: React.FC = ({ }; useEffect(() => { - validateToken(); - }, []); + void validateToken(); + }, [validateToken]); useEffect(() => { if (user && isAuthenticated) { @@ -260,15 +198,15 @@ export const AuthProvider: React.FC = ({ value={{ user, logout, + refreshSession: validateToken, loading, deleteUser, isAuthenticated, hasRole, apiHost, - token, markSignedIn, hasSignedInBefore: autoDetectPreviousSignin ? hasSignedInBefore : false, - mode, + mode: authMode, credentials, updateCredential, deleteCredential, @@ -276,9 +214,7 @@ export const AuthProvider: React.FC = ({ handlePasskeyLogin, }} > - - {children} - + {children} ); }; diff --git a/src/client/createSeamlessAuthClient.ts b/src/client/createSeamlessAuthClient.ts new file mode 100644 index 0000000..4239b53 --- /dev/null +++ b/src/client/createSeamlessAuthClient.ts @@ -0,0 +1,300 @@ +/* + * 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 { + startAuthentication, + startRegistration, + type RegistrationResponseJSON, + WebAuthnError, +} from '@simplewebauthn/browser'; + +import { AuthMode, createFetchWithAuth } from '../fetchWithAuth'; +import { Credential, User } from '../types'; + +export interface SeamlessAuthClientOptions { + apiHost: string; + mode: AuthMode; +} + +export interface LoginInput { + identifier: string; + passkeyAvailable: boolean; +} + +export interface RegisterInput { + email: string; + phone: string; + bootstrapToken?: string | null; +} + +export interface PasskeyMetadata { + friendlyName: string; + platform: string; + browser: string; + deviceInfo: string; +} + +export interface CurrentUserResult { + user: User; + credentials: Credential[]; +} + +export interface PasskeyLoginResult { + success: boolean; + mfaRequired: boolean; + message: string; +} + +export interface PasskeyRegistrationResult { + success: boolean; + message: string; +} + +export interface SeamlessAuthClient { + getCurrentUser: () => Promise; + login: (input: LoginInput) => Promise; + loginWithPasskey: () => Promise; + logout: () => Promise; + deleteUser: () => Promise; + register: (input: RegisterInput) => Promise; + requestPhoneOtp: () => Promise; + verifyPhoneOtp: (verificationToken: string) => Promise; + requestEmailOtp: () => Promise; + verifyEmailOtp: (verificationToken: string) => Promise; + requestMagicLink: () => Promise; + checkMagicLink: () => Promise; + verifyMagicLink: (token: string) => Promise; + registerPasskey: (metadata: PasskeyMetadata) => Promise; + updateCredential: (input: { id: string; friendlyName: string | null }) => Promise; + deleteCredential: (id: string) => Promise; +} + +export const createSeamlessAuthClient = ( + opts: SeamlessAuthClientOptions +): SeamlessAuthClient => { + const fetchWithAuth = createFetchWithAuth({ + authMode: opts.mode, + authHost: opts.apiHost, + }); + + return { + getCurrentUser: () => + fetchWithAuth(`users/me`, { + method: 'GET', + }), + + login: input => + fetchWithAuth(`/login`, { + method: 'POST', + body: JSON.stringify(input), + }), + + loginWithPasskey: async () => { + const response = await fetchWithAuth(`/webAuthn/login/start`, { + method: 'POST', + }); + + if (!response.ok) { + return { + success: false, + mfaRequired: false, + message: 'Failed to start passkey login.', + }; + } + + try { + const options = await response.json(); + const credential = await startAuthentication({ optionsJSON: options }); + + const verificationResponse = await fetchWithAuth(`/webAuthn/login/finish`, { + method: 'POST', + body: JSON.stringify({ assertionResponse: credential }), + }); + + if (!verificationResponse.ok) { + return { + success: false, + mfaRequired: false, + message: 'Failed to verify passkey.', + }; + } + + const verificationResult = await verificationResponse.json(); + + if (verificationResult.message === 'Success') { + return { + success: !verificationResult.mfaLogin, + mfaRequired: Boolean(verificationResult.mfaLogin), + message: verificationResult.mfaLogin + ? 'Passkey login requires MFA.' + : 'Passkey login succeeded.', + }; + } + + return { + success: false, + mfaRequired: false, + message: verificationResult.message ?? 'Passkey login failed.', + }; + } catch (error) { + console.error('Passkey login error:', error); + return { + success: false, + mfaRequired: false, + message: 'Passkey login failed.', + }; + } + }, + + logout: () => + fetchWithAuth(`/logout`, { + method: 'GET', + }), + + deleteUser: () => + fetchWithAuth(`/users/delete`, { + method: 'DELETE', + }), + + register: input => + fetchWithAuth(`/registration/register`, { + method: 'POST', + body: JSON.stringify({ + email: input.email, + phone: input.phone, + ...(input.bootstrapToken ? { bootstrapToken: input.bootstrapToken } : {}), + }), + }), + + requestPhoneOtp: () => + fetchWithAuth(`/otp/generate-phone-otp`, { + method: 'GET', + }), + + verifyPhoneOtp: verificationToken => + fetchWithAuth(`/otp/verify-phone-otp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + verificationToken, + }), + credentials: 'include', + }), + + requestEmailOtp: () => + fetchWithAuth(`/otp/generate-email-otp`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }), + + verifyEmailOtp: verificationToken => + fetchWithAuth(`/otp/verify-email-otp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + verificationToken, + }), + credentials: 'include', + }), + + requestMagicLink: () => + fetchWithAuth(`/magic-link`, { + method: 'GET', + }), + + checkMagicLink: () => + fetchWithAuth(`/magic-link/check`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }), + + verifyMagicLink: token => + fetchWithAuth(`/magic-link/verify/${token}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }), + + registerPasskey: async metadata => { + const challengeRes = await fetchWithAuth(`/webAuthn/register/start`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }); + + if (!challengeRes.ok) { + return { + success: false, + message: 'Failed to fetch passkey registration challenge.', + }; + } + + const options = await challengeRes.json(); + + let attestationResponse: RegistrationResponseJSON; + + try { + attestationResponse = await startRegistration({ optionsJSON: options }); + } catch (error) { + if (error instanceof WebAuthnError) { + return { + success: false, + message: error.name, + }; + } + + console.error('Passkey registration error:', error); + return { + success: false, + message: 'Passkey registration failed.', + }; + } + + const verificationResp = await fetchWithAuth(`/webAuthn/register/finish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + attestationResponse, + metadata, + }), + credentials: 'include', + }); + + if (!verificationResp.ok) { + return { + success: false, + message: 'Verification failed.', + }; + } + + return { + success: true, + message: 'Passkey registered successfully.', + }; + }, + + updateCredential: input => + fetchWithAuth(`users/credentials`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }), + + deleteCredential: id => + fetchWithAuth(`users/credentials`, { + method: 'DELETE', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }), + }; +}; diff --git a/src/components/MagicLinkSent.tsx b/src/components/MagicLinkSent.tsx index 952bef7..fc7691a 100644 --- a/src/components/MagicLinkSent.tsx +++ b/src/components/MagicLinkSent.tsx @@ -6,24 +6,18 @@ import React, { useEffect, useState } from 'react'; import { useAuth } from '@/AuthProvider'; -import { useInternalAuth } from '@/context/InternalAuthContext'; +import { useAuthClient } from '@/hooks/useAuthClient'; import { useNavigate, useLocation } from 'react-router-dom'; import styles from '@/styles/magiclink.module.css'; -import { createFetchWithAuth } from '@/fetchWithAuth'; const MagicLinkSent: React.FC = () => { - const { apiHost, mode: authMode } = useAuth(); - const { validateToken } = useInternalAuth(); + const { refreshSession } = useAuth(); const navigate = useNavigate(); const location = useLocation(); + const authClient = useAuthClient(); - const fetchWithAuth = createFetchWithAuth({ - authMode, - authHost: apiHost, - }); - - const identifier = location.state?.identifier; + const identifier = location.state?.identifier as string | undefined; const [cooldown, setCooldown] = useState(30); @@ -38,12 +32,7 @@ const MagicLinkSent: React.FC = () => { const resend = async () => { if (cooldown > 0) return; - await fetchWithAuth(`/magic-link`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); + await authClient.requestMagicLink(); setCooldown(30); }; @@ -53,14 +42,9 @@ const MagicLinkSent: React.FC = () => { channel.onmessage = async event => { if (event.data?.type === 'MAGIC_LINK_AUTH_SUCCESS') { - const response = await fetchWithAuth(`/magic-link/check`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); + const response = await authClient.checkMagicLink(); if (response.status === 200) { - await validateToken(); + await refreshSession(); navigate('/'); } } @@ -69,19 +53,14 @@ const MagicLinkSent: React.FC = () => { return () => { channel.close(); }; - }, [fetchWithAuth, navigate, validateToken]); + }, [authClient, navigate, refreshSession]); useEffect(() => { const interval = setInterval(async () => { try { - const response = await fetchWithAuth(`/magic-link/check`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); + const response = await authClient.checkMagicLink(); if (response.status === 200) { - await validateToken(); + await refreshSession(); navigate('/'); } } catch { @@ -90,7 +69,7 @@ const MagicLinkSent: React.FC = () => { }, 5000); return () => clearInterval(interval); - }, [apiHost, validateToken, navigate]); + }, [authClient, refreshSession, navigate]); return (
@@ -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 ce2c28a..2d60cba 100644 --- a/src/views/Login.tsx +++ b/src/views/Login.tsx @@ -7,21 +7,22 @@ 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 authClient = useAuthClient(); + const { passkeySupported } = usePasskeySupport(); const [identifier, setIdentifier] = useState(''); const [email, setEmail] = useState(''); const [mode, setMode] = useState<'login' | 'register'>('register'); @@ -30,23 +31,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 +70,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 +85,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 +116,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,20 +132,43 @@ const Login: React.FC = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + setFormErrors(''); + setShowFallbackOptions(false); + + try { + if (mode === 'login') { + const loginRes = await login(identifier, passkeySupported); + + if (loginRes.ok && passkeySupported) { + const passkeyResult = await handlePasskeyLogin(); + if (passkeyResult) { + navigate('/'); + return; + } - if (mode === 'login') { - const loginRes = await login(identifier, passkeyAvailable); + setShowFallbackOptions(true); + setFormErrors( + 'Passkey sign-in could not be completed. Choose another sign-in method.' + ); + return; + } - if (loginRes.ok && passkeyAvailable) { - const passkeyResult = await handlePasskeyLogin(); - if (passkeyResult) { - navigate('/'); + if (loginRes.ok) { + setShowFallbackOptions(true); + return; } - } else { - 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

- + {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 ; -}; - -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); + }); +}); From a370208ccc28398f1b88323adc31ae3f07ea9702 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 23 Apr 2026 16:52:09 -0400 Subject: [PATCH 2/3] fix: fix linting --- src/views/Login.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/Login.tsx b/src/views/Login.tsx index ad307ef..5f5fc2a 100644 --- a/src/views/Login.tsx +++ b/src/views/Login.tsx @@ -135,7 +135,7 @@ const Login: React.FC = () => { if (mode === 'login') { const loginRes = await login(identifier, passkeySupported); - if (loginRes.ok && passkeySupported) { + if (loginRes?.ok && passkeySupported) { const passkeyResult = await handlePasskeyLogin(); if (passkeyResult) { navigate('/'); @@ -149,7 +149,7 @@ const Login: React.FC = () => { return; } - if (loginRes.ok) { + if (loginRes?.ok) { setShowFallbackOptions(true); return; } From 0152e8228d6da9b90de9a4d86d0147d85bf3f28c Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 23 Apr 2026 17:39:37 -0400 Subject: [PATCH 3/3] 0.1.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d5b9ac0..fd33844 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@seamless-auth/react", - "version": "0.0.10", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/react", - "version": "0.0.10", + "version": "0.1.0", "license": "AGPL-3.0-only", "dependencies": { "@simplewebauthn/browser": "^13.1.0", diff --git a/package.json b/package.json index 1ff71ff..e6d0e9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@seamless-auth/react", - "version": "0.0.10", + "version": "0.1.0", "description": "A drop-in authentication solution for modern React applications.", "type": "module", "exports": {