Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default [
}),
commonjs(),
typescript({
tsconfig: './tsconfig.json',
tsconfig: './tsconfig.build.json',
}),
postcss({
modules: {
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
Loading
Loading