auth-yes/ui/components/PasskeysPage.tsx

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>
);
};