119 lines
3.5 KiB
JavaScript
119 lines
3.5 KiB
JavaScript
/**
|
|
* This file is part of the SplendidBear Websites' projects.
|
|
*
|
|
* Copyright (c) 2026 @ www.splendidbear.org
|
|
*
|
|
* For the full copyright and license information, please view the LICENSE
|
|
* file that was distributed with this source code.
|
|
*/
|
|
|
|
import React, { useState, useCallback } from 'react';
|
|
import { shape, string } from 'prop-types';
|
|
|
|
const base64ToArrayBuffer = base64 => {
|
|
const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/')));
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return bytes.buffer;
|
|
};
|
|
|
|
const arrayBufferToBase64url = buffer =>
|
|
btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
|
.replace(/([+\/=])/g, m => ('+' === m ? '-' : '/' === m ? '_' : ''));
|
|
|
|
const credentialToJSON = credential => ({
|
|
id: credential.id,
|
|
rawId: arrayBufferToBase64url(credential.rawId),
|
|
type: credential.type,
|
|
response: {
|
|
authenticatorData: arrayBufferToBase64url(credential.response.authenticatorData),
|
|
clientDataJSON: arrayBufferToBase64url(credential.response.clientDataJSON),
|
|
signature: arrayBufferToBase64url(credential.response.signature),
|
|
userHandle: credential.response.userHandle
|
|
? arrayBufferToBase64url(credential.response.userHandle)
|
|
: null,
|
|
},
|
|
});
|
|
|
|
const PasskeyLogin = ({ apiRoutes }) => {
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
|
|
const handleLogin = useCallback(async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
|
|
try {
|
|
const beginResponse = await fetch(apiRoutes.authenticationBegin, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
if (!beginResponse.ok) throw new Error('Failed to get authentication options');
|
|
const options = await beginResponse.json();
|
|
|
|
const publicKey = {
|
|
...options,
|
|
challenge: base64ToArrayBuffer(options.challenge),
|
|
allowCredentials: (options.allowCredentials ?? []).map(cred => ({
|
|
...cred,
|
|
id: base64ToArrayBuffer(cred.id),
|
|
})),
|
|
};
|
|
|
|
const credential = await navigator.credentials.get({ publicKey });
|
|
if (!credential) throw new Error('Authentication was cancelled');
|
|
|
|
const completeResponse = await fetch(apiRoutes.authenticationComplete, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ credential: credentialToJSON(credential) }),
|
|
});
|
|
|
|
if (!completeResponse.ok) {
|
|
const body = await completeResponse.json();
|
|
throw new Error(body.error || 'Authentication failed');
|
|
}
|
|
|
|
const result = await completeResponse.json();
|
|
if (result.success) {
|
|
window.location.href = result.redirect || '/';
|
|
}
|
|
} catch (err) {
|
|
setError(err.message);
|
|
}
|
|
|
|
setLoading(false);
|
|
}, [apiRoutes]);
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="auth-passkey-btn"
|
|
onClick={handleLogin}
|
|
disabled={loading}
|
|
>
|
|
<i className={loading ? 'fa fa-spinner fa-spin' : 'fa fa-key'} />
|
|
{loading ? 'Waiting for passkey…' : 'Sign In with Passkey'}
|
|
</button>
|
|
{error && (
|
|
<p className="auth-error" style={{ marginTop: '10px' }}>
|
|
<i className="fa fa-exclamation-triangle" /> {error}
|
|
</p>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default PasskeyLogin;
|
|
|
|
PasskeyLogin.propTypes = {
|
|
apiRoutes: shape({
|
|
authenticationBegin: string.isRequired,
|
|
authenticationComplete: string.isRequired,
|
|
}).isRequired,
|
|
};
|