my own indieAuth provider!
indiko.dunkirk.sh/docs
indieauth
oauth2-server
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();