my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server
at main 822 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 ( 573 result.client && 574 result.client.clientId && 575 result.client.clientSecret 576 ) { 577 const secretModal = document.getElementById( 578 "secretModal", 579 ) as HTMLElement; 580 const generatedClientId = document.getElementById( 581 "generatedClientId", 582 ) as HTMLElement; 583 const generatedSecret = document.getElementById( 584 "generatedSecret", 585 ) as HTMLElement; 586 587 if (generatedClientId && generatedSecret && secretModal) { 588 generatedClientId.textContent = result.client.clientId; 589 generatedSecret.textContent = result.client.clientSecret; 590 secretModal.classList.add("active"); 591 } 592 } 593 } else { 594 showToast("Client updated successfully"); 595 } 596 597 await loadClients(); 598 } catch (error) { 599 console.error("Failed to save client:", error); 600 showToast( 601 `Failed to ${isEdit ? "update" : "create"} client: ${error instanceof Error ? error.message : "Unknown error"}`, 602 "error", 603 ); 604 } 605}); 606 607(window as any).regenerateSecret = async (clientId: string, event?: Event) => { 608 const btn = event?.target as HTMLButtonElement | undefined; 609 610 // Double-click confirmation pattern (same as delete) 611 if (btn?.dataset.confirmState === "pending") { 612 // Second click - execute regenerate 613 delete btn.dataset.confirmState; 614 btn.disabled = true; 615 btn.textContent = "regenerating..."; 616 617 try { 618 const response = await fetch( 619 `/api/admin/clients/${encodeURIComponent(clientId)}/secret`, 620 { 621 method: "POST", 622 headers: { 623 Authorization: `Bearer ${token}`, 624 }, 625 }, 626 ); 627 628 if (!response.ok) { 629 throw new Error("Failed to regenerate secret"); 630 } 631 632 const data = await response.json(); 633 634 // Show the secret in modal 635 const secretModal = document.getElementById("secretModal") as HTMLElement; 636 const generatedClientId = document.getElementById( 637 "generatedClientId", 638 ) as HTMLElement; 639 const generatedSecret = document.getElementById( 640 "generatedSecret", 641 ) as HTMLElement; 642 643 if (generatedClientId && generatedSecret && secretModal) { 644 generatedClientId.textContent = clientId; 645 generatedSecret.textContent = data.clientSecret; 646 secretModal.classList.add("active"); 647 } 648 649 btn.disabled = false; 650 btn.textContent = "regenerate secret"; 651 } catch (error) { 652 console.error("Failed to regenerate secret:", error); 653 showToast( 654 "Failed to regenerate client secret. Please try again.", 655 "error", 656 ); 657 btn.disabled = false; 658 btn.textContent = "regenerate secret"; 659 } 660 } else { 661 // First click - set pending state 662 if (btn) { 663 const originalText = btn.textContent; 664 btn.dataset.confirmState = "pending"; 665 btn.textContent = "you sure?"; 666 667 // Reset after 3 seconds if not confirmed 668 setTimeout(() => { 669 if (btn.dataset.confirmState === "pending") { 670 delete btn.dataset.confirmState; 671 btn.textContent = originalText; 672 } 673 }, 3000); 674 } 675 } 676}; 677 678(window as any).revokeUserPermission = async ( 679 clientId: string, 680 username: string, 681 event?: Event, 682) => { 683 const btn = event?.target as HTMLButtonElement | undefined; 684 685 // Double-click confirmation pattern 686 if (btn?.dataset.confirmState === "pending") { 687 // Second click - execute revoke 688 delete btn.dataset.confirmState; 689 btn.disabled = true; 690 btn.textContent = "revoking..."; 691 692 try { 693 const response = await fetch( 694 `/api/admin/apps/${encodeURIComponent(clientId)}/users/${encodeURIComponent(username)}`, 695 { 696 method: "DELETE", 697 headers: { 698 Authorization: `Bearer ${token}`, 699 }, 700 }, 701 ); 702 703 if (!response.ok) { 704 throw new Error("Failed to revoke permission"); 705 } 706 707 // Reload the client details 708 const detailsDiv = document.getElementById( 709 `details-${encodeURIComponent(clientId)}`, 710 ); 711 if (detailsDiv) { 712 detailsDiv.dataset.loaded = "false"; 713 } 714 715 const card = document.querySelector( 716 `[data-client-id="${clientId}"]`, 717 ) as HTMLElement; 718 if (card) { 719 card.classList.remove("expanded"); 720 } 721 722 await loadClients(); 723 } catch (error) { 724 console.error("Failed to revoke permission:", error); 725 showToast("Failed to revoke permission. Please try again.", "error"); 726 btn.disabled = false; 727 btn.textContent = "revoke"; 728 } 729 } else { 730 // First click - set pending state 731 if (btn) { 732 const originalText = btn.textContent; 733 btn.dataset.confirmState = "pending"; 734 btn.textContent = "you sure?"; 735 736 // Reset after 3 seconds if not confirmed 737 setTimeout(() => { 738 if (btn.dataset.confirmState === "pending") { 739 delete btn.dataset.confirmState; 740 btn.textContent = originalText; 741 } 742 }, 3000); 743 } 744 } 745}; 746 747// Secret modal handlers 748const secretModal = document.getElementById("secretModal") as HTMLElement; 749const secretModalClose = document.getElementById( 750 "secretModalClose", 751) as HTMLButtonElement; 752const copyClientIdBtn = document.getElementById( 753 "copyClientIdBtn", 754) as HTMLButtonElement; 755const copySecretBtn = document.getElementById( 756 "copySecretBtn", 757) as HTMLButtonElement; 758 759secretModalClose?.addEventListener("click", () => { 760 secretModal?.classList.remove("active"); 761}); 762 763copyClientIdBtn?.addEventListener("click", async () => { 764 const generatedClientId = document.getElementById( 765 "generatedClientId", 766 ) as HTMLElement; 767 if (generatedClientId) { 768 try { 769 await navigator.clipboard.writeText(generatedClientId.textContent || ""); 770 const originalText = copyClientIdBtn.textContent; 771 copyClientIdBtn.textContent = "copied! ✓"; 772 setTimeout(() => { 773 copyClientIdBtn.textContent = originalText; 774 }, 2000); 775 } catch (error) { 776 console.error("Failed to copy:", error); 777 showToast("Failed to copy to clipboard", "error"); 778 } 779 } 780}); 781 782copySecretBtn?.addEventListener("click", async () => { 783 const generatedSecret = document.getElementById( 784 "generatedSecret", 785 ) as HTMLElement; 786 if (generatedSecret) { 787 try { 788 await navigator.clipboard.writeText(generatedSecret.textContent || ""); 789 const originalText = copySecretBtn.textContent; 790 copySecretBtn.textContent = "copied! ✓"; 791 setTimeout(() => { 792 copySecretBtn.textContent = originalText; 793 }, 2000); 794 } catch (error) { 795 console.error("Failed to copy:", error); 796 showToast("Failed to copy to clipboard", "error"); 797 } 798 } 799}); 800 801// Close modals on escape key 802document.addEventListener("keydown", (e) => { 803 if (e.key === "Escape") { 804 clientModal?.classList.remove("active"); 805 secretModal?.classList.remove("active"); 806 } 807}); 808 809// Close modals on outside click 810clientModal?.addEventListener("click", (e) => { 811 if (e.target === clientModal) { 812 clientModal.classList.remove("active"); 813 } 814}); 815 816secretModal?.addEventListener("click", (e) => { 817 if (e.target === secretModal) { 818 secretModal.classList.remove("active"); 819 } 820}); 821 822checkAuth();