| … | … | @@ -4,6 +4,8 @@ |
| 4 | 4 | |
| 5 | 5 | let currentSource = '' |
| 6 | 6 | let oldestTimestamp = null |
| 7 | let currentUser = null |
| 8 | let currentProfile = null |
| 7 | 9 | |
| 8 | 10 | // --- Router --- |
| 9 | 11 | function route () { |
| … | … | @@ -14,17 +16,30 @@ function route () { |
| 14 | 16 | pages.forEach(p => { p.style.display = 'none' }) |
| 15 | 17 | links.forEach(l => l.classList.remove('active')) |
| 16 | 18 | |
| 17 | | if (hash.startsWith('#/compose')) { |
| 19 | const isLoggedIn = !!(currentUser && currentUser.userId) |
| 20 | |
| 21 | // Hide nav items for anonymous users |
| 22 | document.querySelector('[data-page="compose"]').style.display = isLoggedIn ? '' : 'none' |
| 23 | document.querySelector('[data-page="notifications"]').style.display = isLoggedIn ? '' : 'none' |
| 24 | // Settings only visible when logged in — anonymous gets login page |
| 25 | const settingsLabel = document.querySelector('[data-page="settings"]') |
| 26 | if (settingsLabel) settingsLabel.textContent = isLoggedIn ? 'Settings' : 'Login' |
| 27 | |
| 28 | if (hash.startsWith('#/compose') && isLoggedIn) { |
| 18 | 29 | document.getElementById('page-compose').style.display = 'block' |
| 19 | 30 | document.querySelector('[data-page="compose"]').classList.add('active') |
| 20 | | } else if (hash.startsWith('#/notifications')) { |
| 31 | } else if (hash.startsWith('#/notifications') && isLoggedIn) { |
| 21 | 32 | document.getElementById('page-notifications').style.display = 'block' |
| 22 | 33 | document.querySelector('[data-page="notifications"]').classList.add('active') |
| 23 | 34 | loadNotifications() |
| 24 | 35 | } else if (hash.startsWith('#/settings')) { |
| 25 | 36 | document.getElementById('page-settings').style.display = 'block' |
| 26 | 37 | document.querySelector('[data-page="settings"]').classList.add('active') |
| 27 | | loadAccounts() |
| 38 | loadSettings() |
| 39 | } else if (hash.startsWith('#/admin')) { |
| 40 | document.getElementById('page-admin').style.display = 'block' |
| 41 | document.querySelector('[data-page="admin"]').classList.add('active') |
| 42 | loadAdmin() |
| 28 | 43 | } else { |
| 29 | 44 | document.getElementById('page-timeline').style.display = 'block' |
| 30 | 45 | document.querySelector('[data-page="timeline"]').classList.add('active') |
| … | … | @@ -32,10 +47,405 @@ function route () { |
| 32 | 47 | } |
| 33 | 48 | } |
| 34 | 49 | |
| 50 | // --- Identity status in header --- |
| 51 | async function loadIdentityStatus () { |
| 52 | if (typeof Identity === 'undefined' || !Identity.isReady) return |
| 53 | |
| 54 | try { |
| 55 | const data = await API.me() |
| 56 | currentUser = data.user |
| 57 | const statusEl = document.getElementById('identity-status') |
| 58 | |
| 59 | if (data.user) { |
| 60 | const name = data.user.username ? '@' + data.user.username : (data.user.name || 'Anonymous') |
| 61 | const badge = data.user.admin ? ' [admin]' : '' |
| 62 | statusEl.textContent = name + badge |
| 63 | |
| 64 | if (data.user.admin) { |
| 65 | document.getElementById('nav-admin').style.display = '' |
| 66 | } |
| 67 | } else { |
| 68 | statusEl.textContent = '' |
| 69 | } |
| 70 | } catch (err) { |
| 71 | console.error('[identity] Failed to load status:', err.message) |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | // --- Settings page --- |
| 76 | async function loadSettings () { |
| 77 | const isLoggedIn = !!(currentUser && currentUser.userId) |
| 78 | |
| 79 | if (!isLoggedIn) { |
| 80 | // Show login page for anonymous |
| 81 | renderIdentityCard() |
| 82 | // Hide all other settings sections |
| 83 | document.getElementById('profile-section').innerHTML = '' |
| 84 | document.getElementById('identities-section').innerHTML = '' |
| 85 | document.getElementById('keypair-section').innerHTML = '' |
| 86 | document.getElementById('accounts-list').innerHTML = '' |
| 87 | return |
| 88 | } |
| 89 | |
| 90 | // Fetch full profile with identities |
| 91 | try { |
| 92 | const res = await API._fetch('/api/user/profile') |
| 93 | const data = await res.json() |
| 94 | if (data.user) { |
| 95 | currentProfile = data |
| 96 | currentUser = data.user |
| 97 | } |
| 98 | } catch {} |
| 99 | |
| 100 | renderIdentityCard() |
| 101 | renderProfileSection() |
| 102 | renderIdentitiesSection() |
| 103 | renderKeypairSection() |
| 104 | updateThemeButtons() |
| 105 | loadAccounts() |
| 106 | } |
| 107 | |
| 108 | function renderIdentityCard () { |
| 109 | const el = document.getElementById('identity-card') |
| 110 | if (!currentUser) { |
| 111 | el.innerHTML = ` |
| 112 | <div style="color:var(--text-muted);margin-bottom:12px">Not logged in.</div> |
| 113 | <div style="display:flex;gap:8px;flex-wrap:wrap"> |
| 114 | <button class="btn btn-primary" onclick="doLogin()">Login</button> |
| 115 | <button class="btn btn-small" onclick="showImportDialog()">Import Keypair</button> |
| 116 | </div> |
| 117 | <p style="font-size:12px;color:var(--text-muted);margin-top:8px"> |
| 118 | Login generates a new identity. Import Keypair links an existing identity from another device. |
| 119 | </p> |
| 120 | ` |
| 121 | return |
| 122 | } |
| 123 | |
| 124 | const u = currentUser |
| 125 | const permLevel = u.admin ? 'admin' : (u.globalPermissions && u.globalPermissions.includes('write') ? 'write' : 'read') |
| 126 | const permLabel = permLevel.charAt(0).toUpperCase() + permLevel.slice(1) |
| 127 | |
| 128 | el.innerHTML = ` |
| 129 | <div class="identity-card-header"> |
| 130 | <div class="identity-avatar">👤</div> |
| 131 | <div> |
| 132 | <div class="identity-name">${esc(u.name || 'Anonymous')}</div> |
| 133 | <div class="identity-handle">${u.username ? '@' + esc(u.username) : '<em>no username set</em>'}</div> |
| 134 | </div> |
| 135 | <span class="perm-badge ${permLevel}" style="margin-left:auto">${permLabel}</span> |
| 136 | </div> |
| 137 | <div style="font-size:12px;color:var(--text-muted)"> |
| 138 | User ID: <code class="mono">${esc(u.userId)}</code> |
| 139 | ${u.createdAt ? ' · Joined ' + new Date(u.createdAt).toLocaleDateString() : ''} |
| 140 | </div> |
| 141 | ` |
| 142 | } |
| 143 | |
| 144 | function renderProfileSection () { |
| 145 | const el = document.getElementById('profile-section') |
| 146 | if (!currentUser) { el.innerHTML = ''; return } |
| 147 | |
| 148 | const u = currentUser |
| 149 | el.innerHTML = ` |
| 150 | <div class="settings-row"> |
| 151 | <span class="settings-label">Username</span> |
| 152 | <span class="settings-value"> |
| 153 | ${u.username |
| 154 | ? '@' + esc(u.username) + ' <button class="btn btn-small" onclick="promptUsername()">Change</button>' |
| 155 | : '<button class="btn btn-small" onclick="promptUsername()">Set username</button>' |
| 156 | } |
| 157 | </span> |
| 158 | </div> |
| 159 | <div class="settings-row"> |
| 160 | <span class="settings-label">Display Name</span> |
| 161 | <span class="settings-value"> |
| 162 | ${esc(u.name || 'Anonymous')} |
| 163 | <button class="btn btn-small" onclick="promptDisplayName()">Edit</button> |
| 164 | </span> |
| 165 | </div> |
| 166 | ` |
| 167 | } |
| 168 | |
| 169 | function renderIdentitiesSection () { |
| 170 | const el = document.getElementById('identities-section') |
| 171 | if (!currentProfile || !currentProfile.identities) { |
| 172 | el.innerHTML = '<div style="color:var(--text-muted);font-size:13px">No identities loaded</div>' |
| 173 | return |
| 174 | } |
| 175 | |
| 176 | const methodIcons = { |
| 177 | 'web-identity': '🌐', |
| 178 | 'ssh-key': '🔒', |
| 179 | 'keet-identity': '💳', |
| 180 | 'bearer-token': '🔑' |
| 181 | } |
| 182 | |
| 183 | el.innerHTML = currentProfile.identities.map(id => ` |
| 184 | <div class="identity-item"> |
| 185 | <span>${methodIcons[id.method] || '👤'}</span> |
| 186 | <span class="identity-method">${esc(id.method)}</span> |
| 187 | <code class="mono" style="flex:1;overflow:hidden;text-overflow:ellipsis">${esc(id.publicKey || id.identityId)}</code> |
| 188 | <button class="btn btn-small" onclick="copyText('${esc(id.publicKey || id.identityId)}')">Copy</button> |
| 189 | </div> |
| 190 | `).join('') |
| 191 | } |
| 192 | |
| 193 | function renderKeypairSection () { |
| 194 | const el = document.getElementById('keypair-section') |
| 195 | if (typeof Identity === 'undefined' || !Identity.isReady) { |
| 196 | el.innerHTML = '<div style="color:var(--text-muted);font-size:13px">Identity not initialized</div>' |
| 197 | return |
| 198 | } |
| 199 | |
| 200 | const pubkey = Identity.publicKey || '' |
| 201 | const keypair = localStorage.getItem('hs-keypair') || '' |
| 202 | |
| 203 | el.innerHTML = ` |
| 204 | <div class="settings-row"> |
| 205 | <span class="settings-label">Public Key</span> |
| 206 | <code class="mono" style="flex:1;overflow:hidden;text-overflow:ellipsis">${esc(pubkey)}</code> |
| 207 | <button class="btn btn-small" onclick="copyText('${esc(pubkey)}')">Copy</button> |
| 208 | </div> |
| 209 | <div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap"> |
| 210 | <button class="btn btn-small" onclick="toggleKeypairDisplay()">Show Full Keypair</button> |
| 211 | <button class="btn btn-small" onclick="copyText('${esc(keypair)}')">Copy Full Keypair</button> |
| 212 | <button class="btn btn-small" onclick="exportKeypairJSON()">Export JSON</button> |
| 213 | </div> |
| 214 | <div id="keypair-display" style="display:none;margin-top:8px"> |
| 215 | <div style="background:var(--bg-input);padding:8px;border-radius:var(--radius);border:1px solid var(--border)"> |
| 216 | <div style="font-size:11px;color:var(--text-muted);margin-bottom:4px">Full Keypair (132 chars) — <span style="color:var(--red);font-weight:600">KEEP SECRET</span></div> |
| 217 | <code class="mono" style="color:var(--red)">${esc(keypair)}</code> |
| 218 | </div> |
| 219 | </div> |
| 220 | ` |
| 221 | } |
| 222 | |
| 223 | function toggleKeypairDisplay () { |
| 224 | const el = document.getElementById('keypair-display') |
| 225 | el.style.display = el.style.display === 'none' ? 'block' : 'none' |
| 226 | } |
| 227 | |
| 228 | function exportKeypairJSON () { |
| 229 | const keypair = localStorage.getItem('hs-keypair') |
| 230 | if (!keypair) { alert('No keypair found'); return } |
| 231 | |
| 232 | const data = JSON.stringify({ |
| 233 | publicKey: keypair.substring(0, 44), |
| 234 | keypair: keypair, |
| 235 | exportedAt: new Date().toISOString(), |
| 236 | app: 'hyper-social-web' |
| 237 | }, null, 2) |
| 238 | |
| 239 | const blob = new Blob([data], { type: 'application/json' }) |
| 240 | const url = URL.createObjectURL(blob) |
| 241 | const a = document.createElement('a') |
| 242 | a.href = url |
| 243 | a.download = 'hyper-social-keypair.json' |
| 244 | a.click() |
| 245 | URL.revokeObjectURL(url) |
| 246 | } |
| 247 | |
| 248 | function copyText (text) { |
| 249 | navigator.clipboard.writeText(text).then(() => { |
| 250 | // Brief visual feedback would be nice but keep it simple |
| 251 | }).catch(() => { |
| 252 | // Fallback for older browsers |
| 253 | const ta = document.createElement('textarea') |
| 254 | ta.value = text |
| 255 | document.body.appendChild(ta) |
| 256 | ta.select() |
| 257 | document.execCommand('copy') |
| 258 | document.body.removeChild(ta) |
| 259 | }) |
| 260 | } |
| 261 | |
| 262 | // --- Login --- |
| 263 | async function doLogin () { |
| 264 | if (typeof Identity === 'undefined') { alert('Identity system not loaded'); return } |
| 265 | |
| 266 | // Generate a new keypair for this user |
| 267 | if (!Identity.publicKey) { |
| 268 | const keypair = await Identity._generateKeypair() |
| 269 | localStorage.setItem('hs-keypair', keypair) |
| 270 | // Re-init to load the new keypair |
| 271 | await Identity.init(API.base) |
| 272 | } |
| 273 | |
| 274 | if (!Identity.publicKey) { alert('Failed to generate keypair'); return } |
| 275 | |
| 276 | // Register with the server |
| 277 | const base = API.base |
| 278 | const result = await Identity.register(base) |
| 279 | if (!result || result.error) { |
| 280 | alert('Registration failed: ' + (result ? result.error : 'unknown error')) |
| 281 | return |
| 282 | } |
| 283 | |
| 284 | Identity.markRegistered() |
| 285 | currentUser = result.user |
| 286 | |
| 287 | // Prompt for username |
| 288 | const username = prompt('Choose a username (@handle):') |
| 289 | if (username) { |
| 290 | try { |
| 291 | const res = await API.setUsername(username.replace(/^@/, '')) |
| 292 | if (!res.error) { |
| 293 | Identity.setUsername(res.user.username) |
| 294 | currentUser = { ...currentUser, username: res.user.username } |
| 295 | } |
| 296 | } catch {} |
| 297 | } |
| 298 | |
| 299 | await loadIdentityStatus() |
| 300 | loadSettings() |
| 301 | } |
| 302 | |
| 303 | // --- Profile editing --- |
| 304 | async function promptUsername () { |
| 305 | const username = prompt('Choose a username (lowercase, 3-32 chars, letters/numbers/dashes):') |
| 306 | if (!username) return |
| 307 | |
| 308 | try { |
| 309 | const res = await API.setUsername(username) |
| 310 | if (res.error) { alert('Error: ' + res.error); return } |
| 311 | if (typeof Identity !== 'undefined') Identity.setUsername(username) |
| 312 | currentUser = { ...currentUser, username } |
| 313 | loadSettings() |
| 314 | loadIdentityStatus() |
| 315 | } catch (err) { |
| 316 | alert('Error: ' + err.message) |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | async function promptDisplayName () { |
| 321 | const name = prompt('Enter display name:', currentUser ? currentUser.name : '') |
| 322 | if (!name) return |
| 323 | |
| 324 | try { |
| 325 | const res = await API._fetch('/api/auth/profile', { |
| 326 | method: 'PATCH', |
| 327 | headers: { 'Content-Type': 'application/json' }, |
| 328 | body: JSON.stringify({ name }) |
| 329 | }) |
| 330 | const data = await res.json() |
| 331 | if (data.error) { alert('Error: ' + data.error); return } |
| 332 | currentUser = { ...currentUser, name } |
| 333 | loadSettings() |
| 334 | loadIdentityStatus() |
| 335 | } catch (err) { |
| 336 | alert('Error: ' + err.message) |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | // --- Theme --- |
| 341 | function updateThemeButtons () { |
| 342 | const current = currentUser ? (currentUser.theme || 'dark') : (localStorage.getItem('hs-theme') || 'dark') |
| 343 | document.querySelectorAll('.theme-btn').forEach(btn => { |
| 344 | btn.classList.toggle('active', btn.dataset.theme === current) |
| 345 | }) |
| 346 | } |
| 347 | |
| 348 | async function setTheme (theme) { |
| 349 | document.documentElement.setAttribute('data-theme', theme) |
| 350 | localStorage.setItem('hs-theme', theme) |
| 351 | |
| 352 | if (currentUser) { |
| 353 | try { |
| 354 | await API._fetch('/api/auth/profile', { |
| 355 | method: 'PATCH', |
| 356 | headers: { 'Content-Type': 'application/json' }, |
| 357 | body: JSON.stringify({ theme }) |
| 358 | }) |
| 359 | currentUser.theme = theme |
| 360 | } catch {} |
| 361 | } |
| 362 | updateThemeButtons() |
| 363 | } |
| 364 | |
| 365 | // --- Device linking --- |
| 366 | function showImportDialog () { |
| 367 | const input = prompt('Paste your 132-character keypair string:') |
| 368 | if (!input) return |
| 369 | |
| 370 | if (input.length !== 132) { |
| 371 | alert('Invalid keypair length. Expected 132 characters (44 public + 88 secret).') |
| 372 | return |
| 373 | } |
| 374 | |
| 375 | const newPubkey = input.substring(0, 44) |
| 376 | const resultEl = document.getElementById('device-link-result') |
| 377 | |
| 378 | // Register the new pubkey with the server (link to current user) |
| 379 | if (currentUser) { |
| 380 | API._fetch('/api/auth/import-keypair', { |
| 381 | method: 'POST', |
| 382 | headers: { 'Content-Type': 'application/json' }, |
| 383 | body: JSON.stringify({ publicKey: newPubkey }) |
| 384 | }).then(res => res.json()).then(data => { |
| 385 | if (data.error) { |
| 386 | showResult(resultEl, 'Import failed: ' + data.error, 'error') |
| 387 | return |
| 388 | } |
| 389 | // Save new keypair locally |
| 390 | localStorage.setItem('hs-keypair', input) |
| 391 | localStorage.setItem('hs-pubkey', newPubkey) |
| 392 | showResult(resultEl, 'Keypair imported. Reloading...', 'success') |
| 393 | setTimeout(() => location.reload(), 1000) |
| 394 | }).catch(err => { |
| 395 | showResult(resultEl, err.message, 'error') |
| 396 | }) |
| 397 | } else { |
| 398 | // No user yet — just store locally |
| 399 | localStorage.setItem('hs-keypair', input) |
| 400 | showResult(resultEl, 'Keypair imported. Reloading...', 'success') |
| 401 | setTimeout(() => location.reload(), 1000) |
| 402 | } |
| 403 | } |
| 404 | |
| 405 | function showMergeDialog () { |
| 406 | const targetKey = prompt('Paste the 44-character public key of the account to merge into yours:') |
| 407 | if (!targetKey) return |
| 408 | |
| 409 | if (targetKey.length !== 44) { |
| 410 | alert('Invalid public key length. Expected 44 characters (base64).') |
| 411 | return |
| 412 | } |
| 413 | |
| 414 | const resultEl = document.getElementById('device-link-result') |
| 415 | |
| 416 | API._fetch('/api/merge/initiate', { |
| 417 | method: 'POST', |
| 418 | headers: { 'Content-Type': 'application/json' }, |
| 419 | body: JSON.stringify({ targetPublicKey: targetKey }) |
| 420 | }).then(res => res.json()).then(data => { |
| 421 | if (data.error) { |
| 422 | showResult(resultEl, 'Merge failed: ' + data.error, 'error') |
| 423 | return |
| 424 | } |
| 425 | showResult(resultEl, `Merge initiated (ID: ${data.mergeId.substring(0, 8)}...). The other account must confirm by signing challenge: ${data.challenge.substring(0, 16)}... Expires: ${new Date(data.expiresAt).toLocaleTimeString()}`, 'success') |
| 426 | }).catch(err => { |
| 427 | showResult(resultEl, err.message, 'error') |
| 428 | }) |
| 429 | } |
| 430 | |
| 431 | // --- Logout --- |
| 432 | function logout () { |
| 433 | if (!confirm('Clear your identity from this browser? You will need your keypair to log back in.')) return |
| 434 | if (typeof Identity !== 'undefined') Identity.clear() |
| 435 | location.reload() |
| 436 | } |
| 437 | |
| 35 | 438 | // --- Timeline --- |
| 36 | 439 | async function loadTimeline (append) { |
| 37 | 440 | const container = document.getElementById('timeline') |
| 38 | 441 | const loadMoreBtn = document.getElementById('load-more') |
| 442 | const isLoggedIn = !!(currentUser && currentUser.userId) |
| 443 | |
| 444 | // Hide filters for anonymous users |
| 445 | const filtersEl = document.querySelector('.filters') |
| 446 | const refreshBtn = document.getElementById('refresh-btn') |
| 447 | if (filtersEl) filtersEl.style.display = isLoggedIn ? 'flex' : 'none' |
| 448 | if (refreshBtn) refreshBtn.style.display = isLoggedIn ? '' : 'none' |
| 39 | 449 | |
| 40 | 450 | if (!append) { |
| 41 | 451 | container.innerHTML = '<div style="color:var(--text-muted);padding:20px;text-align:center">Loading...</div>' |
| … | … | @@ -43,22 +453,23 @@ async function loadTimeline (append) { |
| 43 | 453 | } |
| 44 | 454 | |
| 45 | 455 | const opts = { limit: 30 } |
| 46 | | if (currentSource) opts.source = currentSource |
| 456 | // Anonymous users only see local (your) posts |
| 457 | if (!isLoggedIn) { |
| 458 | opts.source = 'local' |
| 459 | } else if (currentSource) { |
| 460 | opts.source = currentSource |
| 461 | } |
| 47 | 462 | if (append && oldestTimestamp) opts.before = oldestTimestamp |
| 48 | 463 | |
| 49 | 464 | let data |
| 50 | 465 | try { |
| 51 | | console.log('[timeline] fetching', API.base + '/api/timeline', opts) |
| 52 | 466 | data = await API.timeline(opts) |
| 53 | | console.log('[timeline] got', data.items ? data.items.length : 0, 'items') |
| 54 | 467 | } catch (err) { |
| 55 | | console.error('[timeline] fetch error:', err) |
| 56 | | container.innerHTML = '<div style="color:var(--red);padding:20px;text-align:center">Error loading timeline: ' + err.message + '</div>' |
| 468 | container.innerHTML = '<div style="color:var(--red);padding:20px;text-align:center">Error loading timeline: ' + esc(err.message) + '</div>' |
| 57 | 469 | return |
| 58 | 470 | } |
| 59 | 471 | |
| 60 | 472 | const items = data.items || [] |
| 61 | | |
| 62 | 473 | if (!append) container.innerHTML = '' |
| 63 | 474 | |
| 64 | 475 | if (items.length === 0 && !append) { |
| … | … | @@ -118,13 +529,110 @@ async function disconnectAccount (id) { |
| 118 | 529 | loadAccounts() |
| 119 | 530 | } |
| 120 | 531 | |
| 532 | // --- Admin --- |
| 533 | async function loadAdmin () { |
| 534 | const infoEl = document.getElementById('admin-info') |
| 535 | const usersEl = document.getElementById('admin-users') |
| 536 | |
| 537 | if (!currentUser || !currentUser.admin) { |
| 538 | infoEl.innerHTML = '<div style="color:var(--red)">Admin access required</div>' |
| 539 | usersEl.innerHTML = '' |
| 540 | return |
| 541 | } |
| 542 | |
| 543 | infoEl.innerHTML = '<div>Logged in as admin: <strong>' + esc(currentUser.username || currentUser.name) + '</strong></div>' |
| 544 | usersEl.innerHTML = '<div style="color:var(--text-muted)">Loading users...</div>' |
| 545 | |
| 546 | try { |
| 547 | const data = await API._fetch('/api/admin/users') |
| 548 | const res = await data.json() |
| 549 | const users = res.users || [] |
| 550 | |
| 551 | if (users.length === 0) { |
| 552 | usersEl.innerHTML = '<div style="color:var(--text-muted)">No users</div>' |
| 553 | return |
| 554 | } |
| 555 | |
| 556 | usersEl.innerHTML = users.map(u => { |
| 557 | const perms = (u.globalPermissions || []).join(', ') |
| 558 | const isAdmin = u.globalPermissions && u.globalPermissions.includes('admin') |
| 559 | const promoteBtn = isAdmin ? '' : ` <button class="btn btn-small" onclick="promoteUser('${esc(u.userId)}')">Promote</button>` |
| 560 | const isSelf = currentUser && u.userId === currentUser.userId |
| 561 | const deleteBtn = isSelf ? '' : ` <button class="btn btn-small btn-danger" onclick="deleteUser('${esc(u.userId)}')">Delete</button>` |
| 562 | return ` |
| 563 | <div class="settings-card"> |
| 564 | <strong>${esc(u.username ? '@' + u.username : u.name || 'Anonymous')}</strong> |
| 565 | <span style="color:var(--text-muted);font-size:0.85em;margin-left:8px">${esc(u.userId.substring(0, 8))}...</span> |
| 566 | <span class="perm-badge ${isAdmin ? 'admin' : 'read'}" style="margin-left:8px">${esc(perms)}</span> |
| 567 | ${promoteBtn}${deleteBtn} |
| 568 | <div style="font-size:12px;color:var(--text-muted);margin-top:4px"> |
| 569 | Identities: ${u.identities.length} | Created: ${new Date(u.createdAt).toLocaleDateString()} |
| 570 | </div> |
| 571 | </div> |
| 572 | ` |
| 573 | }).join('') |
| 574 | } catch (err) { |
| 575 | usersEl.innerHTML = '<div style="color:var(--red)">Error: ' + esc(err.message) + '</div>' |
| 576 | } |
| 577 | } |
| 578 | |
| 579 | async function deleteUser (userId) { |
| 580 | if (!confirm('Delete this user and all their identities? This cannot be undone.')) return |
| 581 | try { |
| 582 | const res = await API._fetch('/api/admin/users/' + encodeURIComponent(userId), { method: 'DELETE' }) |
| 583 | const data = await res.json() |
| 584 | if (data.error) { alert('Error: ' + data.error); return } |
| 585 | loadAdmin() |
| 586 | } catch (err) { |
| 587 | alert('Error: ' + err.message) |
| 588 | } |
| 589 | } |
| 590 | |
| 591 | async function promoteUser (userId) { |
| 592 | if (!confirm('Promote this user to admin?')) return |
| 593 | try { |
| 594 | const res = await API._fetch('/api/admin/promote', { |
| 595 | method: 'POST', |
| 596 | headers: { 'Content-Type': 'application/json' }, |
| 597 | body: JSON.stringify({ userId }) |
| 598 | }) |
| 599 | const data = await res.json() |
| 600 | if (data.error) { alert('Error: ' + data.error); return } |
| 601 | loadAdmin() |
| 602 | } catch (err) { |
| 603 | alert('Error: ' + err.message) |
| 604 | } |
| 605 | } |
| 606 | |
| 607 | // --- Helpers --- |
| 608 | function esc (str) { |
| 609 | if (!str) return '' |
| 610 | const div = document.createElement('div') |
| 611 | div.textContent = String(str) |
| 612 | return div.innerHTML |
| 613 | } |
| 614 | |
| 615 | function showResult (el, msg, type) { |
| 616 | if (!el) return |
| 617 | el.textContent = msg |
| 618 | el.className = 'result ' + type |
| 619 | el.style.display = 'block' |
| 620 | setTimeout(() => { el.style.display = 'none' }, 8000) |
| 621 | } |
| 622 | |
| 121 | 623 | // --- Init --- |
| 122 | 624 | document.addEventListener('DOMContentLoaded', async () => { |
| 123 | | // Initialize identity (generates keypair on first visit) |
| 625 | // Apply saved theme |
| 626 | const savedTheme = localStorage.getItem('hs-theme') || 'dark' |
| 627 | document.documentElement.setAttribute('data-theme', savedTheme) |
| 628 | |
| 629 | // Initialize identity |
| 124 | 630 | if (typeof Identity !== 'undefined') { |
| 125 | 631 | await Identity.init(API.base) |
| 126 | 632 | } |
| 127 | 633 | |
| 634 | await loadIdentityStatus() |
| 635 | |
| 128 | 636 | initComposer() |
| 129 | 637 | |
| 130 | 638 | // Filter buttons |
| … | … | @@ -155,6 +663,11 @@ document.addEventListener('DOMContentLoaded', async () => { |
| 155 | 663 | // Load more |
| 156 | 664 | document.getElementById('load-more').addEventListener('click', () => loadTimeline(true)) |
| 157 | 665 | |
| 666 | // Theme buttons |
| 667 | document.querySelectorAll('.theme-btn').forEach(btn => { |
| 668 | btn.addEventListener('click', () => setTheme(btn.dataset.theme)) |
| 669 | }) |
| 670 | |
| 158 | 671 | // Platform fields toggle |
| 159 | 672 | document.getElementById('connect-platform').addEventListener('change', (e) => { |
| 160 | 673 | document.getElementById('mastodon-fields').style.display = e.target.value === 'mastodon' ? 'block' : 'none' |