277 lines
9.0 KiB
TypeScript
277 lines
9.0 KiB
TypeScript
import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx";
|
|
|
|
export const PasskeysPage = ({
|
|
passkeys,
|
|
isAdmin = false,
|
|
}: {
|
|
passkeys: any[];
|
|
isAdmin?: boolean;
|
|
}) => {
|
|
return (
|
|
<AuthenticatedLayout
|
|
title="Passkeys"
|
|
currentPath="/dashboard/passkeys"
|
|
isAdmin={isAdmin}
|
|
>
|
|
<div class="card">
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
marginBottom: "1rem",
|
|
}}
|
|
>
|
|
<h2 style={{ margin: 0, border: "none", padding: 0 }}>
|
|
Registered Passkeys
|
|
</h2>
|
|
<button type="button" id="addPasskeyBtn" class="btn-primary">
|
|
+ Add New Passkey
|
|
</button>
|
|
</div>
|
|
<p
|
|
style={{
|
|
color: "#6c757d",
|
|
fontSize: "0.9rem",
|
|
marginBottom: "1.5rem",
|
|
}}
|
|
>
|
|
Manage your registered hardware tokens. We recommend having at least
|
|
two registered.
|
|
</p>
|
|
|
|
<div
|
|
id="addPasskeyContainer"
|
|
style={{
|
|
display: "none",
|
|
marginBottom: "1.5rem",
|
|
padding: "1rem",
|
|
background: "#f8f9fa",
|
|
borderRadius: "6px",
|
|
border: "1px solid #dee2e6",
|
|
}}
|
|
>
|
|
<h3 style={{ marginTop: 0, fontSize: "1.1rem" }}>
|
|
Register New Passkey
|
|
</h3>
|
|
<p style={{ fontSize: "0.9rem", color: "#6c757d" }}>
|
|
Please insert your new hardware token and follow the prompts.
|
|
</p>
|
|
<div
|
|
id="addPasskeyStatus"
|
|
style={{ marginBottom: "1rem", fontSize: "0.9rem" }}
|
|
>
|
|
</div>
|
|
<div style={{ display: "flex", gap: "1rem" }}>
|
|
<button
|
|
type="button"
|
|
id="confirmAddPasskeyBtn"
|
|
class="btn-primary"
|
|
style={{ background: "#28a745" }}
|
|
>
|
|
Start Registration
|
|
</button>
|
|
<button
|
|
type="button"
|
|
id="cancelAddPasskeyBtn"
|
|
class="btn-danger"
|
|
style={{ background: "#6c757d" }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Uses (Counter)</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{passkeys.length === 0
|
|
? (
|
|
<tr>
|
|
<td
|
|
colSpan={3}
|
|
style={{ textAlign: "center", padding: "2rem" }}
|
|
>
|
|
No passkeys found.
|
|
</td>
|
|
</tr>
|
|
)
|
|
: (
|
|
passkeys.map((passkey) => (
|
|
<tr key={passkey.id}>
|
|
<td>
|
|
<code
|
|
style={{
|
|
background: "#f1f3f5",
|
|
padding: "0.2rem 0.4rem",
|
|
borderRadius: "4px",
|
|
}}
|
|
>
|
|
{passkey.id.split("-")[0]}...
|
|
</code>
|
|
</td>
|
|
<td>{passkey.counter}</td>
|
|
<td>
|
|
<button
|
|
type="button"
|
|
class="btn-danger revoke-btn"
|
|
data-passkey-id={passkey.id}
|
|
disabled={passkeys.length <= 1}
|
|
title={passkeys.length <= 1
|
|
? "Cannot remove last passkey"
|
|
: "Remove"}
|
|
style={passkeys.length <= 1
|
|
? { opacity: 0.5, cursor: "not-allowed" }
|
|
: {}}
|
|
>
|
|
Remove
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
{passkeys.length <= 1 && (
|
|
<p
|
|
style={{
|
|
fontSize: "0.85rem",
|
|
color: "#dc3545",
|
|
marginTop: "1rem",
|
|
}}
|
|
>
|
|
* You must register another passkey before you can remove your
|
|
only remaining one.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<script
|
|
dangerouslySetInnerHTML={{
|
|
__html: `
|
|
// Revoke Passkey Logic
|
|
document.querySelectorAll('.revoke-btn').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
if (e.target.disabled) return;
|
|
if (!confirm('Are you sure you want to remove this passkey? This action cannot be undone.')) return;
|
|
|
|
const passkeyId = e.target.getAttribute('data-passkey-id');
|
|
const originalText = e.target.textContent;
|
|
e.target.textContent = 'Removing...';
|
|
e.target.disabled = true;
|
|
|
|
try {
|
|
const res = await fetch(\`/api/passkeys/\${passkeyId}\`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (res.ok) {
|
|
window.location.reload();
|
|
} else {
|
|
const data = await res.json();
|
|
alert(data.error || 'Failed to remove passkey');
|
|
e.target.textContent = originalText;
|
|
e.target.disabled = false;
|
|
}
|
|
} catch (err) {
|
|
alert('An error occurred');
|
|
e.target.textContent = originalText;
|
|
e.target.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
// Add Passkey Logic
|
|
const addContainer = document.getElementById('addPasskeyContainer');
|
|
const addBtn = document.getElementById('addPasskeyBtn');
|
|
const cancelBtn = document.getElementById('cancelAddPasskeyBtn');
|
|
const confirmBtn = document.getElementById('confirmAddPasskeyBtn');
|
|
const statusDiv = document.getElementById('addPasskeyStatus');
|
|
|
|
addBtn.addEventListener('click', () => {
|
|
addContainer.style.display = 'block';
|
|
addBtn.style.display = 'none';
|
|
});
|
|
|
|
cancelBtn.addEventListener('click', () => {
|
|
addContainer.style.display = 'none';
|
|
addBtn.style.display = 'block';
|
|
statusDiv.textContent = '';
|
|
});
|
|
|
|
confirmBtn.addEventListener('click', async () => {
|
|
confirmBtn.disabled = true;
|
|
statusDiv.textContent = 'Setting up passkey... Follow the prompt on your device.';
|
|
statusDiv.style.color = '#007bff';
|
|
|
|
try {
|
|
// 1. Fetch challenge
|
|
const resp = await fetch("/api/passkeys/register/challenge", {
|
|
method: "POST",
|
|
});
|
|
const data = await resp.json();
|
|
|
|
if (!resp.ok) {
|
|
statusDiv.textContent = data.error || "Failed to get registration challenge";
|
|
statusDiv.style.color = 'red';
|
|
confirmBtn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
// 2. Pass challenge to authenticator
|
|
const { startRegistration } = SimpleWebAuthnBrowser;
|
|
let attResp;
|
|
try {
|
|
attResp = await startRegistration(data.options);
|
|
} catch (error) {
|
|
statusDiv.textContent = error.message || "Registration failed on device";
|
|
statusDiv.style.color = 'red';
|
|
confirmBtn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
// 3. Send response back to verify
|
|
const verificationResp = await fetch("/api/passkeys/register/verify", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
response: attResp,
|
|
}),
|
|
});
|
|
|
|
const verificationJSON = await verificationResp.json();
|
|
|
|
if (verificationJSON.success) {
|
|
statusDiv.textContent = "Passkey added successfully! Reloading...";
|
|
statusDiv.style.color = 'green';
|
|
setTimeout(() => window.location.reload(), 1000);
|
|
} else {
|
|
statusDiv.textContent = verificationJSON.error || "Registration verification failed";
|
|
statusDiv.style.color = 'red';
|
|
confirmBtn.disabled = false;
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
statusDiv.textContent = 'An unexpected error occurred.';
|
|
statusDiv.style.color = 'red';
|
|
confirmBtn.disabled = false;
|
|
}
|
|
});
|
|
`,
|
|
}}
|
|
>
|
|
</script>
|
|
</AuthenticatedLayout>
|
|
);
|
|
};
|