735 lines
21 KiB
Svelte
735 lines
21 KiB
Svelte
<script>
|
|
export let isLoggedIn = false;
|
|
export let userRole = "";
|
|
export let isPolicyAdmin = false;
|
|
export let policyEnabled = false;
|
|
export let policyJson = "";
|
|
export let isLoadingPolicy = false;
|
|
export let policyMessage = "";
|
|
export let policyMessageType = "";
|
|
export let validationErrors = [];
|
|
export let policyAdmins = [];
|
|
export let policyFollows = [];
|
|
|
|
import { createEventDispatcher } from "svelte";
|
|
const dispatch = createEventDispatcher();
|
|
|
|
// New admin input
|
|
let newAdminInput = "";
|
|
|
|
function loadPolicy() {
|
|
dispatch("loadPolicy");
|
|
}
|
|
|
|
function validatePolicy() {
|
|
dispatch("validatePolicy");
|
|
}
|
|
|
|
function savePolicy() {
|
|
dispatch("savePolicy");
|
|
}
|
|
|
|
function formatJson() {
|
|
dispatch("formatJson");
|
|
}
|
|
|
|
function openLoginModal() {
|
|
dispatch("openLoginModal");
|
|
}
|
|
|
|
function refreshFollows() {
|
|
dispatch("refreshFollows");
|
|
}
|
|
|
|
function addPolicyAdmin() {
|
|
if (newAdminInput.trim()) {
|
|
dispatch("addPolicyAdmin", newAdminInput.trim());
|
|
newAdminInput = "";
|
|
}
|
|
}
|
|
|
|
function removePolicyAdmin(pubkey) {
|
|
dispatch("removePolicyAdmin", pubkey);
|
|
}
|
|
|
|
// Parse admins from current policy JSON for display
|
|
$: {
|
|
try {
|
|
if (policyJson) {
|
|
const parsed = JSON.parse(policyJson);
|
|
policyAdmins = parsed.policy_admins || [];
|
|
}
|
|
} catch (e) {
|
|
// Ignore parse errors
|
|
}
|
|
}
|
|
|
|
// Pretty-print example policy for reference
|
|
const examplePolicy = `{
|
|
"kind": {
|
|
"whitelist": [0, 1, 3, 6, 7, 10002],
|
|
"blacklist": []
|
|
},
|
|
"global": {
|
|
"description": "Global rules applied to all events",
|
|
"size_limit": 65536,
|
|
"max_age_of_event": 86400,
|
|
"max_age_event_in_future": 300
|
|
},
|
|
"rules": {
|
|
"1": {
|
|
"description": "Kind 1 (short text notes)",
|
|
"content_limit": 8192,
|
|
"write_allow_follows": true
|
|
},
|
|
"30023": {
|
|
"description": "Long-form articles",
|
|
"content_limit": 100000,
|
|
"tag_validation": {
|
|
"d": "^[a-z0-9-]{1,64}$",
|
|
"t": "^[a-z0-9-]{1,32}$"
|
|
}
|
|
}
|
|
},
|
|
"default_policy": "allow",
|
|
"policy_admins": ["<your-hex-pubkey>"],
|
|
"policy_follow_whitelist_enabled": true
|
|
}`;
|
|
</script>
|
|
|
|
<div class="policy-view">
|
|
<h2>Policy Configuration</h2>
|
|
{#if isLoggedIn && (userRole === "owner" || isPolicyAdmin)}
|
|
<div class="policy-section">
|
|
<div class="policy-header">
|
|
<h3>Policy Editor</h3>
|
|
<div class="policy-status">
|
|
<span class="status-badge" class:enabled={policyEnabled}>
|
|
{policyEnabled ? "Policy Enabled" : "Policy Disabled"}
|
|
</span>
|
|
{#if isPolicyAdmin}
|
|
<span class="admin-badge">Policy Admin</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="policy-info">
|
|
<p>
|
|
Edit the policy JSON below and click "Save & Publish" to update the relay's policy configuration.
|
|
Changes are applied immediately after validation.
|
|
</p>
|
|
<p class="info-note">
|
|
Policy updates are published as kind 12345 events and require policy admin permissions.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="editor-container">
|
|
<textarea
|
|
class="policy-editor"
|
|
bind:value={policyJson}
|
|
placeholder="Loading policy configuration..."
|
|
disabled={isLoadingPolicy}
|
|
spellcheck="false"
|
|
></textarea>
|
|
</div>
|
|
|
|
{#if validationErrors.length > 0}
|
|
<div class="validation-errors">
|
|
<h4>Validation Errors:</h4>
|
|
<ul>
|
|
{#each validationErrors as error}
|
|
<li>{error}</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="policy-actions">
|
|
<button
|
|
class="policy-btn load-btn"
|
|
on:click={loadPolicy}
|
|
disabled={isLoadingPolicy}
|
|
>
|
|
Load Current
|
|
</button>
|
|
<button
|
|
class="policy-btn format-btn"
|
|
on:click={formatJson}
|
|
disabled={isLoadingPolicy}
|
|
>
|
|
Format JSON
|
|
</button>
|
|
<button
|
|
class="policy-btn validate-btn"
|
|
on:click={validatePolicy}
|
|
disabled={isLoadingPolicy}
|
|
>
|
|
Validate
|
|
</button>
|
|
<button
|
|
class="policy-btn save-btn"
|
|
on:click={savePolicy}
|
|
disabled={isLoadingPolicy}
|
|
>
|
|
Save & Publish
|
|
</button>
|
|
</div>
|
|
|
|
{#if policyMessage}
|
|
<div
|
|
class="policy-message"
|
|
class:error={policyMessageType === "error"}
|
|
class:success={policyMessageType === "success"}
|
|
>
|
|
{policyMessage}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Policy Admins Section -->
|
|
<div class="policy-section">
|
|
<h3>Policy Administrators</h3>
|
|
<div class="policy-info">
|
|
<p>
|
|
Policy admins can update the relay's policy configuration via kind 12345 events.
|
|
Their follows get whitelisted if <code>policy_follow_whitelist_enabled</code> is true in the policy.
|
|
</p>
|
|
<p class="info-note">
|
|
<strong>Note:</strong> Policy admins are separate from relay admins (ORLY_ADMINS).
|
|
Changes here update the JSON editor - click "Save & Publish" to apply.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="admin-list">
|
|
{#if policyAdmins.length === 0}
|
|
<p class="no-items">No policy admins configured</p>
|
|
{:else}
|
|
{#each policyAdmins as admin}
|
|
<div class="admin-item">
|
|
<span class="admin-pubkey" title={admin}>{admin.substring(0, 16)}...{admin.substring(admin.length - 8)}</span>
|
|
<button
|
|
class="remove-btn"
|
|
on:click={() => removePolicyAdmin(admin)}
|
|
disabled={isLoadingPolicy}
|
|
title="Remove admin"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="add-admin">
|
|
<input
|
|
type="text"
|
|
placeholder="npub or hex pubkey"
|
|
bind:value={newAdminInput}
|
|
disabled={isLoadingPolicy}
|
|
on:keydown={(e) => e.key === "Enter" && addPolicyAdmin()}
|
|
/>
|
|
<button
|
|
class="policy-btn add-btn"
|
|
on:click={addPolicyAdmin}
|
|
disabled={isLoadingPolicy || !newAdminInput.trim()}
|
|
>
|
|
+ Add Admin
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Policy Follow Whitelist Section -->
|
|
<div class="policy-section">
|
|
<h3>Policy Follow Whitelist</h3>
|
|
<div class="policy-info">
|
|
<p>
|
|
Pubkeys followed by policy admins (kind 3 events).
|
|
These get automatic read+write access when rules have <code>write_allow_follows: true</code>.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="follows-header">
|
|
<span class="follows-count">{policyFollows.length} pubkey(s) in whitelist</span>
|
|
<button
|
|
class="policy-btn refresh-btn"
|
|
on:click={refreshFollows}
|
|
disabled={isLoadingPolicy}
|
|
>
|
|
🔄 Refresh Follows
|
|
</button>
|
|
</div>
|
|
|
|
<div class="follows-list">
|
|
{#if policyFollows.length === 0}
|
|
<p class="no-items">No follows loaded. Click "Refresh Follows" to load from database.</p>
|
|
{:else}
|
|
<div class="follows-grid">
|
|
{#each policyFollows as follow}
|
|
<div class="follow-item" title={follow}>
|
|
{follow.substring(0, 12)}...{follow.substring(follow.length - 6)}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="policy-section">
|
|
<h3>Policy Reference</h3>
|
|
<div class="reference-content">
|
|
<h4>Structure Overview</h4>
|
|
<ul class="field-list">
|
|
<li><code>kind.whitelist</code> - Only allow these event kinds (takes precedence)</li>
|
|
<li><code>kind.blacklist</code> - Deny these event kinds (if no whitelist)</li>
|
|
<li><code>global</code> - Rules applied to all events</li>
|
|
<li><code>rules</code> - Per-kind rules (keyed by kind number as string)</li>
|
|
<li><code>default_policy</code> - "allow" or "deny" when no rules match</li>
|
|
<li><code>policy_admins</code> - Hex pubkeys that can update policy</li>
|
|
<li><code>policy_follow_whitelist_enabled</code> - Enable follow-based access</li>
|
|
</ul>
|
|
|
|
<h4>Rule Fields</h4>
|
|
<ul class="field-list">
|
|
<li><code>description</code> - Human-readable rule description</li>
|
|
<li><code>write_allow</code> / <code>write_deny</code> - Pubkey lists for write access</li>
|
|
<li><code>read_allow</code> / <code>read_deny</code> - Pubkey lists for read access</li>
|
|
<li><code>write_allow_follows</code> - Grant access to policy admin follows</li>
|
|
<li><code>size_limit</code> - Max total event size in bytes</li>
|
|
<li><code>content_limit</code> - Max content field size in bytes</li>
|
|
<li><code>max_expiry</code> - Max expiry offset in seconds</li>
|
|
<li><code>max_age_of_event</code> - Max age of created_at in seconds</li>
|
|
<li><code>max_age_event_in_future</code> - Max future offset in seconds</li>
|
|
<li><code>must_have_tags</code> - Required tag letters (e.g., ["d", "t"])</li>
|
|
<li><code>tag_validation</code> - Regex patterns for tag values</li>
|
|
<li><code>script</code> - Path to external validation script</li>
|
|
</ul>
|
|
|
|
<h4>Example Policy</h4>
|
|
<pre class="example-json">{examplePolicy}</pre>
|
|
</div>
|
|
</div>
|
|
{:else if isLoggedIn}
|
|
<div class="permission-denied">
|
|
<p>Policy configuration requires owner or policy admin permissions.</p>
|
|
<p>
|
|
To become a policy admin, ask an existing policy admin to add your pubkey
|
|
to the <code>policy_admins</code> list.
|
|
</p>
|
|
<p>
|
|
Current user role: <strong>{userRole || "none"}</strong>
|
|
</p>
|
|
</div>
|
|
{:else}
|
|
<div class="login-prompt">
|
|
<p>Please log in to access policy configuration.</p>
|
|
<button class="login-btn" on:click={openLoginModal}>Log In</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.policy-view {
|
|
width: 100%;
|
|
max-width: 1200px;
|
|
margin: 0;
|
|
padding: 20px;
|
|
background: var(--header-bg);
|
|
color: var(--text-color);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.policy-view h2 {
|
|
margin: 0 0 1.5rem 0;
|
|
color: var(--text-color);
|
|
font-size: 1.8rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.policy-section {
|
|
background-color: var(--card-bg);
|
|
border-radius: 8px;
|
|
padding: 1.5em;
|
|
margin-bottom: 1.5rem;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.policy-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.policy-header h3 {
|
|
margin: 0;
|
|
color: var(--text-color);
|
|
font-size: 1.2rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.policy-status {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.status-badge {
|
|
padding: 0.25em 0.75em;
|
|
border-radius: 1rem;
|
|
font-size: 0.8em;
|
|
font-weight: 600;
|
|
background: var(--danger);
|
|
color: white;
|
|
}
|
|
|
|
.status-badge.enabled {
|
|
background: var(--success);
|
|
}
|
|
|
|
.admin-badge {
|
|
padding: 0.25em 0.75em;
|
|
border-radius: 1rem;
|
|
font-size: 0.8em;
|
|
font-weight: 600;
|
|
background: var(--primary);
|
|
color: white;
|
|
}
|
|
|
|
.policy-info {
|
|
margin-bottom: 1rem;
|
|
padding: 1rem;
|
|
background: var(--bg-color);
|
|
border-radius: 4px;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.policy-info p {
|
|
margin: 0 0 0.5rem 0;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.policy-info p:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.info-note {
|
|
font-size: 0.9em;
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.editor-container {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.policy-editor {
|
|
width: 100%;
|
|
height: 400px;
|
|
padding: 1em;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 4px;
|
|
background: var(--input-bg);
|
|
color: var(--input-text-color);
|
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
font-size: 0.85em;
|
|
line-height: 1.5;
|
|
resize: vertical;
|
|
tab-size: 2;
|
|
}
|
|
|
|
.policy-editor:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.validation-errors {
|
|
margin-bottom: 1rem;
|
|
padding: 1rem;
|
|
background: var(--danger-bg, rgba(220, 53, 69, 0.1));
|
|
border: 1px solid var(--danger);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.validation-errors h4 {
|
|
margin: 0 0 0.5rem 0;
|
|
color: var(--danger);
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.validation-errors ul {
|
|
margin: 0;
|
|
padding-left: 1.5rem;
|
|
}
|
|
|
|
.validation-errors li {
|
|
color: var(--danger);
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.policy-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.policy-btn {
|
|
background: var(--primary);
|
|
color: white;
|
|
border: none;
|
|
padding: 0.5em 1em;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.9em;
|
|
transition: background-color 0.2s, filter 0.2s;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25em;
|
|
}
|
|
|
|
.policy-btn:hover:not(:disabled) {
|
|
filter: brightness(1.1);
|
|
}
|
|
|
|
.policy-btn:disabled {
|
|
background: var(--secondary);
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.load-btn {
|
|
background: var(--info);
|
|
}
|
|
|
|
.format-btn {
|
|
background: var(--secondary);
|
|
}
|
|
|
|
.validate-btn {
|
|
background: var(--warning);
|
|
}
|
|
|
|
.save-btn {
|
|
background: var(--success);
|
|
}
|
|
|
|
.policy-message {
|
|
padding: 1rem;
|
|
border-radius: 4px;
|
|
margin-top: 1rem;
|
|
background: var(--info-bg, rgba(23, 162, 184, 0.1));
|
|
color: var(--info-text, var(--text-color));
|
|
border: 1px solid var(--info);
|
|
}
|
|
|
|
.policy-message.error {
|
|
background: var(--danger-bg, rgba(220, 53, 69, 0.1));
|
|
color: var(--danger-text, var(--danger));
|
|
border: 1px solid var(--danger);
|
|
}
|
|
|
|
.policy-message.success {
|
|
background: var(--success-bg, rgba(40, 167, 69, 0.1));
|
|
color: var(--success-text, var(--success));
|
|
border: 1px solid var(--success);
|
|
}
|
|
|
|
.reference-content h4 {
|
|
margin: 1rem 0 0.5rem 0;
|
|
color: var(--text-color);
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.reference-content h4:first-child {
|
|
margin-top: 0;
|
|
}
|
|
|
|
.field-list {
|
|
margin: 0 0 1rem 0;
|
|
padding-left: 1.5rem;
|
|
}
|
|
|
|
.field-list li {
|
|
margin-bottom: 0.25rem;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.field-list code {
|
|
background: var(--code-bg, rgba(0, 0, 0, 0.1));
|
|
padding: 0.1em 0.4em;
|
|
border-radius: 3px;
|
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.example-json {
|
|
background: var(--input-bg);
|
|
color: var(--input-text-color);
|
|
padding: 1rem;
|
|
border-radius: 4px;
|
|
border: 1px solid var(--border-color);
|
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
font-size: 0.8em;
|
|
line-height: 1.4;
|
|
overflow-x: auto;
|
|
white-space: pre;
|
|
margin: 0;
|
|
}
|
|
|
|
.permission-denied,
|
|
.login-prompt {
|
|
text-align: center;
|
|
padding: 2em;
|
|
background-color: var(--card-bg);
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border-color);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.permission-denied p,
|
|
.login-prompt p {
|
|
margin: 0 0 1rem 0;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.permission-denied code {
|
|
background: var(--code-bg, rgba(0, 0, 0, 0.1));
|
|
padding: 0.2em 0.4em;
|
|
border-radius: 0.25rem;
|
|
font-family: monospace;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.login-btn {
|
|
background: var(--primary);
|
|
color: white;
|
|
border: none;
|
|
padding: 0.75em 1.5em;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-weight: bold;
|
|
font-size: 0.9em;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.login-btn:hover {
|
|
filter: brightness(1.1);
|
|
}
|
|
|
|
/* Admin list styles */
|
|
.admin-list {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.admin-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.5em 0.75em;
|
|
background: var(--bg-color);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 4px;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.admin-pubkey {
|
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
font-size: 0.85em;
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.remove-btn {
|
|
background: var(--danger);
|
|
color: white;
|
|
border: none;
|
|
width: 24px;
|
|
height: 24px;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
font-size: 0.8em;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: filter 0.2s;
|
|
}
|
|
|
|
.remove-btn:hover:not(:disabled) {
|
|
filter: brightness(0.9);
|
|
}
|
|
|
|
.remove-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.add-admin {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.add-admin input {
|
|
flex: 1;
|
|
padding: 0.5em 0.75em;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 4px;
|
|
background: var(--input-bg);
|
|
color: var(--input-text-color);
|
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
font-size: 0.85em;
|
|
}
|
|
|
|
.add-btn {
|
|
background: var(--success);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.no-items {
|
|
color: var(--text-color);
|
|
opacity: 0.6;
|
|
font-style: italic;
|
|
padding: 1rem;
|
|
text-align: center;
|
|
}
|
|
|
|
/* Follow list styles */
|
|
.follows-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.follows-count {
|
|
font-weight: 600;
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.refresh-btn {
|
|
background: var(--info);
|
|
}
|
|
|
|
.follows-list {
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 4px;
|
|
background: var(--bg-color);
|
|
}
|
|
|
|
.follows-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
gap: 0.5rem;
|
|
padding: 0.75rem;
|
|
}
|
|
|
|
.follow-item {
|
|
padding: 0.4em 0.6em;
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 4px;
|
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
font-size: 0.75em;
|
|
color: var(--text-color);
|
|
text-overflow: ellipsis;
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
}
|
|
</style>
|