LoginModal.svelte raw

   1  <script>
   2      import { createEventDispatcher, onMount, onDestroy } from "svelte";
   3      import { PrivateKeySigner } from "./nostr.js";
   4      import { generateSecretKey, getPublicKey } from "nostr-tools/pure";
   5      import { nsecEncode, npubEncode, decode as nip19Decode } from "nostr-tools/nip19";
   6      import { encryptNsec, decryptNsec, isValidNsec } from "./nsec-crypto.js";
   7  
   8      const dispatch = createEventDispatcher();
   9  
  10      export let showModal = false;
  11      export let isDarkTheme = false;
  12  
  13      let activeTab = "extension";
  14      let nsecInput = "";
  15      let encryptionPassword = "";
  16      let confirmPassword = "";
  17      let unlockPassword = "";
  18      let isLoading = false;
  19      let isGenerating = false;
  20      let isDeriving = false;
  21      let errorMessage = "";
  22      let successMessage = "";
  23      let generatedNsec = "";
  24      let generatedNpub = "";
  25      let npubInput = "";
  26  
  27      // Deriving modal timer
  28      let derivingElapsed = 0;
  29      let derivingStartTime = null;
  30      let derivingAnimationFrame = null;
  31  
  32      function startDerivingTimer() {
  33          derivingElapsed = 0;
  34          derivingStartTime = performance.now();
  35          updateDerivingTimer();
  36      }
  37  
  38      function updateDerivingTimer() {
  39          if (derivingStartTime !== null) {
  40              derivingElapsed = (performance.now() - derivingStartTime) / 1000;
  41              derivingAnimationFrame = requestAnimationFrame(updateDerivingTimer);
  42          }
  43      }
  44  
  45      function stopDerivingTimer() {
  46          derivingStartTime = null;
  47          if (derivingAnimationFrame) {
  48              cancelAnimationFrame(derivingAnimationFrame);
  49              derivingAnimationFrame = null;
  50          }
  51      }
  52  
  53      onDestroy(() => {
  54          stopDerivingTimer();
  55      });
  56  
  57      // Check if there's an encrypted key stored
  58      let hasEncryptedKey = false;
  59      let storedPubkey = "";
  60  
  61      onMount(() => {
  62          checkStoredCredentials();
  63      });
  64  
  65      function checkStoredCredentials() {
  66          hasEncryptedKey = !!localStorage.getItem("nostr_privkey_encrypted");
  67          storedPubkey = localStorage.getItem("nostr_pubkey") || "";
  68      }
  69  
  70      // Reset to show the nsec input form
  71      function clearStoredCredentials() {
  72          localStorage.removeItem("nostr_privkey_encrypted");
  73          localStorage.removeItem("nostr_privkey");
  74          localStorage.removeItem("nostr_pubkey");
  75          localStorage.removeItem("nostr_auth_method");
  76          hasEncryptedKey = false;
  77          storedPubkey = "";
  78          unlockPassword = "";
  79          errorMessage = "";
  80          successMessage = "";
  81      }
  82  
  83      function closeModal() {
  84          showModal = false;
  85          nsecInput = "";
  86          npubInput = "";
  87          encryptionPassword = "";
  88          confirmPassword = "";
  89          unlockPassword = "";
  90          errorMessage = "";
  91          successMessage = "";
  92          generatedNsec = "";
  93          generatedNpub = "";
  94          dispatch("close");
  95      }
  96  
  97      // Re-check stored credentials when modal opens
  98      $: if (showModal) {
  99          checkStoredCredentials();
 100      }
 101  
 102      // Unlock with stored encrypted key
 103      async function unlockWithPassword() {
 104          isLoading = true;
 105          isDeriving = true;
 106          startDerivingTimer();
 107          errorMessage = "";
 108          successMessage = "";
 109  
 110          try {
 111              if (!unlockPassword) {
 112                  throw new Error("Please enter your password");
 113              }
 114  
 115              const encryptedData = localStorage.getItem("nostr_privkey_encrypted");
 116              if (!encryptedData) {
 117                  throw new Error("No encrypted key found");
 118              }
 119  
 120              // Decrypt the nsec (library validates bech32 checksum)
 121              const nsec = await decryptNsec(encryptedData, unlockPassword);
 122  
 123              stopDerivingTimer();
 124              isDeriving = false;
 125  
 126              // Create signer and login
 127              const signer = PrivateKeySigner.fromKey(nsec);
 128              const publicKey = await signer.getPublicKey();
 129  
 130              dispatch("login", {
 131                  method: "nsec",
 132                  pubkey: publicKey,
 133                  privateKey: nsec,
 134                  signer: signer,
 135              });
 136  
 137              closeModal();
 138          } catch (error) {
 139              stopDerivingTimer();
 140              if (error.message.includes("decrypt") || error.message.includes("tag")) {
 141                  errorMessage = "Invalid password";
 142              } else {
 143                  errorMessage = error.message;
 144              }
 145          } finally {
 146              isLoading = false;
 147              isDeriving = false;
 148              stopDerivingTimer();
 149          }
 150      }
 151  
 152      function switchTab(tab) {
 153          activeTab = tab;
 154          errorMessage = "";
 155          successMessage = "";
 156          generatedNsec = "";
 157          generatedNpub = "";
 158      }
 159  
 160      // Generate a new nsec using cryptographically secure random bytes
 161      async function generateNewKey() {
 162          isGenerating = true;
 163          errorMessage = "";
 164          successMessage = "";
 165  
 166          try {
 167              // Generate a new secret key using system entropy (crypto.getRandomValues)
 168              const secretKey = generateSecretKey();
 169  
 170              // Encode as nsec (bech32)
 171              const nsec = nsecEncode(secretKey);
 172  
 173              // Get the corresponding public key and encode as npub
 174              const pubkey = getPublicKey(secretKey);
 175              const npub = npubEncode(pubkey);
 176  
 177              generatedNsec = nsec;
 178              generatedNpub = npub;
 179              nsecInput = nsec;
 180  
 181              successMessage = "New key generated! Set an encryption password below to secure it.";
 182          } catch (error) {
 183              errorMessage = "Failed to generate key: " + error.message;
 184          } finally {
 185              isGenerating = false;
 186          }
 187      }
 188  
 189      async function loginWithExtension() {
 190          isLoading = true;
 191          errorMessage = "";
 192          successMessage = "";
 193  
 194          try {
 195              // Check if window.nostr is available
 196              if (!window.nostr) {
 197                  throw new Error(
 198                      "No Nostr extension found. Please install a NIP-07 compatible extension like nos2x or Alby.",
 199                  );
 200              }
 201  
 202              // Get public key from extension
 203              const pubkey = await window.nostr.getPublicKey();
 204  
 205              if (pubkey) {
 206                  // Store authentication info
 207                  localStorage.setItem("nostr_auth_method", "extension");
 208                  localStorage.setItem("nostr_pubkey", pubkey);
 209  
 210                  successMessage = "Successfully logged in with extension!";
 211                  dispatch("login", {
 212                      method: "extension",
 213                      pubkey: pubkey,
 214                      signer: window.nostr,
 215                  });
 216  
 217                  setTimeout(() => {
 218                      closeModal();
 219                  }, 1500);
 220              }
 221          } catch (error) {
 222              errorMessage = error.message;
 223          } finally {
 224              isLoading = false;
 225          }
 226      }
 227  
 228      async function loginWithNpub() {
 229          isLoading = true;
 230          errorMessage = "";
 231  
 232          try {
 233              const input = npubInput.trim();
 234              if (!input) {
 235                  throw new Error("Please enter an npub");
 236              }
 237  
 238              let pubkey;
 239              if (/^[0-9a-f]{64}$/i.test(input)) {
 240                  pubkey = input.toLowerCase();
 241              } else {
 242                  const decoded = nip19Decode(input);
 243                  if (decoded.type !== "npub") {
 244                      throw new Error("Invalid npub — expected an npub1... string or 64-char hex pubkey");
 245                  }
 246                  pubkey = decoded.data;
 247              }
 248  
 249              localStorage.setItem("nostr_auth_method", "npub");
 250              localStorage.setItem("nostr_pubkey", pubkey);
 251  
 252              dispatch("login", {
 253                  method: "npub",
 254                  pubkey: pubkey,
 255                  signer: null,
 256              });
 257  
 258              closeModal();
 259          } catch (error) {
 260              errorMessage = error.message;
 261          } finally {
 262              isLoading = false;
 263          }
 264      }
 265  
 266      async function loginWithNsec() {
 267          isLoading = true;
 268          errorMessage = "";
 269          successMessage = "";
 270  
 271          try {
 272              if (!nsecInput.trim()) {
 273                  throw new Error("Please enter your nsec");
 274              }
 275  
 276              // Validate nsec format and bech32 checksum
 277              if (!isValidNsec(nsecInput.trim())) {
 278                  throw new Error('Invalid nsec format or checksum');
 279              }
 280  
 281              // Validate password if provided
 282              if (encryptionPassword) {
 283                  if (encryptionPassword.length < 8) {
 284                      throw new Error("Password must be at least 8 characters");
 285                  }
 286                  if (encryptionPassword !== confirmPassword) {
 287                      throw new Error("Passwords do not match");
 288                  }
 289              }
 290  
 291              // Create PrivateKeySigner from nsec
 292              const signer = PrivateKeySigner.fromKey(nsecInput.trim());
 293  
 294              // Get the public key from the signer
 295              const publicKey = await signer.getPublicKey();
 296  
 297              // Store with encryption if password provided
 298              localStorage.setItem("nostr_auth_method", "nsec");
 299              localStorage.setItem("nostr_pubkey", publicKey);
 300  
 301              if (encryptionPassword) {
 302                  // Encrypt the nsec before storing
 303                  isDeriving = true;
 304                  startDerivingTimer();
 305                  const encryptedNsec = await encryptNsec(nsecInput.trim(), encryptionPassword);
 306                  stopDerivingTimer();
 307                  isDeriving = false;
 308                  localStorage.setItem("nostr_privkey_encrypted", encryptedNsec);
 309                  localStorage.removeItem("nostr_privkey"); // Remove any plaintext key
 310              } else {
 311                  // Store plaintext (less secure)
 312                  localStorage.setItem("nostr_privkey", nsecInput.trim());
 313                  localStorage.removeItem("nostr_privkey_encrypted");
 314                  successMessage = "Successfully logged in with nsec!";
 315              }
 316  
 317              dispatch("login", {
 318                  method: "nsec",
 319                  pubkey: publicKey,
 320                  privateKey: nsecInput.trim(),
 321                  signer: signer,
 322              });
 323  
 324              setTimeout(() => {
 325                  closeModal();
 326              }, 1500);
 327          } catch (error) {
 328              errorMessage = error.message;
 329          } finally {
 330              isLoading = false;
 331          }
 332      }
 333  
 334      function handleKeydown(event) {
 335          if (event.key === "Escape") {
 336              closeModal();
 337          }
 338          if (event.key === "Enter" && activeTab === "nsec") {
 339              loginWithNsec();
 340          }
 341          if (event.key === "Enter" && activeTab === "npub") {
 342              loginWithNpub();
 343          }
 344      }
 345  </script>
 346  
 347  <svelte:window on:keydown={handleKeydown} />
 348  
 349  {#if showModal}
 350      <div
 351          class="modal-overlay"
 352          on:click={closeModal}
 353          on:keydown={(e) => e.key === "Escape" && closeModal()}
 354          role="button"
 355          tabindex="0"
 356      >
 357          <div
 358              class="modal"
 359              class:dark-theme={isDarkTheme}
 360              on:click|stopPropagation
 361              on:keydown|stopPropagation
 362          >
 363              <div class="modal-header">
 364                  <h2>Login to Nostr</h2>
 365                  <button class="close-btn" on:click={closeModal}>&times;</button>
 366              </div>
 367  
 368              <div class="tab-container">
 369                  <div class="tabs">
 370                      <button
 371                          class="tab-btn"
 372                          class:active={activeTab === "extension"}
 373                          on:click={() => switchTab("extension")}
 374                      >
 375                          Extension
 376                      </button>
 377                      <button
 378                          class="tab-btn"
 379                          class:active={activeTab === "nsec"}
 380                          on:click={() => switchTab("nsec")}
 381                      >
 382                          Nsec
 383                      </button>
 384                      <button
 385                          class="tab-btn"
 386                          class:active={activeTab === "npub"}
 387                          on:click={() => switchTab("npub")}
 388                      >
 389                          Read-only
 390                      </button>
 391                  </div>
 392  
 393                  <div class="tab-content">
 394                      {#if activeTab === "extension"}
 395                          <div class="extension-login">
 396                              <p>
 397                                  Login using a NIP-07 compatible browser
 398                                  extension like nos2x or Alby.
 399                              </p>
 400                              <button
 401                                  class="login-extension-btn"
 402                                  on:click={loginWithExtension}
 403                                  disabled={isLoading}
 404                              >
 405                                  {isLoading
 406                                      ? "Connecting..."
 407                                      : "Log in using extension"}
 408                              </button>
 409                          </div>
 410                      {:else if activeTab === "npub"}
 411                          <div class="extension-login">
 412                              <p>
 413                                  Enter an npub to browse in read-only mode.
 414                                  You won't be able to post or sign events.
 415                              </p>
 416                              <input
 417                                  type="text"
 418                                  placeholder="npub1... or hex pubkey"
 419                                  bind:value={npubInput}
 420                                  disabled={isLoading}
 421                                  class="nsec-input"
 422                              />
 423                              <button
 424                                  class="login-nsec-btn"
 425                                  on:click={loginWithNpub}
 426                                  disabled={isLoading || !npubInput.trim()}
 427                              >
 428                                  {isLoading ? "Logging in..." : "Browse read-only"}
 429                              </button>
 430                          </div>
 431                      {:else}
 432                          <div class="nsec-login">
 433                              {#if hasEncryptedKey}
 434                                  <!-- Unlock existing encrypted key -->
 435                                  <p>
 436                                      You have a stored encrypted key. Enter your
 437                                      password to unlock it.
 438                                  </p>
 439  
 440                                  {#if storedPubkey}
 441                                      <div class="stored-info">
 442                                          <label>Stored public key:</label>
 443                                          <code class="npub-display">{storedPubkey.slice(0, 16)}...{storedPubkey.slice(-8)}</code>
 444                                      </div>
 445                                  {/if}
 446  
 447                                  <input
 448                                      type="password"
 449                                      placeholder="Enter your password"
 450                                      bind:value={unlockPassword}
 451                                      disabled={isLoading || isDeriving}
 452                                      class="password-input"
 453                                  />
 454  
 455                                  <button
 456                                      class="login-nsec-btn"
 457                                      on:click={unlockWithPassword}
 458                                      disabled={isLoading || isDeriving || !unlockPassword}
 459                                  >
 460                                      {#if isDeriving}
 461                                          Deriving key...
 462                                      {:else if isLoading}
 463                                          Unlocking...
 464                                      {:else}
 465                                          Unlock
 466                                      {/if}
 467                                  </button>
 468  
 469                                  <button
 470                                      class="clear-btn"
 471                                      on:click={clearStoredCredentials}
 472                                      disabled={isLoading || isDeriving}
 473                                  >
 474                                      Clear stored key &amp; start fresh
 475                                  </button>
 476                              {:else}
 477                                  <!-- Normal nsec entry / generation -->
 478                                  <p>
 479                                      Enter your nsec or generate a new one. Optionally
 480                                      set a password to encrypt it securely.
 481                                  </p>
 482  
 483                                  <button
 484                                      class="generate-btn"
 485                                      on:click={generateNewKey}
 486                                      disabled={isLoading || isGenerating}
 487                                  >
 488                                      {isGenerating
 489                                          ? "Generating..."
 490                                          : "Generate New Key"}
 491                                  </button>
 492  
 493                                  {#if generatedNpub}
 494                                      <div class="generated-info">
 495                                          <label>Your new public key (npub):</label>
 496                                          <code class="npub-display">{generatedNpub}</code>
 497                                      </div>
 498                                  {/if}
 499  
 500                                  <input
 501                                      type="password"
 502                                      placeholder="nsec1..."
 503                                      bind:value={nsecInput}
 504                                      disabled={isLoading || isDeriving}
 505                                      class="nsec-input"
 506                                  />
 507  
 508                                  <div class="password-section">
 509                                      <label>Encryption Password (optional but recommended):</label>
 510                                      <input
 511                                          type="password"
 512                                          placeholder="Enter password (min 8 chars)"
 513                                          bind:value={encryptionPassword}
 514                                          disabled={isLoading || isDeriving}
 515                                          class="password-input"
 516                                      />
 517                                      {#if encryptionPassword}
 518                                          <input
 519                                              type="password"
 520                                              placeholder="Confirm password"
 521                                              bind:value={confirmPassword}
 522                                              disabled={isLoading || isDeriving}
 523                                              class="password-input"
 524                                          />
 525                                  {/if}
 526                                  <small class="password-hint">
 527                                      Password uses Argon2id with ~3 second derivation time for security.
 528                                  </small>
 529                                  </div>
 530  
 531                                  <button
 532                                      class="login-nsec-btn"
 533                                      on:click={loginWithNsec}
 534                                      disabled={isLoading || isDeriving || !nsecInput.trim()}
 535                                  >
 536                                      {#if isDeriving}
 537                                          Deriving key...
 538                                      {:else if isLoading}
 539                                          Logging in...
 540                                      {:else}
 541                                          Log in with nsec
 542                                      {/if}
 543                                  </button>
 544                              {/if}
 545                          </div>
 546                      {/if}
 547  
 548                      {#if errorMessage}
 549                          <div class="message error-message">{errorMessage}</div>
 550                      {/if}
 551  
 552                      {#if successMessage}
 553                          <div class="message success-message">
 554                              {successMessage}
 555                          </div>
 556                      {/if}
 557                  </div>
 558              </div>
 559          </div>
 560      </div>
 561  {/if}
 562  
 563  {#if isDeriving}
 564      <div class="deriving-overlay">
 565          <div class="deriving-modal" class:dark-theme={isDarkTheme}>
 566              <div class="deriving-spinner"></div>
 567              <h3>Deriving encryption key</h3>
 568              <div class="deriving-timer">{derivingElapsed.toFixed(1)}s</div>
 569              <p class="deriving-note">This may take 3-6 seconds for security</p>
 570          </div>
 571      </div>
 572  {/if}
 573  
 574  <style>
 575      .modal-overlay {
 576          position: fixed;
 577          top: 0;
 578          left: 0;
 579          width: 100%;
 580          height: 100%;
 581          background-color: rgba(0, 0, 0, 0.5);
 582          display: flex;
 583          justify-content: center;
 584          align-items: center;
 585          z-index: 1000;
 586      }
 587  
 588      .modal {
 589          background: var(--bg-color);
 590          border-radius: 8px;
 591          box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
 592          width: 90%;
 593          max-width: 500px;
 594          max-height: 90vh;
 595          overflow-y: auto;
 596          border: 1px solid var(--border-color);
 597      }
 598  
 599      .modal-header {
 600          display: flex;
 601          justify-content: space-between;
 602          align-items: center;
 603          padding: 20px;
 604          border-bottom: 1px solid var(--border-color);
 605      }
 606  
 607      .modal-header h2 {
 608          margin: 0;
 609          color: var(--text-color);
 610          font-size: 1.5rem;
 611      }
 612  
 613      .close-btn {
 614          background: none;
 615          border: none;
 616          font-size: 1.5rem;
 617          cursor: pointer;
 618          color: var(--text-color);
 619          padding: 0;
 620          width: 30px;
 621          height: 30px;
 622          display: flex;
 623          align-items: center;
 624          justify-content: center;
 625          border-radius: 50%;
 626          transition: background-color 0.2s;
 627      }
 628  
 629      .close-btn:hover {
 630          background-color: var(--tab-hover-bg);
 631      }
 632  
 633      .tab-container {
 634          padding: 20px;
 635      }
 636  
 637      .tabs {
 638          display: flex;
 639          border-bottom: 1px solid var(--border-color);
 640          margin-bottom: 20px;
 641      }
 642  
 643      .tab-btn {
 644          flex: 1;
 645          padding: 12px 16px;
 646          background: none;
 647          border: none;
 648          cursor: pointer;
 649          color: var(--text-color);
 650          font-size: 1rem;
 651          transition: all 0.2s;
 652          border-bottom: 2px solid transparent;
 653      }
 654  
 655      .tab-btn:hover {
 656          background-color: var(--tab-hover-bg);
 657      }
 658  
 659      .tab-btn.active {
 660          border-bottom-color: var(--primary);
 661          color: var(--primary);
 662      }
 663  
 664      .tab-content {
 665          min-height: 200px;
 666      }
 667  
 668      .extension-login,
 669      .nsec-login {
 670          display: flex;
 671          flex-direction: column;
 672          gap: 16px;
 673      }
 674  
 675      .extension-login p,
 676      .nsec-login p {
 677          margin: 0;
 678          color: var(--text-color);
 679          line-height: 1.5;
 680      }
 681  
 682      .login-extension-btn,
 683      .login-nsec-btn {
 684          padding: 12px 24px;
 685          background: var(--primary);
 686          color: var(--text-color);
 687          border: none;
 688          border-radius: 6px;
 689          cursor: pointer;
 690          font-size: 1rem;
 691          transition: background-color 0.2s;
 692      }
 693  
 694      .login-extension-btn:hover:not(:disabled),
 695      .login-nsec-btn:hover:not(:disabled) {
 696          background: #00acc1;
 697      }
 698  
 699      .login-extension-btn:disabled,
 700      .login-nsec-btn:disabled {
 701          background: #ccc;
 702          cursor: not-allowed;
 703      }
 704  
 705      .nsec-input {
 706          padding: 12px;
 707          border: 1px solid var(--input-border);
 708          border-radius: 6px;
 709          font-size: 1rem;
 710          background: var(--bg-color);
 711          color: var(--text-color);
 712      }
 713  
 714      .nsec-input:focus {
 715          outline: none;
 716          border-color: var(--primary);
 717      }
 718  
 719      .generate-btn {
 720          padding: 10px 20px;
 721          background: var(--success);
 722          color: white;
 723          border: none;
 724          border-radius: 6px;
 725          cursor: pointer;
 726          font-size: 0.95rem;
 727          transition: background-color 0.2s;
 728      }
 729  
 730      .generate-btn:hover:not(:disabled) {
 731          background: var(--success);
 732          filter: brightness(0.9);
 733      }
 734  
 735      .generate-btn:disabled {
 736          background: #ccc;
 737          cursor: not-allowed;
 738      }
 739  
 740      .generated-info {
 741          background: var(--card-bg, #f5f5f5);
 742          padding: 12px;
 743          border-radius: 6px;
 744          border: 1px solid var(--border-color);
 745      }
 746  
 747      .generated-info label {
 748          display: block;
 749          font-size: 0.85rem;
 750          color: var(--muted-foreground, #666);
 751          margin-bottom: 6px;
 752      }
 753  
 754      .npub-display {
 755          display: block;
 756          word-break: break-all;
 757          font-size: 0.85rem;
 758          background: var(--bg-color);
 759          padding: 8px;
 760          border-radius: 4px;
 761          color: var(--text-color);
 762      }
 763  
 764      .password-section {
 765          display: flex;
 766          flex-direction: column;
 767          gap: 8px;
 768      }
 769  
 770      .password-section label {
 771          font-size: 0.9rem;
 772          color: var(--text-color);
 773          font-weight: 500;
 774      }
 775  
 776      .password-input {
 777          padding: 10px 12px;
 778          border: 1px solid var(--input-border);
 779          border-radius: 6px;
 780          font-size: 0.95rem;
 781          background: var(--bg-color);
 782          color: var(--text-color);
 783      }
 784  
 785      .password-input:focus {
 786          outline: none;
 787          border-color: var(--primary);
 788      }
 789  
 790      .password-hint {
 791          font-size: 0.8rem;
 792          color: var(--muted-foreground, #888);
 793          font-style: italic;
 794      }
 795  
 796      .stored-info {
 797          background: var(--card-bg, #f5f5f5);
 798          padding: 12px;
 799          border-radius: 6px;
 800          border: 1px solid var(--border-color);
 801      }
 802  
 803      .stored-info label {
 804          display: block;
 805          font-size: 0.85rem;
 806          color: var(--muted-foreground, #666);
 807          margin-bottom: 6px;
 808      }
 809  
 810      .clear-btn {
 811          padding: 10px 20px;
 812          background: transparent;
 813          color: var(--error, #dc3545);
 814          border: 1px solid var(--error, #dc3545);
 815          border-radius: 6px;
 816          cursor: pointer;
 817          font-size: 0.9rem;
 818          transition: all 0.2s;
 819      }
 820  
 821      .clear-btn:hover:not(:disabled) {
 822          background: var(--error, #dc3545);
 823          color: white;
 824      }
 825  
 826      .clear-btn:disabled {
 827          opacity: 0.5;
 828          cursor: not-allowed;
 829      }
 830  
 831      .message {
 832          padding: 10px;
 833          border-radius: 4px;
 834          margin-top: 16px;
 835          text-align: center;
 836      }
 837  
 838      .error-message {
 839          background: #ffebee;
 840          color: #c62828;
 841          border: 1px solid #ffcdd2;
 842      }
 843  
 844      .success-message {
 845          background: #e8f5e8;
 846          color: #2e7d32;
 847          border: 1px solid #c8e6c9;
 848      }
 849  
 850      .modal.dark-theme .error-message {
 851          background: #4a2c2a;
 852          color: #ffcdd2;
 853          border: 1px solid #6d4c41;
 854      }
 855  
 856      .modal.dark-theme .success-message {
 857          background: #2e4a2e;
 858          color: #a5d6a7;
 859          border: 1px solid var(--success);
 860      }
 861  
 862      /* Deriving modal overlay */
 863      .deriving-overlay {
 864          position: fixed;
 865          top: 0;
 866          left: 0;
 867          width: 100%;
 868          height: 100%;
 869          background-color: rgba(0, 0, 0, 0.7);
 870          display: flex;
 871          justify-content: center;
 872          align-items: center;
 873          z-index: 2000;
 874      }
 875  
 876      .deriving-modal {
 877          background: var(--bg-color, #fff);
 878          border-radius: 12px;
 879          padding: 2rem;
 880          text-align: center;
 881          box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
 882          min-width: 280px;
 883      }
 884  
 885      .deriving-modal h3 {
 886          margin: 1rem 0 0.5rem;
 887          color: var(--text-color, #333);
 888          font-size: 1.2rem;
 889      }
 890  
 891      .deriving-timer {
 892          font-size: 2.5rem;
 893          font-weight: bold;
 894          color: var(--primary);
 895          font-family: monospace;
 896          margin: 0.5rem 0;
 897      }
 898  
 899      .deriving-note {
 900          margin: 0.5rem 0 0;
 901          color: var(--muted-foreground, #666);
 902          font-size: 0.9rem;
 903      }
 904  
 905      .deriving-spinner {
 906          width: 48px;
 907          height: 48px;
 908          border: 4px solid var(--border-color, #e0e0e0);
 909          border-top-color: var(--primary);
 910          border-radius: 50%;
 911          margin: 0 auto;
 912          animation: spin 1s linear infinite;
 913      }
 914  
 915      @keyframes spin {
 916          to {
 917              transform: rotate(360deg);
 918          }
 919      }
 920  
 921      .deriving-modal.dark-theme {
 922          background: #1a1a1a;
 923      }
 924  
 925      .deriving-modal.dark-theme h3 {
 926          color: #fff;
 927      }
 928  
 929      .deriving-modal.dark-theme .deriving-note {
 930          color: #aaa;
 931      }
 932  </style>
 933