my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server
at go-port 605 lines 16 kB view raw
1const token = localStorage.getItem("indiko_session"); 2const footer = document.getElementById("footer") as HTMLElement; 3const invitesList = document.getElementById("invitesList") as HTMLElement; 4const createInviteBtn = document.getElementById( 5 "createInviteBtn", 6) as HTMLButtonElement; 7 8// Check auth and display user 9async function checkAuth() { 10 if (!token) { 11 window.location.href = "/login"; 12 return; 13 } 14 15 try { 16 const response = await fetch("/api/hello", { 17 headers: { 18 Authorization: `Bearer ${token}`, 19 }, 20 }); 21 22 if (response.status === 401 || response.status === 403) { 23 localStorage.removeItem("indiko_session"); 24 window.location.href = "/login"; 25 return; 26 } 27 28 const data = await response.json(); 29 30 footer.innerHTML = `admin • signed in as <strong><a href="/u/${data.username}">${data.username}</a></strong> • <a href="/login" id="logoutLink">sign out</a> 31 <div class="back-link"><a href="/">← back to dashboard</a></div>`; 32 33 // Handle logout 34 document 35 .getElementById("logoutLink") 36 ?.addEventListener("click", async (e) => { 37 e.preventDefault(); 38 try { 39 await fetch("/auth/logout", { 40 method: "POST", 41 headers: { 42 Authorization: `Bearer ${token}`, 43 }, 44 }); 45 } catch { 46 // Ignore logout errors 47 } 48 localStorage.removeItem("indiko_session"); 49 window.location.href = "/login"; 50 }); 51 52 // Check if admin 53 if (!data.isAdmin) { 54 window.location.href = "/"; 55 return; 56 } 57 58 // Load invites 59 loadInvites(); 60 } catch (error) { 61 console.error("Auth check failed:", error); 62 footer.textContent = "error loading user info"; 63 usersList.innerHTML = '<div class="error">Failed to load users</div>'; 64 } 65} 66 67async function createInvite() { 68 // Show the create invite modal 69 const modal = document.getElementById("createInviteModal"); 70 if (modal) { 71 modal.style.display = "flex"; 72 // Load apps for role assignment 73 await loadAppsForInvite(); 74 } 75} 76 77async function loadAppsForInvite() { 78 try { 79 const response = await fetch("/api/admin/clients", { 80 headers: { 81 Authorization: `Bearer ${token}`, 82 }, 83 }); 84 85 if (!response.ok) { 86 throw new Error("Failed to load apps"); 87 } 88 89 const data = await response.json(); 90 const appRolesContainer = document.getElementById("appRolesContainer"); 91 92 if (!appRolesContainer) return; 93 94 if (data.clients.length === 0) { 95 appRolesContainer.innerHTML = 96 '<p style="color: var(--old-rose); font-size: 0.875rem;">No pre-registered apps available</p>'; 97 return; 98 } 99 100 appRolesContainer.innerHTML = data.clients 101 .filter((app: { isPreregistered: boolean }) => app.isPreregistered) 102 .map( 103 (app: { 104 id: number; 105 clientId: string; 106 name: string; 107 roles: string[]; 108 }) => { 109 const roleOptions = 110 app.roles.length > 0 111 ? app.roles 112 .map((role) => `<option value="${role}">${role}</option>`) 113 .join("") 114 : '<option value="" disabled>No roles defined yet</option>'; 115 116 const displayName = app.name || app.clientId; 117 118 return ` 119 <div class="app-role-item"> 120 <label> 121 <input type="checkbox" name="appRole" value="${app.id}" data-client-id="${app.clientId}"> 122 <span>${displayName}</span> 123 </label> 124 <select class="role-select" data-app-id="${app.id}" disabled> 125 <option value="">Select role...</option> 126 ${roleOptions} 127 </select> 128 </div> 129 `; 130 }, 131 ) 132 .join(""); 133 134 // Enable/disable role select when checkbox changes 135 const checkboxes = appRolesContainer.querySelectorAll( 136 'input[name="appRole"]', 137 ); 138 checkboxes.forEach((checkbox) => { 139 checkbox.addEventListener("change", (e) => { 140 const target = e.target as HTMLInputElement; 141 const appId = target.value; 142 const roleSelect = appRolesContainer.querySelector( 143 `select.role-select[data-app-id="${appId}"]`, 144 ) as HTMLSelectElement; 145 146 if (roleSelect) { 147 roleSelect.disabled = !target.checked; 148 if (!target.checked) { 149 roleSelect.value = ""; 150 } 151 } 152 }); 153 }); 154 } catch (error) { 155 console.error("Failed to load apps:", error); 156 } 157} 158 159async function submitCreateInvite() { 160 const maxUsesInput = document.getElementById("maxUses") as HTMLInputElement; 161 const expiresAtInput = document.getElementById( 162 "expiresAt", 163 ) as HTMLInputElement; 164 const noteInput = document.getElementById( 165 "inviteNote", 166 ) as HTMLTextAreaElement; 167 const messageInput = document.getElementById( 168 "inviteMessage", 169 ) as HTMLTextAreaElement; 170 const submitBtn = document.getElementById( 171 "submitInviteBtn", 172 ) as HTMLButtonElement; 173 174 const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value, 10) : 1; 175 const expiresAt = expiresAtInput.value 176 ? Math.floor(new Date(expiresAtInput.value).getTime() / 1000) 177 : null; 178 const note = noteInput.value.trim() || null; 179 const message = messageInput.value.trim() || null; 180 181 // Collect app roles 182 const appRolesContainer = document.getElementById("appRolesContainer"); 183 const appRoles: Array<{ appId: number; role: string }> = []; 184 185 if (appRolesContainer) { 186 const checkedBoxes = appRolesContainer.querySelectorAll( 187 'input[name="appRole"]:checked', 188 ); 189 checkedBoxes.forEach((checkbox) => { 190 const appId = parseInt((checkbox as HTMLInputElement).value, 10); 191 const roleSelect = appRolesContainer.querySelector( 192 `select.role-select[data-app-id="${appId}"]`, 193 ) as HTMLSelectElement; 194 195 let role = ""; 196 if (roleSelect && roleSelect.value) { 197 role = roleSelect.value; 198 } 199 200 if (role) { 201 appRoles.push({ 202 appId, 203 role, 204 }); 205 } 206 }); 207 } 208 209 submitBtn.disabled = true; 210 submitBtn.textContent = "creating..."; 211 212 try { 213 const response = await fetch("/api/invites/create", { 214 method: "POST", 215 headers: { 216 Authorization: `Bearer ${token}`, 217 "Content-Type": "application/json", 218 }, 219 body: JSON.stringify({ 220 maxUses, 221 expiresAt, 222 note, 223 message, 224 appRoles: appRoles.length > 0 ? appRoles : undefined, 225 }), 226 }); 227 228 if (!response.ok) { 229 throw new Error("Failed to create invite"); 230 } 231 232 await loadInvites(); 233 closeCreateInviteModal(); 234 } catch (error) { 235 console.error("Failed to create invite:", error); 236 alert("Failed to create invite"); 237 } finally { 238 submitBtn.disabled = false; 239 submitBtn.textContent = "create invite"; 240 } 241} 242 243function closeCreateInviteModal() { 244 const modal = document.getElementById("createInviteModal"); 245 if (modal) { 246 modal.style.display = "none"; 247 // Reset form 248 (document.getElementById("maxUses") as HTMLInputElement).value = "1"; 249 (document.getElementById("expiresAt") as HTMLInputElement).value = ""; 250 (document.getElementById("inviteNote") as HTMLTextAreaElement).value = ""; 251 (document.getElementById("inviteMessage") as HTMLTextAreaElement).value = 252 ""; 253 const appRolesContainer = document.getElementById("appRolesContainer"); 254 if (appRolesContainer) { 255 appRolesContainer 256 .querySelectorAll('input[type="checkbox"]') 257 .forEach((input) => { 258 (input as HTMLInputElement).checked = false; 259 }); 260 appRolesContainer.querySelectorAll("select").forEach((select) => { 261 (select as HTMLSelectElement).value = ""; 262 (select as HTMLSelectElement).disabled = true; 263 }); 264 } 265 } 266} 267 268// Expose functions to global scope for HTML onclick handlers 269(window as any).submitCreateInvite = submitCreateInvite; 270(window as any).closeCreateInviteModal = closeCreateInviteModal; 271 272async function loadInvites() { 273 try { 274 const response = await fetch("/api/invites", { 275 headers: { 276 Authorization: `Bearer ${token}`, 277 }, 278 }); 279 280 if (!response.ok) { 281 throw new Error("Failed to load invites"); 282 } 283 284 const data = await response.json(); 285 286 if (data.invites.length === 0) { 287 invitesList.innerHTML = 288 '<div class="loading">No invites created yet</div>'; 289 return; 290 } 291 292 invitesList.innerHTML = data.invites 293 .map( 294 (invite: { 295 id: number; 296 code: string; 297 maxUses: number; 298 currentUses: number; 299 isExpired: boolean; 300 isFullyUsed: boolean; 301 expiresAt: number | null; 302 note: string | null; 303 message: string | null; 304 createdAt: number; 305 createdBy: string; 306 inviteUrl: string; 307 appRoles: Array<{ 308 clientId: string; 309 name: string | null; 310 role: string; 311 }>; 312 usedBy: Array<{ username: string; usedAt: number }>; 313 }) => { 314 const createdDate = new Date( 315 invite.createdAt * 1000, 316 ).toLocaleDateString(); 317 318 let status = `${invite.currentUses}/${invite.maxUses} used`; 319 if (invite.isExpired) { 320 status += " (expired)"; 321 } else if (invite.isFullyUsed) { 322 status += " (fully used)"; 323 } 324 325 const expiryInfo = invite.expiresAt 326 ? `Expires: ${new Date(invite.expiresAt * 1000).toLocaleString()}` 327 : "No expiry"; 328 329 const roleInfo = 330 invite.appRoles.length > 0 331 ? `<div class="invite-roles">App roles: ${invite.appRoles 332 .map((r) => { 333 const appName = r.name || r.clientId; 334 return `${appName} (${r.role})`; 335 }) 336 .join(", ")}</div>` 337 : ""; 338 339 const usedByInfo = 340 invite.usedBy.length > 0 341 ? `<div class="invite-used-by">Used by: ${invite.usedBy.map((u) => `${u.username} (${new Date(u.usedAt * 1000).toLocaleDateString()})`).join(", ")}</div>` 342 : ""; 343 344 const noteInfo = invite.note 345 ? `<div class="invite-note">Internal note: ${invite.note}</div>` 346 : ""; 347 348 const messageInfo = invite.message 349 ? `<div class="invite-message">Message to invitees: ${invite.message}</div>` 350 : ""; 351 352 const isActive = !invite.isExpired && !invite.isFullyUsed; 353 354 return ` 355 <div class="invite-item ${isActive ? "" : "invite-inactive"}"> 356 <div> 357 <div class="invite-code">${invite.code}</div> 358 <div class="invite-meta">Created by ${invite.createdBy} on ${createdDate}${status}</div> 359 <div class="invite-meta">${expiryInfo}</div> 360 ${noteInfo} 361 ${messageInfo} 362 ${roleInfo} 363 ${usedByInfo} 364 <div class="invite-url">${invite.inviteUrl}</div> 365 </div> 366 <div class="invite-actions-btns"> 367 <button class="btn-copy" data-invite-id="${invite.id}" data-invite-url="${invite.inviteUrl}" ${isActive ? "" : "disabled"}>copy link</button> 368 <button class="btn-edit" onclick="editInvite(${invite.id})" ${isActive ? "" : "disabled"}>edit</button> 369 <button class="btn-delete" onclick="deleteInvite(${invite.id}, event)">delete</button> 370 </div> 371 </div> 372 `; 373 }, 374 ) 375 .join(""); 376 377 // Add copy button handlers 378 const copyButtons = invitesList.querySelectorAll(".btn-copy"); 379 copyButtons.forEach((btn) => { 380 btn.addEventListener("click", async (e) => { 381 const button = e.target as HTMLButtonElement; 382 const url = button.dataset.inviteUrl; 383 if (!url) return; 384 385 try { 386 await navigator.clipboard.writeText(url); 387 const originalText = button.textContent; 388 button.textContent = "copied!"; 389 setTimeout(() => { 390 button.textContent = originalText; 391 }, 2000); 392 } catch (error) { 393 console.error("Failed to copy:", error); 394 } 395 }); 396 }); 397 } catch (error) { 398 console.error("Failed to load invites:", error); 399 invitesList.innerHTML = '<div class="error">Failed to load invites</div>'; 400 } 401} 402 403checkAuth(); 404 405createInviteBtn.addEventListener("click", createInvite); 406 407// Close modals on escape key 408document.addEventListener("keydown", (e) => { 409 if (e.key === "Escape") { 410 closeCreateInviteModal(); 411 closeEditInviteModal(); 412 } 413}); 414 415// Close modals on outside click 416document.getElementById("createInviteModal")?.addEventListener("click", (e) => { 417 if (e.target === e.currentTarget) { 418 closeCreateInviteModal(); 419 } 420}); 421 422document.getElementById("editInviteModal")?.addEventListener("click", (e) => { 423 if (e.target === e.currentTarget) { 424 closeEditInviteModal(); 425 } 426}); 427 428let currentEditInviteId: number | null = null; 429 430// Make editInvite globally available for onclick handler 431(window as any).editInvite = async (inviteId: number) => { 432 try { 433 const response = await fetch("/api/invites", { 434 headers: { 435 Authorization: `Bearer ${token}`, 436 }, 437 }); 438 439 if (!response.ok) { 440 throw new Error("Failed to load invite"); 441 } 442 443 const data = await response.json(); 444 const invite = data.invites.find( 445 (inv: { id: number }) => inv.id === inviteId, 446 ); 447 448 if (!invite) { 449 throw new Error("Invite not found"); 450 } 451 452 currentEditInviteId = inviteId; 453 454 // Populate form 455 (document.getElementById("editMaxUses") as HTMLInputElement).value = String( 456 invite.maxUses, 457 ); 458 (document.getElementById("editInviteNote") as HTMLTextAreaElement).value = 459 invite.note || ""; 460 ( 461 document.getElementById("editInviteMessage") as HTMLTextAreaElement 462 ).value = invite.message || ""; 463 464 // Handle expiration date 465 const expiresAtInput = document.getElementById( 466 "editExpiresAt", 467 ) as HTMLInputElement; 468 if (invite.expiresAt) { 469 const date = new Date(invite.expiresAt * 1000); 470 const localDatetime = new Date( 471 date.getTime() - date.getTimezoneOffset() * 60000, 472 ) 473 .toISOString() 474 .slice(0, 16); 475 expiresAtInput.value = localDatetime; 476 } else { 477 expiresAtInput.value = ""; 478 } 479 480 // Show modal 481 const modal = document.getElementById("editInviteModal"); 482 if (modal) { 483 modal.style.display = "flex"; 484 } 485 } catch (error) { 486 console.error("Failed to load invite:", error); 487 alert("Failed to load invite"); 488 } 489}; 490 491(window as any).submitEditInvite = async () => { 492 if (currentEditInviteId === null) return; 493 494 const maxUsesInput = document.getElementById( 495 "editMaxUses", 496 ) as HTMLInputElement; 497 const expiresAtInput = document.getElementById( 498 "editExpiresAt", 499 ) as HTMLInputElement; 500 const noteInput = document.getElementById( 501 "editInviteNote", 502 ) as HTMLTextAreaElement; 503 const messageInput = document.getElementById( 504 "editInviteMessage", 505 ) as HTMLTextAreaElement; 506 const submitBtn = document.getElementById( 507 "submitEditInviteBtn", 508 ) as HTMLButtonElement; 509 510 const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value, 10) : null; 511 const expiresAt = expiresAtInput.value 512 ? Math.floor(new Date(expiresAtInput.value).getTime() / 1000) 513 : null; 514 const note = noteInput.value.trim() || null; 515 const message = messageInput.value.trim() || null; 516 517 submitBtn.disabled = true; 518 submitBtn.textContent = "saving..."; 519 520 try { 521 const response = await fetch(`/api/invites/${currentEditInviteId}`, { 522 method: "PATCH", 523 headers: { 524 Authorization: `Bearer ${token}`, 525 "Content-Type": "application/json", 526 }, 527 body: JSON.stringify({ maxUses, expiresAt, note, message }), 528 }); 529 530 if (!response.ok) { 531 throw new Error("Failed to update invite"); 532 } 533 534 await loadInvites(); 535 closeEditInviteModal(); 536 } catch (error) { 537 console.error("Failed to update invite:", error); 538 alert("Failed to update invite"); 539 } finally { 540 submitBtn.disabled = false; 541 submitBtn.textContent = "save changes"; 542 } 543}; 544 545(window as any).closeEditInviteModal = () => { 546 const modal = document.getElementById("editInviteModal"); 547 if (modal) { 548 modal.style.display = "none"; 549 currentEditInviteId = null; 550 (document.getElementById("editMaxUses") as HTMLInputElement).value = ""; 551 (document.getElementById("editExpiresAt") as HTMLInputElement).value = ""; 552 (document.getElementById("editInviteNote") as HTMLTextAreaElement).value = 553 ""; 554 ( 555 document.getElementById("editInviteMessage") as HTMLTextAreaElement 556 ).value = ""; 557 } 558}; 559 560(window as any).deleteInvite = async (inviteId: number, event?: Event) => { 561 const btn = event?.target as HTMLButtonElement | undefined; 562 563 // Double-click confirmation pattern 564 if (btn?.dataset.confirmState === "pending") { 565 // Second click - execute delete 566 delete btn.dataset.confirmState; 567 btn.textContent = "deleting..."; 568 btn.disabled = true; 569 570 try { 571 const response = await fetch(`/api/invites/${inviteId}`, { 572 method: "DELETE", 573 headers: { 574 Authorization: `Bearer ${token}`, 575 }, 576 }); 577 578 if (!response.ok) { 579 throw new Error("Failed to delete invite"); 580 } 581 582 await loadInvites(); 583 } catch (error) { 584 console.error("Failed to delete invite:", error); 585 alert("Failed to delete invite"); 586 btn.textContent = "delete"; 587 btn.disabled = false; 588 } 589 } else { 590 // First click - set pending state 591 if (btn) { 592 const originalText = btn.textContent; 593 btn.dataset.confirmState = "pending"; 594 btn.textContent = "you sure?"; 595 596 // Reset after 3 seconds if not confirmed 597 setTimeout(() => { 598 if (btn.dataset.confirmState === "pending") { 599 delete btn.dataset.confirmState; 600 btn.textContent = originalText; 601 } 602 }, 3000); 603 } 604 } 605};