You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
734 lines
21 KiB
734 lines
21 KiB
<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>
|
|
|