/**
* User Profile UI
*
* Shows user identity, permissions, and settings
*/
import { renderPage } from './page-layout.js'
const PAGE_CSS = `
/* User Status Card */
.user-status {
background: #161b22;
border: 2px solid #238636;
border-radius: 6px;
padding: 20px;
margin-bottom: 30px;
}
.user-status.read-only { border-color: #58a6ff; }
.user-status-header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 15px;
}
.status-badge {
padding: 6px 12px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
}
.status-badge.admin {
background: #238636;
color: white;
}
.status-badge.write {
background: #58a6ff;
color: white;
}
.status-badge.read {
background: #6e7681;
color: white;
}
.user-id {
font-family: monospace;
color: #8b949e;
font-size: 12px;
}
/* Sections */
.section {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 20px;
margin-bottom: 20px;
}
.section h2 {
margin-bottom: 15px;
color: #58a6ff;
font-size: 20px;
}
.section h3 {
margin: 20px 0 10px 0;
color: #c9d1d9;
font-size: 16px;
}
/* Identity List */
.identity-list { display: flex; flex-direction: column; gap: 10px; }
.identity-item {
background: #0d1117;
padding: 15px;
border-radius: 4px;
border: 1px solid #30363d;
display: flex;
justify-content: space-between;
align-items: center;
}
.identity-info { flex: 1; }
.identity-type {
font-weight: bold;
color: #58a6ff;
margin-bottom: 5px;
}
.identity-id {
font-family: monospace;
font-size: 11px;
color: #8b949e;
word-break: break-all;
}
.identity-date {
font-size: 12px;
color: #6e7681;
margin-top: 5px;
}
.identity-actions {
display: flex;
gap: 10px;
}
/* Permissions Table */
.permissions-table {
width: 100%;
border-collapse: collapse;
}
.permissions-table th,
.permissions-table td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #30363d;
}
.permissions-table th {
color: #8b949e;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
}
.perm-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
margin-right: 5px;
}
.perm-badge.read { background: #1f6feb; color: white; }
.perm-badge.write { background: #238636; color: white; }
.perm-badge.admin { background: #da3633; color: white; }
/* Buttons */
button {
background: #238636;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
button:hover { background: #2ea043; }
button.secondary {
background: #21262d;
border: 1px solid #30363d;
}
button.secondary:hover { background: #30363d; }
button.danger { background: #da3633; }
button.danger:hover { background: #f85149; }
/* Loading */
.loading {
text-align: center;
padding: 40px;
color: #8b949e;
}
/* Help Text */
.help-text {
background: #161b22;
border-left: 3px solid #58a6ff;
padding: 15px;
margin: 20px 0;
color: #8b949e;
font-size: 14px;
line-height: 1.6;
}
/* Profile Edit */
.profile-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #30363d;
}
.profile-edit-form {
display: none;
margin-top: 20px;
padding: 20px;
background: #0d1117;
border-radius: 6px;
border: 1px solid #30363d;
}
.profile-edit-form.visible { display: block; }
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: #8b949e;
font-size: 14px;
}
.form-group input[type="text"] {
width: 100%;
padding: 8px 12px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #c9d1d9;
font-size: 14px;
}
.form-group input[type="text"]:focus {
outline: none;
border-color: #58a6ff;
}
.avatar-preview {
margin-top: 10px;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
`
export function renderUserProfile(pathPrefix = '') {
const content = `
<div id="user-status" class="user-status">
<div class="loading">Loading your profile...</div>
</div>
<div id="identities-section" class="section" style="display: none;">
<h2>🔑 Your Identities</h2>
<p style="color: #8b949e; margin-bottom: 15px;">
You can link multiple authentication methods to your account. All linked identities share the same permissions.
</p>
<div id="identities-list" class="identity-list"></div>
<h3>Add New Identity</h3>
<div style="display: flex; gap: 10px; margin-top: 10px;">
<button onclick="linkSSHKey()">🔐 Link SSH Key</button>
<button class="secondary" onclick="linkKeetIdentity()">💳 Link Keet Identity</button>
</div>
<h3>Merge Another Account</h3>
<p style="color: #8b949e; margin: 10px 0; font-size: 14px;">
Have multiple pear-git accounts? Merge them into one. All issues, comments, and permissions will be transferred.
</p>
<div id="merge-section">
<button onclick="showMergeForm()">🔗 Merge Another Account</button>
</div>
<div id="merge-form" style="display: none; margin-top: 15px; padding: 20px; background: #0d1117; border-radius: 6px; border: 1px solid #30363d;">
<div class="form-group">
<label for="merge-target-pubkey">Public Key of Account to Merge</label>
<input type="text" id="merge-target-pubkey" placeholder="Paste the 44-character public key of the other account">
<div style="color: #8b949e; font-size: 12px; margin-top: 5px;">
Find this in the other account's Profile → Copy ID button on any web-identity
</div>
</div>
<div style="background: #f8514933; border: 1px solid #f85149; border-radius: 6px; padding: 12px; margin: 15px 0; font-size: 13px;">
<strong style="color: #f85149;">⚠️ Warning:</strong> This will merge the other account INTO this one. The other account's identity will be linked here, and all their data transferred. This cannot be undone.
</div>
<div style="display: flex; gap: 10px;">
<button onclick="initiateMerge()">Create Merge Request</button>
<button class="secondary" onclick="hideMergeForm()">Cancel</button>
</div>
<div id="merge-result" style="display: none; margin-top: 15px; padding: 15px; border-radius: 6px;"></div>
</div>
<div id="pending-merges" style="display: none; margin-top: 20px;">
<h4 style="color: #8b949e; margin-bottom: 10px;">Pending Merge Requests</h4>
<div id="pending-merges-list"></div>
</div>
</div>
<div id="permissions-section" class="section" style="display: none;">
<h2>🔒 Your Permissions</h2>
<table class="permissions-table">
<thead>
<tr>
<th>Repository</th>
<th>Permissions</th>
</tr>
</thead>
<tbody id="permissions-list"></tbody>
</table>
<div class="help-text">
<strong>Need more access?</strong><br>
Contact an administrator to upgrade your permissions. Provide them with your User ID shown above.
</div>
</div>
<div id="backup-section" class="section" style="display: none;">
<h2>💾 Backup Your Identity</h2>
<p style="color: #8b949e; margin-bottom: 15px;">
Your web identity is stored in your browser. Save it to restore access from another device.
</p>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button onclick="downloadKeypair()">📥 Download Keypair</button>
<button class="secondary" onclick="showKeypair()">👁️ Show Keypair</button>
<button class="secondary" onclick="copyFullKeypair()">📋 Copy Full Keypair (132 chars)</button>
<button class="secondary" onclick="showImportDialog()">📤 Import Keypair</button>
</div>
<div id="keypair-display" style="display: none; margin-top: 15px;">
<div style="background: #0d1117; padding: 15px; border-radius: 4px; border: 1px solid #30363d;">
<div style="margin-bottom: 10px;">
<strong style="color: #58a6ff;">Public Key (44 chars):</strong>
<div id="pubkey-text" style="font-family: monospace; font-size: 12px; word-break: break-all; margin-top: 5px; padding: 8px; background: #161b22; border-radius: 4px;"></div>
</div>
<div style="margin-bottom: 10px;">
<strong style="color: #f0883e;">Full Keypair (132 chars) - KEEP SECRET:</strong>
<div id="keypair-text" style="font-family: monospace; font-size: 11px; word-break: break-all; margin-top: 5px; padding: 8px; background: #161b22; border-radius: 4px;"></div>
</div>
<div style="display: flex; gap: 10px; margin-top: 10px;">
<button class="secondary" onclick="copyPubkey()">📋 Copy Public Key</button>
<button onclick="copyFullKeypair()">📋 Copy Full Keypair</button>
</div>
</div>
</div>
<div class="help-text" style="margin-top: 15px; padding: 12px; background: #f8514922; border: 1px solid #f85149; border-radius: 6px;">
<strong style="color: #f85149;">⚠️ Security Warning:</strong> Your full keypair (132 chars) is your private key. Never share it publicly. Only share your public key (44 chars) when needed for account merging.
</div>
</div>
`
const scripts = `
const STORAGE_KEY = 'pear-git-keypair';
let currentUser = null;
// Use server-injected path prefix from identity script
function getBasePath() {
return window.__PATH_PREFIX__ || '';
}
async function loadUserProfile() {
// Wait for identity to be ready (header injection set up)
if (window.identityReady) {
await window.identityReady;
}
try {
const response = await window.fetch(getBasePath() + '/api/user/identities');
if (!response.ok) {
throw new Error('Not authenticated');
}
const data = await response.json();
currentUser = data;
renderUserStatus(data);
renderIdentities(data.identities);
renderPermissions(data.permissions);
document.getElementById('identities-section').style.display = 'block';
document.getElementById('permissions-section').style.display = 'block';
document.getElementById('backup-section').style.display = 'block';
} catch (err) {
console.error('Failed to load profile:', err);
document.getElementById('user-status').innerHTML = \`
<div style="text-align: center; padding: 40px;">
<div style="font-size: 48px; margin-bottom: 20px;">⚠️</div>
<h2 style="color: #f85149; margin-bottom: 10px;">Not Authenticated</h2>
<p style="color: #8b949e;">Please visit the home page to generate an identity.</p>
<a href="./" style="display: inline-block; margin-top: 20px; padding: 10px 20px; background: #238636; color: white; text-decoration: none; border-radius: 6px;">Go to Home</a>
</div>
\`;
}
}
function renderUserStatus(data) {
const hasWrite = data.permissions.global.includes('write') ||
data.permissions.repos.some(r => r.permissions.includes('write'));
const hasAdmin = data.permissions.global.includes('admin') ||
data.permissions.repos.some(r => r.permissions.includes('admin'));
let statusClass = 'read-only';
let badge = '<span class="status-badge read">Read Only</span>';
if (hasAdmin) {
statusClass = 'admin';
badge = '<span class="status-badge admin">Admin</span>';
} else if (hasWrite) {
statusClass = 'write';
badge = '<span class="status-badge write">Write Access</span>';
}
const avatarHtml = data.profilePicture
? \`<img src="\${data.profilePicture}" class="profile-avatar" alt="Profile">\`
: '<div style="font-size: 48px;">👤</div>';
document.getElementById('user-status').className = \`user-status \${statusClass}\`;
document.getElementById('user-status').innerHTML = \`
<div class="user-status-header">
\${avatarHtml}
<div style="flex: 1;">
<h2 style="margin-bottom: 5px;" id="display-name">\${data.name}</h2>
<div class="user-id">User ID: \${data.userId}</div>
</div>
\${badge}
</div>
<div style="color: #8b949e; font-size: 14px; margin-bottom: 10px;">
Registered: \${new Date(data.identities[0]?.createdAt || Date.now()).toLocaleDateString()}
</div>
<button class="secondary" onclick="toggleEditProfile()">✏️ Edit Profile</button>
<div id="profile-edit-form" class="profile-edit-form">
<div class="form-group">
<label for="edit-name">Display Name</label>
<input type="text" id="edit-name" value="\${data.name}" placeholder="Your name">
</div>
<div class="form-group">
<label for="edit-picture">Profile Picture</label>
<input type="file" id="edit-picture" accept="image/*" onchange="previewAvatar(event)">
<div id="avatar-preview" class="avatar-preview"></div>
<div style="color: #8b949e; font-size: 12px; margin-top: 5px;">
Upload an image or leave empty to use default avatar
</div>
</div>
<div class="form-actions">
<button onclick="saveProfile()">💾 Save Changes</button>
<button class="secondary" onclick="toggleEditProfile()">Cancel</button>
</div>
</div>
\`;
}
function renderIdentities(identities) {
const icons = {
'web-identity': '🌐',
'ssh-key': '🔐',
'keet-identity': '💳',
'bearer-token': '🔑'
};
const names = {
'web-identity': 'Browser Identity',
'ssh-key': 'SSH Key',
'keet-identity': 'Keet Identity',
'bearer-token': 'API Token'
};
document.getElementById('identities-list').innerHTML = identities.map(id => \`
<div class="identity-item">
<div class="identity-info">
<div class="identity-type">\${icons[id.method] || '🔑'} \${names[id.method] || id.method}</div>
<div class="identity-id">\${id.identityId}</div>
<div class="identity-date">Added: \${new Date(id.createdAt).toLocaleString()}</div>
</div>
<div class="identity-actions">
<button class="secondary" onclick="copyIdentity('\${id.identityId}')">📋 Copy ID</button>
</div>
</div>
\`).join('');
}
function renderPermissions(permissions) {
const allPerms = [...permissions.repos];
if (permissions.global.length > 0) {
allPerms.unshift({ name: '* (All Repos)', permissions: permissions.global });
}
document.getElementById('permissions-list').innerHTML = allPerms.map(repo => \`
<tr>
<td>\${repo.name}</td>
<td>
\${repo.permissions.map(p => \`
<span class="perm-badge \${p}">\${p}</span>
\`).join('')}
</td>
</tr>
\`).join('');
}
function toggleEditProfile() {
const form = document.getElementById('profile-edit-form');
form.classList.toggle('visible');
}
let profilePictureData = null;
function previewAvatar(event) {
const file = event.target.files[0];
if (!file) return;
// Check file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
alert('Image too large. Please choose an image under 2MB.');
event.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = function(e) {
profilePictureData = e.target.result;
document.getElementById('avatar-preview').innerHTML = \`
<img src="\${profilePictureData}" class="profile-avatar" alt="Preview" style="margin-top: 10px;">
\`;
};
reader.readAsDataURL(file);
}
async function saveProfile() {
const name = document.getElementById('edit-name').value.trim();
if (!name) {
alert('Please enter a display name');
return;
}
try {
const updates = { name };
// Only include picture if one was selected
if (profilePictureData) {
updates.profilePicture = profilePictureData;
}
const response = await window.fetch(getBasePath() + '/api/user/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to update profile');
}
const result = await response.json();
// Update displayed name and avatar
document.getElementById('display-name').textContent = result.user.name;
if (result.user.profilePicture) {
currentUser.profilePicture = result.user.profilePicture;
}
currentUser.name = result.user.name;
// Close form and reset
toggleEditProfile();
profilePictureData = null;
document.getElementById('avatar-preview').innerHTML = '';
alert('✓ Profile updated successfully!');
// Reload to show updated avatar
window.location.reload();
} catch (err) {
console.error('Profile update error:', err);
alert('Failed to update profile: ' + err.message);
}
}
function copyIdentity(identityId) {
navigator.clipboard.writeText(identityId).then(() => {
alert('Identity ID copied to clipboard!');
});
}
async function linkSSHKey() {
const sshKey = prompt('Paste your SSH public key (ssh-rsa ... or ssh-ed25519 ...):');
if (!sshKey) return;
try {
const response = await window.fetch(getBasePath() + '/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!');
loadUserProfile();
} catch (err) {
alert('Failed to link SSH key: ' + err.message);
}
}
function linkKeetIdentity() {
alert('Keet identity linking requires signature verification. This feature will be available in a future update.');
}
// ============================================
// Account Merge Functions
// ============================================
function showMergeForm() {
document.getElementById('merge-form').style.display = 'block';
document.getElementById('merge-target-pubkey').focus();
}
function hideMergeForm() {
document.getElementById('merge-form').style.display = 'none';
document.getElementById('merge-target-pubkey').value = '';
document.getElementById('merge-result').style.display = 'none';
}
async function initiateMerge() {
const targetPubkey = document.getElementById('merge-target-pubkey').value.trim();
const resultDiv = document.getElementById('merge-result');
if (!targetPubkey) {
resultDiv.style.display = 'block';
resultDiv.style.background = '#f8514933';
resultDiv.style.border = '1px solid #f85149';
resultDiv.textContent = 'Please enter the public key of the account to merge.';
return;
}
if (targetPubkey.length !== 44) {
resultDiv.style.display = 'block';
resultDiv.style.background = '#f8514933';
resultDiv.style.border = '1px solid #f85149';
resultDiv.textContent = 'Invalid public key. Must be exactly 44 characters (base64 encoded).';
return;
}
// Get our keypair for auto-signing
const keypair = localStorage.getItem(STORAGE_KEY);
if (!keypair || keypair.length !== 132) {
resultDiv.style.display = 'block';
resultDiv.style.background = '#f8514933';
resultDiv.style.border = '1px solid #f85149';
resultDiv.textContent = 'No valid keypair in browser storage. Cannot initiate merge.';
return;
}
resultDiv.style.display = 'block';
resultDiv.style.background = '#21262d';
resultDiv.style.border = '1px solid #30363d';
resultDiv.textContent = 'Creating merge request and signing...';
try {
// Step 1: Create merge request to get challenge
const initResponse = await window.fetch(getBasePath() + '/api/user/merge/initiate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetPublicKey: targetPubkey })
});
const initData = await initResponse.json();
if (!initResponse.ok) {
resultDiv.style.background = '#f8514933';
resultDiv.style.border = '1px solid #f85149';
resultDiv.textContent = initData.error || 'Failed to create merge request';
return;
}
// Step 2: Sign the challenge with our keypair
const publicKey = keypair.substring(0, 44);
const secretKeyB64 = keypair.substring(44);
const challenge = initData.challenge;
// Wait for signing library
let attempts = 0;
while (!window.signChallenge && attempts < 50) {
await new Promise(r => setTimeout(r, 100));
attempts++;
}
if (!window.signChallenge) {
throw new Error('Signing library not loaded. Please refresh and try again.');
}
const signature = await window.signChallenge(challenge, secretKeyB64);
// Step 3: Submit our signature
const confirmResponse = await window.fetch(getBasePath() + '/api/user/merge/' + initData.mergeId + '/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ signature, publicKey })
});
const confirmData = await confirmResponse.json();
if (!confirmResponse.ok) {
resultDiv.style.background = '#f8514933';
resultDiv.style.border = '1px solid #f85149';
resultDiv.textContent = confirmData.error || 'Failed to sign merge request';
return;
}
// Success - show merge link
const mergeUrl = window.location.origin + getBasePath() + '/merge/' + initData.mergeId;
resultDiv.style.background = '#23863633';
resultDiv.style.border = '1px solid #238636';
resultDiv.innerHTML = \`
<strong style="color: #238636;">✓ Merge Request Created & Signed!</strong>
<p style="margin: 10px 0; color: #c9d1d9;">Your signature is recorded. Now open this link in your <strong>other account's browser</strong> to complete the merge:</p>
<div style="background: #0d1117; padding: 10px; border-radius: 4px; margin: 10px 0; word-break: break-all;">
<a href="\${mergeUrl}" style="color: #58a6ff;">\${mergeUrl}</a>
</div>
<p style="color: #8b949e; font-size: 12px;">Expires: \${new Date(initData.expiresAt).toLocaleString()}</p>
<div style="margin-top: 10px;">
<button onclick="copyMergeLink('\${mergeUrl}')" class="secondary" style="padding: 6px 12px; font-size: 12px;">📋 Copy Link</button>
</div>
<div style="margin-top: 15px; padding: 12px; background: #1f6feb33; border: 1px solid #1f6feb; border-radius: 6px;">
<strong>Next step:</strong> Open the link above in the browser where your <em>other</em> ANProto identity is stored. It will auto-detect and offer one-click confirmation.
</div>
\`;
} catch (err) {
resultDiv.style.background = '#f8514933';
resultDiv.style.border = '1px solid #f85149';
resultDiv.textContent = 'Error: ' + err.message;
}
}
function copyMergeLink(url) {
navigator.clipboard.writeText(url).then(() => {
alert('Merge link copied to clipboard!');
});
}
function copyFullKeypair() {
const keypair = localStorage.getItem(STORAGE_KEY);
if (!keypair) {
alert('No keypair found in browser storage.');
return;
}
navigator.clipboard.writeText(keypair).then(() => {
alert('Full keypair (132 chars) copied to clipboard!');
}).catch(() => {
prompt('Copy manually:', keypair);
});
}
function copyPubkey() {
const keypair = localStorage.getItem(STORAGE_KEY);
if (!keypair) {
alert('No keypair found in browser storage.');
return;
}
const publicKey = keypair.substring(0, 44);
navigator.clipboard.writeText(publicKey).then(() => {
alert('Public key (44 chars) copied to clipboard!');
}).catch(() => {
prompt('Copy manually:', publicKey);
});
}
function downloadKeypair() {
const keypair = localStorage.getItem(STORAGE_KEY);
if (!keypair) {
alert('No keypair found in browser storage.');
return;
}
const blob = new Blob([keypair], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = \`pear-git-keypair-\${Date.now()}.txt\`;
a.click();
URL.revokeObjectURL(url);
}
function showKeypair() {
const keypair = localStorage.getItem(STORAGE_KEY);
if (!keypair) {
alert('No keypair found in browser storage. Your identity may have been created on a different browser or cleared.');
return;
}
const display = document.getElementById('keypair-display');
const keypairText = document.getElementById('keypair-text');
const pubkeyText = document.getElementById('pubkey-text');
if (display.style.display === 'none') {
const publicKey = keypair.substring(0, 44);
pubkeyText.textContent = publicKey;
keypairText.textContent = keypair;
display.style.display = 'block';
} else {
display.style.display = 'none';
}
}
// Load profile on page load
loadUserProfile();
`
return renderPage('User Profile', content, { pathPrefix, activePage: 'profile', css: PAGE_CSS, scripts, maxWidth: 1000 })
}