Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
53 changes: 53 additions & 0 deletions src/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { InternalAuthProvider } from '@/context/InternalAuthContext';
import { startAuthentication } from '@simplewebauthn/browser';
import React, {
createContext,
ReactNode,
Expand Down Expand Up @@ -47,6 +48,8 @@
credentials: Credential[];
updateCredential: (credential: Credential) => Promise<Credential>;
deleteCredential: (credentialId: string) => Promise<void>;
login: (identifier: string, passkeyAvailable: boolean) => Promise<Response>;
handlePasskeyLogin: () => Promise<boolean>;
loading: boolean;
}

Expand Down Expand Up @@ -103,6 +106,54 @@
authHost: apiHost,
});

const login = async (
identifier: string,
passkeyAvailable: boolean
): Promise<Response> => {
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 {
Expand Down Expand Up @@ -196,7 +247,7 @@

useEffect(() => {
validateToken();
}, []);

Check warning on line 250 in src/AuthProvider.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useEffect has a missing dependency: 'validateToken'. Either include it or remove the dependency array

useEffect(() => {
if (user && isAuthenticated) {
Expand All @@ -221,6 +272,8 @@
credentials,
updateCredential,
deleteCredential,
login,
handlePasskeyLogin,
}}
>
<InternalAuthProvider value={{ validateToken, setLoading }}>
Expand Down
2 changes: 1 addition & 1 deletion src/AuthRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const AuthRoutes = () => (
<Route path="/verifyEmailOTP" element={<EmailRegistration />} />
<Route path="/verify-magiclink" element={<VerifyMagicLink />} />
<Route path="/registerPasskey" element={<PasskeyRegistration />} />
<Route path="/magic-link-sent" element={<MagicLinkSent />} />
<Route path="/magiclinks-sent" element={<MagicLinkSent />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
);
1 change: 0 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
*/

import parsePhoneNumberFromString from 'libphonenumber-js';

/**
* isValidEmail
*
Expand Down
94 changes: 20 additions & 74 deletions src/views/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,24 @@
* 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';
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<string>('');
const [email, setEmail] = useState<string>('');
const [mode, setMode] = useState<'login' | 'register'>('register');
Expand Down Expand Up @@ -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('');

Expand Down Expand Up @@ -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.');
Expand Down Expand Up @@ -217,7 +152,18 @@ const Login: React.FC = () => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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();
};

Expand Down
26 changes: 15 additions & 11 deletions src/views/VerifyMagicLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
6 changes: 6 additions & 0 deletions tests/AuthFallbackOptions.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
6 changes: 6 additions & 0 deletions tests/AuthRoutes.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
6 changes: 6 additions & 0 deletions tests/DeviceModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
6 changes: 6 additions & 0 deletions tests/EmailRegistration.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
6 changes: 6 additions & 0 deletions tests/InternalContext.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
6 changes: 6 additions & 0 deletions tests/MagicLinkSent.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
6 changes: 6 additions & 0 deletions tests/OtpInput.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
6 changes: 6 additions & 0 deletions tests/PassKeyLogin.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
8 changes: 7 additions & 1 deletion tests/PhoneInput.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down
6 changes: 6 additions & 0 deletions tests/PhoneRegistration.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
7 changes: 7 additions & 0 deletions tests/RegisterPassKey.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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'));

Expand Down
Loading
Loading