/**
* Identity UI - Auto-generate browser identity on first visit
*
* Simplified approach:
* - Generate keypair on first visit
* - Store in localStorage
* - Always inject X-Wirecore-PublicKey header
* - Signatures optional (future enhancement)
*/
/**
* Render identity UI JavaScript
* This is injected into every page
*/
export function renderIdentityScript(pathPrefix = '') {
return `
<script>
(function() {
'use strict';
const STORAGE_KEY = 'pear-git-keypair';
// Server-injected path prefix (e.g., '/git' or '')
window.__PATH_PREFIX__ = '${pathPrefix}';
window.getBasePath = function() { return window.__PATH_PREFIX__; };
const basePath = window.getBasePath();
console.log('[Identity] Base path:', basePath || '(root)');
// Load ed25519 signing library with sha512 configuration
let ed25519 = null;
let _ed25519Resolve = null;
const ed25519Ready = new Promise(resolve => { _ed25519Resolve = resolve; });
(async () => {
try {
const [ed25519Module, hashesModule] = await Promise.all([
import(basePath + '/vendor/noble-ed25519.js'),
import(basePath + '/vendor/noble-sha512.js')
]);
// Configure sha512 for ed25519 v2.x
ed25519Module.etc.sha512Sync = (...m) => hashesModule.sha512(ed25519Module.etc.concatBytes(...m));
ed25519Module.etc.sha512Async = async (...m) => hashesModule.sha512(ed25519Module.etc.concatBytes(...m));
ed25519 = ed25519Module;
console.log('[Identity] ✓ ed25519 loaded and configured');
_ed25519Resolve(true);
} catch (err) {
console.warn('[Identity] Failed to load ed25519:', err.message);
console.warn('[Identity] Signatures disabled - using legacy auth');
_ed25519Resolve(false);
}
})();
// Wait for ed25519 with timeout (returns true if loaded, false if timeout/failed)
async function waitForEd25519(timeoutMs) {
if (ed25519) return true;
const timeout = new Promise(resolve => setTimeout(() => resolve(false), timeoutMs));
return Promise.race([ed25519Ready, timeout]);
}
// CRITICAL: Set up interception IMMEDIATELY (before any async operations)
let publicKey = null;
let secretKey = null;
let currentChallenge = null;
let challengeExpiry = 0;
const existingKeypair = localStorage.getItem(STORAGE_KEY);
if (existingKeypair) {
publicKey = existingKeypair.substring(0, 44);
secretKey = existingKeypair.substring(44, 132);
console.log('[Identity] Loaded key from storage:', publicKey.substring(0, 8) + '...');
}
// Signing function
async function signChallenge(challenge, secretKeyB64) {
if (!ed25519) {
throw new Error('ed25519 not loaded');
}
const challengeBytes = new TextEncoder().encode(challenge);
const secretKeyBytes = base64ToBytes(secretKeyB64);
if (secretKeyBytes.length !== 64) {
throw new Error('Invalid secret key length: ' + secretKeyBytes.length);
}
// ed25519 private key is first 32 bytes
const privateKey = secretKeyBytes.slice(0, 32);
const signature = await ed25519.signAsync(challengeBytes, privateKey);
if (signature.length !== 64) {
throw new Error('Invalid signature length: ' + signature.length);
}
return bytesToBase64(signature);
}
function base64ToBytes(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function bytesToBase64(bytes) {
const binary = String.fromCharCode(...bytes);
return btoa(binary);
}
// Get or refresh challenge
async function ensureChallenge() {
const now = Date.now();
// Return cached challenge if still valid (expires 1 min before server)
if (currentChallenge && now < challengeExpiry) {
return currentChallenge;
}
// Get new challenge
try {
const response = await originalFetch.call(window, basePath + '/api/auth/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context: 'web-identity' })
});
if (!response.ok) {
throw new Error('Failed to get challenge: ' + response.status);
}
const data = await response.json();
currentChallenge = data;
// Expire 1 minute before server (5min - 1min = 4min)
challengeExpiry = now + 240000;
console.log('[Identity] ✓ Challenge obtained:', data.challengeId.substring(0, 8) + '...');
return currentChallenge;
} catch (err) {
console.error('[Identity] Failed to get challenge:', err);
return null;
}
}
// Intercept fetch with retry on challenge failure
const originalFetch = window.fetch;
window.fetch = async function(url, options = {}, _retryCount = 0) {
options = options || {};
options.headers = options.headers || {};
// Skip internal requests
if (url.includes('/api/identity/generate') || url.includes('/api/identity/create') || url.includes('/api/identity/username-available') || url.includes('/api/auth/challenge')) {
return originalFetch.call(this, url, options);
}
// Add authentication headers
if (publicKey) {
options.headers['X-Wirecore-PublicKey'] = publicKey;
// Wait for ed25519 to load (up to 3s) before sending authenticated requests
if (!ed25519 && secretKey) {
await waitForEd25519(3000);
}
// Add signature if ed25519 is loaded
if (ed25519 && secretKey) {
try {
const challenge = await ensureChallenge();
if (challenge) {
const signature = await signChallenge(challenge.challenge, secretKey);
options.headers['X-Wirecore-Signature'] = signature;
options.headers['X-Wirecore-Challenge'] = challenge.challengeId;
console.log('[Identity] ✓ Fetch with signature:', url);
} else {
console.log('[Identity] ✓ Fetch (no challenge):', url);
}
} catch (err) {
console.warn('[Identity] Signing failed, using legacy auth:', err.message);
console.log('[Identity] ✓ Fetch (legacy):', url);
}
} else {
console.log('[Identity] ✓ Fetch (legacy):', url);
}
}
const response = await originalFetch.call(this, url, options);
// If 401 and we haven't retried yet, wait for ed25519, get fresh challenge and re-sign
if (response.status === 401 && _retryCount < 2) {
// On first retry, wait for ed25519 if not loaded yet
if (!ed25519 && _retryCount === 0) {
console.warn('[Identity] Got 401, waiting for ed25519 before retry...');
await waitForEd25519(5000);
}
console.warn('[Identity] Retrying with fresh challenge (attempt ' + (_retryCount + 1) + ')...');
currentChallenge = null;
challengeExpiry = 0;
return window.fetch(url, { ...options, headers: { ...options.headers } }, _retryCount + 1);
}
return response;
};
// Intercept XMLHttpRequest (CRITICAL - some code uses XHR!)
const originalXHROpen = XMLHttpRequest.prototype.open;
const originalXHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
this._xhr_url = url;
this._xhr_method = method;
return originalXHROpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = async function(...args) {
// Skip internal requests
if (this._xhr_url && !this._xhr_url.includes('/api/identity/') && !this._xhr_url.includes('/api/auth/challenge')) {
if (publicKey) {
try {
this.setRequestHeader('X-Wirecore-PublicKey', publicKey);
// Wait for ed25519 to load (up to 3s) before sending authenticated requests
if (!ed25519 && secretKey) {
await waitForEd25519(3000);
}
// Add signature if ed25519 is loaded
if (ed25519 && secretKey) {
const challenge = await ensureChallenge();
if (challenge) {
const signature = await signChallenge(challenge.challenge, secretKey);
this.setRequestHeader('X-Wirecore-Signature', signature);
this.setRequestHeader('X-Wirecore-Challenge', challenge.challengeId);
console.log('[Identity] ✓ XHR with signature:', this._xhr_method, this._xhr_url);
}
} else {
console.log('[Identity] ✓ XHR (legacy):', this._xhr_method, this._xhr_url);
}
} catch (err) {
console.warn('[Identity] XHR signing failed:', err.message);
}
}
}
return originalXHRSend.apply(this, args);
};
console.log('[Identity] Interceptors installed', publicKey ? '(key: ' + publicKey.substring(0, 8) + '...)' : '(no key yet)');
/**
* Get or generate identity
*/
async function ensureIdentity() {
let keypair = localStorage.getItem(STORAGE_KEY);
if (!keypair) {
console.log('[Identity] Generating new identity...');
try {
const response = await fetch(basePath + '/api/identity/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error('Failed to generate identity: ' + response.status);
}
const data = await response.json();
keypair = data.keypair;
// Store in localStorage
localStorage.setItem(STORAGE_KEY, keypair);
console.log('[Identity] ✓ Identity generated:', data.publicKey.substring(0, 8) + '...');
// Show welcome modal
showWelcomeModal(data);
} catch (err) {
console.error('[Identity] Failed to generate identity:', err);
throw err;
}
} else {
console.log('[Identity] ✓ Loaded existing identity');
}
return keypair;
}
/**
* Setup header injection (update the keys for the already-installed interceptor)
*/
function setupHeaderInjection(keypair) {
publicKey = keypair.substring(0, 44);
secretKey = keypair.substring(44, 132);
console.log('[Identity] ✓ Keys updated for interceptors:', publicKey.substring(0, 8) + '...');
console.log('[Identity] Signatures:', ed25519 ? 'enabled' : 'disabled (legacy mode)');
}
/**
* Show welcome modal for new users
*/
function showWelcomeModal(data) {
const modal = document.createElement('div');
modal.id = 'identity-welcome-modal';
modal.innerHTML = \`
<style>
#identity-welcome-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
#identity-welcome-modal .modal-content {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 30px;
max-width: 600px;
color: #c9d1d9;
}
#identity-welcome-modal h2 {
margin: 0 0 20px 0;
color: #58a6ff;
}
#identity-welcome-modal p {
line-height: 1.6;
margin: 10px 0;
}
#identity-welcome-modal .keypair-box {
background: #0d1117;
padding: 15px;
border-radius: 4px;
font-family: monospace;
font-size: 11px;
word-break: break-all;
margin: 20px 0;
border: 1px solid #30363d;
}
#identity-welcome-modal .warning {
background: #3d1f00;
border: 1px solid #6e3500;
padding: 15px;
border-radius: 4px;
margin: 20px 0;
}
#identity-welcome-modal .warning strong {
color: #f85149;
}
#identity-welcome-modal .buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
#identity-welcome-modal button {
background: #238636;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
#identity-welcome-modal button:hover {
background: #2ea043;
}
#identity-welcome-modal .secondary {
background: #21262d;
}
#identity-welcome-modal .secondary:hover {
background: #30363d;
}
</style>
<div class="modal-content">
<h2>🎉 Welcome to Pear Git!</h2>
<p>
Your identity has been created. You now have <strong>read-only</strong> access to all repositories.
</p>
<p>
<strong>Your Public Key:</strong>
</p>
<div class="keypair-box">\${data.publicKey}</div>
<div class="warning">
<strong>⚠️ Important:</strong> Save your keypair to restore your identity later.
If you lose this, you'll need to create a new identity.
</div>
<details>
<summary style="cursor: pointer; padding: 10px 0;">Show Full Keypair (132 characters)</summary>
<div class="keypair-box">\${data.keypair}</div>
<button onclick="copyKeypair('\${data.keypair}')" class="secondary">
📋 Copy Keypair
</button>
</details>
<div style="margin-top: 20px; padding: 15px; background: #0d1117; border: 1px solid #30363d; border-radius: 4px;">
<p style="font-size: 14px; color: #8b949e; margin-bottom: 12px;">
To get <strong style="color: #c9d1d9;">admin access</strong>, run this on the server:
</p>
<div style="background: #161b22; padding: 10px; border-radius: 4px; font-family: monospace; font-size: 12px; word-break: break-all; color: #79c0ff;">
pgh admin promote \${data.publicKey}
</div>
</div>
<div class="buttons">
<button onclick="window.location.href=window.getBasePath()+'/profile'">
View Profile
</button>
<button class="secondary" onclick="document.getElementById('identity-welcome-modal').remove(); showImportDialog()">
Import Existing Identity
</button>
<button class="secondary" onclick="document.getElementById('identity-welcome-modal').remove()">
Got it!
</button>
</div>
</div>
\`;
document.body.appendChild(modal);
}
/**
* Copy keypair to clipboard
*/
window.copyKeypair = function(keypair) {
if (navigator.clipboard) {
navigator.clipboard.writeText(keypair).then(() => {
alert('Keypair copied to clipboard!');
}).catch(err => {
console.error('Failed to copy:', err);
prompt('Copy this keypair manually:', keypair);
});
} else {
prompt('Copy this keypair manually:', keypair);
}
};
/**
* Import keypair - allows restoring identity on a different device
*/
window.importKeypair = function(keypairInput) {
if (!keypairInput) {
keypairInput = prompt('Paste your 132-character ANProto keypair:');
}
if (!keypairInput) return false;
keypairInput = keypairInput.trim();
if (keypairInput.length !== 132) {
alert('Invalid keypair. Must be exactly 132 characters (44 public + 88 secret).');
return false;
}
// Validate it looks like base64
if (!/^[A-Za-z0-9+/=]+$/.test(keypairInput)) {
alert('Invalid keypair format. Must be base64 encoded.');
return false;
}
// Store in localStorage
localStorage.setItem(STORAGE_KEY, keypairInput);
// Update current session
publicKey = keypairInput.substring(0, 44);
secretKey = keypairInput.substring(44, 132);
console.log('[Identity] ✓ Keypair imported:', publicKey.substring(0, 8) + '...');
alert('Keypair imported! Refreshing page...');
// Reload to apply the new identity
window.location.reload();
return true;
};
/**
* Show import dialog (can be called from console or UI)
*/
window.showImportDialog = function() {
const existing = localStorage.getItem(STORAGE_KEY);
const existingPubKey = existing ? existing.substring(0, 16) + '...' : 'none';
const modal = document.createElement('div');
modal.id = 'identity-import-modal';
modal.innerHTML = \`
<style>
#identity-import-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
#identity-import-modal .modal-content {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 30px;
max-width: 500px;
width: 90%;
color: #c9d1d9;
}
#identity-import-modal h2 {
margin: 0 0 15px 0;
color: #58a6ff;
}
#identity-import-modal textarea {
width: 100%;
height: 80px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #c9d1d9;
padding: 12px;
font-family: monospace;
font-size: 12px;
margin: 15px 0;
}
#identity-import-modal .buttons {
display: flex;
gap: 10px;
margin-top: 15px;
}
#identity-import-modal button {
flex: 1;
padding: 12px;
border-radius: 6px;
border: none;
cursor: pointer;
font-size: 14px;
}
#identity-import-modal .primary {
background: #238636;
color: white;
}
#identity-import-modal .secondary {
background: #21262d;
color: #c9d1d9;
border: 1px solid #30363d;
}
#identity-import-modal .warning {
background: #f8514933;
border: 1px solid #f85149;
padding: 10px;
border-radius: 6px;
font-size: 13px;
margin-top: 10px;
}
#identity-import-modal .current {
font-size: 12px;
color: #8b949e;
margin-bottom: 10px;
}
</style>
<div class="modal-content">
<h2>🔑 Import Keypair</h2>
<p>Paste your 132-character ANProto keypair to restore your identity on this device.</p>
<div class="current">Current identity: \${existingPubKey}</div>
<textarea id="import-keypair-input" placeholder="Paste your 132-character keypair here..."></textarea>
<div class="warning">
<strong>⚠️ Warning:</strong> This will replace your current identity on this device.
</div>
<div class="buttons">
<button class="secondary" onclick="document.getElementById('identity-import-modal').remove()">Cancel</button>
<button class="primary" onclick="doImport()">Import</button>
</div>
</div>
\`;
window.doImport = function() {
const input = document.getElementById('import-keypair-input').value;
if (window.importKeypair(input)) {
document.getElementById('identity-import-modal').remove();
}
};
document.body.appendChild(modal);
document.getElementById('import-keypair-input').focus();
};
/**
* Expose signing function for other scripts (e.g., merge)
*/
window.signChallenge = async function(challenge, secretKeyB64) {
// Wait for ed25519 to load
let attempts = 0;
while (!ed25519 && attempts < 50) {
await new Promise(r => setTimeout(r, 100));
attempts++;
}
if (!ed25519) {
throw new Error('ed25519 signing library not loaded');
}
const challengeBytes = new TextEncoder().encode(challenge);
const secretKeyBytes = base64ToBytes(secretKeyB64);
if (secretKeyBytes.length !== 64) {
throw new Error('Invalid secret key length: ' + secretKeyBytes.length);
}
const privateKey = secretKeyBytes.slice(0, 32);
const signature = await ed25519.signAsync(challengeBytes, privateKey);
return bytesToBase64(signature);
};
// ========================================
// Attestation Storage & Recovery
// ========================================
const ATTESTATION_KEY = 'pear-git-attestation';
/**
* Fetch and store a permission attestation from the server.
* Called after successful login/promote to cache the signed proof.
*/
window.fetchAttestation = async function() {
try {
const response = await fetch(basePath + '/api/user/attestation');
if (!response.ok) {
console.warn('[Identity] Could not fetch attestation:', response.status);
return null;
}
const data = await response.json();
if (data.attestation) {
localStorage.setItem(ATTESTATION_KEY, JSON.stringify(data.attestation));
console.log('[Identity] Attestation saved to localStorage');
return data.attestation;
}
} catch (err) {
console.warn('[Identity] Attestation fetch failed:', err.message);
}
return null;
};
/**
* Attempt to restore permissions from a stored attestation.
* Used when server returns 401/403 but we have a valid attestation.
*/
window.restoreFromAttestation = async function() {
const stored = localStorage.getItem(ATTESTATION_KEY);
if (!stored) {
console.log('[Identity] No stored attestation for restore');
return false;
}
const keypairStr = localStorage.getItem(STORAGE_KEY);
if (!keypairStr || !ed25519) {
console.warn('[Identity] Cannot restore: missing keypair or ed25519');
return false;
}
try {
const attestation = JSON.parse(stored);
const pubKey = keypairStr.substring(0, 44);
// Get a fresh challenge for ownership proof
const challengeResp = await originalFetch.call(window, basePath + '/api/auth/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context: 'attestation-restore' })
});
if (!challengeResp.ok) throw new Error('Failed to get challenge');
const challengeData = await challengeResp.json();
// Sign the challenge
const sig = await signChallenge(challengeData.challenge, keypairStr.substring(44, 132));
// Submit restore request
const restoreResp = await originalFetch.call(window, basePath + '/api/auth/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
attestation,
challengeId: challengeData.challengeId,
publicKey: pubKey,
signature: sig
})
});
if (!restoreResp.ok) {
const err = await restoreResp.json();
throw new Error(err.error || 'Restore failed');
}
const result = await restoreResp.json();
console.log('[Identity] Permissions restored!', result.permissions);
// Store fresh attestation
if (result.attestation) {
localStorage.setItem(ATTESTATION_KEY, JSON.stringify(result.attestation));
}
return true;
} catch (err) {
console.error('[Identity] Restore from attestation failed:', err.message);
return false;
}
};
/**
* Export identity (keypair + attestation) as a downloadable JSON backup.
*/
window.exportIdentity = function() {
const keypair = localStorage.getItem(STORAGE_KEY);
const attestation = localStorage.getItem(ATTESTATION_KEY);
if (!keypair) {
alert('No identity to export');
return;
}
const backup = {
version: 1,
exportedAt: new Date().toISOString(),
keypair,
publicKey: keypair.substring(0, 44),
attestation: attestation ? JSON.parse(attestation) : null
};
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'pear-git-identity-' + keypair.substring(0, 8) + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log('[Identity] Identity exported');
};
/**
* Import identity from a backup file.
*/
window.importIdentityFile = function(fileInput) {
const file = fileInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try {
const backup = JSON.parse(e.target.result);
if (!backup.keypair || backup.keypair.length !== 132) {
alert('Invalid backup file: missing or invalid keypair');
return;
}
localStorage.setItem(STORAGE_KEY, backup.keypair);
if (backup.attestation) {
localStorage.setItem(ATTESTATION_KEY, JSON.stringify(backup.attestation));
}
alert('Identity imported! Refreshing...');
window.location.reload();
} catch (err) {
alert('Failed to import: ' + err.message);
}
};
reader.readAsText(file);
};
/**
* Sign out — clear identity and reload
*/
window.signOut = function() {
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ATTESTATION_KEY);
publicKey = null;
secretKey = null;
window.location.reload();
};
/**
* Show the Sign In / Create Identity modal
*/
window.showSignInModal = function() {
const modal = document.createElement('div');
modal.id = 'identity-signin-modal';
modal.innerHTML = \`
<style>
#identity-signin-modal {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex; align-items: center; justify-content: center;
z-index: 10000;
}
#identity-signin-modal .modal-content {
background: #161b22; border: 1px solid #30363d; border-radius: 6px;
padding: 30px; max-width: 480px; width: 90%; color: #c9d1d9;
}
#identity-signin-modal h2 { margin: 0 0 20px 0; color: #58a6ff; }
#identity-signin-modal .tabs { display: flex; gap: 0; margin-bottom: 20px; }
#identity-signin-modal .tab {
flex: 1; padding: 10px; text-align: center; cursor: pointer;
border-bottom: 2px solid #30363d; color: #8b949e; font-size: 14px;
}
#identity-signin-modal .tab.active { border-bottom-color: #58a6ff; color: #c9d1d9; }
#identity-signin-modal .tab-content { display: none; }
#identity-signin-modal .tab-content.active { display: block; }
#identity-signin-modal label { display: block; font-size: 13px; color: #8b949e; margin-bottom: 6px; margin-top: 15px; }
#identity-signin-modal input, #identity-signin-modal textarea {
width: 100%; background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
color: #c9d1d9; padding: 10px; font-size: 14px; box-sizing: border-box;
}
#identity-signin-modal textarea { height: 80px; font-family: monospace; font-size: 12px; margin-top: 8px; }
#identity-signin-modal .username-status { font-size: 12px; margin-top: 4px; min-height: 18px; }
#identity-signin-modal .available { color: #3fb950; }
#identity-signin-modal .taken { color: #f85149; }
#identity-signin-modal .buttons { display: flex; gap: 10px; margin-top: 20px; }
#identity-signin-modal button {
flex: 1; padding: 12px; border-radius: 6px; border: none; cursor: pointer; font-size: 14px;
}
#identity-signin-modal .primary { background: #238636; color: white; }
#identity-signin-modal .primary:disabled { background: #21262d; color: #484f58; cursor: not-allowed; }
#identity-signin-modal .secondary { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; }
#identity-signin-modal .backup-info {
background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
padding: 15px; margin-top: 15px; font-family: monospace; font-size: 11px; word-break: break-all;
}
#identity-signin-modal .success-msg { color: #3fb950; font-weight: 500; }
</style>
<div class="modal-content">
<h2>Sign In</h2>
<div class="tabs">
<div class="tab active" data-tab="create" onclick="switchSignInTab('create')">Create Identity</div>
<div class="tab" data-tab="import" onclick="switchSignInTab('import')">Import Keypair</div>
</div>
<div id="tab-create" class="tab-content active">
<label for="signin-username">Username</label>
<input type="text" id="signin-username" placeholder="pick-a-username" maxlength="30"
oninput="checkUsername(this.value)" autocomplete="off" />
<div id="username-status" class="username-status"></div>
<label for="signin-displayname">Display Name (optional)</label>
<input type="text" id="signin-displayname" placeholder="Your Name" maxlength="60" />
<div id="create-result" style="display:none;">
<p class="success-msg">Identity created!</p>
<label>Your keypair (save this!):</label>
<div class="backup-info" id="create-keypair"></div>
<button class="secondary" style="margin-top:10px; flex:none; width:auto; padding:8px 16px;"
onclick="copyKeypair(document.getElementById('create-keypair').textContent)">Copy Keypair</button>
</div>
<div class="buttons" id="create-buttons">
<button class="secondary" onclick="document.getElementById('identity-signin-modal').remove()">Cancel</button>
<button class="primary" id="create-btn" onclick="doCreateIdentity()" disabled>Create</button>
</div>
</div>
<div id="tab-import" class="tab-content">
<label>Paste your 132-character keypair</label>
<textarea id="import-keypair-field" placeholder="Paste keypair here..."></textarea>
<div class="buttons">
<button class="secondary" onclick="document.getElementById('identity-signin-modal').remove()">Cancel</button>
<button class="primary" onclick="doImportFromModal()">Import</button>
</div>
</div>
</div>
\`;
document.body.appendChild(modal);
document.getElementById('signin-username').focus();
};
let _usernameCheckTimer = null;
let _usernameAvailable = false;
window.switchSignInTab = function(tab) {
document.querySelectorAll('#identity-signin-modal .tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('#identity-signin-modal .tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + tab));
};
window.checkUsername = function(value) {
const status = document.getElementById('username-status');
const btn = document.getElementById('create-btn');
_usernameAvailable = false;
if (btn) btn.disabled = true;
if (_usernameCheckTimer) clearTimeout(_usernameCheckTimer);
const normalized = value.toLowerCase().trim();
if (normalized.length < 3) {
status.textContent = normalized.length > 0 ? 'Must be at least 3 characters' : '';
status.className = 'username-status taken';
return;
}
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(normalized) && normalized.length >= 3) {
status.textContent = 'Letters, numbers, and hyphens only';
status.className = 'username-status taken';
return;
}
status.textContent = 'Checking...';
status.className = 'username-status';
_usernameCheckTimer = setTimeout(async () => {
try {
const resp = await originalFetch.call(window, basePath + '/api/identity/username-available?username=' + encodeURIComponent(normalized));
const data = await resp.json();
if (data.available) {
status.textContent = '\\u2713 Available';
status.className = 'username-status available';
_usernameAvailable = true;
if (btn) btn.disabled = false;
} else {
status.textContent = 'Username taken';
status.className = 'username-status taken';
}
} catch {
status.textContent = 'Check failed';
status.className = 'username-status taken';
}
}, 400);
};
window.doCreateIdentity = async function() {
if (!_usernameAvailable) return;
const username = document.getElementById('signin-username').value.toLowerCase().trim();
const displayName = document.getElementById('signin-displayname').value.trim() || username;
const btn = document.getElementById('create-btn');
btn.disabled = true;
btn.textContent = 'Creating...';
try {
const resp = await originalFetch.call(window, basePath + '/api/identity/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, displayName })
});
const data = await resp.json();
if (!resp.ok) {
alert(data.error || 'Failed to create identity');
btn.disabled = false;
btn.textContent = 'Create';
return;
}
// Store keypair
localStorage.setItem(STORAGE_KEY, data.keypair);
setupHeaderInjection(data.keypair);
// Show success with backup info
document.getElementById('create-result').style.display = 'block';
document.getElementById('create-keypair').textContent = data.keypair;
document.getElementById('create-buttons').innerHTML =
'<button class="primary" onclick="document.getElementById(\\'identity-signin-modal\\').remove(); window.location.reload();">Done</button>';
// Fetch attestation
setTimeout(() => window.fetchAttestation(), 1000);
} catch (err) {
alert('Error: ' + err.message);
btn.disabled = false;
btn.textContent = 'Create';
}
};
window.doImportFromModal = function() {
const input = document.getElementById('import-keypair-field').value;
if (window.importKeypair(input)) {
const modal = document.getElementById('identity-signin-modal');
if (modal) modal.remove();
}
};
/**
* Render the identity badge / sign-in button in the page
*/
function renderIdentityUI() {
const container = document.createElement('div');
container.id = 'identity-badge';
container.style.cssText = 'position:fixed; top:0; right:12px; height:56px; display:flex; align-items:center; z-index:9999; font-family:system-ui; font-size:13px;';
if (publicKey) {
// Logged in — show user badge
const abbrev = publicKey.substring(0, 8);
container.innerHTML =
'<div style="display:flex; align-items:center; gap:8px; background:#161b22; border:1px solid #30363d; border-radius:6px; padding:6px 12px;">' +
'<span style="color:#3fb950; font-size:10px;">\\u25CF</span>' +
'<a href="' + basePath + '/profile" style="color:#c9d1d9; text-decoration:none;">' + abbrev + '...</a>' +
'<button onclick="signOut()" style="background:none; border:none; color:#8b949e; cursor:pointer; font-size:11px; padding:2px 4px;" title="Sign out">\\u2715</button>' +
'</div>';
} else {
// Anonymous — show sign-in button
container.innerHTML =
'<button onclick="showSignInModal()" style="background:#238636; color:white; border:none; padding:6px 14px; border-radius:6px; cursor:pointer; font-size:13px; font-weight:500;">Sign In</button>';
}
document.body.appendChild(container);
}
/**
* Initialize identity system — anonymous by default
*/
window.identityReady = (async function() {
try {
const existing = localStorage.getItem(STORAGE_KEY);
if (existing) {
// Existing user — restore session seamlessly
setupHeaderInjection(existing);
// Wait for ed25519 before fetching attestation
ed25519Ready.then(loaded => {
if (loaded) {
setTimeout(() => window.fetchAttestation(), 500);
}
});
console.log('[Identity] Restored existing identity');
} else {
console.log('[Identity] Anonymous browsing (no identity)');
}
// Render sign-in button or user badge
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', renderIdentityUI);
} else {
renderIdentityUI();
}
return !!existing;
} catch (err) {
console.error('[Identity] Init error:', err);
return false;
}
})();
console.log('[Identity] System initialized');
})();
</script>
`;
}
/**
* Render identity management UI (for settings page)
*/
export function renderIdentityManager() {
return `
<div class="identity-manager">
<style>
.identity-manager {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 20px;
margin: 20px 0;
}
.identity-manager h3 {
margin: 0 0 15px 0;
color: #58a6ff;
}
.identity-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.identity-item {
background: #0d1117;
padding: 15px;
border-radius: 4px;
border: 1px solid #30363d;
}
.identity-method {
font-weight: bold;
color: #58a6ff;
margin-bottom: 5px;
}
.identity-id {
font-family: monospace;
font-size: 11px;
color: #8b949e;
}
.add-identity-btn {
background: #238636;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
margin-top: 15px;
}
.add-identity-btn:hover {
background: #2ea043;
}
</style>
<h3>Your Identities</h3>
<div id="identity-list" class="identity-list">
<div style="color: #8b949e;">Loading...</div>
</div>
<button class="add-identity-btn" onclick="linkSSHKey()">
🔑 Link SSH Key
</button>
</div>
<script>
function getBasePath() {
return window.__PATH_PREFIX__ || '';
}
async function loadIdentities() {
try {
const basePath = getBasePath();
const response = await fetch(basePath + '/api/user/identities');
if (!response.ok) {
document.getElementById('identity-list').innerHTML =
'<div style="color: #f85149;">Not authenticated</div>';
return;
}
const data = await response.json();
const list = document.getElementById('identity-list');
if (data.identities.length === 0) {
list.innerHTML = '<div style="color: #8b949e;">No identities linked</div>';
return;
}
list.innerHTML = data.identities.map(id => \`
<div class="identity-item">
<div class="identity-method">\${getMethodName(id.method)}</div>
<div class="identity-id">\${id.identityId}</div>
<div style="font-size: 12px; color: #8b949e; margin-top: 5px;">
Created: \${new Date(id.createdAt).toLocaleString()}
</div>
</div>
\`).join('');
} catch (err) {
console.error('Failed to load identities:', err);
document.getElementById('identity-list').innerHTML =
'<div style="color: #f85149;">Failed to load identities</div>';
}
}
function getMethodName(method) {
const names = {
'web-identity': '🌐 Browser Identity',
'bearer-token': '🔑 API Token',
'ssh-key': '🔐 SSH Key',
'keet-identity': '💳 Keet Identity'
};
return names[method] || method;
}
async function linkSSHKey() {
const sshKey = prompt('Paste your SSH public key (ssh-rsa ...):');
if (!sshKey) return;
try {
const basePath = getBasePath();
const response = await fetch(basePath + '/api/user/link-ssh-key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sshPublicKey: sshKey })
});
if (!response.ok) {
const error = await response.json();
alert('Failed to link SSH key: ' + error.error);
return;
}
alert('✓ SSH key linked successfully!');
loadIdentities();
} catch (err) {
console.error('Failed to link SSH key:', err);
alert('Failed to link SSH key: ' + err.message);
}
}
// Load on page load
loadIdentities();
</script>
`;
}