- Extracted `PasskeyTable` and `WebAuthnScript` into `ui/components/auth/`. - Refactored `PasskeysPage.tsx` and `RegisterPage.tsx` to use the new components instead of inline scripts and HTML. - Deleted the duplicate `ui/public/ui/utils/bip39_wordlist.ts` and `ui/public/ui/utils/bip39.ts`. - Updated all import references to use `ui/utils/bip39_wordlist.ts` and `/public/utils/bip39.ts`. Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
236 lines
9.2 KiB
TypeScript
236 lines
9.2 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
};
|