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 (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();