ManagedACL.svelte raw
1 <script>
2 import { onMount } from "svelte";
3 import { getApiBase } from "./config.js";
4
5 // Props
6 export let userSigner;
7 export let userPubkey;
8
9 // State management
10 let activeTab = "pubkeys";
11 let isLoading = false;
12 let message = "";
13 let messageType = "info";
14
15 // Relay configuration state
16 let relayName = "";
17 let relayDescription = "";
18 let relayIcon = "";
19
20 // Banned pubkeys
21 let bannedPubkeys = [];
22 let newBannedPubkey = "";
23 let newBannedPubkeyReason = "";
24
25 // Allowed pubkeys
26 let allowedPubkeys = [];
27 let newAllowedPubkey = "";
28 let newAllowedPubkeyReason = "";
29
30 // Banned events
31 let bannedEvents = [];
32 let newBannedEvent = "";
33 let newBannedEventReason = "";
34
35 // Allowed events
36 let allowedEvents = [];
37 let newAllowedEvent = "";
38 let newAllowedEventReason = "";
39
40 // Blocked IPs
41 let blockedIPs = [];
42 let newBlockedIP = "";
43 let newBlockedIPReason = "";
44
45 // Allowed kinds
46 let allowedKinds = [];
47 let newAllowedKind = "";
48
49 // Events needing moderation
50 let eventsNeedingModeration = [];
51
52 // Relay config
53 let relayConfig = {
54 relay_name: "",
55 relay_description: "",
56 relay_icon: "",
57 };
58
59 // Supported methods
60 const supportedMethods = [
61 "supportedmethods",
62 "banpubkey",
63 "listbannedpubkeys",
64 "allowpubkey",
65 "listallowedpubkeys",
66 "listeventsneedingmoderation",
67 "allowevent",
68 "banevent",
69 "listbannedevents",
70 "changerelayname",
71 "changerelaydescription",
72 "changerelayicon",
73 "allowkind",
74 "disallowkind",
75 "listallowedkinds",
76 "blockip",
77 "unblockip",
78 "listblockedips",
79 ];
80
81 // Load relay info on component mount
82 onMount(() => {
83 // Small delay to ensure component is fully rendered
84 setTimeout(() => {
85 fetchRelayInfo();
86 }, 100);
87 });
88
89 // Reactive statement to ensure form updates when relayConfig changes
90 $: console.log("relayConfig changed:", relayConfig);
91
92 // Fetch current relay information
93 async function fetchRelayInfo() {
94 try {
95 isLoading = true;
96 console.log("Fetching relay info from /");
97 const response = await fetch(getApiBase() +"/", {
98 headers: {
99 Accept: "application/nostr+json",
100 },
101 });
102 console.log("Response status:", response.status);
103 console.log("Response headers:", response.headers);
104
105 if (response.ok) {
106 const relayInfo = await response.json();
107 console.log("Raw relay info:", relayInfo);
108
109 // Reassign the entire object to trigger Svelte reactivity
110 relayConfig = {
111 relay_name: relayInfo.name || "",
112 relay_description: relayInfo.description || "",
113 relay_icon: relayInfo.icon || "",
114 };
115
116 console.log("Updated relayConfig:", relayConfig);
117 console.log("Loaded relay info:", relayInfo);
118
119 message = "Relay configuration loaded successfully";
120 messageType = "success";
121 } else {
122 console.error(
123 "Failed to fetch relay info, status:",
124 response.status,
125 );
126 message = `Failed to fetch relay info: ${response.status}`;
127 messageType = "error";
128 }
129 } catch (error) {
130 console.error("Failed to fetch relay info:", error);
131 message = `Failed to fetch relay info: ${error.message}`;
132 messageType = "error";
133 } finally {
134 isLoading = false;
135 }
136 }
137
138 // Create NIP-98 authentication event for HTTP requests
139 async function createNIP98AuthEvent(method, url) {
140 if (!userSigner) {
141 throw new Error(
142 "No signer available for authentication. Please log in with a Nostr extension.",
143 );
144 }
145
146 if (!userPubkey) {
147 throw new Error("No user pubkey available for authentication.");
148 }
149
150 // Get the full URL
151 const fullUrl = getApiBase() +url;
152
153 // Create NIP-98 authentication event
154 const authEvent = {
155 kind: 27235, // HTTPAuth kind
156 created_at: Math.floor(Date.now() / 1000),
157 tags: [
158 ["u", fullUrl],
159 ["method", method],
160 ],
161 content: "",
162 pubkey: userPubkey,
163 };
164
165 // Sign the authentication event
166 const signedAuthEvent = await userSigner.signEvent(authEvent);
167
168 // Encode the signed event as base64
169 const eventJson = JSON.stringify(signedAuthEvent);
170 const eventBase64 = btoa(eventJson);
171
172 return `Nostr ${eventBase64}`;
173 }
174
175 // Make NIP-86 API call with NIP-98 authentication
176 async function callNIP86API(method, params = []) {
177 try {
178 isLoading = true;
179 message = "";
180
181 const request = {
182 method: method,
183 params: params,
184 };
185
186 // Create NIP-98 authentication header
187 const authHeader = await createNIP98AuthEvent("POST", "/api/nip86");
188
189 const response = await fetch("/api/nip86", {
190 method: "POST",
191 headers: {
192 "Content-Type": "application/nostr+json+rpc",
193 Authorization: authHeader,
194 },
195 body: JSON.stringify(request),
196 });
197
198 if (!response.ok) {
199 throw new Error(
200 `HTTP ${response.status}: ${response.statusText}`,
201 );
202 }
203
204 const result = await response.json();
205
206 if (result.error) {
207 throw new Error(result.error);
208 }
209
210 return result.result;
211 } catch (error) {
212 console.error("NIP-86 API error:", error);
213 message = error.message;
214 messageType = "error";
215 throw error;
216 } finally {
217 isLoading = false;
218 }
219 }
220
221 // Load data functions
222 async function loadBannedPubkeys() {
223 try {
224 bannedPubkeys = await callNIP86API("listbannedpubkeys");
225 } catch (error) {
226 console.error("Failed to load banned pubkeys:", error);
227 }
228 }
229
230 async function loadAllowedPubkeys() {
231 try {
232 allowedPubkeys = await callNIP86API("listallowedpubkeys");
233 } catch (error) {
234 console.error("Failed to load allowed pubkeys:", error);
235 }
236 }
237
238 async function loadBannedEvents() {
239 try {
240 bannedEvents = await callNIP86API("listbannedevents");
241 } catch (error) {
242 console.error("Failed to load banned events:", error);
243 }
244 }
245
246 // Removed loadAllowedEvents - method doesn't exist in NIP-86 API
247
248 async function loadBlockedIPs() {
249 try {
250 blockedIPs = await callNIP86API("listblockedips");
251 } catch (error) {
252 console.error("Failed to load blocked IPs:", error);
253 }
254 }
255
256 async function loadAllowedKinds() {
257 try {
258 allowedKinds = await callNIP86API("listallowedkinds");
259 } catch (error) {
260 console.error("Failed to load allowed kinds:", error);
261 }
262 }
263
264 async function loadEventsNeedingModeration() {
265 try {
266 isLoading = true;
267 eventsNeedingModeration = await callNIP86API(
268 "listeventsneedingmoderation",
269 );
270 console.log(
271 "Loaded events needing moderation:",
272 eventsNeedingModeration,
273 );
274 } catch (error) {
275 console.error("Failed to load events needing moderation:", error);
276 message = `Failed to load moderation events: ${error.message}`;
277 messageType = "error";
278 // Set empty array to prevent further issues
279 eventsNeedingModeration = [];
280 } finally {
281 isLoading = false;
282 }
283 }
284
285 // Action functions
286 async function banPubkey() {
287 if (!newBannedPubkey) return;
288
289 try {
290 await callNIP86API("banpubkey", [
291 newBannedPubkey,
292 newBannedPubkeyReason,
293 ]);
294 message = "Pubkey banned successfully";
295 messageType = "success";
296 newBannedPubkey = "";
297 newBannedPubkeyReason = "";
298 await loadBannedPubkeys();
299 } catch (error) {
300 console.error("Failed to ban pubkey:", error);
301 }
302 }
303
304 async function allowPubkey() {
305 if (!newAllowedPubkey) return;
306
307 try {
308 await callNIP86API("allowpubkey", [
309 newAllowedPubkey,
310 newAllowedPubkeyReason,
311 ]);
312 message = "Pubkey allowed successfully";
313 messageType = "success";
314 newAllowedPubkey = "";
315 newAllowedPubkeyReason = "";
316 await loadAllowedPubkeys();
317 } catch (error) {
318 console.error("Failed to allow pubkey:", error);
319 }
320 }
321
322 async function banEvent() {
323 if (!newBannedEvent) return;
324
325 try {
326 await callNIP86API("banevent", [
327 newBannedEvent,
328 newBannedEventReason,
329 ]);
330 message = "Event banned successfully";
331 messageType = "success";
332 newBannedEvent = "";
333 newBannedEventReason = "";
334 await loadBannedEvents();
335 } catch (error) {
336 console.error("Failed to ban event:", error);
337 }
338 }
339
340 async function allowEvent() {
341 if (!newAllowedEvent) return;
342
343 try {
344 await callNIP86API("allowevent", [
345 newAllowedEvent,
346 newAllowedEventReason,
347 ]);
348 message = "Event allowed successfully";
349 messageType = "success";
350 newAllowedEvent = "";
351 newAllowedEventReason = "";
352 // Note: No need to reload allowed events list as method doesn't exist
353 } catch (error) {
354 console.error("Failed to allow event:", error);
355 }
356 }
357
358 async function blockIP() {
359 if (!newBlockedIP) return;
360
361 try {
362 await callNIP86API("blockip", [newBlockedIP, newBlockedIPReason]);
363 message = "IP blocked successfully";
364 messageType = "success";
365 newBlockedIP = "";
366 newBlockedIPReason = "";
367 await loadBlockedIPs();
368 } catch (error) {
369 console.error("Failed to block IP:", error);
370 }
371 }
372
373 async function allowKind() {
374 if (!newAllowedKind) return;
375
376 const kindNum = parseInt(newAllowedKind);
377 if (isNaN(kindNum)) {
378 message = "Invalid kind number";
379 messageType = "error";
380 return;
381 }
382
383 try {
384 await callNIP86API("allowkind", [kindNum]);
385 message = "Kind allowed successfully";
386 messageType = "success";
387 newAllowedKind = "";
388 await loadAllowedKinds();
389 } catch (error) {
390 console.error("Failed to allow kind:", error);
391 }
392 }
393
394 async function disallowKind(kind) {
395 try {
396 await callNIP86API("disallowkind", [kind]);
397 message = "Kind disallowed successfully";
398 messageType = "success";
399 await loadAllowedKinds();
400 } catch (error) {
401 console.error("Failed to disallow kind:", error);
402 }
403 }
404
405 async function updateRelayName() {
406 if (!relayConfig.relay_name) return;
407
408 try {
409 await callNIP86API("changerelayname", [relayConfig.relay_name]);
410 message = "Relay name updated successfully";
411 messageType = "success";
412 // Refresh relay info to show updated values
413 await fetchRelayInfo();
414 } catch (error) {
415 console.error("Failed to update relay name:", error);
416 }
417 }
418
419 async function updateRelayDescription() {
420 if (!relayConfig.relay_description) return;
421
422 try {
423 await callNIP86API("changerelaydescription", [
424 relayConfig.relay_description,
425 ]);
426 message = "Relay description updated successfully";
427 messageType = "success";
428 // Refresh relay info to show updated values
429 await fetchRelayInfo();
430 } catch (error) {
431 console.error("Failed to update relay description:", error);
432 }
433 }
434
435 async function updateRelayIcon() {
436 if (!relayConfig.relay_icon) return;
437
438 try {
439 await callNIP86API("changerelayicon", [relayConfig.relay_icon]);
440 message = "Relay icon updated successfully";
441 messageType = "success";
442 // Refresh relay info to show updated values
443 await fetchRelayInfo();
444 } catch (error) {
445 console.error("Failed to update relay icon:", error);
446 }
447 }
448
449 // Update all relay configuration at once
450 async function updateRelayConfiguration() {
451 try {
452 isLoading = true;
453 message = "";
454
455 const updates = [];
456
457 // Update relay name if provided
458 if (relayConfig.relay_name) {
459 updates.push(
460 callNIP86API("changerelayname", [relayConfig.relay_name]),
461 );
462 }
463
464 // Update relay description if provided
465 if (relayConfig.relay_description) {
466 updates.push(
467 callNIP86API("changerelaydescription", [
468 relayConfig.relay_description,
469 ]),
470 );
471 }
472
473 // Update relay icon if provided
474 if (relayConfig.relay_icon) {
475 updates.push(
476 callNIP86API("changerelayicon", [relayConfig.relay_icon]),
477 );
478 }
479
480 if (updates.length === 0) {
481 message = "No changes to update";
482 messageType = "info";
483 return;
484 }
485
486 // Execute all updates in parallel
487 await Promise.all(updates);
488
489 message = "Relay configuration updated successfully";
490 messageType = "success";
491
492 // Refresh relay info to show updated values
493 await fetchRelayInfo();
494 } catch (error) {
495 console.error("Failed to update relay configuration:", error);
496 message = `Failed to update relay configuration: ${error.message}`;
497 messageType = "error";
498 } finally {
499 isLoading = false;
500 }
501 }
502
503 async function allowEventFromModeration(eventId) {
504 try {
505 await callNIP86API("allowevent", [
506 eventId,
507 "Approved from moderation queue",
508 ]);
509 message = "Event allowed successfully";
510 messageType = "success";
511 await loadEventsNeedingModeration();
512 } catch (error) {
513 console.error("Failed to allow event from moderation:", error);
514 }
515 }
516
517 async function banEventFromModeration(eventId) {
518 try {
519 await callNIP86API("banevent", [
520 eventId,
521 "Banned from moderation queue",
522 ]);
523 message = "Event banned successfully";
524 messageType = "success";
525 await loadEventsNeedingModeration();
526 } catch (error) {
527 console.error("Failed to ban event from moderation:", error);
528 }
529 }
530
531 // Load data when component mounts
532 async function loadAllData() {
533 await Promise.all([
534 loadBannedPubkeys(),
535 loadAllowedPubkeys(),
536 loadBannedEvents(),
537 // loadAllowedEvents(), // Removed - method doesn't exist
538 loadBlockedIPs(),
539 loadAllowedKinds(),
540 // Note: loadEventsNeedingModeration() removed to prevent freezing
541 ]);
542 }
543
544 // Initialize - only load basic data, not moderation
545 loadAllData();
546 </script>
547
548 <div>
549 <div class="header">
550 <h2>Managed ACL Configuration</h2>
551 <p>Configure access control using NIP-86 management API</p>
552 <div class="owner-only-notice">
553 <strong>Owner Only:</strong> This interface is restricted to relay owners
554 only.
555 </div>
556 </div>
557
558 {#if message}
559 <div class="message {messageType}">
560 {message}
561 </div>
562 {/if}
563
564 <div class="tabs">
565 <button
566 class="tab {activeTab === 'pubkeys' ? 'active' : ''}"
567 on:click={() => (activeTab = "pubkeys")}
568 >
569 Pubkeys
570 </button>
571 <button
572 class="tab {activeTab === 'events' ? 'active' : ''}"
573 on:click={() => (activeTab = "events")}
574 >
575 Events
576 </button>
577 <button
578 class="tab {activeTab === 'ips' ? 'active' : ''}"
579 on:click={() => (activeTab = "ips")}
580 >
581 IPs
582 </button>
583 <button
584 class="tab {activeTab === 'kinds' ? 'active' : ''}"
585 on:click={() => (activeTab = "kinds")}
586 >
587 Kinds
588 </button>
589 <button
590 class="tab {activeTab === 'moderation' ? 'active' : ''}"
591 on:click={() => {
592 activeTab = "moderation";
593 // Load moderation data only when tab is opened
594 if (
595 !eventsNeedingModeration ||
596 eventsNeedingModeration.length === 0
597 ) {
598 loadEventsNeedingModeration();
599 }
600 }}
601 >
602 Moderation
603 </button>
604 <button
605 class="tab {activeTab === 'relay' ? 'active' : ''}"
606 on:click={() => (activeTab = "relay")}
607 >
608 Relay Config
609 </button>
610 </div>
611
612 <div class="tab-content">
613 {#if activeTab === "pubkeys"}
614 <div class="pubkeys-section">
615 <div class="section">
616 <h3>Banned Pubkeys</h3>
617 <div class="add-form">
618 <input
619 type="text"
620 placeholder="Pubkey (64 hex chars)"
621 bind:value={newBannedPubkey}
622 />
623 <input
624 type="text"
625 placeholder="Reason (optional)"
626 bind:value={newBannedPubkeyReason}
627 />
628 <button on:click={banPubkey} disabled={isLoading}
629 >Ban Pubkey</button
630 >
631 </div>
632 <div class="list">
633 {#if bannedPubkeys && bannedPubkeys.length > 0}
634 {#each bannedPubkeys as item}
635 <div class="list-item">
636 <span class="pubkey">{item.pubkey}</span>
637 {#if item.reason}
638 <span class="reason">{item.reason}</span
639 >
640 {/if}
641 </div>
642 {/each}
643 {:else}
644 <div class="no-items">
645 <p>No banned pubkeys configured.</p>
646 </div>
647 {/if}
648 </div>
649 </div>
650
651 <div class="section">
652 <h3>Allowed Pubkeys</h3>
653 <div class="add-form">
654 <input
655 type="text"
656 placeholder="Pubkey (64 hex chars)"
657 bind:value={newAllowedPubkey}
658 />
659 <input
660 type="text"
661 placeholder="Reason (optional)"
662 bind:value={newAllowedPubkeyReason}
663 />
664 <button on:click={allowPubkey} disabled={isLoading}
665 >Allow Pubkey</button
666 >
667 </div>
668 <div class="list">
669 {#if allowedPubkeys && allowedPubkeys.length > 0}
670 {#each allowedPubkeys as item}
671 <div class="list-item">
672 <span class="pubkey">{item.pubkey}</span>
673 {#if item.reason}
674 <span class="reason">{item.reason}</span
675 >
676 {/if}
677 </div>
678 {/each}
679 {:else}
680 <div class="no-items">
681 <p>No allowed pubkeys configured.</p>
682 </div>
683 {/if}
684 </div>
685 </div>
686 </div>
687 {/if}
688
689 {#if activeTab === "events"}
690 <div class="events-section">
691 <div class="section">
692 <h3>Banned Events</h3>
693 <div class="add-form">
694 <input
695 type="text"
696 placeholder="Event ID (64 hex chars)"
697 bind:value={newBannedEvent}
698 />
699 <input
700 type="text"
701 placeholder="Reason (optional)"
702 bind:value={newBannedEventReason}
703 />
704 <button on:click={banEvent} disabled={isLoading}
705 >Ban Event</button
706 >
707 </div>
708 <div class="list">
709 {#if bannedEvents && bannedEvents.length > 0}
710 {#each bannedEvents as item}
711 <div class="list-item">
712 <span class="event-id">{item.id}</span>
713 {#if item.reason}
714 <span class="reason">{item.reason}</span
715 >
716 {/if}
717 </div>
718 {/each}
719 {:else}
720 <div class="no-items">
721 <p>No banned events configured.</p>
722 </div>
723 {/if}
724 </div>
725 </div>
726
727 <div class="section">
728 <h3>Allowed Events</h3>
729 <div class="add-form">
730 <input
731 type="text"
732 placeholder="Event ID (64 hex chars)"
733 bind:value={newAllowedEvent}
734 />
735 <input
736 type="text"
737 placeholder="Reason (optional)"
738 bind:value={newAllowedEventReason}
739 />
740 <button on:click={allowEvent} disabled={isLoading}
741 >Allow Event</button
742 >
743 </div>
744 <div class="list">
745 {#if allowedEvents && allowedEvents.length > 0}
746 {#each allowedEvents as item}
747 <div class="list-item">
748 <span class="event-id">{item.id}</span>
749 {#if item.reason}
750 <span class="reason">{item.reason}</span
751 >
752 {/if}
753 </div>
754 {/each}
755 {:else}
756 <div class="no-items">
757 <p>No allowed events configured.</p>
758 </div>
759 {/if}
760 </div>
761 </div>
762 </div>
763 {/if}
764
765 {#if activeTab === "ips"}
766 <div class="ips-section">
767 <div class="section">
768 <h3>Blocked IPs</h3>
769 <div class="add-form">
770 <input
771 type="text"
772 placeholder="IP Address"
773 bind:value={newBlockedIP}
774 />
775 <input
776 type="text"
777 placeholder="Reason (optional)"
778 bind:value={newBlockedIPReason}
779 />
780 <button on:click={blockIP} disabled={isLoading}
781 >Block IP</button
782 >
783 </div>
784 <div class="list">
785 {#if blockedIPs && blockedIPs.length > 0}
786 {#each blockedIPs as item}
787 <div class="list-item">
788 <span class="ip">{item.ip}</span>
789 {#if item.reason}
790 <span class="reason">{item.reason}</span
791 >
792 {/if}
793 </div>
794 {/each}
795 {:else}
796 <div class="no-items">
797 <p>No blocked IPs configured.</p>
798 </div>
799 {/if}
800 </div>
801 </div>
802 </div>
803 {/if}
804
805 {#if activeTab === "kinds"}
806 <div class="kinds-section">
807 <div class="section">
808 <h3>Allowed Event Kinds</h3>
809 <div class="add-form">
810 <input
811 type="number"
812 placeholder="Kind number"
813 bind:value={newAllowedKind}
814 />
815 <button on:click={allowKind} disabled={isLoading}
816 >Allow Kind</button
817 >
818 </div>
819 <div class="list">
820 {#if allowedKinds && allowedKinds.length > 0}
821 {#each allowedKinds as kind}
822 <div class="list-item">
823 <span class="kind">Kind {kind}</span>
824 <button
825 class="remove-btn"
826 on:click={() => disallowKind(kind)}
827 >Remove</button
828 >
829 </div>
830 {/each}
831 {:else}
832 <div class="no-items">
833 <p>
834 No allowed kinds configured. All kinds are
835 allowed by default.
836 </p>
837 </div>
838 {/if}
839 </div>
840 </div>
841 </div>
842 {/if}
843
844 {#if activeTab === "moderation"}
845 <div class="moderation-section">
846 <div class="section">
847 <h3>Events Needing Moderation</h3>
848 <button
849 on:click={loadEventsNeedingModeration}
850 disabled={isLoading}>Refresh</button
851 >
852 <div class="list">
853 {#if eventsNeedingModeration && eventsNeedingModeration.length > 0}
854 {#each eventsNeedingModeration as item}
855 <div class="list-item">
856 <span class="event-id">{item.id}</span>
857 {#if item.reason}
858 <span class="reason">{item.reason}</span
859 >
860 {/if}
861 <div class="actions">
862 <button
863 on:click={() =>
864 allowEventFromModeration(
865 item.id,
866 )}>Allow</button
867 >
868 <button
869 on:click={() =>
870 banEventFromModeration(item.id)}
871 >Ban</button
872 >
873 </div>
874 </div>
875 {/each}
876 {:else}
877 <div class="no-items">
878 <p>No events need moderation at this time.</p>
879 </div>
880 {/if}
881 </div>
882 </div>
883 </div>
884 {/if}
885
886 {#if activeTab === "relay"}
887 <div class="relay-section">
888 <div class="section">
889 <h3>Relay Configuration</h3>
890 <div class="config-actions">
891 <button
892 on:click={fetchRelayInfo}
893 disabled={isLoading}
894 class="refresh-btn"
895 >
896 🔄 Refresh from Relay Info
897 </button>
898 </div>
899 <div class="config-form">
900 <div class="form-group">
901 <label for="relay-name">Relay Name</label>
902 <input
903 id="relay-name"
904 type="text"
905 bind:value={relayConfig.relay_name}
906 placeholder="Enter relay name"
907 />
908 </div>
909 <div class="form-group">
910 <label for="relay-description"
911 >Relay Description</label
912 >
913 <textarea
914 id="relay-description"
915 bind:value={relayConfig.relay_description}
916 placeholder="Enter relay description"
917 ></textarea>
918 </div>
919 <div class="form-group">
920 <label for="relay-icon">Relay Icon URL</label>
921 <input
922 id="relay-icon"
923 type="url"
924 bind:value={relayConfig.relay_icon}
925 placeholder="Enter icon URL"
926 />
927 </div>
928 <div class="config-update-section">
929 <button
930 on:click={updateRelayConfiguration}
931 disabled={isLoading}
932 class="update-all-btn"
933 >
934 {#if isLoading}
935 ⏳ Updating...
936 {:else}
937 💾 Update Configuration
938 {/if}
939 </button>
940 </div>
941 </div>
942 </div>
943 </div>
944 {/if}
945 </div>
946 </div>
947
948 <style>
949 .header {
950 margin-bottom: 30px;
951 }
952
953 .header h2 {
954 margin: 0 0 10px 0;
955 color: var(--text-color);
956 }
957
958 .header p {
959 margin: 0;
960 color: var(--text-color);
961 opacity: 0.8;
962 }
963
964 .owner-only-notice {
965 margin-top: 10px;
966 padding: 8px 12px;
967 background-color: var(--warning-bg);
968 border: 1px solid var(--warning);
969 border-radius: 4px;
970 color: var(--text-color);
971 font-size: 0.9em;
972 }
973
974 .message {
975 padding: 10px 15px;
976 border-radius: 4px;
977 margin-bottom: 20px;
978 }
979
980 .message.success {
981 background-color: var(--success-bg);
982 color: var(--success-text);
983 border: 1px solid var(--success);
984 }
985
986 .message.error {
987 background-color: var(--error-bg);
988 color: var(--error-text);
989 border: 1px solid var(--danger);
990 }
991
992 .message.info {
993 background-color: var(--primary-bg);
994 color: var(--text-color);
995 border: 1px solid var(--info);
996 }
997
998 .tabs {
999 display: flex;
1000 border-bottom: 1px solid var(--border-color);
1001 margin-bottom: 20px;
1002 }
1003
1004 .tab {
1005 padding: 10px 20px;
1006 border: none;
1007 background: none;
1008 cursor: pointer;
1009 border-bottom: 2px solid transparent;
1010 transition: all 0.2s;
1011 color: var(--text-color);
1012 }
1013
1014 .tab:hover {
1015 background-color: var(--button-hover-bg);
1016 }
1017
1018 .tab.active {
1019 border-bottom-color: var(--accent-color);
1020 color: var(--accent-color);
1021 }
1022
1023 .tab-content {
1024 min-height: 400px;
1025 }
1026
1027 .section {
1028 margin-bottom: 30px;
1029 }
1030
1031 .section h3 {
1032 margin: 0 0 15px 0;
1033 color: var(--text-color);
1034 }
1035
1036 .add-form {
1037 display: flex;
1038 gap: 10px;
1039 margin-bottom: 20px;
1040 flex-wrap: wrap;
1041 }
1042
1043 .add-form input {
1044 padding: 8px 12px;
1045 border: 1px solid var(--input-border);
1046 border-radius: 4px;
1047 background: var(--bg-color);
1048 color: var(--text-color);
1049 flex: 1;
1050 min-width: 200px;
1051 }
1052
1053 .add-form button {
1054 padding: 8px 16px;
1055 background-color: var(--accent-color);
1056 color: var(--text-color);
1057 border: none;
1058 border-radius: 4px;
1059 cursor: pointer;
1060 }
1061
1062 .add-form button:disabled {
1063 background-color: var(--secondary);
1064 cursor: not-allowed;
1065 }
1066
1067 .list {
1068 border: 1px solid var(--border-color);
1069 border-radius: 4px;
1070 max-height: 300px;
1071 overflow-y: auto;
1072 background: var(--bg-color);
1073 }
1074
1075 .list-item {
1076 padding: 10px 15px;
1077 border-bottom: 1px solid var(--border-color);
1078 display: flex;
1079 align-items: center;
1080 gap: 15px;
1081 color: var(--text-color);
1082 }
1083
1084 .list-item:last-child {
1085 border-bottom: none;
1086 }
1087
1088 .pubkey,
1089 .event-id,
1090 .ip,
1091 .kind {
1092 font-family: monospace;
1093 font-size: 0.9em;
1094 color: var(--text-color);
1095 }
1096
1097 .reason {
1098 color: var(--text-color);
1099 opacity: 0.7;
1100 font-style: italic;
1101 }
1102
1103 .remove-btn {
1104 padding: 4px 8px;
1105 background-color: var(--danger);
1106 color: var(--text-color);
1107 border: none;
1108 border-radius: 3px;
1109 cursor: pointer;
1110 font-size: 0.8em;
1111 }
1112
1113 .actions {
1114 display: flex;
1115 gap: 5px;
1116 margin-left: auto;
1117 }
1118
1119 .actions button {
1120 padding: 4px 8px;
1121 border: none;
1122 border-radius: 3px;
1123 cursor: pointer;
1124 font-size: 0.8em;
1125 }
1126
1127 .actions button:first-child {
1128 background-color: var(--success);
1129 color: var(--text-color);
1130 }
1131
1132 .actions button:last-child {
1133 background-color: var(--danger);
1134 color: var(--text-color);
1135 }
1136
1137 .config-form {
1138 display: flex;
1139 flex-direction: column;
1140 gap: 20px;
1141 }
1142
1143 .form-group {
1144 display: flex;
1145 flex-direction: column;
1146 gap: 10px;
1147 }
1148
1149 .form-group label {
1150 font-weight: bold;
1151 color: var(--text-color);
1152 }
1153
1154 .form-group input,
1155 .form-group textarea {
1156 padding: 8px 12px;
1157 border: 1px solid var(--input-border);
1158 border-radius: 4px;
1159 background: var(--bg-color);
1160 color: var(--text-color);
1161 }
1162
1163 .form-group textarea {
1164 min-height: 80px;
1165 resize: vertical;
1166 }
1167
1168 .config-actions {
1169 margin-bottom: 20px;
1170 padding: 10px;
1171 background-color: var(--button-bg);
1172 border-radius: 4px;
1173 }
1174
1175 .refresh-btn {
1176 padding: 8px 16px;
1177 background-color: var(--success);
1178 color: var(--text-color);
1179 border: none;
1180 border-radius: 4px;
1181 cursor: pointer;
1182 font-size: 0.9em;
1183 }
1184
1185 .refresh-btn:hover:not(:disabled) {
1186 background-color: var(--success);
1187 filter: brightness(0.9);
1188 }
1189
1190 .refresh-btn:disabled {
1191 background-color: var(--secondary);
1192 cursor: not-allowed;
1193 }
1194
1195 .config-update-section {
1196 margin-top: 20px;
1197 padding: 15px;
1198 background-color: var(--button-bg);
1199 border-radius: 6px;
1200 text-align: center;
1201 }
1202
1203 .update-all-btn {
1204 padding: 12px 24px;
1205 background-color: var(--success);
1206 color: var(--text-color);
1207 border: none;
1208 border-radius: 6px;
1209 cursor: pointer;
1210 font-size: 1em;
1211 font-weight: 600;
1212 min-width: 200px;
1213 }
1214
1215 .update-all-btn:hover:not(:disabled) {
1216 background-color: var(--success);
1217 filter: brightness(0.9);
1218 transform: translateY(-1px);
1219 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
1220 }
1221
1222 .update-all-btn:disabled {
1223 background-color: var(--secondary);
1224 cursor: not-allowed;
1225 transform: none;
1226 box-shadow: none;
1227 }
1228
1229 .no-items {
1230 padding: 20px;
1231 text-align: center;
1232 color: var(--text-color);
1233 opacity: 0.7;
1234 font-style: italic;
1235 }
1236 </style>
1237