Merge pull request #43 from mrteye/ui-webauthn-refactor-8252947523855268978
Refactor WebAuthn UI Components & Deduplicate Bip39 Assets Phase 3 of the UI decomposition task. I successfully decomposed the WebAuthn client scripts out of the main page components (`PasskeysPage.tsx` and `RegisterPage.tsx`), and I also deduplicated the wordlist asset by removing the duplicate copy under `ui/public/ui/utils/` and pointing all imports to the correct location.
This commit is contained in:
commit
9e2cd81e4d
@ -1,4 +1,6 @@
|
|||||||
import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx";
|
import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx";
|
||||||
|
import { PasskeyTable } from "./auth/PasskeyTable.tsx";
|
||||||
|
import { WebAuthnScript } from "./auth/WebAuthnScript.tsx";
|
||||||
|
|
||||||
export const PasskeysPage = ({
|
export const PasskeysPage = ({
|
||||||
passkeys,
|
passkeys,
|
||||||
@ -84,145 +86,7 @@ export const PasskeysPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Table View (≥ 768px) */}
|
<PasskeyTable passkeys={passkeys} />
|
||||||
<div class="card desktop-only" style="display: none;">
|
|
||||||
<div class="table-container">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Credential ID</th>
|
|
||||||
<th>Sign Counter</th>
|
|
||||||
<th>AAGUID Vendor</th>
|
|
||||||
<th>Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{passkeys.length === 0
|
|
||||||
? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={4}
|
|
||||||
style="text-align: center; padding: 2rem; color: var(--text-muted);"
|
|
||||||
>
|
|
||||||
No passkeys registered.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
passkeys.map((passkey) => (
|
|
||||||
<tr key={passkey.id}>
|
|
||||||
<td>
|
|
||||||
<code style="background: var(--surface-muted); padding: 0.2rem 0.5rem; border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem;">
|
|
||||||
{passkey.credential_id
|
|
||||||
? `${passkey.credential_id.substring(0, 16)}...`
|
|
||||||
: passkey.id}
|
|
||||||
</code>
|
|
||||||
</td>
|
|
||||||
<td style="color: var(--text-secondary);">
|
|
||||||
{passkey.counter}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge badge-info">
|
|
||||||
{passkey.aaguid
|
|
||||||
? passkey.aaguid.substring(0, 8)
|
|
||||||
: "FIDO2 Key"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-danger revoke-passkey-btn"
|
|
||||||
data-passkey-id={passkey.id}
|
|
||||||
disabled={passkeys.length <= 1}
|
|
||||||
style={passkeys.length <= 1
|
|
||||||
? "opacity: 0.5; cursor: not-allowed;"
|
|
||||||
: ""}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Adaptive Cards View (< 768px) */}
|
|
||||||
<div
|
|
||||||
class="mobile-only"
|
|
||||||
style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem;"
|
|
||||||
>
|
|
||||||
{passkeys.length === 0
|
|
||||||
? (
|
|
||||||
<div class="card" style="text-align: center; padding: 2rem;">
|
|
||||||
<p style="color: var(--text-muted); margin: 0;">
|
|
||||||
No passkeys registered.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
passkeys.map((passkey) => (
|
|
||||||
<div class="card" key={passkey.id} style="margin-bottom: 0;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
|
|
||||||
<div style="display: flex; align-items: center; gap: 0.65rem;">
|
|
||||||
<div style="display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; background: var(--primary-light); color: var(--primary); border-radius: var(--radius-md);">
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<circle cx="7.5" cy="15.5" r="5.5"></circle>
|
|
||||||
<path d="m21 2-9.6 9.6"></path>
|
|
||||||
<path d="m15.5 7.5 3 3L22 7l-3-3"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style="font-weight: 700; color: var(--text-primary);">
|
|
||||||
Passkey Credential
|
|
||||||
</div>
|
|
||||||
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
|
||||||
{passkey.credential_id
|
|
||||||
? `${passkey.credential_id.substring(0, 14)}...`
|
|
||||||
: passkey.id}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="badge badge-info">
|
|
||||||
{passkey.aaguid ? "Hardware/OS" : "FIDO2"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 1rem;">
|
|
||||||
<div>
|
|
||||||
<strong>Counter:</strong> {passkey.counter} authentications
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-danger revoke-passkey-btn"
|
|
||||||
data-passkey-id={passkey.id}
|
|
||||||
disabled={passkeys.length <= 1}
|
|
||||||
style={`width: 100%; min-height: 44px; ${
|
|
||||||
passkeys.length <= 1
|
|
||||||
? "opacity: 0.5; cursor: not-allowed;"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{passkeys.length <= 1
|
|
||||||
? "Cannot Remove Last Passkey"
|
|
||||||
: "Remove Passkey"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Emergency Recovery Backup Section */}
|
{/* Emergency Recovery Backup Section */}
|
||||||
<div class="card" style="border-left: 4px solid var(--warning);">
|
<div class="card" style="border-left: 4px solid var(--warning);">
|
||||||
@ -318,137 +182,7 @@ export const PasskeysPage = ({
|
|||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script
|
<WebAuthnScript isPasskeys />
|
||||||
type="module"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `
|
|
||||||
import { entropyToMnemonic } from '/public/ui/utils/bip39.ts';
|
|
||||||
|
|
||||||
// Revoke Passkey Logic
|
|
||||||
document.querySelectorAll('.revoke-passkey-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async (e) => {
|
|
||||||
if (e.currentTarget.disabled) return;
|
|
||||||
if (!confirm('Are you sure you want to remove this passkey?')) return;
|
|
||||||
|
|
||||||
const passkeyId = e.currentTarget.getAttribute('data-passkey-id');
|
|
||||||
const originalText = e.currentTarget.textContent;
|
|
||||||
e.currentTarget.textContent = 'Removing...';
|
|
||||||
e.currentTarget.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.currentTarget.textContent = originalText;
|
|
||||||
e.currentTarget.disabled = false;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
alert('An error occurred');
|
|
||||||
e.currentTarget.textContent = originalText;
|
|
||||||
e.currentTarget.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 = 'inline-flex';
|
|
||||||
statusDiv.textContent = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
confirmBtn?.addEventListener('click', async () => {
|
|
||||||
confirmBtn.disabled = true;
|
|
||||||
statusDiv.textContent = 'Follow prompt on device...';
|
|
||||||
statusDiv.style.color = 'var(--primary)';
|
|
||||||
|
|
||||||
try {
|
|
||||||
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 = 'var(--danger)';
|
|
||||||
confirmBtn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { startRegistration } = SimpleWebAuthnBrowser;
|
|
||||||
const attResp = await startRegistration({ optionsJSON: data.options });
|
|
||||||
|
|
||||||
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 registered successfully! Reloading...";
|
|
||||||
statusDiv.style.color = 'var(--success-text)';
|
|
||||||
setTimeout(() => window.location.reload(), 1000);
|
|
||||||
} else {
|
|
||||||
statusDiv.textContent = verificationJSON.error || "Registration verification failed";
|
|
||||||
statusDiv.style.color = 'var(--danger)';
|
|
||||||
confirmBtn.disabled = false;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
statusDiv.textContent = err.message || 'An error occurred.';
|
|
||||||
statusDiv.style.color = 'var(--danger)';
|
|
||||||
confirmBtn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re-generate Recovery Voucher
|
|
||||||
let backupMnemonic = "";
|
|
||||||
document.getElementById('regenVoucherBtn')?.addEventListener('click', async () => {
|
|
||||||
const entropy = new Uint8Array(16);
|
|
||||||
crypto.getRandomValues(entropy);
|
|
||||||
backupMnemonic = await entropyToMnemonic(entropy);
|
|
||||||
|
|
||||||
const grid = document.getElementById('generatedWordGrid');
|
|
||||||
grid.innerHTML = backupMnemonic.split(' ').map((w, idx) => \`
|
|
||||||
<div class="word-cell">
|
|
||||||
<span style="color: var(--text-muted); font-size: 0.75rem; width: 18px;">\${idx + 1}.</span>
|
|
||||||
<span style="color: var(--text-primary); font-weight: 600;">\${w}</span>
|
|
||||||
</div>
|
|
||||||
\`).join('');
|
|
||||||
|
|
||||||
document.getElementById('voucherModal').style.display = 'block';
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('copyVoucherBtn')?.addEventListener('click', async () => {
|
|
||||||
if (!backupMnemonic) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(backupMnemonic);
|
|
||||||
const btn = document.getElementById('copyVoucherBtn');
|
|
||||||
const origHtml = btn.innerHTML;
|
|
||||||
btn.innerHTML = '<span>✓ Copied 12 Words to Clipboard!</span>';
|
|
||||||
setTimeout(() => { btn.innerHTML = origHtml; }, 2500);
|
|
||||||
} catch (_e) {
|
|
||||||
alert(backupMnemonic);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
</script>
|
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -92,7 +92,7 @@ export const RecoveryPage = () => {
|
|||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `
|
__html: `
|
||||||
import init, { Share, reconstruct_secret } from '/public/wasm/sss_recovery_bg.wasm.js';
|
import init, { Share, reconstruct_secret } from '/public/wasm/sss_recovery_bg.wasm.js';
|
||||||
import { mnemonicToEntropy } from '/public/ui/utils/bip39.ts';
|
import { mnemonicToEntropy } from '/public/utils/bip39.ts';
|
||||||
|
|
||||||
const methodSelect = document.getElementById('recovery-method');
|
const methodSelect = document.getElementById('recovery-method');
|
||||||
const voucherSection = document.getElementById('voucher-section');
|
const voucherSection = document.getElementById('voucher-section');
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Layout } from "./Layout.tsx";
|
import { Layout } from "./Layout.tsx";
|
||||||
|
import { WebAuthnScript } from "./auth/WebAuthnScript.tsx";
|
||||||
|
|
||||||
export const RegisterPage = (
|
export const RegisterPage = (
|
||||||
{ initialCode = "" }: { initialCode?: string },
|
{ initialCode = "" }: { initialCode?: string },
|
||||||
@ -212,93 +213,7 @@ export const RegisterPage = (
|
|||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script src="/public/auth-client.js?v=6"></script>
|
<WebAuthnScript isRegister />
|
||||||
<script
|
|
||||||
type="module"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `
|
|
||||||
import { entropyToMnemonic } from '/public/ui/utils/bip39.ts';
|
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const codeParam = urlParams.get('code');
|
|
||||||
if (codeParam) {
|
|
||||||
const input = document.getElementById('inviteCode');
|
|
||||||
if (input) {
|
|
||||||
input.value = codeParam;
|
|
||||||
document.getElementById('username').focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let generatedMnemonic = "";
|
|
||||||
|
|
||||||
document.getElementById('registerBtn').addEventListener('click', async () => {
|
|
||||||
const username = document.getElementById('username').value.trim();
|
|
||||||
const inviteCode = document.getElementById('inviteCode').value.trim();
|
|
||||||
|
|
||||||
if (!username || !inviteCode) {
|
|
||||||
setStatus("Username and Invite Code are required.", true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loader = document.getElementById('loadingIndicator');
|
|
||||||
const btn = document.getElementById('registerBtn');
|
|
||||||
loader.style.display = 'block';
|
|
||||||
btn.disabled = true;
|
|
||||||
setStatus('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await startWebAuthnRegistration(username, inviteCode);
|
|
||||||
if (res && res.success) {
|
|
||||||
// Generate 16 bytes of entropy for 12 BIP-39 words
|
|
||||||
const entropy = new Uint8Array(16);
|
|
||||||
crypto.getRandomValues(entropy);
|
|
||||||
generatedMnemonic = await entropyToMnemonic(entropy);
|
|
||||||
|
|
||||||
const words = generatedMnemonic.split(' ');
|
|
||||||
const grid = document.getElementById('wordGrid');
|
|
||||||
grid.innerHTML = words.map((w, idx) => \`
|
|
||||||
<div class="word-cell">
|
|
||||||
<span class="word-num">\${idx + 1}.</span>
|
|
||||||
<span class="word-text">\${w}</span>
|
|
||||||
</div>
|
|
||||||
\`).join('');
|
|
||||||
|
|
||||||
// Switch to Step 2
|
|
||||||
document.getElementById('step1Container').style.display = 'none';
|
|
||||||
document.getElementById('step2Container').style.display = 'block';
|
|
||||||
document.getElementById('registerTitle').textContent = "Recovery Voucher";
|
|
||||||
document.getElementById('registerSubtitle').textContent = "Save your 12-word backup key in a safe place.";
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
loader.style.display = 'none';
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('copyWordsBtn').addEventListener('click', async () => {
|
|
||||||
if (!generatedMnemonic) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(generatedMnemonic);
|
|
||||||
const btn = document.getElementById('copyWordsBtn');
|
|
||||||
const origHtml = btn.innerHTML;
|
|
||||||
btn.innerHTML = '<span>✓ Copied to Clipboard!</span>';
|
|
||||||
btn.style.borderColor = 'var(--success)';
|
|
||||||
btn.style.color = 'var(--success-text)';
|
|
||||||
setTimeout(() => {
|
|
||||||
btn.innerHTML = origHtml;
|
|
||||||
btn.style.borderColor = '';
|
|
||||||
btn.style.color = '';
|
|
||||||
}, 2500);
|
|
||||||
} catch (_e) {
|
|
||||||
alert("Select and copy the words manually: " + generatedMnemonic);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
</script>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
145
ui/components/auth/PasskeyTable.tsx
Normal file
145
ui/components/auth/PasskeyTable.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
export const PasskeyTable = ({ passkeys }: { passkeys: any[] }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop Table View (≥ 768px) */}
|
||||||
|
<div class="card desktop-only" style="display: none;">
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Credential ID</th>
|
||||||
|
<th>Sign Counter</th>
|
||||||
|
<th>AAGUID Vendor</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{passkeys.length === 0
|
||||||
|
? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={4}
|
||||||
|
style="text-align: center; padding: 2rem; color: var(--text-muted);"
|
||||||
|
>
|
||||||
|
No passkeys registered.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
passkeys.map((passkey) => (
|
||||||
|
<tr key={passkey.id}>
|
||||||
|
<td>
|
||||||
|
<code style="background: var(--surface-muted); padding: 0.2rem 0.5rem; border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem;">
|
||||||
|
{passkey.credential_id
|
||||||
|
? `${passkey.credential_id.substring(0, 16)}...`
|
||||||
|
: passkey.id}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td style="color: var(--text-secondary);">
|
||||||
|
{passkey.counter}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-info">
|
||||||
|
{passkey.aaguid
|
||||||
|
? passkey.aaguid.substring(0, 8)
|
||||||
|
: "FIDO2 Key"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger revoke-passkey-btn"
|
||||||
|
data-passkey-id={passkey.id}
|
||||||
|
disabled={passkeys.length <= 1}
|
||||||
|
style={passkeys.length <= 1
|
||||||
|
? "opacity: 0.5; cursor: not-allowed;"
|
||||||
|
: ""}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Adaptive Cards View (< 768px) */}
|
||||||
|
<div
|
||||||
|
class="mobile-only"
|
||||||
|
style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem;"
|
||||||
|
>
|
||||||
|
{passkeys.length === 0
|
||||||
|
? (
|
||||||
|
<div class="card" style="text-align: center; padding: 2rem;">
|
||||||
|
<p style="color: var(--text-muted); margin: 0;">
|
||||||
|
No passkeys registered.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
passkeys.map((passkey) => (
|
||||||
|
<div class="card" key={passkey.id} style="margin-bottom: 0;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.65rem;">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; background: var(--primary-light); color: var(--primary); border-radius: var(--radius-md);">
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="7.5" cy="15.5" r="5.5"></circle>
|
||||||
|
<path d="m21 2-9.6 9.6"></path>
|
||||||
|
<path d="m15.5 7.5 3 3L22 7l-3-3"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: 700; color: var(--text-primary);">
|
||||||
|
Passkey Credential
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||||
|
{passkey.credential_id
|
||||||
|
? `${passkey.credential_id.substring(0, 14)}...`
|
||||||
|
: passkey.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="badge badge-info">
|
||||||
|
{passkey.aaguid ? "Hardware/OS" : "FIDO2"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 1rem;">
|
||||||
|
<div>
|
||||||
|
<strong>Counter:</strong> {passkey.counter} authentications
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger revoke-passkey-btn"
|
||||||
|
data-passkey-id={passkey.id}
|
||||||
|
disabled={passkeys.length <= 1}
|
||||||
|
style={`width: 100%; min-height: 44px; ${
|
||||||
|
passkeys.length <= 1
|
||||||
|
? "opacity: 0.5; cursor: not-allowed;"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{passkeys.length <= 1
|
||||||
|
? "Cannot Remove Last Passkey"
|
||||||
|
: "Remove Passkey"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
235
ui/components/auth/WebAuthnScript.tsx
Normal file
235
ui/components/auth/WebAuthnScript.tsx
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
export const WebAuthnScript = ({
|
||||||
|
isRegister = false,
|
||||||
|
isPasskeys = false,
|
||||||
|
}: {
|
||||||
|
isRegister?: boolean;
|
||||||
|
isPasskeys?: boolean;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script src="/public/auth-client.js?v=6"></script>
|
||||||
|
{isRegister && (
|
||||||
|
<script
|
||||||
|
type="module"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
import { entropyToMnemonic } from '/public/utils/bip39.ts';
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const codeParam = urlParams.get('code');
|
||||||
|
if (codeParam) {
|
||||||
|
const input = document.getElementById('inviteCode');
|
||||||
|
if (input) {
|
||||||
|
input.value = codeParam;
|
||||||
|
document.getElementById('username').focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let generatedMnemonic = "";
|
||||||
|
|
||||||
|
document.getElementById('registerBtn').addEventListener('click', async () => {
|
||||||
|
const username = document.getElementById('username').value.trim();
|
||||||
|
const inviteCode = document.getElementById('inviteCode').value.trim();
|
||||||
|
|
||||||
|
if (!username || !inviteCode) {
|
||||||
|
setStatus("Username and Invite Code are required.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader = document.getElementById('loadingIndicator');
|
||||||
|
const btn = document.getElementById('registerBtn');
|
||||||
|
loader.style.display = 'block';
|
||||||
|
btn.disabled = true;
|
||||||
|
setStatus('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await startWebAuthnRegistration(username, inviteCode);
|
||||||
|
if (res && res.success) {
|
||||||
|
// Generate 16 bytes of entropy for 12 BIP-39 words
|
||||||
|
const entropy = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(entropy);
|
||||||
|
generatedMnemonic = await entropyToMnemonic(entropy);
|
||||||
|
|
||||||
|
const words = generatedMnemonic.split(' ');
|
||||||
|
const grid = document.getElementById('wordGrid');
|
||||||
|
grid.innerHTML = words.map((w, idx) => \`
|
||||||
|
<div class="word-cell">
|
||||||
|
<span class="word-num">\${idx + 1}.</span>
|
||||||
|
<span class="word-text">\${w}</span>
|
||||||
|
</div>
|
||||||
|
\`).join('');
|
||||||
|
|
||||||
|
// Switch to Step 2
|
||||||
|
document.getElementById('step1Container').style.display = 'none';
|
||||||
|
document.getElementById('step2Container').style.display = 'block';
|
||||||
|
document.getElementById('registerTitle').textContent = "Recovery Voucher";
|
||||||
|
document.getElementById('registerSubtitle').textContent = "Save your 12-word backup key in a safe place.";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
loader.style.display = 'none';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('copyWordsBtn').addEventListener('click', async () => {
|
||||||
|
if (!generatedMnemonic) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(generatedMnemonic);
|
||||||
|
const btn = document.getElementById('copyWordsBtn');
|
||||||
|
const origHtml = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<span>✓ Copied to Clipboard!</span>';
|
||||||
|
btn.style.borderColor = 'var(--success)';
|
||||||
|
btn.style.color = 'var(--success-text)';
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = origHtml;
|
||||||
|
btn.style.borderColor = '';
|
||||||
|
btn.style.color = '';
|
||||||
|
}, 2500);
|
||||||
|
} catch (_e) {
|
||||||
|
alert("Select and copy the words manually: " + generatedMnemonic);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
</script>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPasskeys && (
|
||||||
|
<script
|
||||||
|
type="module"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
import { entropyToMnemonic } from '/public/utils/bip39.ts';
|
||||||
|
|
||||||
|
// Revoke Passkey Logic
|
||||||
|
document.querySelectorAll('.revoke-passkey-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
if (e.currentTarget.disabled) return;
|
||||||
|
if (!confirm('Are you sure you want to remove this passkey?')) return;
|
||||||
|
|
||||||
|
const passkeyId = e.currentTarget.getAttribute('data-passkey-id');
|
||||||
|
const originalText = e.currentTarget.textContent;
|
||||||
|
e.currentTarget.textContent = 'Removing...';
|
||||||
|
e.currentTarget.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.currentTarget.textContent = originalText;
|
||||||
|
e.currentTarget.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('An error occurred');
|
||||||
|
e.currentTarget.textContent = originalText;
|
||||||
|
e.currentTarget.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 = 'inline-flex';
|
||||||
|
statusDiv.textContent = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmBtn?.addEventListener('click', async () => {
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
statusDiv.textContent = 'Follow prompt on device...';
|
||||||
|
statusDiv.style.color = 'var(--primary)';
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 = 'var(--danger)';
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startRegistration } = SimpleWebAuthnBrowser;
|
||||||
|
const attResp = await startRegistration({ optionsJSON: data.options });
|
||||||
|
|
||||||
|
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 registered successfully! Reloading...";
|
||||||
|
statusDiv.style.color = 'var(--success-text)';
|
||||||
|
setTimeout(() => window.location.reload(), 1000);
|
||||||
|
} else {
|
||||||
|
statusDiv.textContent = verificationJSON.error || "Registration verification failed";
|
||||||
|
statusDiv.style.color = 'var(--danger)';
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
statusDiv.textContent = err.message || 'An error occurred.';
|
||||||
|
statusDiv.style.color = 'var(--danger)';
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-generate Recovery Voucher
|
||||||
|
let backupMnemonic = "";
|
||||||
|
document.getElementById('regenVoucherBtn')?.addEventListener('click', async () => {
|
||||||
|
const entropy = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(entropy);
|
||||||
|
backupMnemonic = await entropyToMnemonic(entropy);
|
||||||
|
|
||||||
|
const grid = document.getElementById('generatedWordGrid');
|
||||||
|
grid.innerHTML = backupMnemonic.split(' ').map((w, idx) => \`
|
||||||
|
<div class="word-cell">
|
||||||
|
<span style="color: var(--text-muted); font-size: 0.75rem; width: 18px;">\${idx + 1}.</span>
|
||||||
|
<span style="color: var(--text-primary); font-weight: 600;">\${w}</span>
|
||||||
|
</div>
|
||||||
|
\`).join('');
|
||||||
|
|
||||||
|
document.getElementById('voucherModal').style.display = 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('copyVoucherBtn')?.addEventListener('click', async () => {
|
||||||
|
if (!backupMnemonic) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(backupMnemonic);
|
||||||
|
const btn = document.getElementById('copyVoucherBtn');
|
||||||
|
const origHtml = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<span>✓ Copied 12 Words to Clipboard!</span>';
|
||||||
|
setTimeout(() => { btn.innerHTML = origHtml; }, 2500);
|
||||||
|
} catch (_e) {
|
||||||
|
alert(backupMnemonic);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
</script>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,71 +0,0 @@
|
|||||||
import { WORDLIST } from "./bip39_wordlist.ts";
|
|
||||||
|
|
||||||
export async function entropyToMnemonic(entropy: Uint8Array): Promise<string> {
|
|
||||||
if (entropy.length < 16 || entropy.length > 32 || entropy.length % 4 !== 0) {
|
|
||||||
throw new Error("Invalid entropy length");
|
|
||||||
}
|
|
||||||
|
|
||||||
const entropyBits = Array.from(entropy)
|
|
||||||
.map((b) => b.toString(2).padStart(8, "0"))
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const entropyBuffer = new Uint8Array(entropy.length);
|
|
||||||
entropyBuffer.set(entropy);
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", entropyBuffer);
|
|
||||||
const hashBits = Array.from(new Uint8Array(hashBuffer))
|
|
||||||
.map((b) => b.toString(2).padStart(8, "0"))
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const checksumLength = entropy.length / 4;
|
|
||||||
const checksum = hashBits.slice(0, checksumLength);
|
|
||||||
|
|
||||||
const bits = entropyBits + checksum;
|
|
||||||
const chunks = bits.match(/(.{1,11})/g) || [];
|
|
||||||
|
|
||||||
const mnemonic = chunks.map((binaryStr) => {
|
|
||||||
const index = parseInt(binaryStr, 2);
|
|
||||||
return WORDLIST[index];
|
|
||||||
});
|
|
||||||
|
|
||||||
return mnemonic.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function mnemonicToEntropy(mnemonic: string): Promise<Uint8Array> {
|
|
||||||
const words = mnemonic.normalize("NFKD").trim().split(/\s+/);
|
|
||||||
if (words.length % 3 !== 0) {
|
|
||||||
throw new Error("Invalid mnemonic length");
|
|
||||||
}
|
|
||||||
|
|
||||||
const bits = words
|
|
||||||
.map((word) => {
|
|
||||||
const index = WORDLIST.indexOf(word);
|
|
||||||
if (index === -1) {
|
|
||||||
throw new Error(`Invalid word in mnemonic: ${word}`);
|
|
||||||
}
|
|
||||||
return index.toString(2).padStart(11, "0");
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const dividerIndex = Math.floor(bits.length / 33) * 32;
|
|
||||||
const entropyBits = bits.slice(0, dividerIndex);
|
|
||||||
const checksumBits = bits.slice(dividerIndex);
|
|
||||||
|
|
||||||
const entropy = new Uint8Array(entropyBits.length / 8);
|
|
||||||
for (let i = 0; i < entropy.length; i++) {
|
|
||||||
entropy[i] = parseInt(entropyBits.slice(i * 8, (i + 1) * 8), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", entropy);
|
|
||||||
const hashBits = Array.from(new Uint8Array(hashBuffer))
|
|
||||||
.map((b) => b.toString(2).padStart(8, "0"))
|
|
||||||
.join("");
|
|
||||||
const expectedChecksum = hashBits.slice(0, checksumBits.length);
|
|
||||||
|
|
||||||
if (expectedChecksum !== checksumBits) {
|
|
||||||
// Explicitly zeroize on failure
|
|
||||||
entropy.fill(0);
|
|
||||||
throw new Error("Invalid mnemonic checksum");
|
|
||||||
}
|
|
||||||
|
|
||||||
return entropy;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user