411 lines
13 KiB
JavaScript
411 lines
13 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, { Fragment, useCallback, useEffect, useState } from 'react';
|
|
import Dialog from '@mui/material/Dialog';
|
|
import DialogTitle from '@mui/material/DialogTitle';
|
|
import DialogContent from '@mui/material/DialogContent';
|
|
import DialogActions from '@mui/material/DialogActions';
|
|
import Button from '@mui/material/Button';
|
|
import TextField from '@mui/material/TextField';
|
|
|
|
const DIALOG_SX = {
|
|
'& .MuiDialog-paper': {
|
|
background: '#0a0e14',
|
|
color: '#e0e0e0',
|
|
},
|
|
'& .MuiTextField-root .MuiInputLabel-root': {
|
|
color: '#9e9e9e',
|
|
},
|
|
'& .MuiTextField-root .MuiOutlinedInput-root': {
|
|
'& fieldset': {
|
|
borderColor: 'rgba(35, 111, 135, 0.5)',
|
|
},
|
|
'&:hover fieldset': {
|
|
borderColor: 'rgba(35, 111, 135, 0.8)',
|
|
},
|
|
'&.Mui-focused fieldset': {
|
|
borderColor: '#236f87',
|
|
},
|
|
},
|
|
'& .MuiTextField-root .MuiOutlinedInput-input': {
|
|
color: '#e0e0e0',
|
|
},
|
|
'& .MuiFormHelperText-root': {
|
|
width: '500px',
|
|
maxWidth: '94vw',
|
|
overflow: 'hidden',
|
|
color: '#e0e0e0',
|
|
},
|
|
'& .MuiBackdrop-root': {
|
|
background: 'rgba(2, 4, 8, 0.88)',
|
|
backdropFilter: 'blur(4px)',
|
|
},
|
|
};
|
|
|
|
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 credentialToJSON = credential => {
|
|
const attestationObject = credential.response.attestationObject;
|
|
const clientDataJSON = credential.response.clientDataJSON;
|
|
|
|
return {
|
|
id: credential.id,
|
|
rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/([+\/=])/g, m => ('+' === m ? '-' : '/' === m ? '_' : '')),
|
|
type: credential.type,
|
|
response: {
|
|
attestationObject: btoa(String.fromCharCode(...new Uint8Array(attestationObject))).replace(/([+\/=])/g, m => ('+' === m ? '-' : '/' === m ? '_' : '')),
|
|
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(clientDataJSON))).replace(/([+\/=])/g, m => ('+' === m ? '-' : '/' === m ? '_' : '')),
|
|
},
|
|
};
|
|
};
|
|
|
|
const PasskeyManager = ({ credentials, apiRoutes }) => {
|
|
const [addModalOpen, setAddModalOpen] = useState(false);
|
|
const [renameModalOpen, setRenameModalOpen] = useState(false);
|
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
|
const [selectedCredential, setSelectedCredential] = useState(null);
|
|
const [passkeyName, setPasskeyName] = useState('');
|
|
const [renameName, setRenameName] = useState('');
|
|
const [status, setStatus] = useState({ type: '', message: '' });
|
|
const [loading, setLoading] = useState(false);
|
|
const [credentialsList, setCredentialsList] = useState(credentials);
|
|
|
|
useEffect(() => {
|
|
setCredentialsList(credentials);
|
|
}, [credentials]);
|
|
|
|
const showStatus = useCallback((message, type) => {
|
|
setStatus({ message, type });
|
|
}, []);
|
|
|
|
const closeAddModal = useCallback(() => {
|
|
setAddModalOpen(false);
|
|
setPasskeyName('');
|
|
setStatus({ type: '', message: '' });
|
|
}, []);
|
|
|
|
const openAddModal = useCallback(() => {
|
|
setPasskeyName('');
|
|
setStatus({ type: '', message: '' });
|
|
setAddModalOpen(true);
|
|
}, []);
|
|
|
|
const handleAddPasskey = useCallback(async () => {
|
|
if (!passkeyName.trim()) {
|
|
showStatus('Please enter a passkey name', 'error');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
showStatus('Starting registration...', 'loading');
|
|
|
|
try {
|
|
const optionsResponse = await fetch(apiRoutes.registrationBegin, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ credentialName: passkeyName.trim() }),
|
|
});
|
|
|
|
if (!optionsResponse.ok) throw new Error('Failed to get registration options');
|
|
const options = await optionsResponse.json();
|
|
|
|
showStatus('Please touch your security key or use biometric authentication...', 'loading');
|
|
|
|
const publicKey = {
|
|
...options,
|
|
challenge: base64ToArrayBuffer(options.challenge),
|
|
user: {
|
|
...options.user,
|
|
id: base64ToArrayBuffer(options.user.id),
|
|
},
|
|
attestation: 'direct',
|
|
};
|
|
|
|
const credential = await navigator.credentials.create({ publicKey });
|
|
|
|
if (!credential) {
|
|
showStatus('Registration was cancelled', 'error');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
showStatus('Verifying credential...', 'loading');
|
|
|
|
const completeResponse = await fetch(apiRoutes.registrationComplete, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
credential: credentialToJSON(credential),
|
|
}),
|
|
});
|
|
|
|
if (!completeResponse.ok) throw new Error('Failed to complete registration');
|
|
|
|
showStatus('Passkey registered successfully!', 'success');
|
|
setTimeout(() => window.location.reload(), 1500);
|
|
} catch (error) {
|
|
console.error('Registration error:', error);
|
|
showStatus('Error: ' + error.message, 'error');
|
|
}
|
|
|
|
setLoading(false);
|
|
}, [passkeyName, apiRoutes, showStatus]);
|
|
|
|
const openRenameModal = useCallback(credential => {
|
|
setSelectedCredential(credential);
|
|
setRenameName(credential.credentialName);
|
|
setStatus({ type: '', message: '' });
|
|
setRenameModalOpen(true);
|
|
}, []);
|
|
|
|
const closeRenameModal = useCallback(() => {
|
|
setRenameModalOpen(false);
|
|
setSelectedCredential(null);
|
|
setRenameName('');
|
|
setStatus({ type: '', message: '' });
|
|
}, []);
|
|
|
|
const handleRename = useCallback(async () => {
|
|
if (!renameName.trim() || !selectedCredential) {
|
|
showStatus('Please enter a new name', 'error');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
|
|
try {
|
|
const response = await fetch(`${apiRoutes.credentials}/${selectedCredential.id}/rename`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: renameName.trim() }),
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to rename passkey');
|
|
|
|
window.location.reload();
|
|
} catch (error) {
|
|
console.error('Rename error:', error);
|
|
showStatus('Error: ' + error.message, 'error');
|
|
}
|
|
|
|
setLoading(false);
|
|
}, [renameName, selectedCredential, apiRoutes, showStatus]);
|
|
|
|
const openDeleteModal = useCallback(credential => {
|
|
setSelectedCredential(credential);
|
|
setDeleteModalOpen(true);
|
|
}, []);
|
|
|
|
const closeDeleteModal = useCallback(() => {
|
|
setDeleteModalOpen(false);
|
|
setSelectedCredential(null);
|
|
}, []);
|
|
|
|
const handleDelete = useCallback(async () => {
|
|
if (!selectedCredential) return;
|
|
|
|
setLoading(true);
|
|
|
|
try {
|
|
const response = await fetch(`${apiRoutes.credentials}/${selectedCredential.id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
if (response.ok) {
|
|
window.location.reload();
|
|
} else {
|
|
showStatus('Failed to delete passkey', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Delete error:', error);
|
|
showStatus('Error: ' + error.message, 'error');
|
|
}
|
|
|
|
setLoading(false);
|
|
setDeleteModalOpen(false);
|
|
}, [selectedCredential, apiRoutes, showStatus]);
|
|
|
|
const statusClass = 'error' === status.type
|
|
? 'registration-status--error'
|
|
: 'success' === status.type
|
|
? 'registration-status--success'
|
|
: 'loading' === status.type
|
|
? 'registration-status--loading'
|
|
: '';
|
|
|
|
return (
|
|
<div className="passkey-manager">
|
|
<div className="passkey-manager__actions">
|
|
<button className="btn btn--primary" onClick={openAddModal} type="button">
|
|
<i className="fa fa-plus" /> Add Passkey
|
|
</button>
|
|
</div>
|
|
|
|
<div className="passkey-manager__list">
|
|
{0 < credentialsList.length ? (
|
|
credentialsList.map(credential => (
|
|
<div key={credential.id} className="passkey-item" data-credential-id={credential.id}>
|
|
<div className="passkey-item__header">
|
|
<div className="passkey-item__info">
|
|
<h3 className="passkey-item__name">{credential.credentialName}</h3>
|
|
<p className="passkey-item__meta">
|
|
Created: {credential.createdAt}
|
|
{credential.lastUsedAt && (
|
|
<>
|
|
<br />Last used: {credential.lastUsedAt}
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="passkey-item__badges">
|
|
{credential.isBackupEligible && (
|
|
<span className="badge badge--info" title="This passkey can be backed up">
|
|
<i className="fa fa-cloud" /> Backup Eligible
|
|
</span>
|
|
)}
|
|
{credential.isBackupAuthenticated && (
|
|
<span className="badge badge--success" title="This passkey is backed up">
|
|
<i className="fa fa-check" /> Backed Up
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="passkey-item__actions">
|
|
<button
|
|
className="btn btn--sm btn--warning"
|
|
onClick={() => openRenameModal(credential)}
|
|
type="button"
|
|
>
|
|
<i className="fa fa-edit" /> Rename
|
|
</button>
|
|
<button
|
|
className="btn btn--sm btn--danger"
|
|
onClick={() => openDeleteModal(credential)}
|
|
type="button"
|
|
>
|
|
<i className="fa fa-trash" /> Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="empty-state">
|
|
<i className="fa fa-key empty-state__icon" />
|
|
<p className="empty-state__text">No passkeys registered yet.</p>
|
|
<p className="empty-state__subtext">
|
|
Add your first passkey to get started with secure, passwordless authentication.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Dialog open={addModalOpen} onClose={closeAddModal} sx={DIALOG_SX}>
|
|
<DialogTitle>Add New Passkey</DialogTitle>
|
|
<DialogContent>
|
|
<TextField
|
|
autoFocus
|
|
fullWidth
|
|
id="passkeyName"
|
|
label="Passkey Name"
|
|
placeholder="e.g., My Laptop, iPhone"
|
|
value={passkeyName}
|
|
onChange={e => setPasskeyName(e.target.value)}
|
|
onKeyUp={e => 'Enter' === e.key && handleAddPasskey()}
|
|
margin="dense"
|
|
variant="outlined"
|
|
helperText="Give this passkey a descriptive name to help you remember it."
|
|
/>
|
|
{status.message && (
|
|
<div className={`registration-status ${statusClass}`}>
|
|
{status.message}
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={closeAddModal} disabled={loading}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleAddPasskey} variant="contained" disabled={loading}>
|
|
Continue
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
<Dialog open={renameModalOpen} onClose={closeRenameModal} sx={DIALOG_SX}>
|
|
<DialogTitle>Rename Passkey</DialogTitle>
|
|
<DialogContent>
|
|
<TextField
|
|
autoFocus
|
|
fullWidth
|
|
id="renamePasskeyName"
|
|
label="New Name"
|
|
value={renameName}
|
|
onChange={e => setRenameName(e.target.value)}
|
|
onKeyUp={e => 'Enter' === e.key && handleRename()}
|
|
margin="dense"
|
|
variant="outlined"
|
|
/>
|
|
{status.message && (
|
|
<div className={`registration-status ${statusClass}`}>
|
|
{status.message}
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={closeRenameModal} disabled={loading}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleRename} variant="contained" disabled={loading}>
|
|
Rename
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
<Dialog open={deleteModalOpen} onClose={closeDeleteModal} sx={DIALOG_SX}>
|
|
<DialogTitle>Delete Passkey</DialogTitle>
|
|
<DialogContent>
|
|
<p>
|
|
Are you sure you want to delete the passkey
|
|
{selectedCredential && (
|
|
<Fragment>
|
|
<strong>{selectedCredential.credentialName}</strong>?
|
|
This action cannot be undone.
|
|
</Fragment>
|
|
)}
|
|
</p>
|
|
{status.message && (
|
|
<div className={`registration-status ${statusClass}`}>
|
|
{status.message}
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={closeDeleteModal} disabled={loading}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleDelete} variant="contained" color="error" disabled={loading}>
|
|
Delete
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PasskeyManager;
|