diff --git a/package.json b/package.json index 2bd19ec..1212ffe 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "build": "rollup -c", "test": "jest", "coverage": "npm test -- --coverage", - "lint": "eslint ./src", + "lint": "eslint ./src ./tests", "format": "prettier --write .", "prepare": "husky", "semantic-release": "semantic-release", diff --git a/rollup.config.js b/rollup.config.js index bd004c1..394e805 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -33,7 +33,7 @@ export default [ }), commonjs(), typescript({ - tsconfig: './tsconfig.json', + tsconfig: './tsconfig.build.json', }), postcss({ modules: { diff --git a/src/AuthProvider.tsx b/src/AuthProvider.tsx index 9ff4bda..32a59a0 100644 --- a/src/AuthProvider.tsx +++ b/src/AuthProvider.tsx @@ -5,6 +5,7 @@ */ import { InternalAuthProvider } from '@/context/InternalAuthContext'; +import { startAuthentication } from '@simplewebauthn/browser'; import React, { createContext, ReactNode, @@ -47,6 +48,8 @@ export interface AuthContextType { credentials: Credential[]; updateCredential: (credential: Credential) => Promise; deleteCredential: (credentialId: string) => Promise; + login: (identifier: string, passkeyAvailable: boolean) => Promise; + handlePasskeyLogin: () => Promise; loading: boolean; } @@ -103,6 +106,54 @@ export const AuthProvider: React.FC = ({ authHost: apiHost, }); + const login = async ( + identifier: string, + passkeyAvailable: boolean + ): Promise => { + const response = await fetchWithAuth(`/login`, { + method: 'POST', + body: JSON.stringify({ identifier, passkeyAvailable }), + }); + + return response; + }; + + 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 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); + return false; + } + }; + const logout = useCallback(async () => { if (user) { try { @@ -221,6 +272,8 @@ export const AuthProvider: React.FC = ({ credentials, updateCredential, deleteCredential, + login, + handlePasskeyLogin, }} > diff --git a/src/AuthRoutes.tsx b/src/AuthRoutes.tsx index 78caeac..81d2dd3 100644 --- a/src/AuthRoutes.tsx +++ b/src/AuthRoutes.tsx @@ -22,7 +22,7 @@ export const AuthRoutes = () => ( } /> } /> } /> - } /> + } /> } /> ); diff --git a/src/utils.ts b/src/utils.ts index ac0bcc0..c211ec8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,7 +5,6 @@ */ import parsePhoneNumberFromString from 'libphonenumber-js'; - /** * isValidEmail * diff --git a/src/views/Login.tsx b/src/views/Login.tsx index 129df02..ce2c28a 100644 --- a/src/views/Login.tsx +++ b/src/views/Login.tsx @@ -4,13 +4,10 @@ * See LICENSE file in the project root for full license information */ -import { startAuthentication } from '@simplewebauthn/browser'; import { useAuth } from '@/AuthProvider'; import PhoneInputWithCountryCode from '@/components/phoneInput'; -import { useInternalAuth } from '@/context/InternalAuthContext'; import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; - import styles from '@/styles/login.module.css'; import { isPasskeySupported, isValidEmail, isValidPhoneNumber } from '../utils'; import { createFetchWithAuth } from '@/fetchWithAuth'; @@ -18,8 +15,13 @@ import AuthFallbackOptions from '@/components/AuthFallbackOptions'; const Login: React.FC = () => { const navigate = useNavigate(); - const { apiHost, hasSignedInBefore, mode: authMode } = useAuth(); - const { validateToken } = useInternalAuth(); + const { + apiHost, + hasSignedInBefore, + mode: authMode, + login, + handlePasskeyLogin, + } = useAuth(); const [identifier, setIdentifier] = useState(''); const [email, setEmail] = useState(''); const [mode, setMode] = useState<'login' | 'register'>('register'); @@ -76,73 +78,6 @@ const Login: React.FC = () => { return isValidEmail(email) && isValidPhoneNumber(phone); }; - const handlePasskeyLogin = async () => { - try { - const response = await fetchWithAuth(`/webAuthn/login/start`, { - method: 'POST', - }); - - 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', - body: JSON.stringify({ assertionResponse: credential }), - }); - - if (!verificationResponse.ok) { - console.error('Failed to verify passkey'); - } - - const verificationResult = await verificationResponse.json(); - - if (verificationResult.message === 'Success') { - if (verificationResult.mfaLogin) { - navigate('/mfaLogin'); - return; - } - await validateToken(); - navigate('/'); - return; - } else { - console.error('Passkey login failed:', verificationResult.message); - } - } catch (error) { - console.error('Passkey login error:', error); - } - }; - - const login = async () => { - setFormErrors(''); - - const response = await fetchWithAuth(`/login`, { - method: 'POST', - body: JSON.stringify({ identifier, passkeyAvailable }), - }); - - if (!response.ok) { - setFormErrors('Failed to send login link. Please try again.'); - return; - } - - if (!passkeyAvailable) { - setShowFallbackOptions(true); - return; - } - - try { - await handlePasskeyLogin(); - } catch (err) { - console.error('Passkey login failed', err); - setShowFallbackOptions(true); - } - }; - const register = async () => { setFormErrors(''); @@ -188,7 +123,7 @@ const Login: React.FC = () => { return; } - navigate('/magic-link-sent'); + navigate('/magiclinks-sent'); } catch (err) { console.error(err); setFormErrors('Failed to send magic link.'); @@ -217,7 +152,18 @@ const Login: React.FC = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (mode === 'login') login(); + if (mode === 'login') { + const loginRes = await login(identifier, passkeyAvailable); + + if (loginRes.ok && passkeyAvailable) { + const passkeyResult = await handlePasskeyLogin(); + if (passkeyResult) { + navigate('/'); + } + } else { + setShowFallbackOptions(true); + } + } if (mode === 'register') register(); }; diff --git a/src/views/VerifyMagicLink.tsx b/src/views/VerifyMagicLink.tsx index c7bb96f..89faa49 100644 --- a/src/views/VerifyMagicLink.tsx +++ b/src/views/VerifyMagicLink.tsx @@ -27,17 +27,21 @@ const VerifyMagicLink: React.FC = () => { useEffect(() => { const verify = async () => { - const response = await fetchWithAuth(`/magic-link/verify/${token}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - console.error('Failed to verify token'); - setError('Failed to verify token'); - return; + try { + const response = await fetchWithAuth(`/magic-link/verify/${token}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + console.error('Failed to verify token'); + setError('Failed to verify token'); + return; + } + } catch (error) { + console.error(error); } const channel = new BroadcastChannel('seamless-auth'); diff --git a/tests/AuthFallbackOptions.test.tsx b/tests/AuthFallbackOptions.test.tsx index e563bb8..36f38e3 100644 --- a/tests/AuthFallbackOptions.test.tsx +++ b/tests/AuthFallbackOptions.test.tsx @@ -1,3 +1,9 @@ +/* + * 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, fireEvent } from '@testing-library/react'; import AuthFallbackOptions from '@/components/AuthFallbackOptions'; diff --git a/tests/AuthRoutes.test.tsx b/tests/AuthRoutes.test.tsx index d97a45b..64ff1a7 100644 --- a/tests/AuthRoutes.test.tsx +++ b/tests/AuthRoutes.test.tsx @@ -1,3 +1,9 @@ +/* + * 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 { MemoryRouter } from 'react-router-dom'; import { render, screen } from '@testing-library/react'; import { AuthRoutes } from '../src/AuthRoutes'; diff --git a/tests/DeviceModal.test.tsx b/tests/DeviceModal.test.tsx index 3fc42da..759d324 100644 --- a/tests/DeviceModal.test.tsx +++ b/tests/DeviceModal.test.tsx @@ -1,3 +1,9 @@ +/* + * 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, fireEvent } from '@testing-library/react'; import DeviceNameModal from '@/components/DeviceNameModal'; diff --git a/tests/EmailRegistration.test.tsx b/tests/EmailRegistration.test.tsx index 387c8d4..1816839 100644 --- a/tests/EmailRegistration.test.tsx +++ b/tests/EmailRegistration.test.tsx @@ -1,3 +1,9 @@ +/* + * 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, fireEvent, act, waitFor } from '@testing-library/react'; import EmailRegistration from '@/views/EmailRegistration'; diff --git a/tests/InternalContext.test.tsx b/tests/InternalContext.test.tsx index 522056f..9f06cb6 100644 --- a/tests/InternalContext.test.tsx +++ b/tests/InternalContext.test.tsx @@ -1,3 +1,9 @@ +/* + * 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, diff --git a/tests/MagicLinkSent.test.tsx b/tests/MagicLinkSent.test.tsx index e2607b0..c7cec7a 100644 --- a/tests/MagicLinkSent.test.tsx +++ b/tests/MagicLinkSent.test.tsx @@ -1,3 +1,9 @@ +/* + * 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, fireEvent, act } from '@testing-library/react'; import MagicLinkSent from '@/components/MagicLinkSent'; diff --git a/tests/OtpInput.test.tsx b/tests/OtpInput.test.tsx index 09e1f3b..a2ef799 100644 --- a/tests/OtpInput.test.tsx +++ b/tests/OtpInput.test.tsx @@ -1,3 +1,9 @@ +/* + * 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, fireEvent } from '@testing-library/react'; import OtpInput from '@/components/OtpInput'; diff --git a/tests/PassKeyLogin.test.tsx b/tests/PassKeyLogin.test.tsx index 48d3df3..ea167ac 100644 --- a/tests/PassKeyLogin.test.tsx +++ b/tests/PassKeyLogin.test.tsx @@ -1,3 +1,9 @@ +/* + * 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, fireEvent, waitFor } from '@testing-library/react'; import PassKeyLogin from '../src/views/PassKeyLogin'; diff --git a/tests/PhoneInput.test.tsx b/tests/PhoneInput.test.tsx index b0a3701..978c7d1 100644 --- a/tests/PhoneInput.test.tsx +++ b/tests/PhoneInput.test.tsx @@ -1,4 +1,10 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +/* + * 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, fireEvent } from '@testing-library/react'; import PhoneInputWithCountryCode from '@/components/phoneInput'; jest.mock('libphonenumber-js', () => ({ diff --git a/tests/PhoneRegistration.test.tsx b/tests/PhoneRegistration.test.tsx index d620734..06305fd 100644 --- a/tests/PhoneRegistration.test.tsx +++ b/tests/PhoneRegistration.test.tsx @@ -1,3 +1,9 @@ +/* + * 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, fireEvent, act } from '@testing-library/react'; import PhoneRegistration from '@/views/PhoneRegistration'; diff --git a/tests/RegisterPassKey.test.tsx b/tests/RegisterPassKey.test.tsx index 53ccd0d..6f0390a 100644 --- a/tests/RegisterPassKey.test.tsx +++ b/tests/RegisterPassKey.test.tsx @@ -1,3 +1,9 @@ +/* + * 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, fireEvent, waitFor } from '@testing-library/react'; import RegisterPasskey from '../src/views/PassKeyRegistration'; @@ -130,6 +136,7 @@ describe('RegisterPasskey', () => { json: async () => ({ challenge: 'xyz' }), }); + // eslint-disable-next-line @typescript-eslint/no-require-imports const { WebAuthnError } = require('@simplewebauthn/browser'); mockStartRegistration.mockRejectedValueOnce(new WebAuthnError('Failure')); diff --git a/tests/TermsModal.test.tsx b/tests/TermsModal.test.tsx index 76109e9..58cbabf 100644 --- a/tests/TermsModal.test.tsx +++ b/tests/TermsModal.test.tsx @@ -1,3 +1,9 @@ +/* + * 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, fireEvent } from '@testing-library/react'; import TermsModal from '../src/components/TermsModal'; diff --git a/tests/VerifyMagicLink.test.tsx b/tests/VerifyMagicLink.test.tsx index 0362c22..4b83dee 100644 --- a/tests/VerifyMagicLink.test.tsx +++ b/tests/VerifyMagicLink.test.tsx @@ -1,3 +1,9 @@ +/* + * 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, act } from '@testing-library/react'; import VerifyMagicLink from '@/views/VerifyMagicLink'; diff --git a/tests/authProvider.test.tsx b/tests/authProvider.test.tsx index 79f9110..7cc21e9 100644 --- a/tests/authProvider.test.tsx +++ b/tests/authProvider.test.tsx @@ -1,3 +1,9 @@ +/* + * 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 { act, render, screen, waitFor } from '@testing-library/react'; import { AuthProvider, useAuth } from '../src/AuthProvider'; import { createFetchWithAuth } from '../src/fetchWithAuth'; diff --git a/tests/fetchWithAuth.test.tsx b/tests/fetchWithAuth.test.tsx index 3e61e5b..0048a07 100644 --- a/tests/fetchWithAuth.test.tsx +++ b/tests/fetchWithAuth.test.tsx @@ -1,3 +1,9 @@ +/* + * 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 { createFetchWithAuth } from '../src/fetchWithAuth'; const mockFetch = jest.fn(); diff --git a/tests/login.test.tsx b/tests/login.test.tsx index 9cd19f0..d924b25 100644 --- a/tests/login.test.tsx +++ b/tests/login.test.tsx @@ -1,3 +1,9 @@ +/* + * 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, fireEvent, act, waitFor } from '@testing-library/react'; import Login from '@/views/Login'; @@ -51,6 +57,7 @@ describe('Login', () => { apiHost: 'http://localhost', hasSignedInBefore: true, mode: 'web', + login: () => jest.fn(), }); (useInternalAuth as jest.Mock).mockReturnValue({ @@ -97,8 +104,13 @@ describe('Login', () => { }); test('login triggers API request', async () => { - mockFetch.mockResolvedValue({ ok: true }); - + const mockLogin = jest.fn().mockResolvedValueOnce({ ok: true }); + (useAuth as jest.Mock).mockReturnValue({ + apiHost: 'http://localhost', + hasSignedInBefore: true, + mode: 'web', + login: mockLogin, + }); render(); const input = screen.getByPlaceholderText(/email or phone number/i); @@ -117,10 +129,7 @@ describe('Login', () => { fireEvent.click(loginButton); }); - expect(mockFetch).toHaveBeenCalledWith( - '/login', - expect.objectContaining({ method: 'POST' }) - ); + expect(mockLogin).toHaveBeenCalled(); }); test('fallback options appear if passkeys unavailable', async () => { @@ -164,7 +173,7 @@ describe('Login', () => { fireEvent.click(magicLink); }); - expect(navigate).toHaveBeenCalledWith('/magic-link-sent'); + expect(navigate).toHaveBeenCalledWith('/magiclinks-sent'); }); test('phone OTP option navigates to verify phone', async () => { diff --git a/tests/usePreviousSignin.test.tsx b/tests/usePreviousSignin.test.tsx index 0648f1e..a7e9887 100644 --- a/tests/usePreviousSignin.test.tsx +++ b/tests/usePreviousSignin.test.tsx @@ -1,3 +1,9 @@ +/* + * 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, act } from '@testing-library/react'; import { usePreviousSignIn } from '../src/hooks/usePreviousSignIn'; diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 14557f6..dd14ed2 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -1,3 +1,9 @@ +/* + * 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 { isValidEmail, isValidPhoneNumber, diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..f5e8fd0 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "./dist", + "outDir": "./dist", + "sourceMap": true + }, + "include": ["src"], + "exclude": ["tests", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/tsconfig.dev.json b/tsconfig.dev.json new file mode 100644 index 0000000..b9ec368 --- /dev/null +++ b/tsconfig.dev.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src", "tests"] +} diff --git a/tsconfig.json b/tsconfig.json index 766a24b..a2e990d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,22 +2,14 @@ "compilerOptions": { "target": "ES2020", "module": "ESNext", - "declaration": true, - "declarationDir": "./dist", - "outDir": "./dist", "moduleResolution": "Bundler", "jsx": "react-jsx", "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "allowSyntheticDefaultImports": true, "strict": true, - "sourceMap": true, - "baseUrl": "./src", "paths": { - "@/*": ["*"] + "@/*": ["./src/*"] }, "types": ["jest", "node", "@testing-library/jest-dom"] - }, - "include": ["src"] + } }