PolicyView.svelte raw

   1  <script>
   2      export let isLoggedIn = false;
   3      export let userRole = "";
   4      export let isPolicyAdmin = false;
   5      export let policyEnabled = false;
   6      export let policyJson = "";
   7      export let isLoadingPolicy = false;
   8      export let policyMessage = "";
   9      export let policyMessageType = "";
  10      export let validationErrors = [];
  11      export let policyAdmins = [];
  12      export let policyFollows = [];
  13  
  14      import { createEventDispatcher } from "svelte";
  15      const dispatch = createEventDispatcher();
  16  
  17      // New admin input
  18      let newAdminInput = "";
  19  
  20      function loadPolicy() {
  21          dispatch("loadPolicy");
  22      }
  23  
  24      function validatePolicy() {
  25          dispatch("validatePolicy");
  26      }
  27  
  28      function savePolicy() {
  29          dispatch("savePolicy");
  30      }
  31  
  32      function formatJson() {
  33          dispatch("formatJson");
  34      }
  35  
  36      function openLoginModal() {
  37          dispatch("openLoginModal");
  38      }
  39  
  40      function refreshFollows() {
  41          dispatch("refreshFollows");
  42      }
  43  
  44      function addPolicyAdmin() {
  45          if (newAdminInput.trim()) {
  46              dispatch("addPolicyAdmin", newAdminInput.trim());
  47              newAdminInput = "";
  48          }
  49      }
  50  
  51      function removePolicyAdmin(pubkey) {
  52          dispatch("removePolicyAdmin", pubkey);
  53      }
  54  
  55      // Parse admins from current policy JSON for display
  56      $: {
  57          try {
  58              if (policyJson) {
  59                  const parsed = JSON.parse(policyJson);
  60                  policyAdmins = parsed.policy_admins || [];
  61              }
  62          } catch (e) {
  63              // Ignore parse errors
  64          }
  65      }
  66  
  67      // Pretty-print example policy for reference
  68      const examplePolicy = `{
  69    "kind": {
  70      "whitelist": [0, 1, 3, 6, 7, 10002],
  71      "blacklist": []
  72    },
  73    "global": {
  74      "description": "Global rules applied to all events",
  75      "size_limit": 65536,
  76      "max_age_of_event": 86400,
  77      "max_age_event_in_future": 300
  78    },
  79    "rules": {
  80      "1": {
  81        "description": "Kind 1 (short text notes)",
  82        "content_limit": 8192,
  83        "write_allow_follows": true
  84      },
  85      "30023": {
  86        "description": "Long-form articles",
  87        "content_limit": 100000,
  88        "tag_validation": {
  89          "d": "^[a-z0-9-]{1,64}$",
  90          "t": "^[a-z0-9-]{1,32}$"
  91        }
  92      }
  93    },
  94    "default_policy": "allow",
  95    "policy_admins": ["<your-hex-pubkey>"],
  96    "policy_follow_whitelist_enabled": true
  97  }`;
  98  </script>
  99  
 100  <div class="policy-view">
 101      <h2>Policy Configuration</h2>
 102      {#if isLoggedIn && (userRole === "owner" || isPolicyAdmin)}
 103          <div class="policy-section">
 104              <div class="policy-header">
 105                  <h3>Policy Editor</h3>
 106                  <div class="policy-status">
 107                      <span class="status-badge" class:enabled={policyEnabled}>
 108                          {policyEnabled ? "Policy Enabled" : "Policy Disabled"}
 109                      </span>
 110                      {#if isPolicyAdmin}
 111                          <span class="admin-badge">Policy Admin</span>
 112                      {/if}
 113                  </div>
 114              </div>
 115  
 116              <div class="policy-info">
 117                  <p>
 118                      Edit the policy JSON below and click "Save & Publish" to update the relay's policy configuration.
 119                      Changes are applied immediately after validation.
 120                  </p>
 121                  <p class="info-note">
 122                      Policy updates are published as kind 12345 events and require policy admin permissions.
 123                  </p>
 124              </div>
 125  
 126              <div class="editor-container">
 127                  <textarea
 128                      class="policy-editor"
 129                      bind:value={policyJson}
 130                      placeholder="Loading policy configuration..."
 131                      disabled={isLoadingPolicy}
 132                      spellcheck="false"
 133                  ></textarea>
 134              </div>
 135  
 136              {#if validationErrors.length > 0}
 137                  <div class="validation-errors">
 138                      <h4>Validation Errors:</h4>
 139                      <ul>
 140                          {#each validationErrors as error}
 141                              <li>{error}</li>
 142                          {/each}
 143                      </ul>
 144                  </div>
 145              {/if}
 146  
 147              <div class="policy-actions">
 148                  <button
 149                      class="policy-btn load-btn"
 150                      on:click={loadPolicy}
 151                      disabled={isLoadingPolicy}
 152                  >
 153                      Load Current
 154                  </button>
 155                  <button
 156                      class="policy-btn format-btn"
 157                      on:click={formatJson}
 158                      disabled={isLoadingPolicy}
 159                  >
 160                      Format JSON
 161                  </button>
 162                  <button
 163                      class="policy-btn validate-btn"
 164                      on:click={validatePolicy}
 165                      disabled={isLoadingPolicy}
 166                  >
 167                      Validate
 168                  </button>
 169                  <button
 170                      class="policy-btn save-btn"
 171                      on:click={savePolicy}
 172                      disabled={isLoadingPolicy}
 173                  >
 174                      Save & Publish
 175                  </button>
 176              </div>
 177  
 178              {#if policyMessage}
 179                  <div
 180                      class="policy-message"
 181                      class:error={policyMessageType === "error"}
 182                      class:success={policyMessageType === "success"}
 183                  >
 184                      {policyMessage}
 185                  </div>
 186              {/if}
 187          </div>
 188  
 189          <!-- Policy Admins Section -->
 190          <div class="policy-section">
 191              <h3>Policy Administrators</h3>
 192              <div class="policy-info">
 193                  <p>
 194                      Policy admins can update the relay's policy configuration via kind 12345 events.
 195                      Their follows get whitelisted if <code>policy_follow_whitelist_enabled</code> is true in the policy.
 196                  </p>
 197                  <p class="info-note">
 198                      <strong>Note:</strong> Policy admins are separate from relay admins (ORLY_ADMINS).
 199                      Changes here update the JSON editor - click "Save & Publish" to apply.
 200                  </p>
 201              </div>
 202  
 203              <div class="admin-list">
 204                  {#if policyAdmins.length === 0}
 205                      <p class="no-items">No policy admins configured</p>
 206                  {:else}
 207                      {#each policyAdmins as admin}
 208                          <div class="admin-item">
 209                              <span class="admin-pubkey" title={admin}>{admin.substring(0, 16)}...{admin.substring(admin.length - 8)}</span>
 210                              <button
 211                                  class="remove-btn"
 212                                  on:click={() => removePolicyAdmin(admin)}
 213                                  disabled={isLoadingPolicy}
 214                                  title="Remove admin"
 215                              >
 216   217                              </button>
 218                          </div>
 219                      {/each}
 220                  {/if}
 221              </div>
 222  
 223              <div class="add-admin">
 224                  <input
 225                      type="text"
 226                      placeholder="npub or hex pubkey"
 227                      bind:value={newAdminInput}
 228                      disabled={isLoadingPolicy}
 229                      on:keydown={(e) => e.key === "Enter" && addPolicyAdmin()}
 230                  />
 231                  <button
 232                      class="policy-btn add-btn"
 233                      on:click={addPolicyAdmin}
 234                      disabled={isLoadingPolicy || !newAdminInput.trim()}
 235                  >
 236                      + Add Admin
 237                  </button>
 238              </div>
 239          </div>
 240  
 241          <!-- Policy Follow Whitelist Section -->
 242          <div class="policy-section">
 243              <h3>Policy Follow Whitelist</h3>
 244              <div class="policy-info">
 245                  <p>
 246                      Pubkeys followed by policy admins (kind 3 events).
 247                      These get automatic read+write access when rules have <code>write_allow_follows: true</code>.
 248                  </p>
 249              </div>
 250  
 251              <div class="follows-header">
 252                  <span class="follows-count">{policyFollows.length} pubkey(s) in whitelist</span>
 253                  <button
 254                      class="policy-btn refresh-btn"
 255                      on:click={refreshFollows}
 256                      disabled={isLoadingPolicy}
 257                  >
 258                      🔄 Refresh Follows
 259                  </button>
 260              </div>
 261  
 262              <div class="follows-list">
 263                  {#if policyFollows.length === 0}
 264                      <p class="no-items">No follows loaded. Click "Refresh Follows" to load from database.</p>
 265                  {:else}
 266                      <div class="follows-grid">
 267                          {#each policyFollows as follow}
 268                              <div class="follow-item" title={follow}>
 269                                  {follow.substring(0, 12)}...{follow.substring(follow.length - 6)}
 270                              </div>
 271                          {/each}
 272                      </div>
 273                  {/if}
 274              </div>
 275          </div>
 276  
 277          <div class="policy-section">
 278              <h3>Policy Reference</h3>
 279              <div class="reference-content">
 280                  <h4>Structure Overview</h4>
 281                  <ul class="field-list">
 282                      <li><code>kind.whitelist</code> - Only allow these event kinds (takes precedence)</li>
 283                      <li><code>kind.blacklist</code> - Deny these event kinds (if no whitelist)</li>
 284                      <li><code>global</code> - Rules applied to all events</li>
 285                      <li><code>rules</code> - Per-kind rules (keyed by kind number as string)</li>
 286                      <li><code>default_policy</code> - "allow" or "deny" when no rules match</li>
 287                      <li><code>policy_admins</code> - Hex pubkeys that can update policy</li>
 288                      <li><code>policy_follow_whitelist_enabled</code> - Enable follow-based access</li>
 289                  </ul>
 290  
 291                  <h4>Rule Fields</h4>
 292                  <ul class="field-list">
 293                      <li><code>description</code> - Human-readable rule description</li>
 294                      <li><code>write_allow</code> / <code>write_deny</code> - Pubkey lists for write access</li>
 295                      <li><code>read_allow</code> / <code>read_deny</code> - Pubkey lists for read access</li>
 296                      <li><code>write_allow_follows</code> - Grant access to policy admin follows</li>
 297                      <li><code>size_limit</code> - Max total event size in bytes</li>
 298                      <li><code>content_limit</code> - Max content field size in bytes</li>
 299                      <li><code>max_expiry</code> - Max expiry offset in seconds</li>
 300                      <li><code>max_age_of_event</code> - Max age of created_at in seconds</li>
 301                      <li><code>max_age_event_in_future</code> - Max future offset in seconds</li>
 302                      <li><code>must_have_tags</code> - Required tag letters (e.g., ["d", "t"])</li>
 303                      <li><code>tag_validation</code> - Regex patterns for tag values</li>
 304                      <li><code>script</code> - Path to external validation script</li>
 305                  </ul>
 306  
 307                  <h4>Example Policy</h4>
 308                  <pre class="example-json">{examplePolicy}</pre>
 309              </div>
 310          </div>
 311      {:else if isLoggedIn}
 312          <div class="permission-denied">
 313              <p>Policy configuration requires owner or policy admin permissions.</p>
 314              <p>
 315                  To become a policy admin, ask an existing policy admin to add your pubkey
 316                  to the <code>policy_admins</code> list.
 317              </p>
 318              <p>
 319                  Current user role: <strong>{userRole || "none"}</strong>
 320              </p>
 321          </div>
 322      {:else}
 323          <div class="login-prompt">
 324              <p>Please log in to access policy configuration.</p>
 325              <button class="login-btn" on:click={openLoginModal}>Log In</button>
 326          </div>
 327      {/if}
 328  </div>
 329  
 330  <style>
 331      .policy-view {
 332          width: 100%;
 333          max-width: 1200px;
 334          margin: 0;
 335          padding: 20px;
 336          background: var(--header-bg);
 337          color: var(--text-color);
 338          border-radius: 8px;
 339          box-sizing: border-box;
 340      }
 341  
 342      .policy-view h2 {
 343          margin: 0 0 1.5rem 0;
 344          color: var(--text-color);
 345          font-size: 1.8rem;
 346          font-weight: 600;
 347      }
 348  
 349      .policy-section {
 350          background-color: var(--card-bg);
 351          border-radius: 8px;
 352          padding: 1.5em;
 353          margin-bottom: 1.5rem;
 354          border: 1px solid var(--border-color);
 355      }
 356  
 357      .policy-header {
 358          display: flex;
 359          justify-content: space-between;
 360          align-items: center;
 361          margin-bottom: 1rem;
 362      }
 363  
 364      .policy-header h3 {
 365          margin: 0;
 366          color: var(--text-color);
 367          font-size: 1.2rem;
 368          font-weight: 600;
 369      }
 370  
 371      .policy-status {
 372          display: flex;
 373          gap: 0.5rem;
 374      }
 375  
 376      .status-badge {
 377          padding: 0.25em 0.75em;
 378          border-radius: 1rem;
 379          font-size: 0.8em;
 380          font-weight: 600;
 381          background: var(--danger);
 382          color: white;
 383      }
 384  
 385      .status-badge.enabled {
 386          background: var(--success);
 387      }
 388  
 389      .admin-badge {
 390          padding: 0.25em 0.75em;
 391          border-radius: 1rem;
 392          font-size: 0.8em;
 393          font-weight: 600;
 394          background: var(--primary);
 395          color: white;
 396      }
 397  
 398      .policy-info {
 399          margin-bottom: 1rem;
 400          padding: 1rem;
 401          background: var(--bg-color);
 402          border-radius: 4px;
 403          border: 1px solid var(--border-color);
 404      }
 405  
 406      .policy-info p {
 407          margin: 0 0 0.5rem 0;
 408          line-height: 1.5;
 409      }
 410  
 411      .policy-info p:last-child {
 412          margin-bottom: 0;
 413      }
 414  
 415      .info-note {
 416          font-size: 0.9em;
 417          opacity: 0.8;
 418      }
 419  
 420      .editor-container {
 421          margin-bottom: 1rem;
 422      }
 423  
 424      .policy-editor {
 425          width: 100%;
 426          height: 400px;
 427          padding: 1em;
 428          border: 1px solid var(--border-color);
 429          border-radius: 4px;
 430          background: var(--input-bg);
 431          color: var(--input-text-color);
 432          font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
 433          font-size: 0.85em;
 434          line-height: 1.5;
 435          resize: vertical;
 436          tab-size: 2;
 437      }
 438  
 439      .policy-editor:disabled {
 440          opacity: 0.6;
 441          cursor: not-allowed;
 442      }
 443  
 444      .validation-errors {
 445          margin-bottom: 1rem;
 446          padding: 1rem;
 447          background: var(--danger-bg, rgba(220, 53, 69, 0.1));
 448          border: 1px solid var(--danger);
 449          border-radius: 4px;
 450      }
 451  
 452      .validation-errors h4 {
 453          margin: 0 0 0.5rem 0;
 454          color: var(--danger);
 455          font-size: 1rem;
 456      }
 457  
 458      .validation-errors ul {
 459          margin: 0;
 460          padding-left: 1.5rem;
 461      }
 462  
 463      .validation-errors li {
 464          color: var(--danger);
 465          margin-bottom: 0.25rem;
 466      }
 467  
 468      .policy-actions {
 469          display: flex;
 470          gap: 0.5rem;
 471          flex-wrap: wrap;
 472      }
 473  
 474      .policy-btn {
 475          background: var(--primary);
 476          color: white;
 477          border: none;
 478          padding: 0.5em 1em;
 479          border-radius: 4px;
 480          cursor: pointer;
 481          font-size: 0.9em;
 482          transition: background-color 0.2s, filter 0.2s;
 483          display: flex;
 484          align-items: center;
 485          gap: 0.25em;
 486      }
 487  
 488      .policy-btn:hover:not(:disabled) {
 489          filter: brightness(1.1);
 490      }
 491  
 492      .policy-btn:disabled {
 493          background: var(--secondary);
 494          cursor: not-allowed;
 495      }
 496  
 497      .load-btn {
 498          background: var(--info);
 499      }
 500  
 501      .format-btn {
 502          background: var(--secondary);
 503      }
 504  
 505      .validate-btn {
 506          background: var(--warning);
 507      }
 508  
 509      .save-btn {
 510          background: var(--success);
 511      }
 512  
 513      .policy-message {
 514          padding: 1rem;
 515          border-radius: 4px;
 516          margin-top: 1rem;
 517          background: var(--info-bg, rgba(23, 162, 184, 0.1));
 518          color: var(--info-text, var(--text-color));
 519          border: 1px solid var(--info);
 520      }
 521  
 522      .policy-message.error {
 523          background: var(--danger-bg, rgba(220, 53, 69, 0.1));
 524          color: var(--danger-text, var(--danger));
 525          border: 1px solid var(--danger);
 526      }
 527  
 528      .policy-message.success {
 529          background: var(--success-bg, rgba(40, 167, 69, 0.1));
 530          color: var(--success-text, var(--success));
 531          border: 1px solid var(--success);
 532      }
 533  
 534      .reference-content h4 {
 535          margin: 1rem 0 0.5rem 0;
 536          color: var(--text-color);
 537          font-size: 1rem;
 538      }
 539  
 540      .reference-content h4:first-child {
 541          margin-top: 0;
 542      }
 543  
 544      .field-list {
 545          margin: 0 0 1rem 0;
 546          padding-left: 1.5rem;
 547      }
 548  
 549      .field-list li {
 550          margin-bottom: 0.25rem;
 551          line-height: 1.5;
 552      }
 553  
 554      .field-list code {
 555          background: var(--code-bg, rgba(0, 0, 0, 0.1));
 556          padding: 0.1em 0.4em;
 557          border-radius: 3px;
 558          font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
 559          font-size: 0.9em;
 560      }
 561  
 562      .example-json {
 563          background: var(--input-bg);
 564          color: var(--input-text-color);
 565          padding: 1rem;
 566          border-radius: 4px;
 567          border: 1px solid var(--border-color);
 568          font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
 569          font-size: 0.8em;
 570          line-height: 1.4;
 571          overflow-x: auto;
 572          white-space: pre;
 573          margin: 0;
 574      }
 575  
 576      .permission-denied,
 577      .login-prompt {
 578          text-align: center;
 579          padding: 2em;
 580          background-color: var(--card-bg);
 581          border-radius: 8px;
 582          border: 1px solid var(--border-color);
 583          color: var(--text-color);
 584      }
 585  
 586      .permission-denied p,
 587      .login-prompt p {
 588          margin: 0 0 1rem 0;
 589          line-height: 1.4;
 590      }
 591  
 592      .permission-denied code {
 593          background: var(--code-bg, rgba(0, 0, 0, 0.1));
 594          padding: 0.2em 0.4em;
 595          border-radius: 0.25rem;
 596          font-family: monospace;
 597          font-size: 0.9em;
 598      }
 599  
 600      .login-btn {
 601          background: var(--primary);
 602          color: white;
 603          border: none;
 604          padding: 0.75em 1.5em;
 605          border-radius: 4px;
 606          cursor: pointer;
 607          font-weight: bold;
 608          font-size: 0.9em;
 609          transition: background-color 0.2s;
 610      }
 611  
 612      .login-btn:hover {
 613          filter: brightness(1.1);
 614      }
 615  
 616      /* Admin list styles */
 617      .admin-list {
 618          margin-bottom: 1rem;
 619      }
 620  
 621      .admin-item {
 622          display: flex;
 623          justify-content: space-between;
 624          align-items: center;
 625          padding: 0.5em 0.75em;
 626          background: var(--bg-color);
 627          border: 1px solid var(--border-color);
 628          border-radius: 4px;
 629          margin-bottom: 0.5rem;
 630      }
 631  
 632      .admin-pubkey {
 633          font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
 634          font-size: 0.85em;
 635          color: var(--text-color);
 636      }
 637  
 638      .remove-btn {
 639          background: var(--danger);
 640          color: white;
 641          border: none;
 642          width: 24px;
 643          height: 24px;
 644          border-radius: 50%;
 645          cursor: pointer;
 646          font-size: 0.8em;
 647          display: flex;
 648          align-items: center;
 649          justify-content: center;
 650          transition: filter 0.2s;
 651      }
 652  
 653      .remove-btn:hover:not(:disabled) {
 654          filter: brightness(0.9);
 655      }
 656  
 657      .remove-btn:disabled {
 658          opacity: 0.5;
 659          cursor: not-allowed;
 660      }
 661  
 662      .add-admin {
 663          display: flex;
 664          gap: 0.5rem;
 665      }
 666  
 667      .add-admin input {
 668          flex: 1;
 669          padding: 0.5em 0.75em;
 670          border: 1px solid var(--border-color);
 671          border-radius: 4px;
 672          background: var(--input-bg);
 673          color: var(--input-text-color);
 674          font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
 675          font-size: 0.85em;
 676      }
 677  
 678      .add-btn {
 679          background: var(--success);
 680          white-space: nowrap;
 681      }
 682  
 683      .no-items {
 684          color: var(--text-color);
 685          opacity: 0.6;
 686          font-style: italic;
 687          padding: 1rem;
 688          text-align: center;
 689      }
 690  
 691      /* Follow list styles */
 692      .follows-header {
 693          display: flex;
 694          justify-content: space-between;
 695          align-items: center;
 696          margin-bottom: 1rem;
 697      }
 698  
 699      .follows-count {
 700          font-weight: 600;
 701          color: var(--text-color);
 702      }
 703  
 704      .refresh-btn {
 705          background: var(--info);
 706      }
 707  
 708      .follows-list {
 709          max-height: 300px;
 710          overflow-y: auto;
 711          border: 1px solid var(--border-color);
 712          border-radius: 4px;
 713          background: var(--bg-color);
 714      }
 715  
 716      .follows-grid {
 717          display: grid;
 718          grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
 719          gap: 0.5rem;
 720          padding: 0.75rem;
 721      }
 722  
 723      .follow-item {
 724          padding: 0.4em 0.6em;
 725          background: var(--card-bg);
 726          border: 1px solid var(--border-color);
 727          border-radius: 4px;
 728          font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
 729          font-size: 0.75em;
 730          color: var(--text-color);
 731          text-overflow: ellipsis;
 732          overflow: hidden;
 733          white-space: nowrap;
 734      }
 735  </style>
 736