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