/**
* Account Merge UI — server-rendered HTML pages for the account merge flow.
*
* Extracted from routes/auth.js to separate rendering from routing.
*/
function renderMergePage(mergeRequest, pathPrefix = '') {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Merge Accounts - Pear Git</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0d1117;
color: #c9d1d9;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
max-width: 500px;
width: 100%;
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
padding: 40px;
}
h1 { font-size: 24px; margin-bottom: 10px; }
.subtitle { color: #8b949e; margin-bottom: 30px; }
.accounts {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
margin: 30px 0;
}
.account {
text-align: center;
padding: 20px;
background: #0d1117;
border-radius: 8px;
flex: 1;
}
.account-label { font-size: 12px; color: #8b949e; margin-bottom: 8px; }
.pubkey {
font-family: monospace;
font-size: 12px;
color: #58a6ff;
word-break: break-all;
}
.arrow { font-size: 24px; color: #238636; }
.status {
display: flex;
gap: 10px;
margin: 20px 0;
}
.status-item {
flex: 1;
padding: 12px;
border-radius: 6px;
text-align: center;
font-size: 14px;
}
.status-item.pending { background: #21262d; color: #8b949e; }
.status-item.signed { background: #238636; color: white; }
.warning {
background: #f8514933;
border: 1px solid #f85149;
border-radius: 6px;
padding: 16px;
margin: 20px 0;
font-size: 14px;
}
.warning strong { color: #f85149; }
.keypair-input {
margin: 20px 0;
}
.keypair-input label {
display: block;
margin-bottom: 8px;
font-size: 14px;
}
.keypair-input textarea {
width: 100%;
height: 80px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #c9d1d9;
padding: 12px;
font-family: monospace;
font-size: 12px;
resize: none;
}
.auto-detected {
background: #23863633;
border: 1px solid #238636;
border-radius: 6px;
padding: 16px;
margin: 20px 0;
text-align: center;
}
.auto-detected .icon { font-size: 32px; margin-bottom: 10px; }
.auto-detected .label { color: #238636; font-weight: 600; margin-bottom: 5px; }
.auto-detected .pubkey { font-family: monospace; font-size: 12px; color: #8b949e; }
.manual-toggle {
text-align: center;
margin-top: 10px;
}
.manual-toggle a {
color: #58a6ff;
cursor: pointer;
font-size: 12px;
text-decoration: underline;
}
.hidden { display: none !important; }
.button {
width: 100%;
padding: 14px;
background: #238636;
border: none;
border-radius: 6px;
color: white;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin-top: 10px;
}
.button:hover { background: #2ea043; }
.button:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; }
.button.secondary {
background: #21262d;
border: 1px solid #30363d;
}
.button.secondary:hover { background: #30363d; }
.expires {
text-align: center;
font-size: 12px;
color: #8b949e;
margin-top: 20px;
}
.result {
margin-top: 20px;
padding: 16px;
border-radius: 6px;
display: none;
}
.result.success { background: #23863633; border: 1px solid #238636; }
.result.error { background: #f8514933; border: 1px solid #f85149; }
</style>
</head>
<body>
<div class="container">
<h1>Merge Accounts</h1>
<p class="subtitle">Link two ANProto identities into one account</p>
<div class="accounts">
<div class="account">
<div class="account-label">Primary Account</div>
<div class="pubkey">${mergeRequest.primaryPublicKey.substring(0, 16)}...</div>
</div>
<div class="arrow">→</div>
<div class="account">
<div class="account-label">Merging Into Primary</div>
<div class="pubkey">${mergeRequest.secondaryPublicKey.substring(0, 16)}...</div>
</div>
</div>
<div class="status">
<div class="status-item ${mergeRequest.primarySig ? 'signed' : 'pending'}">
${mergeRequest.primarySig ? '✓ Primary Signed' : '○ Primary Pending'}
</div>
<div class="status-item ${mergeRequest.secondarySig ? 'signed' : 'pending'}">
${mergeRequest.secondarySig ? '✓ Secondary Signed' : '○ Secondary Pending'}
</div>
</div>
<div class="warning">
<strong>Warning:</strong> After merging, all issues, comments, and permissions from the secondary account will be transferred to the primary account. This action cannot be undone.
</div>
<!-- Auto-detected keypair section (shown when keypair found in localStorage) -->
<div id="auto-detected-section" class="auto-detected hidden">
<div class="icon">🔑</div>
<div class="label">Keypair Auto-Detected</div>
<div class="pubkey" id="detected-pubkey"></div>
<div id="detected-role" style="margin-top: 8px; font-size: 14px;"></div>
</div>
<button id="auto-sign-btn" class="button hidden" onclick="autoSignMerge()">One-Click Confirm Merge</button>
<div id="manual-toggle" class="manual-toggle hidden">
<a onclick="showManualInput()">Use different keypair manually</a>
</div>
<!-- Manual keypair input (hidden when auto-detected) -->
<div id="manual-section" class="keypair-input">
<label>Enter your ANProto keypair to sign:</label>
<textarea id="keypair" placeholder="Paste your 132-character keypair here..."></textarea>
</div>
<button id="manual-sign-btn" class="button" onclick="signMerge()">Sign & Confirm Merge</button>
<div id="result" class="result"></div>
<div class="expires">
Expires: ${new Date(mergeRequest.expiresAt).toLocaleString()}
</div>
</div>
<script type="module">
import * as ed25519 from 'https://esm.sh/@noble/ed25519@2.0.0';
import { sha512 } from 'https://esm.sh/@noble/hashes@1.3.0/sha512';
// Configure ed25519 to use sha512
ed25519.etc.sha512Sync = (...m) => sha512(ed25519.etc.concatBytes(...m));
ed25519.etc.sha512Async = async (...m) => sha512(ed25519.etc.concatBytes(...m));
// Make sign function available globally
window.ed25519Sign = async function(message, secretKey) {
const msgBytes = new TextEncoder().encode(message);
const signature = await ed25519.signAsync(msgBytes, secretKey);
return signature;
};
window.ed25519Ready = true;
console.log('[Merge] ed25519 configured and ready');
</script>
<script>
const mergeId = '${mergeRequest.mergeId}';
const challenge = '${mergeRequest.challenge}';
const basePath = '${pathPrefix}';
const primaryPublicKey = '${mergeRequest.primaryPublicKey}';
const secondaryPublicKey = '${mergeRequest.secondaryPublicKey}';
const primarySigned = ${mergeRequest.primarySig ? 'true' : 'false'};
const secondarySigned = ${mergeRequest.secondarySig ? 'true' : 'false'};
let detectedKeypair = null;
let detectedRole = null;
function base64ToBytes(base64) {
const binString = atob(base64);
return Uint8Array.from(binString, (m) => m.codePointAt(0));
}
function bytesToBase64(bytes) {
const binString = Array.from(bytes, (x) => String.fromCodePoint(x)).join('');
return btoa(binString);
}
// Check localStorage for keypair on page load
function checkForStoredKeypair() {
const stored = localStorage.getItem('pear-git-keypair');
if (!stored || stored.length !== 132) return null;
const publicKeyB64 = stored.substring(0, 44);
// Check if this matches primary or secondary
if (publicKeyB64 === primaryPublicKey) {
return { keypair: stored, role: 'primary', publicKey: publicKeyB64, needsSignature: !primarySigned };
} else if (publicKeyB64 === secondaryPublicKey) {
return { keypair: stored, role: 'secondary', publicKey: publicKeyB64, needsSignature: !secondarySigned };
}
return null;
}
// Initialize page
function initPage() {
const detected = checkForStoredKeypair();
if (detected) {
detectedKeypair = detected.keypair;
detectedRole = detected.role;
// Show auto-detected section
document.getElementById('auto-detected-section').classList.remove('hidden');
document.getElementById('detected-pubkey').textContent = detected.publicKey.substring(0, 16) + '...';
if (detected.needsSignature) {
document.getElementById('detected-role').innerHTML =
'You are the <strong>' + detected.role + '</strong> account. Click below to sign.';
document.getElementById('auto-sign-btn').classList.remove('hidden');
} else {
document.getElementById('detected-role').innerHTML =
'You are the <strong>' + detected.role + '</strong> account. Already signed!';
document.getElementById('detected-role').style.color = '#238636';
}
document.getElementById('manual-toggle').classList.remove('hidden');
// Hide manual input
document.getElementById('manual-section').classList.add('hidden');
document.getElementById('manual-sign-btn').classList.add('hidden');
}
console.log('[Merge] Page initialized, detected:', detected);
}
function showManualInput() {
document.getElementById('auto-detected-section').classList.add('hidden');
document.getElementById('auto-sign-btn').classList.add('hidden');
document.getElementById('manual-toggle').classList.add('hidden');
document.getElementById('manual-section').classList.remove('hidden');
document.getElementById('manual-sign-btn').classList.remove('hidden');
}
async function signWithKeypair(keypairInput) {
const resultDiv = document.getElementById('result');
if (keypairInput.length !== 132) {
resultDiv.className = 'result error';
resultDiv.style.display = 'block';
resultDiv.textContent = 'Invalid keypair. Must be 132 characters (44 public + 88 secret).';
return;
}
try {
// Extract public key (first 44 chars) and secret key (last 88 chars)
const publicKeyB64 = keypairInput.substring(0, 44);
const secretKeyB64 = keypairInput.substring(44);
// Decode secret key and extract signing key (first 32 bytes)
const secretKeyBytes = base64ToBytes(secretKeyB64);
const signingKey = secretKeyBytes.slice(0, 32);
// Sign the challenge
const signatureBytes = await window.ed25519Sign(challenge, signingKey);
const signatureB64 = bytesToBase64(signatureBytes);
// Submit to server
const response = await fetch(basePath + '/api/user/merge/' + mergeId + '/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
signature: signatureB64,
publicKey: publicKeyB64
})
});
const result = await response.json();
if (response.ok) {
resultDiv.className = 'result success';
resultDiv.style.display = 'block';
if (result.stats) {
resultDiv.innerHTML = '<strong>Merge Complete!</strong><br>' +
'Identities transferred: ' + result.stats.identitiesTransferred + '<br>' +
'Issues migrated: ' + result.stats.issuesMigrated + '<br>' +
'Comments migrated: ' + result.stats.commentsMigrated;
} else {
resultDiv.textContent = result.message;
}
// Reload after 2 seconds if complete
if (result.stats) {
setTimeout(() => location.reload(), 2000);
}
} else {
resultDiv.className = 'result error';
resultDiv.style.display = 'block';
resultDiv.textContent = result.error || 'Failed to confirm merge';
}
} catch (err) {
resultDiv.className = 'result error';
resultDiv.style.display = 'block';
resultDiv.textContent = 'Error: ' + err.message;
}
}
async function autoSignMerge() {
if (!detectedKeypair) {
alert('No keypair detected');
return;
}
// Disable button to prevent double-click
const btn = document.getElementById('auto-sign-btn');
btn.disabled = true;
btn.textContent = 'Signing...';
await signWithKeypair(detectedKeypair);
btn.disabled = false;
btn.textContent = 'One-Click Confirm Merge';
}
async function signMerge() {
const keypairInput = document.getElementById('keypair').value.trim();
await signWithKeypair(keypairInput);
}
// Initialize when ed25519 is ready
function waitForEd25519() {
if (window.ed25519Ready) {
initPage();
} else {
setTimeout(waitForEd25519, 50);
}
}
waitForEd25519();
</script>
</body>
</html>
`;
}
/**
* Render merge error page
*/
function renderMergeError(message, pathPrefix = '') {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Merge Error - Pear Git</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0d1117;
color: #c9d1d9;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
max-width: 400px;
text-align: center;
}
.icon { font-size: 64px; margin-bottom: 20px; }
h1 { font-size: 24px; margin-bottom: 10px; color: #f85149; }
p { color: #8b949e; margin-bottom: 30px; }
a {
display: inline-block;
padding: 12px 24px;
background: #21262d;
border: 1px solid #30363d;
border-radius: 6px;
color: #c9d1d9;
text-decoration: none;
}
a:hover { background: #30363d; }
</style>
</head>
<body>
<div class="container">
<div class="icon">⚠️</div>
<h1>Merge Error</h1>
<p>${message}</p>
<a href="${pathPrefix}/">Return Home</a>
</div>
</body>
</html>
`;
}
/**
* Render merge complete page
*/
function renderMergeComplete(mergeRequest, pathPrefix = '') {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Merge Complete - Pear Git</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0d1117;
color: #c9d1d9;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
max-width: 400px;
text-align: center;
}
.icon { font-size: 64px; margin-bottom: 20px; }
h1 { font-size: 24px; margin-bottom: 10px; color: #238636; }
p { color: #8b949e; margin-bottom: 20px; }
.stats {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
text-align: left;
}
.stat { margin: 8px 0; }
.stat-label { color: #8b949e; }
.stat-value { color: #58a6ff; font-weight: 600; }
a {
display: inline-block;
padding: 12px 24px;
background: #238636;
border-radius: 6px;
color: white;
text-decoration: none;
font-weight: 600;
}
a:hover { background: #2ea043; }
</style>
</head>
<body>
<div class="container">
<div class="icon">✓</div>
<h1>Merge Complete!</h1>
<p>Your accounts have been successfully merged.</p>
${mergeRequest.stats ? `
<div class="stats">
<div class="stat">
<span class="stat-label">Identities transferred:</span>
<span class="stat-value">${mergeRequest.stats.identitiesTransferred}</span>
</div>
<div class="stat">
<span class="stat-label">Issues migrated:</span>
<span class="stat-value">${mergeRequest.stats.issuesMigrated}</span>
</div>
<div class="stat">
<span class="stat-label">Comments migrated:</span>
<span class="stat-value">${mergeRequest.stats.commentsMigrated}</span>
</div>
</div>
` : ''}
<a href="${pathPrefix}/">Return Home</a>
</div>
</body>
</html>
`;
}
export { renderMergePage, renderMergeError, renderMergeComplete }