my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 818 lines 24 kB view raw
1const token = localStorage.getItem("indiko_session"); 2const footer = document.getElementById("footer") as HTMLElement; 3const clientsList = document.getElementById("clientsList") as HTMLElement; 4const createClientBtn = document.getElementById( 5 "createClientBtn", 6) as HTMLButtonElement; 7const clientModal = document.getElementById("clientModal") as HTMLElement; 8const modalClose = document.getElementById("modalClose") as HTMLButtonElement; 9const cancelBtn = document.getElementById("cancelBtn") as HTMLButtonElement; 10const clientForm = document.getElementById("clientForm") as HTMLFormElement; 11const modalTitle = document.getElementById("modalTitle") as HTMLElement; 12const addRedirectUriBtn = document.getElementById( 13 "addRedirectUriBtn", 14) as HTMLButtonElement; 15const redirectUrisList = document.getElementById( 16 "redirectUrisList", 17) as HTMLElement; 18const toast = document.getElementById("toast") as HTMLElement; 19 20function showToast(message: string, type: "success" | "error" = "success") { 21 toast.textContent = message; 22 toast.className = `toast ${type} show`; 23 24 setTimeout(() => { 25 toast.classList.remove("show"); 26 }, 3000); 27} 28 29async function checkAuth() { 30 if (!token) { 31 window.location.href = "/login"; 32 return; 33 } 34 35 try { 36 const response = await fetch("/api/hello", { 37 headers: { 38 Authorization: `Bearer ${token}`, 39 }, 40 }); 41 42 if (response.status === 401 || response.status === 403) { 43 localStorage.removeItem("indiko_session"); 44 window.location.href = "/login"; 45 return; 46 } 47 48 const data = await response.json(); 49 50 footer.innerHTML = `admin • signed in as <strong><a href="/u/${data.username}">${data.username}</a></strong> • <a href="/login" id="logoutLink">sign out</a> 51 <div class="back-link"><a href="/">← back to dashboard</a></div>`; 52 53 document 54 .getElementById("logoutLink") 55 ?.addEventListener("click", async (e) => { 56 e.preventDefault(); 57 try { 58 await fetch("/auth/logout", { 59 method: "POST", 60 headers: { 61 Authorization: `Bearer ${token}`, 62 }, 63 }); 64 } catch { 65 // Ignore logout errors 66 } 67 localStorage.removeItem("indiko_session"); 68 window.location.href = "/login"; 69 }); 70 71 if (!data.isAdmin) { 72 window.location.href = "/"; 73 return; 74 } 75 76 loadClients(); 77 } catch (error) { 78 console.error("Auth check failed:", error); 79 footer.textContent = "error loading user info"; 80 clientsList.innerHTML = '<div class="error">Failed to load clients</div>'; 81 } 82} 83 84interface Client { 85 id: number; 86 clientId: string; 87 name: string; 88 logoUrl: string | null; 89 description: string | null; 90 redirectUris: string[]; 91 isPreregistered: boolean; 92 availableRoles: string[] | null; 93 defaultRole: string | null; 94 firstSeen: number; 95 lastUsed: number; 96} 97 98interface ClientUser { 99 username: string; 100 name: string; 101 scopes: string[]; 102 role: string | null; 103 grantedAt: number; 104 lastUsed: number; 105} 106 107interface AppPermission { 108 username: string; 109 name: string; 110 scopes: string[]; 111 grantedAt: number; 112 lastUsed: number; 113} 114 115async function loadClients() { 116 try { 117 const response = await fetch("/api/admin/clients", { 118 headers: { 119 Authorization: `Bearer ${token}`, 120 }, 121 }); 122 123 if (!response.ok) { 124 throw new Error("Failed to load clients"); 125 } 126 127 const data = await response.json(); 128 displayClients(data.clients); 129 } catch (error) { 130 console.error("Failed to load clients:", error); 131 clientsList.innerHTML = '<div class="error">Failed to load clients</div>'; 132 } 133} 134 135function displayClients(clients: Client[]) { 136 if (clients.length === 0) { 137 clientsList.innerHTML = 138 '<div class="empty">No OAuth clients registered yet.</div>'; 139 return; 140 } 141 142 clientsList.innerHTML = clients 143 .map((client) => { 144 const lastUsedDate = new Date( 145 client.lastUsed * 1000, 146 ).toLocaleDateString(); 147 const firstSeenDate = new Date( 148 client.firstSeen * 1000, 149 ).toLocaleDateString(); 150 151 return ` 152 <div class="client-card" data-client-id="${client.clientId}"> 153 <div class="client-header" onclick="toggleClient('${client.clientId}')"> 154 <div class="client-logo"> 155 ${ 156 client.logoUrl 157 ? `<img src="${client.logoUrl}" alt="${client.name}" />` 158 : `<div class="client-logo-placeholder">🔐</div>` 159 } 160 </div> 161 <div class="client-info"> 162 <div class="client-name">${client.name}</div> 163 <div class="client-id">${client.clientId}</div> 164 ${client.description ? `<div class="client-description">${client.description}</div>` : ""} 165 <div class="client-badges"> 166 <span class="badge ${client.isPreregistered ? "badge-preregistered" : "badge-auto"}"> 167 ${client.isPreregistered ? "pre-registered" : "auto-registered"} 168 </span> 169 <span class="badge badge-auto">first seen ${firstSeenDate}</span> 170 <span class="badge badge-auto">last used ${lastUsedDate}</span> 171 </div> 172 </div> 173 <div class="client-actions" style="display: flex; gap: 0.5rem; align-items: center;"> 174 ${ 175 client.isPreregistered 176 ? ` 177 <button class="btn-edit" onclick="event.stopPropagation(); editClient('${client.clientId}')">edit</button> 178 <button class="btn-delete" onclick="event.stopPropagation(); deleteClient('${client.clientId}', event)">delete</button> 179 ` 180 : "" 181 } 182 <span class="expand-indicator">details <span class="arrow">▼</span></span> 183 </div> 184 </div> 185 <div class="client-details" id="details-${encodeURIComponent(client.clientId)}"> 186 <div class="loading">loading details...</div> 187 </div> 188 </div> 189 `; 190 }) 191 .join(""); 192} 193 194(window as any).toggleClient = async (clientId: string) => { 195 const card = document.querySelector( 196 `[data-client-id="${clientId}"]`, 197 ) as HTMLElement; 198 if (!card) return; 199 200 const isExpanded = card.classList.contains("expanded"); 201 const arrow = card.querySelector(".arrow") as HTMLElement; 202 203 if (isExpanded) { 204 card.classList.remove("expanded"); 205 if (arrow) arrow.textContent = "▼"; 206 return; 207 } 208 209 card.classList.add("expanded"); 210 if (arrow) arrow.textContent = "▲"; 211 212 const detailsDiv = document.getElementById( 213 `details-${encodeURIComponent(clientId)}`, 214 ); 215 if (!detailsDiv) return; 216 217 if (detailsDiv.dataset.loaded === "true") { 218 return; 219 } 220 221 try { 222 const response = await fetch( 223 `/api/admin/clients/${encodeURIComponent(clientId)}`, 224 { 225 headers: { 226 Authorization: `Bearer ${token}`, 227 }, 228 }, 229 ); 230 231 if (!response.ok) { 232 throw new Error("Failed to load client details"); 233 } 234 235 const data = await response.json(); 236 237 detailsDiv.innerHTML = ` 238 ${ 239 data.client.isPreregistered 240 ? ` 241 <div class="detail-section"> 242 <div class="detail-title">client secret</div> 243 <div class="secret-section"> 244 <input type="password" value="••••••••••••••••••••••••" readonly style="background: rgba(0, 0, 0, 0.3); border: 1px solid var(--old-rose); color: var(--lavender); padding: 0.5rem; font-family: monospace; width: 100%; margin-bottom: 0.5rem;" id="secret-${encodeURIComponent(clientId)}" /> 245 <button class="btn-edit" onclick="event.stopPropagation(); regenerateSecret('${clientId}', event)">regenerate secret</button> 246 </div> 247 </div> 248 ` 249 : "" 250 } 251 <div class="detail-section"> 252 <div class="detail-title">redirect uris</div> 253 <div class="redirect-uris"> 254 ${data.client.redirectUris.map((uri: string) => `<div class="redirect-uri">${uri}</div>`).join("")} 255 </div> 256 </div> 257 <div class="detail-section"> 258 <div class="detail-title">authorized users (${data.users.length})</div> 259 ${ 260 data.users.length === 0 261 ? '<div class="empty">No users have authorized this client yet</div>' 262 : `<div class="users-list"> 263 ${data.users 264 .map((user: ClientUser) => { 265 const grantedDate = new Date( 266 user.grantedAt * 1000, 267 ).toLocaleDateString(); 268 const lastUsedDate = new Date( 269 user.lastUsed * 1000, 270 ).toLocaleDateString(); 271 272 return ` 273 <div class="user-item"> 274 <div class="user-info"> 275 <div class="user-name"><a href="/u/${user.username}" onclick="event.stopPropagation();" style="color: var(--lavender); text-decoration: none;">${user.name}</a> (<a href="/u/${user.username}" onclick="event.stopPropagation();" style="color: var(--old-rose); text-decoration: none;">@${user.username}</a>)</div> 276 ${ 277 data.client.isPreregistered && 278 data.client.availableRoles !== null 279 ? ` 280 <div class="user-role-input"> 281 <label style="color: var(--old-rose); font-size: 0.75rem;">ROLE${data.client.availableRoles.length > 0 ? "" : " (OPTIONAL)"}:</label> 282 ${ 283 data.client.availableRoles.length > 0 284 ? `<select data-username="${user.username}" data-client-id="${clientId}" style="padding: 0.5rem; background: rgba(0, 0, 0, 0.3); border: 1px solid var(--old-rose); color: var(--lavender); font-family: inherit; font-size: 0.875rem;"> 285 <option value="">No role</option> 286 ${data.client.availableRoles 287 .map( 288 (role: string) => ` 289 <option value="${role}" ${user.role === role ? "selected" : ""}>${role}</option> 290 `, 291 ) 292 .join("")} 293 </select>` 294 : `<input type="text" value="${user.role || ""}" placeholder="e.g. admin, editor, viewer" data-username="${user.username}" data-client-id="${clientId}" />` 295 } 296 <button onclick="event.stopPropagation(); setUserRole('${clientId}', '${user.username}', this.previousElementSibling.value)">update</button> 297 </div> 298 ` 299 : "" 300 } 301 <div class="user-meta"> 302 Granted ${grantedDate} • Last used ${lastUsedDate} • Scopes: ${user.scopes.join(", ")} 303 </div> 304 </div> 305 <button class="revoke-btn" onclick="event.stopPropagation(); revokeUserPermission('${clientId}', '${user.username}', event)">revoke</button> 306 </div> 307 `; 308 }) 309 .join("")} 310 </div>` 311 } 312 </div> 313 `; 314 315 detailsDiv.dataset.loaded = "true"; 316 } catch (error) { 317 console.error("Failed to load client details:", error); 318 detailsDiv.innerHTML = '<div class="error">Failed to load details</div>'; 319 } 320}; 321 322(window as any).setUserRole = async ( 323 clientId: string, 324 username: string, 325 role: string, 326) => { 327 try { 328 const response = await fetch( 329 `/api/admin/clients/${encodeURIComponent(clientId)}/users/${encodeURIComponent(username)}/role`, 330 { 331 method: "POST", 332 headers: { 333 Authorization: `Bearer ${token}`, 334 "Content-Type": "application/json", 335 }, 336 body: JSON.stringify({ role: role || null }), 337 }, 338 ); 339 340 if (!response.ok) { 341 throw new Error("Failed to set user role"); 342 } 343 344 showToast("User role updated successfully"); 345 } catch (error) { 346 console.error("Failed to set user role:", error); 347 showToast("Failed to update user role. Please try again.", "error"); 348 } 349}; 350 351(window as any).editClient = async (clientId: string) => { 352 try { 353 const response = await fetch( 354 `/api/admin/clients/${encodeURIComponent(clientId)}`, 355 { 356 headers: { 357 Authorization: `Bearer ${token}`, 358 }, 359 }, 360 ); 361 362 if (!response.ok) { 363 throw new Error("Failed to load client"); 364 } 365 366 const data = await response.json(); 367 const client = data.client; 368 369 modalTitle.textContent = "Edit OAuth Client"; 370 (document.getElementById("editClientId") as HTMLInputElement).value = 371 clientId; 372 (document.getElementById("clientName") as HTMLInputElement).value = 373 client.name || ""; 374 (document.getElementById("logoUrl") as HTMLInputElement).value = 375 client.logoUrl || ""; 376 (document.getElementById("description") as HTMLTextAreaElement).value = 377 client.description || ""; 378 (document.getElementById("availableRoles") as HTMLTextAreaElement).value = 379 client.availableRoles ? client.availableRoles.join("\n") : ""; 380 (document.getElementById("defaultRole") as HTMLInputElement).value = 381 client.defaultRole || ""; 382 383 redirectUrisList.innerHTML = client.redirectUris 384 .map( 385 (uri: string) => ` 386 <div class="redirect-uri-item"> 387 <input type="url" class="form-input redirect-uri-input" value="${uri}" required /> 388 <button type="button" class="btn-remove" onclick="removeRedirectUri(this)">remove</button> 389 </div> 390 `, 391 ) 392 .join(""); 393 394 clientModal.classList.add("active"); 395 } catch (error) { 396 console.error("Failed to load client:", error); 397 showToast("Failed to load client details", "error"); 398 } 399}; 400 401(window as any).deleteClient = async (clientId: string, event?: Event) => { 402 const btn = event?.target as HTMLButtonElement | undefined; 403 404 // Double-click confirmation pattern 405 if (btn?.dataset.confirmState === "pending") { 406 // Second click - execute delete 407 delete btn.dataset.confirmState; 408 btn.disabled = true; 409 btn.textContent = "deleting..."; 410 411 try { 412 const response = await fetch( 413 `/api/admin/clients/${encodeURIComponent(clientId)}`, 414 { 415 method: "DELETE", 416 headers: { 417 Authorization: `Bearer ${token}`, 418 }, 419 }, 420 ); 421 422 if (!response.ok) { 423 throw new Error("Failed to delete client"); 424 } 425 426 await loadClients(); 427 } catch (error) { 428 console.error("Failed to delete client:", error); 429 showToast("Failed to delete client. Please try again.", "error"); 430 btn.disabled = false; 431 btn.textContent = "delete"; 432 } 433 } else { 434 // First click - set pending state 435 if (btn) { 436 const originalText = btn.textContent; 437 btn.dataset.confirmState = "pending"; 438 btn.textContent = "you sure?"; 439 440 // Reset after 3 seconds if not confirmed 441 setTimeout(() => { 442 if (btn.dataset.confirmState === "pending") { 443 delete btn.dataset.confirmState; 444 btn.textContent = originalText; 445 } 446 }, 3000); 447 } 448 } 449}; 450 451createClientBtn.addEventListener("click", () => { 452 modalTitle.textContent = "Create OAuth Client"; 453 clientForm.reset(); 454 (document.getElementById("editClientId") as HTMLInputElement).value = ""; 455 redirectUrisList.innerHTML = ` 456 <div class="redirect-uri-item"> 457 <input type="url" class="form-input redirect-uri-input" placeholder="https://example.com/auth/callback" required /> 458 <button type="button" class="btn-remove" onclick="removeRedirectUri(this)">remove</button> 459 </div> 460 `; 461 clientModal.classList.add("active"); 462}); 463 464modalClose.addEventListener("click", () => { 465 clientModal.classList.remove("active"); 466}); 467 468cancelBtn.addEventListener("click", () => { 469 clientModal.classList.remove("active"); 470}); 471 472addRedirectUriBtn.addEventListener("click", () => { 473 const newItem = document.createElement("div"); 474 newItem.className = "redirect-uri-item"; 475 newItem.innerHTML = ` 476 <input type="url" class="form-input redirect-uri-input" placeholder="https://example.com/auth/callback" required /> 477 <button type="button" class="btn-remove" onclick="removeRedirectUri(this)">remove</button> 478 `; 479 redirectUrisList.appendChild(newItem); 480}); 481 482(window as any).removeRedirectUri = (btn: HTMLButtonElement) => { 483 const items = redirectUrisList.querySelectorAll(".redirect-uri-item"); 484 if (items.length > 1) { 485 btn.parentElement?.remove(); 486 } else { 487 showToast("At least one redirect URI is required", "error"); 488 } 489}; 490 491clientForm.addEventListener("submit", async (e) => { 492 e.preventDefault(); 493 494 const editClientId = ( 495 document.getElementById("editClientId") as HTMLInputElement 496 ).value; 497 const name = (document.getElementById("clientName") as HTMLInputElement) 498 .value; 499 const logoUrl = (document.getElementById("logoUrl") as HTMLInputElement) 500 .value; 501 const description = ( 502 document.getElementById("description") as HTMLTextAreaElement 503 ).value; 504 const availableRolesText = ( 505 document.getElementById("availableRoles") as HTMLTextAreaElement 506 ).value; 507 const defaultRole = ( 508 document.getElementById("defaultRole") as HTMLInputElement 509 ).value; 510 511 const redirectUriInputs = Array.from( 512 redirectUrisList.querySelectorAll(".redirect-uri-input"), 513 ) as HTMLInputElement[]; 514 const redirectUris = redirectUriInputs 515 .map((input) => input.value) 516 .filter((uri) => uri.trim()); 517 518 // Parse available roles from textarea (one per line) 519 const availableRoles = availableRolesText 520 .split("\n") 521 .map((r) => r.trim()) 522 .filter((r) => r); 523 524 // Validate default role is in available roles 525 if ( 526 defaultRole && 527 availableRoles.length > 0 && 528 !availableRoles.includes(defaultRole) 529 ) { 530 showToast("Default role must be one of the available roles", "error"); 531 return; 532 } 533 534 if (redirectUris.length === 0) { 535 showToast("At least one redirect URI is required", "error"); 536 return; 537 } 538 539 const isEdit = !!editClientId; 540 const url = isEdit 541 ? `/api/admin/clients/${encodeURIComponent(editClientId)}` 542 : "/api/admin/clients"; 543 const method = isEdit ? "PUT" : "POST"; 544 545 try { 546 const response = await fetch(url, { 547 method, 548 headers: { 549 Authorization: `Bearer ${token}`, 550 "Content-Type": "application/json", 551 }, 552 body: JSON.stringify({ 553 name, 554 logoUrl, 555 description, 556 redirectUris, 557 availableRoles: availableRolesText.trim() ? availableRoles : null, 558 defaultRole: defaultRole || undefined, 559 }), 560 }); 561 562 if (!response.ok) { 563 const error = await response.json(); 564 throw new Error(error.error || "Failed to save client"); 565 } 566 567 clientModal.classList.remove("active"); 568 569 // If creating a new client, show the credentials in modal 570 if (!isEdit) { 571 const result = await response.json(); 572 if (result.client?.clientId && result.client.clientSecret) { 573 const secretModal = document.getElementById( 574 "secretModal", 575 ) as HTMLElement; 576 const generatedClientId = document.getElementById( 577 "generatedClientId", 578 ) as HTMLElement; 579 const generatedSecret = document.getElementById( 580 "generatedSecret", 581 ) as HTMLElement; 582 583 if (generatedClientId && generatedSecret && secretModal) { 584 generatedClientId.textContent = result.client.clientId; 585 generatedSecret.textContent = result.client.clientSecret; 586 secretModal.classList.add("active"); 587 } 588 } 589 } else { 590 showToast("Client updated successfully"); 591 } 592 593 await loadClients(); 594 } catch (error) { 595 console.error("Failed to save client:", error); 596 showToast( 597 `Failed to ${isEdit ? "update" : "create"} client: ${error instanceof Error ? error.message : "Unknown error"}`, 598 "error", 599 ); 600 } 601}); 602 603(window as any).regenerateSecret = async (clientId: string, event?: Event) => { 604 const btn = event?.target as HTMLButtonElement | undefined; 605 606 // Double-click confirmation pattern (same as delete) 607 if (btn?.dataset.confirmState === "pending") { 608 // Second click - execute regenerate 609 delete btn.dataset.confirmState; 610 btn.disabled = true; 611 btn.textContent = "regenerating..."; 612 613 try { 614 const response = await fetch( 615 `/api/admin/clients/${encodeURIComponent(clientId)}/secret`, 616 { 617 method: "POST", 618 headers: { 619 Authorization: `Bearer ${token}`, 620 }, 621 }, 622 ); 623 624 if (!response.ok) { 625 throw new Error("Failed to regenerate secret"); 626 } 627 628 const data = await response.json(); 629 630 // Show the secret in modal 631 const secretModal = document.getElementById("secretModal") as HTMLElement; 632 const generatedClientId = document.getElementById( 633 "generatedClientId", 634 ) as HTMLElement; 635 const generatedSecret = document.getElementById( 636 "generatedSecret", 637 ) as HTMLElement; 638 639 if (generatedClientId && generatedSecret && secretModal) { 640 generatedClientId.textContent = clientId; 641 generatedSecret.textContent = data.clientSecret; 642 secretModal.classList.add("active"); 643 } 644 645 btn.disabled = false; 646 btn.textContent = "regenerate secret"; 647 } catch (error) { 648 console.error("Failed to regenerate secret:", error); 649 showToast( 650 "Failed to regenerate client secret. Please try again.", 651 "error", 652 ); 653 btn.disabled = false; 654 btn.textContent = "regenerate secret"; 655 } 656 } else { 657 // First click - set pending state 658 if (btn) { 659 const originalText = btn.textContent; 660 btn.dataset.confirmState = "pending"; 661 btn.textContent = "you sure?"; 662 663 // Reset after 3 seconds if not confirmed 664 setTimeout(() => { 665 if (btn.dataset.confirmState === "pending") { 666 delete btn.dataset.confirmState; 667 btn.textContent = originalText; 668 } 669 }, 3000); 670 } 671 } 672}; 673 674(window as any).revokeUserPermission = async ( 675 clientId: string, 676 username: string, 677 event?: Event, 678) => { 679 const btn = event?.target as HTMLButtonElement | undefined; 680 681 // Double-click confirmation pattern 682 if (btn?.dataset.confirmState === "pending") { 683 // Second click - execute revoke 684 delete btn.dataset.confirmState; 685 btn.disabled = true; 686 btn.textContent = "revoking..."; 687 688 try { 689 const response = await fetch( 690 `/api/admin/apps/${encodeURIComponent(clientId)}/users/${encodeURIComponent(username)}`, 691 { 692 method: "DELETE", 693 headers: { 694 Authorization: `Bearer ${token}`, 695 }, 696 }, 697 ); 698 699 if (!response.ok) { 700 throw new Error("Failed to revoke permission"); 701 } 702 703 // Reload the client details 704 const detailsDiv = document.getElementById( 705 `details-${encodeURIComponent(clientId)}`, 706 ); 707 if (detailsDiv) { 708 detailsDiv.dataset.loaded = "false"; 709 } 710 711 const card = document.querySelector( 712 `[data-client-id="${clientId}"]`, 713 ) as HTMLElement; 714 if (card) { 715 card.classList.remove("expanded"); 716 } 717 718 await loadClients(); 719 } catch (error) { 720 console.error("Failed to revoke permission:", error); 721 showToast("Failed to revoke permission. Please try again.", "error"); 722 btn.disabled = false; 723 btn.textContent = "revoke"; 724 } 725 } else { 726 // First click - set pending state 727 if (btn) { 728 const originalText = btn.textContent; 729 btn.dataset.confirmState = "pending"; 730 btn.textContent = "you sure?"; 731 732 // Reset after 3 seconds if not confirmed 733 setTimeout(() => { 734 if (btn.dataset.confirmState === "pending") { 735 delete btn.dataset.confirmState; 736 btn.textContent = originalText; 737 } 738 }, 3000); 739 } 740 } 741}; 742 743// Secret modal handlers 744const secretModal = document.getElementById("secretModal") as HTMLElement; 745const secretModalClose = document.getElementById( 746 "secretModalClose", 747) as HTMLButtonElement; 748const copyClientIdBtn = document.getElementById( 749 "copyClientIdBtn", 750) as HTMLButtonElement; 751const copySecretBtn = document.getElementById( 752 "copySecretBtn", 753) as HTMLButtonElement; 754 755secretModalClose?.addEventListener("click", () => { 756 secretModal?.classList.remove("active"); 757}); 758 759copyClientIdBtn?.addEventListener("click", async () => { 760 const generatedClientId = document.getElementById( 761 "generatedClientId", 762 ) as HTMLElement; 763 if (generatedClientId) { 764 try { 765 await navigator.clipboard.writeText(generatedClientId.textContent || ""); 766 const originalText = copyClientIdBtn.textContent; 767 copyClientIdBtn.textContent = "copied! ✓"; 768 setTimeout(() => { 769 copyClientIdBtn.textContent = originalText; 770 }, 2000); 771 } catch (error) { 772 console.error("Failed to copy:", error); 773 showToast("Failed to copy to clipboard", "error"); 774 } 775 } 776}); 777 778copySecretBtn?.addEventListener("click", async () => { 779 const generatedSecret = document.getElementById( 780 "generatedSecret", 781 ) as HTMLElement; 782 if (generatedSecret) { 783 try { 784 await navigator.clipboard.writeText(generatedSecret.textContent || ""); 785 const originalText = copySecretBtn.textContent; 786 copySecretBtn.textContent = "copied! ✓"; 787 setTimeout(() => { 788 copySecretBtn.textContent = originalText; 789 }, 2000); 790 } catch (error) { 791 console.error("Failed to copy:", error); 792 showToast("Failed to copy to clipboard", "error"); 793 } 794 } 795}); 796 797// Close modals on escape key 798document.addEventListener("keydown", (e) => { 799 if (e.key === "Escape") { 800 clientModal?.classList.remove("active"); 801 secretModal?.classList.remove("active"); 802 } 803}); 804 805// Close modals on outside click 806clientModal?.addEventListener("click", (e) => { 807 if (e.target === clientModal) { 808 clientModal.classList.remove("active"); 809 } 810}); 811 812secretModal?.addEventListener("click", (e) => { 813 if (e.target === secretModal) { 814 secretModal.classList.remove("active"); 815 } 816}); 817 818checkAuth();