chg: usr: add modern Webauthn authentication #4
This commit is contained in:
@@ -53,6 +53,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
&--security {
|
||||
color: rgba(149, 207, 245, 0.55);
|
||||
border-color: rgba(35, 111, 135, 0.22);
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
color: rgba(149, 207, 245, 0.9);
|
||||
background: rgba(35, 111, 135, 0.14);
|
||||
border-color: rgba(35, 111, 135, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
&--out {
|
||||
background: transparent;
|
||||
border-color: rgba(173, 10, 5, 0.3);
|
||||
|
||||
@@ -161,6 +161,12 @@
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.auth-below-password {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.auth-remember {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -216,4 +222,66 @@
|
||||
|
||||
&:hover { color: #c5e8ff; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.auth-forgot-password {
|
||||
font: 400 13px 'Rajdhani', sans-serif;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
text-align: center;
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
a {
|
||||
color: #95cff5;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: color 180ms;
|
||||
|
||||
&:hover { color: #c5e8ff; }
|
||||
}
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 20px 0;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.auth-divider::before,
|
||||
.auth-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.auth-divider span {
|
||||
margin: 0 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.auth-passkey-btn {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.auth-passkey-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.auth-passkey-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@@ -144,12 +144,50 @@
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.profile-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 18px;
|
||||
background: rgba(35, 111, 135, 0.12);
|
||||
color: rgba(149, 207, 245, 0.75);
|
||||
border: 1px solid rgba(35, 111, 135, 0.3);
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font: 600 12px 'Rajdhani', sans-serif;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
transition: background 200ms ease, border-color 200ms ease, color 200ms ease;
|
||||
|
||||
i { font-size: 11px; opacity: 0.8; }
|
||||
|
||||
&:hover {
|
||||
background: rgba(35, 111, 135, 0.22);
|
||||
border-color: rgba(35, 111, 135, 0.55);
|
||||
color: rgba(149, 207, 245, 1);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.profile-section__description {
|
||||
font-size: 13px;
|
||||
color: rgba(149, 207, 245, 0.65);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-section__title {
|
||||
font: 700 11px 'Rajdhani', sans-serif;
|
||||
text-transform: uppercase;
|
||||
|
||||
206
assets/css/passkey.scss
Normal file
206
assets/css/passkey.scss
Normal file
@@ -0,0 +1,206 @@
|
||||
$primary: #236f87;
|
||||
$primary-dark: #1a5a70;
|
||||
$danger: #c0392b;
|
||||
$warning: #d68910;
|
||||
$success: #388e3c;
|
||||
$text: #e0e0e0;
|
||||
$text-muted: #9e9e9e;
|
||||
$border: rgba(35, 111, 135, 0.3);
|
||||
$bg-card: #0a0e14;
|
||||
$bg-hover: rgba(35, 111, 135, 0.15);
|
||||
|
||||
.passkey-manager {
|
||||
&__actions {
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.passkey-item {
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
background: $bg-card;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: $bg-hover;
|
||||
border-color: rgba(35, 111, 135, 0.5);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid $border;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&--info {
|
||||
background: rgba(25, 118, 210, 0.2);
|
||||
color: #64b5f6;
|
||||
}
|
||||
|
||||
&--success {
|
||||
background: rgba(56, 142, 60, 0.2);
|
||||
color: #81c784;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
|
||||
&--primary {
|
||||
background: $primary;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: $primary-dark;
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: #546e7a;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #455a64;
|
||||
}
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background: $warning;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: darken($warning, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: $danger;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: darken($danger, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
&--sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
border: 1px dashed $border;
|
||||
border-radius: 8px;
|
||||
|
||||
&__icon {
|
||||
font-size: 48px;
|
||||
color: #455a64;
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 16px;
|
||||
color: $text;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
&__subtext {
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.registration-status {
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-top: 15px;
|
||||
font-size: 14px;
|
||||
|
||||
&--success {
|
||||
background: rgba(56, 142, 60, 0.15);
|
||||
color: #81c784;
|
||||
border: 1px solid rgba(56, 142, 60, 0.3);
|
||||
}
|
||||
|
||||
&--error {
|
||||
background: rgba(192, 57, 43, 0.15);
|
||||
color: #e57373;
|
||||
border: 1px solid rgba(192, 57, 43, 0.3);
|
||||
}
|
||||
|
||||
&--loading {
|
||||
background: rgba(25, 118, 210, 0.15);
|
||||
color: #64b5f6;
|
||||
border: 1px solid rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
}
|
||||
110
assets/js/components/PasskeyLogin.jsx
Normal file
110
assets/js/components/PasskeyLogin.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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;
|
||||
418
assets/js/components/PasskeyManager.jsx
Normal file
418
assets/js/components/PasskeyManager.jsx
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* 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': {
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '46px 46px',
|
||||
border: '1px solid rgba(35, 111, 135, 0.4)',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0, 0, 0, 0.9)',
|
||||
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;
|
||||
37
assets/js/passkey.jsx
Normal file
37
assets/js/passkey.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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 from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import PasskeyManager from './components/PasskeyManager';
|
||||
import PasskeyLogin from './components/PasskeyLogin';
|
||||
|
||||
const passkeyManagerRoot = document.getElementById('passkey-manager-root');
|
||||
|
||||
if (passkeyManagerRoot) {
|
||||
const credentials = JSON.parse(passkeyManagerRoot.dataset.credentials || '[]');
|
||||
const apiRoutes = JSON.parse(passkeyManagerRoot.dataset.apiRoutes || '{}');
|
||||
|
||||
createRoot(passkeyManagerRoot).render(
|
||||
<PasskeyManager
|
||||
credentials={credentials}
|
||||
apiRoutes={apiRoutes}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const passkeyLoginRoot = document.getElementById('passkey-login-root');
|
||||
|
||||
if (passkeyLoginRoot) {
|
||||
const apiRoutes = JSON.parse(passkeyLoginRoot.dataset.apiRoutes || '{}');
|
||||
|
||||
createRoot(passkeyLoginRoot).render(
|
||||
<PasskeyLogin apiRoutes={apiRoutes} />,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user