Files
next.orly.dev/app/web/src/PolicyView.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>