RelayConnectView.svelte raw
1 <script>
2 export let isLoggedIn = false;
3 export let userRole = "";
4 export let userSigner = null;
5 export let userPubkey = "";
6
7 import { createEventDispatcher, onMount } from "svelte";
8 import * as api from "./api.js";
9 import { copyToClipboard, showCopyFeedback } from "./utils.js";
10 import { relayUrl } from "./stores.js";
11
12 const dispatch = createEventDispatcher();
13
14 // State
15 let nrcEnabled = false;
16 let badgerRequired = false;
17 let connections = [];
18 let config = {};
19 let isLoading = false;
20 let message = "";
21 let messageType = "info";
22
23 // New connection form
24 let newLabel = "";
25
26 // URI display modal
27 let showURIModal = false;
28 let currentURI = "";
29 let currentLabel = "";
30
31 let initialLoadDone = false;
32 let currentRelayUrl = "";
33
34 onMount(async () => {
35 currentRelayUrl = $relayUrl || "";
36 await loadNRCConfig();
37 initialLoadDone = true;
38 });
39
40 // Watch for relay URL changes after initial load
41 $: watchedRelayUrl = $relayUrl;
42 $: if (initialLoadDone && watchedRelayUrl !== currentRelayUrl) {
43 currentRelayUrl = watchedRelayUrl;
44 handleRelayChange();
45 }
46
47 function handleRelayChange() {
48 console.log("[RelayConnectView] Relay changed, reloading...");
49 connections = [];
50 config = {};
51 nrcEnabled = false;
52 loadNRCConfig();
53 }
54
55 async function loadNRCConfig() {
56 console.log("[RelayConnectView] loadNRCConfig called, current relayUrl:", $relayUrl);
57 try {
58 const result = await api.fetchNRCConfig();
59 console.log("[RelayConnectView] NRC config result:", result);
60 nrcEnabled = result.enabled;
61 badgerRequired = result.badger_required;
62
63 if (nrcEnabled && isLoggedIn && userRole === "owner") {
64 await loadConnections();
65 }
66 } catch (error) {
67 console.error("Failed to load NRC config:", error);
68 }
69 }
70
71 async function loadConnections() {
72 if (!isLoggedIn || !userSigner || !userPubkey) return;
73
74 isLoading = true;
75 try {
76 const result = await api.fetchNRCConnections(userSigner, userPubkey);
77 connections = result.connections || [];
78 config = result.config || {};
79 } catch (error) {
80 setMessage(`Failed to load connections: ${error.message}`, "error");
81 } finally {
82 isLoading = false;
83 }
84 }
85
86 async function createConnection() {
87 if (!newLabel.trim()) {
88 setMessage("Please enter a label for the connection", "error");
89 return;
90 }
91
92 isLoading = true;
93 try {
94 const result = await api.createNRCConnection(userSigner, userPubkey, newLabel.trim());
95
96 // Show the URI modal with the new connection
97 currentURI = result.uri;
98 currentLabel = result.label;
99 showURIModal = true;
100
101 // Reset form
102 newLabel = "";
103
104 // Reload connections
105 await loadConnections();
106 setMessage(`Connection "${result.label}" created successfully`, "success");
107 } catch (error) {
108 setMessage(`Failed to create connection: ${error.message}`, "error");
109 } finally {
110 isLoading = false;
111 }
112 }
113
114 async function deleteConnection(connId, label) {
115 if (!confirm(`Are you sure you want to delete the connection "${label}"? This will revoke access for any device using this connection.`)) {
116 return;
117 }
118
119 isLoading = true;
120 try {
121 await api.deleteNRCConnection(userSigner, userPubkey, connId);
122 await loadConnections();
123 setMessage(`Connection "${label}" deleted`, "success");
124 } catch (error) {
125 setMessage(`Failed to delete connection: ${error.message}`, "error");
126 } finally {
127 isLoading = false;
128 }
129 }
130
131 async function showConnectionURI(connId, label) {
132 isLoading = true;
133 try {
134 const result = await api.getNRCConnectionURI(userSigner, userPubkey, connId);
135 currentURI = result.uri;
136 currentLabel = label;
137 showURIModal = true;
138 } catch (error) {
139 setMessage(`Failed to get URI: ${error.message}`, "error");
140 } finally {
141 isLoading = false;
142 }
143 }
144
145 async function copyURIToClipboard(event) {
146 const success = await copyToClipboard(currentURI);
147 const button = event.target.closest("button");
148 showCopyFeedback(button, success);
149 if (!success) {
150 setMessage("Failed to copy to clipboard", "error");
151 }
152 }
153
154 function closeURIModal() {
155 showURIModal = false;
156 currentURI = "";
157 currentLabel = "";
158 }
159
160 function setMessage(msg, type = "info") {
161 message = msg;
162 messageType = type;
163 // Auto-clear after 5 seconds
164 setTimeout(() => {
165 if (message === msg) {
166 message = "";
167 }
168 }, 5000);
169 }
170
171 function formatTimestamp(ts) {
172 if (!ts) return "Never";
173 return new Date(ts * 1000).toLocaleString();
174 }
175
176 function openLoginModal() {
177 dispatch("openLoginModal");
178 }
179
180 // Reload when login state changes
181 $: if (isLoggedIn && userRole === "owner" && nrcEnabled) {
182 loadConnections();
183 }
184 </script>
185
186 <div class="relay-connect-view">
187 <h2>Relay Connect</h2>
188 <p class="description">
189 Nostr Relay Connect (NRC) allows remote access to this relay through a public relay tunnel.
190 Create connection strings for your devices to sync securely.
191 </p>
192
193 {#if !nrcEnabled}
194 <div class="not-enabled">
195 {#if badgerRequired}
196 <p>NRC requires the Badger database backend.</p>
197 <p>Set <code>ORLY_DB_TYPE=badger</code> to enable NRC functionality.</p>
198 {:else}
199 <p>NRC is not enabled on this relay.</p>
200 <p>Set <code>ORLY_NRC_ENABLED=true</code> and configure <code>ORLY_NRC_RENDEZVOUS_URL</code> to enable.</p>
201 {/if}
202 </div>
203 {:else if !isLoggedIn}
204 <div class="login-prompt">
205 <p>Please log in to manage relay connections.</p>
206 <button class="login-btn" on:click={openLoginModal}>Log In</button>
207 </div>
208 {:else if userRole !== "owner"}
209 <div class="permission-denied">
210 <p>Owner permission required for relay connection management.</p>
211 <p>Current role: <strong>{userRole || "none"}</strong></p>
212 </div>
213 {:else}
214 <!-- Config status -->
215 <div class="config-status">
216 <div class="status-item">
217 <span class="status-label">Status:</span>
218 <span class="status-value enabled">Enabled</span>
219 </div>
220 <div class="status-item">
221 <span class="status-label">Rendezvous:</span>
222 <span class="status-value">{config.rendezvous_url || "Not configured"}</span>
223 </div>
224 </div>
225
226 <!-- Create new connection -->
227 <div class="section">
228 <h3>Create New Connection</h3>
229 <div class="create-form">
230 <div class="form-group">
231 <label for="new-label">Device Label</label>
232 <input
233 type="text"
234 id="new-label"
235 bind:value={newLabel}
236 placeholder="e.g., Phone, Laptop, Tablet"
237 disabled={isLoading}
238 />
239 </div>
240 <button
241 class="create-btn"
242 on:click={createConnection}
243 disabled={isLoading || !newLabel.trim()}
244 >
245 + Create Connection
246 </button>
247 </div>
248 </div>
249
250 <!-- Connections list -->
251 <div class="section">
252 <h3>Connections ({connections.length})</h3>
253 {#if connections.length === 0}
254 <p class="no-connections">No connections yet. Create one to get started.</p>
255 {:else}
256 <div class="connections-list">
257 {#each connections as conn}
258 <div class="connection-item">
259 <div class="connection-info">
260 <div class="connection-label">{conn.label}</div>
261 <div class="connection-details">
262 <span class="detail">ID: {conn.id.substring(0, 8)}...</span>
263 <span class="detail">Created: {formatTimestamp(conn.created_at)}</span>
264 {#if conn.last_used}
265 <span class="detail">Last used: {formatTimestamp(conn.last_used)}</span>
266 {/if}
267 </div>
268 </div>
269 <div class="connection-actions">
270 <button
271 class="action-btn show-uri-btn"
272 on:click={() => showConnectionURI(conn.id, conn.label)}
273 disabled={isLoading}
274 title="Show connection URI"
275 >
276 Show URI
277 </button>
278 <button
279 class="action-btn delete-btn"
280 on:click={() => deleteConnection(conn.id, conn.label)}
281 disabled={isLoading}
282 title="Delete connection"
283 >
284 Delete
285 </button>
286 </div>
287 </div>
288 {/each}
289 </div>
290 {/if}
291
292 <button
293 class="refresh-btn"
294 on:click={loadConnections}
295 disabled={isLoading}
296 >
297 Refresh
298 </button>
299 </div>
300
301 {#if message}
302 <div class="message" class:error={messageType === "error"} class:success={messageType === "success"}>
303 {message}
304 </div>
305 {/if}
306 {/if}
307 </div>
308
309 <!-- URI Modal -->
310 {#if showURIModal}
311 <div class="modal-overlay" on:click={closeURIModal}>
312 <div class="modal" on:click|stopPropagation>
313 <h3>Connection URI for "{currentLabel}"</h3>
314 <p class="modal-description">
315 Copy this URI to your Nostr client to connect to this relay remotely.
316 Keep it secret - anyone with this URI can access your relay.
317 </p>
318 <div class="uri-display">
319 <textarea readonly>{currentURI}</textarea>
320 </div>
321 <div class="modal-actions">
322 <button class="copy-btn" on:click={copyURIToClipboard}>
323 Copy to Clipboard
324 </button>
325 <button class="close-btn" on:click={closeURIModal}>
326 Close
327 </button>
328 </div>
329 </div>
330 </div>
331 {/if}
332
333 <style>
334 .relay-connect-view {
335 width: 100%;
336 max-width: 800px;
337 margin: 0;
338 padding: 20px;
339 background: var(--header-bg);
340 color: var(--text-color);
341 border-radius: 8px;
342 }
343
344 .relay-connect-view h2 {
345 margin: 0 0 0.5rem 0;
346 color: var(--text-color);
347 font-size: 1.8rem;
348 font-weight: 600;
349 }
350
351 .description {
352 color: var(--muted-foreground);
353 margin-bottom: 1.5rem;
354 line-height: 1.5;
355 }
356
357 .section {
358 background-color: var(--card-bg);
359 border-radius: 8px;
360 padding: 1em;
361 margin-bottom: 1.5rem;
362 border: 1px solid var(--border-color);
363 }
364
365 .section h3 {
366 margin: 0 0 1rem 0;
367 color: var(--text-color);
368 font-size: 1.1rem;
369 font-weight: 600;
370 }
371
372 .config-status {
373 display: flex;
374 flex-direction: column;
375 gap: 0.5rem;
376 margin-bottom: 1.5rem;
377 padding: 1rem;
378 background: var(--card-bg);
379 border-radius: 8px;
380 border: 1px solid var(--border-color);
381 }
382
383 .status-item {
384 display: flex;
385 justify-content: space-between;
386 align-items: center;
387 }
388
389 .status-label {
390 font-weight: 600;
391 color: var(--text-color);
392 }
393
394 .status-value {
395 color: var(--muted-foreground);
396 font-family: monospace;
397 font-size: 0.9em;
398 }
399
400 .status-value.enabled {
401 color: var(--success);
402 }
403
404 /* Create form */
405 .create-form {
406 display: flex;
407 flex-direction: column;
408 gap: 1rem;
409 }
410
411 .form-group {
412 display: flex;
413 flex-direction: column;
414 gap: 0.5rem;
415 }
416
417 .form-group label {
418 font-weight: 500;
419 color: var(--text-color);
420 }
421
422 .form-group input[type="text"] {
423 padding: 0.75em;
424 border: 1px solid var(--border-color);
425 border-radius: 4px;
426 background: var(--input-bg);
427 color: var(--input-text-color);
428 font-size: 1em;
429 }
430
431 .create-btn {
432 background: var(--primary);
433 color: var(--text-color);
434 border: none;
435 padding: 0.75em 1.5em;
436 border-radius: 4px;
437 cursor: pointer;
438 font-size: 1em;
439 font-weight: 500;
440 align-self: flex-start;
441 transition: background-color 0.2s;
442 }
443
444 .create-btn:hover:not(:disabled) {
445 background: var(--accent-hover-color);
446 }
447
448 .create-btn:disabled {
449 background: var(--secondary);
450 cursor: not-allowed;
451 }
452
453 /* Connections list */
454 .connections-list {
455 display: flex;
456 flex-direction: column;
457 gap: 0.75rem;
458 margin-bottom: 1rem;
459 }
460
461 .connection-item {
462 display: flex;
463 justify-content: space-between;
464 align-items: center;
465 padding: 1rem;
466 background: var(--bg-color);
467 border: 1px solid var(--border-color);
468 border-radius: 4px;
469 }
470
471 .connection-info {
472 flex: 1;
473 }
474
475 .connection-label {
476 font-weight: 600;
477 color: var(--text-color);
478 margin-bottom: 0.25rem;
479 }
480
481 .connection-details {
482 display: flex;
483 flex-wrap: wrap;
484 gap: 0.75rem;
485 font-size: 0.85em;
486 color: var(--muted-foreground);
487 }
488
489 .connection-actions {
490 display: flex;
491 gap: 0.5rem;
492 }
493
494 .action-btn {
495 background: var(--primary);
496 color: var(--text-color);
497 border: none;
498 padding: 0.5em 1em;
499 border-radius: 4px;
500 cursor: pointer;
501 font-size: 0.9em;
502 transition: background-color 0.2s;
503 }
504
505 .action-btn:hover:not(:disabled) {
506 background: var(--accent-hover-color);
507 }
508
509 .action-btn:disabled {
510 background: var(--secondary);
511 cursor: not-allowed;
512 }
513
514 .show-uri-btn {
515 background: var(--info);
516 }
517
518 .show-uri-btn:hover:not(:disabled) {
519 filter: brightness(0.9);
520 }
521
522 .delete-btn {
523 background: var(--danger);
524 }
525
526 .delete-btn:hover:not(:disabled) {
527 filter: brightness(0.9);
528 }
529
530 .refresh-btn {
531 background: var(--secondary);
532 color: var(--text-color);
533 border: none;
534 padding: 0.5em 1em;
535 border-radius: 4px;
536 cursor: pointer;
537 font-size: 0.9em;
538 transition: background-color 0.2s;
539 }
540
541 .refresh-btn:hover:not(:disabled) {
542 filter: brightness(0.9);
543 }
544
545 .refresh-btn:disabled {
546 cursor: not-allowed;
547 opacity: 0.6;
548 }
549
550 .no-connections {
551 color: var(--muted-foreground);
552 text-align: center;
553 padding: 2rem;
554 }
555
556 /* Message */
557 .message {
558 padding: 1rem;
559 border-radius: 4px;
560 margin-top: 1rem;
561 background: var(--info-bg, #e7f3ff);
562 color: var(--info-text, #0066cc);
563 border: 1px solid var(--info, #0066cc);
564 }
565
566 .message.error {
567 background: var(--danger-bg);
568 color: var(--danger-text);
569 border-color: var(--danger);
570 }
571
572 .message.success {
573 background: var(--success-bg);
574 color: var(--success-text);
575 border-color: var(--success);
576 }
577
578 /* Modal */
579 .modal-overlay {
580 position: fixed;
581 top: 0;
582 left: 0;
583 right: 0;
584 bottom: 0;
585 background: rgba(0, 0, 0, 0.6);
586 display: flex;
587 align-items: center;
588 justify-content: center;
589 z-index: 1000;
590 }
591
592 .modal {
593 background: var(--card-bg);
594 border-radius: 8px;
595 padding: 1.5rem;
596 max-width: 600px;
597 width: 90%;
598 max-height: 80vh;
599 overflow: auto;
600 border: 1px solid var(--border-color);
601 }
602
603 .modal h3 {
604 margin: 0 0 0.5rem 0;
605 color: var(--text-color);
606 }
607
608 .modal-description {
609 color: var(--muted-foreground);
610 margin-bottom: 1rem;
611 font-size: 0.9em;
612 line-height: 1.5;
613 }
614
615 .uri-display textarea {
616 width: 100%;
617 height: 120px;
618 padding: 0.75em;
619 border: 1px solid var(--border-color);
620 border-radius: 4px;
621 background: var(--input-bg);
622 color: var(--input-text-color);
623 font-family: monospace;
624 font-size: 0.85em;
625 resize: none;
626 word-break: break-all;
627 }
628
629 .modal-actions {
630 display: flex;
631 gap: 0.5rem;
632 margin-top: 1rem;
633 justify-content: flex-end;
634 }
635
636 .copy-btn {
637 background: var(--primary);
638 color: var(--text-color);
639 border: none;
640 padding: 0.75em 1.5em;
641 border-radius: 4px;
642 cursor: pointer;
643 font-weight: 500;
644 transition: background-color 0.2s;
645 }
646
647 .copy-btn:hover {
648 background: var(--accent-hover-color);
649 }
650
651 .close-btn {
652 background: var(--secondary);
653 color: var(--text-color);
654 border: none;
655 padding: 0.75em 1.5em;
656 border-radius: 4px;
657 cursor: pointer;
658 font-weight: 500;
659 transition: background-color 0.2s;
660 }
661
662 .close-btn:hover {
663 filter: brightness(0.9);
664 }
665
666 /* States */
667 .not-enabled,
668 .permission-denied,
669 .login-prompt {
670 text-align: center;
671 padding: 2em;
672 background-color: var(--card-bg);
673 border-radius: 8px;
674 border: 1px solid var(--border-color);
675 color: var(--text-color);
676 }
677
678 .not-enabled p,
679 .permission-denied p,
680 .login-prompt p {
681 margin: 0 0 1rem 0;
682 line-height: 1.4;
683 }
684
685 .not-enabled code {
686 background: var(--code-bg);
687 padding: 0.2em 0.4em;
688 border-radius: 0.25rem;
689 font-family: monospace;
690 font-size: 0.9em;
691 }
692
693 .login-btn {
694 background: var(--primary);
695 color: var(--text-color);
696 border: none;
697 padding: 0.75em 1.5em;
698 border-radius: 4px;
699 cursor: pointer;
700 font-weight: bold;
701 font-size: 0.9em;
702 transition: background-color 0.2s;
703 }
704
705 .login-btn:hover {
706 background: var(--accent-hover-color);
707 }
708 </style>
709