Skip to content

Commit 0676be6

Browse files
authored
Merge pull request #13 from fells-code/loginAbstract
Login abstract
2 parents 9c98c23 + 87035ff commit 0676be6

28 files changed

Lines changed: 228 additions & 107 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"build": "rollup -c",
2424
"test": "jest",
2525
"coverage": "npm test -- --coverage",
26-
"lint": "eslint ./src",
26+
"lint": "eslint ./src ./tests",
2727
"format": "prettier --write .",
2828
"prepare": "husky",
2929
"semantic-release": "semantic-release",

rollup.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export default [
3333
}),
3434
commonjs(),
3535
typescript({
36-
tsconfig: './tsconfig.json',
36+
tsconfig: './tsconfig.build.json',
3737
}),
3838
postcss({
3939
modules: {

src/AuthProvider.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import { InternalAuthProvider } from '@/context/InternalAuthContext';
8+
import { startAuthentication } from '@simplewebauthn/browser';
89
import React, {
910
createContext,
1011
ReactNode,
@@ -47,6 +48,8 @@ export interface AuthContextType {
4748
credentials: Credential[];
4849
updateCredential: (credential: Credential) => Promise<Credential>;
4950
deleteCredential: (credentialId: string) => Promise<void>;
51+
login: (identifier: string, passkeyAvailable: boolean) => Promise<Response>;
52+
handlePasskeyLogin: () => Promise<boolean>;
5053
loading: boolean;
5154
}
5255

@@ -103,6 +106,54 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
103106
authHost: apiHost,
104107
});
105108

109+
const login = async (
110+
identifier: string,
111+
passkeyAvailable: boolean
112+
): Promise<Response> => {
113+
const response = await fetchWithAuth(`/login`, {
114+
method: 'POST',
115+
body: JSON.stringify({ identifier, passkeyAvailable }),
116+
});
117+
118+
return response;
119+
};
120+
121+
const handlePasskeyLogin = async () => {
122+
try {
123+
const response = await fetchWithAuth(`/webAuthn/login/start`, {
124+
method: 'POST',
125+
});
126+
127+
const options = await response.json();
128+
const credential = await startAuthentication({ optionsJSON: options });
129+
130+
const verificationResponse = await fetchWithAuth(`/webAuthn/login/finish`, {
131+
method: 'POST',
132+
body: JSON.stringify({ assertionResponse: credential }),
133+
});
134+
135+
if (!verificationResponse.ok) {
136+
console.error('Failed to verify passkey');
137+
}
138+
139+
const verificationResult = await verificationResponse.json();
140+
141+
if (verificationResult.message === 'Success') {
142+
if (verificationResult.mfaLogin) {
143+
return true;
144+
}
145+
await validateToken();
146+
return false;
147+
} else {
148+
console.error('Passkey login failed:', verificationResult.message);
149+
return false;
150+
}
151+
} catch (error) {
152+
console.error('Passkey login error:', error);
153+
return false;
154+
}
155+
};
156+
106157
const logout = useCallback(async () => {
107158
if (user) {
108159
try {
@@ -221,6 +272,8 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
221272
credentials,
222273
updateCredential,
223274
deleteCredential,
275+
login,
276+
handlePasskeyLogin,
224277
}}
225278
>
226279
<InternalAuthProvider value={{ validateToken, setLoading }}>

src/AuthRoutes.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const AuthRoutes = () => (
2222
<Route path="/verifyEmailOTP" element={<EmailRegistration />} />
2323
<Route path="/verify-magiclink" element={<VerifyMagicLink />} />
2424
<Route path="/registerPasskey" element={<PasskeyRegistration />} />
25-
<Route path="/magic-link-sent" element={<MagicLinkSent />} />
25+
<Route path="/magiclinks-sent" element={<MagicLinkSent />} />
2626
<Route path="*" element={<Navigate to="/login" replace />} />
2727
</Routes>
2828
);

src/utils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
*/
66

77
import parsePhoneNumberFromString from 'libphonenumber-js';
8-
98
/**
109
* isValidEmail
1110
*

src/views/Login.tsx

Lines changed: 20 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,24 @@
44
* See LICENSE file in the project root for full license information
55
*/
66

7-
import { startAuthentication } from '@simplewebauthn/browser';
87
import { useAuth } from '@/AuthProvider';
98
import PhoneInputWithCountryCode from '@/components/phoneInput';
10-
import { useInternalAuth } from '@/context/InternalAuthContext';
119
import React, { useEffect, useState } from 'react';
1210
import { useNavigate } from 'react-router-dom';
13-
1411
import styles from '@/styles/login.module.css';
1512
import { isPasskeySupported, isValidEmail, isValidPhoneNumber } from '../utils';
1613
import { createFetchWithAuth } from '@/fetchWithAuth';
1714
import AuthFallbackOptions from '@/components/AuthFallbackOptions';
1815

1916
const Login: React.FC = () => {
2017
const navigate = useNavigate();
21-
const { apiHost, hasSignedInBefore, mode: authMode } = useAuth();
22-
const { validateToken } = useInternalAuth();
18+
const {
19+
apiHost,
20+
hasSignedInBefore,
21+
mode: authMode,
22+
login,
23+
handlePasskeyLogin,
24+
} = useAuth();
2325
const [identifier, setIdentifier] = useState<string>('');
2426
const [email, setEmail] = useState<string>('');
2527
const [mode, setMode] = useState<'login' | 'register'>('register');
@@ -76,73 +78,6 @@ const Login: React.FC = () => {
7678
return isValidEmail(email) && isValidPhoneNumber(phone);
7779
};
7880

79-
const handlePasskeyLogin = async () => {
80-
try {
81-
const response = await fetchWithAuth(`/webAuthn/login/start`, {
82-
method: 'POST',
83-
});
84-
85-
if (!response.ok) {
86-
console.error('Something went wrong getting webauthn options');
87-
return;
88-
}
89-
90-
const options = await response.json();
91-
const credential = await startAuthentication({ optionsJSON: options });
92-
93-
const verificationResponse = await fetchWithAuth(`/webAuthn/login/finish`, {
94-
method: 'POST',
95-
body: JSON.stringify({ assertionResponse: credential }),
96-
});
97-
98-
if (!verificationResponse.ok) {
99-
console.error('Failed to verify passkey');
100-
}
101-
102-
const verificationResult = await verificationResponse.json();
103-
104-
if (verificationResult.message === 'Success') {
105-
if (verificationResult.mfaLogin) {
106-
navigate('/mfaLogin');
107-
return;
108-
}
109-
await validateToken();
110-
navigate('/');
111-
return;
112-
} else {
113-
console.error('Passkey login failed:', verificationResult.message);
114-
}
115-
} catch (error) {
116-
console.error('Passkey login error:', error);
117-
}
118-
};
119-
120-
const login = async () => {
121-
setFormErrors('');
122-
123-
const response = await fetchWithAuth(`/login`, {
124-
method: 'POST',
125-
body: JSON.stringify({ identifier, passkeyAvailable }),
126-
});
127-
128-
if (!response.ok) {
129-
setFormErrors('Failed to send login link. Please try again.');
130-
return;
131-
}
132-
133-
if (!passkeyAvailable) {
134-
setShowFallbackOptions(true);
135-
return;
136-
}
137-
138-
try {
139-
await handlePasskeyLogin();
140-
} catch (err) {
141-
console.error('Passkey login failed', err);
142-
setShowFallbackOptions(true);
143-
}
144-
};
145-
14681
const register = async () => {
14782
setFormErrors('');
14883

@@ -188,7 +123,7 @@ const Login: React.FC = () => {
188123
return;
189124
}
190125

191-
navigate('/magic-link-sent');
126+
navigate('/magiclinks-sent');
192127
} catch (err) {
193128
console.error(err);
194129
setFormErrors('Failed to send magic link.');
@@ -217,7 +152,18 @@ const Login: React.FC = () => {
217152
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
218153
e.preventDefault();
219154

220-
if (mode === 'login') login();
155+
if (mode === 'login') {
156+
const loginRes = await login(identifier, passkeyAvailable);
157+
158+
if (loginRes.ok && passkeyAvailable) {
159+
const passkeyResult = await handlePasskeyLogin();
160+
if (passkeyResult) {
161+
navigate('/');
162+
}
163+
} else {
164+
setShowFallbackOptions(true);
165+
}
166+
}
221167
if (mode === 'register') register();
222168
};
223169

src/views/VerifyMagicLink.tsx

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,21 @@ const VerifyMagicLink: React.FC = () => {
2727

2828
useEffect(() => {
2929
const verify = async () => {
30-
const response = await fetchWithAuth(`/magic-link/verify/${token}`, {
31-
method: 'GET',
32-
headers: {
33-
'Content-Type': 'application/json',
34-
},
35-
});
36-
37-
if (!response.ok) {
38-
console.error('Failed to verify token');
39-
setError('Failed to verify token');
40-
return;
30+
try {
31+
const response = await fetchWithAuth(`/magic-link/verify/${token}`, {
32+
method: 'GET',
33+
headers: {
34+
'Content-Type': 'application/json',
35+
},
36+
});
37+
38+
if (!response.ok) {
39+
console.error('Failed to verify token');
40+
setError('Failed to verify token');
41+
return;
42+
}
43+
} catch (error) {
44+
console.error(error);
4145
}
4246

4347
const channel = new BroadcastChannel('seamless-auth');

tests/AuthFallbackOptions.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
/*
2+
* Copyright © 2026 Fells Code, LLC
3+
* Licensed under the GNU Affero General Public License v3.0
4+
* See LICENSE file in the project root for full license information
5+
*/
6+
17
import { render, screen, fireEvent } from '@testing-library/react';
28
import AuthFallbackOptions from '@/components/AuthFallbackOptions';
39

tests/AuthRoutes.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
/*
2+
* Copyright © 2026 Fells Code, LLC
3+
* Licensed under the GNU Affero General Public License v3.0
4+
* See LICENSE file in the project root for full license information
5+
*/
6+
17
import { MemoryRouter } from 'react-router-dom';
28
import { render, screen } from '@testing-library/react';
39
import { AuthRoutes } from '../src/AuthRoutes';

tests/DeviceModal.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
/*
2+
* Copyright © 2026 Fells Code, LLC
3+
* Licensed under the GNU Affero General Public License v3.0
4+
* See LICENSE file in the project root for full license information
5+
*/
6+
17
import { render, screen, fireEvent } from '@testing-library/react';
28
import DeviceNameModal from '@/components/DeviceNameModal';
39

0 commit comments

Comments
 (0)