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.
744 lines
20 KiB
744 lines
20 KiB
<script> |
|
export let isLoggedIn = false; |
|
export let userRole = ""; |
|
export let userSigner = null; |
|
export let userPubkey = ""; |
|
|
|
import { createEventDispatcher, onMount } from "svelte"; |
|
import * as api from "./api.js"; |
|
import { copyToClipboard, showCopyFeedback } from "./utils.js"; |
|
|
|
const dispatch = createEventDispatcher(); |
|
|
|
// State |
|
let nrcEnabled = false; |
|
let badgerRequired = false; |
|
let connections = []; |
|
let config = {}; |
|
let isLoading = false; |
|
let message = ""; |
|
let messageType = "info"; |
|
|
|
// New connection form |
|
let newLabel = ""; |
|
let newUseCashu = false; |
|
|
|
// URI display modal |
|
let showURIModal = false; |
|
let currentURI = ""; |
|
let currentLabel = ""; |
|
|
|
onMount(async () => { |
|
await loadNRCConfig(); |
|
}); |
|
|
|
async function loadNRCConfig() { |
|
try { |
|
const result = await api.fetchNRCConfig(); |
|
nrcEnabled = result.enabled; |
|
badgerRequired = result.badger_required; |
|
|
|
if (nrcEnabled && isLoggedIn && userRole === "owner") { |
|
await loadConnections(); |
|
} |
|
} catch (error) { |
|
console.error("Failed to load NRC config:", error); |
|
} |
|
} |
|
|
|
async function loadConnections() { |
|
if (!isLoggedIn || !userSigner || !userPubkey) return; |
|
|
|
isLoading = true; |
|
try { |
|
const result = await api.fetchNRCConnections(userSigner, userPubkey); |
|
connections = result.connections || []; |
|
config = result.config || {}; |
|
} catch (error) { |
|
setMessage(`Failed to load connections: ${error.message}`, "error"); |
|
} finally { |
|
isLoading = false; |
|
} |
|
} |
|
|
|
async function createConnection() { |
|
if (!newLabel.trim()) { |
|
setMessage("Please enter a label for the connection", "error"); |
|
return; |
|
} |
|
|
|
isLoading = true; |
|
try { |
|
const result = await api.createNRCConnection(userSigner, userPubkey, newLabel.trim(), newUseCashu); |
|
|
|
// Show the URI modal with the new connection |
|
currentURI = result.uri; |
|
currentLabel = result.label; |
|
showURIModal = true; |
|
|
|
// Reset form |
|
newLabel = ""; |
|
newUseCashu = false; |
|
|
|
// Reload connections |
|
await loadConnections(); |
|
setMessage(`Connection "${result.label}" created successfully`, "success"); |
|
} catch (error) { |
|
setMessage(`Failed to create connection: ${error.message}`, "error"); |
|
} finally { |
|
isLoading = false; |
|
} |
|
} |
|
|
|
async function deleteConnection(connId, label) { |
|
if (!confirm(`Are you sure you want to delete the connection "${label}"? This will revoke access for any device using this connection.`)) { |
|
return; |
|
} |
|
|
|
isLoading = true; |
|
try { |
|
await api.deleteNRCConnection(userSigner, userPubkey, connId); |
|
await loadConnections(); |
|
setMessage(`Connection "${label}" deleted`, "success"); |
|
} catch (error) { |
|
setMessage(`Failed to delete connection: ${error.message}`, "error"); |
|
} finally { |
|
isLoading = false; |
|
} |
|
} |
|
|
|
async function showConnectionURI(connId, label) { |
|
isLoading = true; |
|
try { |
|
const result = await api.getNRCConnectionURI(userSigner, userPubkey, connId); |
|
currentURI = result.uri; |
|
currentLabel = label; |
|
showURIModal = true; |
|
} catch (error) { |
|
setMessage(`Failed to get URI: ${error.message}`, "error"); |
|
} finally { |
|
isLoading = false; |
|
} |
|
} |
|
|
|
async function copyURIToClipboard(event) { |
|
const success = await copyToClipboard(currentURI); |
|
const button = event.target.closest("button"); |
|
showCopyFeedback(button, success); |
|
if (!success) { |
|
setMessage("Failed to copy to clipboard", "error"); |
|
} |
|
} |
|
|
|
function closeURIModal() { |
|
showURIModal = false; |
|
currentURI = ""; |
|
currentLabel = ""; |
|
} |
|
|
|
function setMessage(msg, type = "info") { |
|
message = msg; |
|
messageType = type; |
|
// Auto-clear after 5 seconds |
|
setTimeout(() => { |
|
if (message === msg) { |
|
message = ""; |
|
} |
|
}, 5000); |
|
} |
|
|
|
function formatTimestamp(ts) { |
|
if (!ts) return "Never"; |
|
return new Date(ts * 1000).toLocaleString(); |
|
} |
|
|
|
function openLoginModal() { |
|
dispatch("openLoginModal"); |
|
} |
|
|
|
// Reload when login state changes |
|
$: if (isLoggedIn && userRole === "owner" && nrcEnabled) { |
|
loadConnections(); |
|
} |
|
</script> |
|
|
|
<div class="relay-connect-view"> |
|
<h2>Relay Connect</h2> |
|
<p class="description"> |
|
Nostr Relay Connect (NRC) allows remote access to this relay through a public relay tunnel. |
|
Create connection strings for your devices to sync securely. |
|
</p> |
|
|
|
{#if !nrcEnabled} |
|
<div class="not-enabled"> |
|
{#if badgerRequired} |
|
<p>NRC requires the Badger database backend.</p> |
|
<p>Set <code>ORLY_DB_TYPE=badger</code> to enable NRC functionality.</p> |
|
{:else} |
|
<p>NRC is not enabled on this relay.</p> |
|
<p>Set <code>ORLY_NRC_ENABLED=true</code> and configure <code>ORLY_NRC_RENDEZVOUS_URL</code> to enable.</p> |
|
{/if} |
|
</div> |
|
{:else if !isLoggedIn} |
|
<div class="login-prompt"> |
|
<p>Please log in to manage relay connections.</p> |
|
<button class="login-btn" on:click={openLoginModal}>Log In</button> |
|
</div> |
|
{:else if userRole !== "owner"} |
|
<div class="permission-denied"> |
|
<p>Owner permission required for relay connection management.</p> |
|
<p>Current role: <strong>{userRole || "none"}</strong></p> |
|
</div> |
|
{:else} |
|
<!-- Config status --> |
|
<div class="config-status"> |
|
<div class="status-item"> |
|
<span class="status-label">Status:</span> |
|
<span class="status-value enabled">Enabled</span> |
|
</div> |
|
<div class="status-item"> |
|
<span class="status-label">Rendezvous:</span> |
|
<span class="status-value">{config.rendezvous_url || "Not configured"}</span> |
|
</div> |
|
{#if config.mint_url} |
|
<div class="status-item"> |
|
<span class="status-label">Cashu Mint:</span> |
|
<span class="status-value">{config.mint_url}</span> |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<!-- Create new connection --> |
|
<div class="section"> |
|
<h3>Create New Connection</h3> |
|
<div class="create-form"> |
|
<div class="form-group"> |
|
<label for="new-label">Device Label</label> |
|
<input |
|
type="text" |
|
id="new-label" |
|
bind:value={newLabel} |
|
placeholder="e.g., Phone, Laptop, Tablet" |
|
disabled={isLoading} |
|
/> |
|
</div> |
|
<div class="form-group checkbox-group"> |
|
<label> |
|
<input |
|
type="checkbox" |
|
bind:checked={newUseCashu} |
|
disabled={isLoading || !config.mint_url} |
|
/> |
|
Include CAT (Cashu Access Token) |
|
{#if !config.mint_url} |
|
<span class="hint">(requires Cashu mint)</span> |
|
{/if} |
|
</label> |
|
</div> |
|
<button |
|
class="create-btn" |
|
on:click={createConnection} |
|
disabled={isLoading || !newLabel.trim()} |
|
> |
|
+ Create Connection |
|
</button> |
|
</div> |
|
</div> |
|
|
|
<!-- Connections list --> |
|
<div class="section"> |
|
<h3>Connections ({connections.length})</h3> |
|
{#if connections.length === 0} |
|
<p class="no-connections">No connections yet. Create one to get started.</p> |
|
{:else} |
|
<div class="connections-list"> |
|
{#each connections as conn} |
|
<div class="connection-item"> |
|
<div class="connection-info"> |
|
<div class="connection-label">{conn.label}</div> |
|
<div class="connection-details"> |
|
<span class="detail">ID: {conn.id.substring(0, 8)}...</span> |
|
<span class="detail">Created: {formatTimestamp(conn.created_at)}</span> |
|
{#if conn.last_used} |
|
<span class="detail">Last used: {formatTimestamp(conn.last_used)}</span> |
|
{/if} |
|
{#if conn.use_cashu} |
|
<span class="badge cashu">CAT</span> |
|
{/if} |
|
</div> |
|
</div> |
|
<div class="connection-actions"> |
|
<button |
|
class="action-btn show-uri-btn" |
|
on:click={() => showConnectionURI(conn.id, conn.label)} |
|
disabled={isLoading} |
|
title="Show connection URI" |
|
> |
|
Show URI |
|
</button> |
|
<button |
|
class="action-btn delete-btn" |
|
on:click={() => deleteConnection(conn.id, conn.label)} |
|
disabled={isLoading} |
|
title="Delete connection" |
|
> |
|
Delete |
|
</button> |
|
</div> |
|
</div> |
|
{/each} |
|
</div> |
|
{/if} |
|
|
|
<button |
|
class="refresh-btn" |
|
on:click={loadConnections} |
|
disabled={isLoading} |
|
> |
|
Refresh |
|
</button> |
|
</div> |
|
|
|
{#if message} |
|
<div class="message" class:error={messageType === "error"} class:success={messageType === "success"}> |
|
{message} |
|
</div> |
|
{/if} |
|
{/if} |
|
</div> |
|
|
|
<!-- URI Modal --> |
|
{#if showURIModal} |
|
<div class="modal-overlay" on:click={closeURIModal}> |
|
<div class="modal" on:click|stopPropagation> |
|
<h3>Connection URI for "{currentLabel}"</h3> |
|
<p class="modal-description"> |
|
Copy this URI to your Nostr client to connect to this relay remotely. |
|
Keep it secret - anyone with this URI can access your relay. |
|
</p> |
|
<div class="uri-display"> |
|
<textarea readonly>{currentURI}</textarea> |
|
</div> |
|
<div class="modal-actions"> |
|
<button class="copy-btn" on:click={copyURIToClipboard}> |
|
Copy to Clipboard |
|
</button> |
|
<button class="close-btn" on:click={closeURIModal}> |
|
Close |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<style> |
|
.relay-connect-view { |
|
width: 100%; |
|
max-width: 800px; |
|
margin: 0; |
|
padding: 20px; |
|
background: var(--header-bg); |
|
color: var(--text-color); |
|
border-radius: 8px; |
|
} |
|
|
|
.relay-connect-view h2 { |
|
margin: 0 0 0.5rem 0; |
|
color: var(--text-color); |
|
font-size: 1.8rem; |
|
font-weight: 600; |
|
} |
|
|
|
.description { |
|
color: var(--muted-foreground); |
|
margin-bottom: 1.5rem; |
|
line-height: 1.5; |
|
} |
|
|
|
.section { |
|
background-color: var(--card-bg); |
|
border-radius: 8px; |
|
padding: 1em; |
|
margin-bottom: 1.5rem; |
|
border: 1px solid var(--border-color); |
|
} |
|
|
|
.section h3 { |
|
margin: 0 0 1rem 0; |
|
color: var(--text-color); |
|
font-size: 1.1rem; |
|
font-weight: 600; |
|
} |
|
|
|
.config-status { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0.5rem; |
|
margin-bottom: 1.5rem; |
|
padding: 1rem; |
|
background: var(--card-bg); |
|
border-radius: 8px; |
|
border: 1px solid var(--border-color); |
|
} |
|
|
|
.status-item { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
} |
|
|
|
.status-label { |
|
font-weight: 600; |
|
color: var(--text-color); |
|
} |
|
|
|
.status-value { |
|
color: var(--muted-foreground); |
|
font-family: monospace; |
|
font-size: 0.9em; |
|
} |
|
|
|
.status-value.enabled { |
|
color: var(--success); |
|
} |
|
|
|
/* Create form */ |
|
.create-form { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 1rem; |
|
} |
|
|
|
.form-group { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0.5rem; |
|
} |
|
|
|
.form-group label { |
|
font-weight: 500; |
|
color: var(--text-color); |
|
} |
|
|
|
.form-group input[type="text"] { |
|
padding: 0.75em; |
|
border: 1px solid var(--border-color); |
|
border-radius: 4px; |
|
background: var(--input-bg); |
|
color: var(--input-text-color); |
|
font-size: 1em; |
|
} |
|
|
|
.checkbox-group { |
|
flex-direction: row; |
|
align-items: center; |
|
} |
|
|
|
.checkbox-group label { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
cursor: pointer; |
|
} |
|
|
|
.checkbox-group input[type="checkbox"] { |
|
width: 1.2em; |
|
height: 1.2em; |
|
} |
|
|
|
.hint { |
|
color: var(--muted-foreground); |
|
font-size: 0.85em; |
|
} |
|
|
|
.create-btn { |
|
background: var(--primary); |
|
color: var(--text-color); |
|
border: none; |
|
padding: 0.75em 1.5em; |
|
border-radius: 4px; |
|
cursor: pointer; |
|
font-size: 1em; |
|
font-weight: 500; |
|
align-self: flex-start; |
|
transition: background-color 0.2s; |
|
} |
|
|
|
.create-btn:hover:not(:disabled) { |
|
background: var(--accent-hover-color); |
|
} |
|
|
|
.create-btn:disabled { |
|
background: var(--secondary); |
|
cursor: not-allowed; |
|
} |
|
|
|
/* Connections list */ |
|
.connections-list { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0.75rem; |
|
margin-bottom: 1rem; |
|
} |
|
|
|
.connection-item { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
padding: 1rem; |
|
background: var(--bg-color); |
|
border: 1px solid var(--border-color); |
|
border-radius: 4px; |
|
} |
|
|
|
.connection-info { |
|
flex: 1; |
|
} |
|
|
|
.connection-label { |
|
font-weight: 600; |
|
color: var(--text-color); |
|
margin-bottom: 0.25rem; |
|
} |
|
|
|
.connection-details { |
|
display: flex; |
|
flex-wrap: wrap; |
|
gap: 0.75rem; |
|
font-size: 0.85em; |
|
color: var(--muted-foreground); |
|
} |
|
|
|
.badge { |
|
background: var(--primary); |
|
color: var(--text-color); |
|
padding: 0.1em 0.4em; |
|
border-radius: 0.25rem; |
|
font-size: 0.75em; |
|
font-weight: 600; |
|
} |
|
|
|
.badge.cashu { |
|
background: var(--warning); |
|
} |
|
|
|
.connection-actions { |
|
display: flex; |
|
gap: 0.5rem; |
|
} |
|
|
|
.action-btn { |
|
background: var(--primary); |
|
color: var(--text-color); |
|
border: none; |
|
padding: 0.5em 1em; |
|
border-radius: 4px; |
|
cursor: pointer; |
|
font-size: 0.9em; |
|
transition: background-color 0.2s; |
|
} |
|
|
|
.action-btn:hover:not(:disabled) { |
|
background: var(--accent-hover-color); |
|
} |
|
|
|
.action-btn:disabled { |
|
background: var(--secondary); |
|
cursor: not-allowed; |
|
} |
|
|
|
.show-uri-btn { |
|
background: var(--info); |
|
} |
|
|
|
.show-uri-btn:hover:not(:disabled) { |
|
filter: brightness(0.9); |
|
} |
|
|
|
.delete-btn { |
|
background: var(--danger); |
|
} |
|
|
|
.delete-btn:hover:not(:disabled) { |
|
filter: brightness(0.9); |
|
} |
|
|
|
.refresh-btn { |
|
background: var(--secondary); |
|
color: var(--text-color); |
|
border: none; |
|
padding: 0.5em 1em; |
|
border-radius: 4px; |
|
cursor: pointer; |
|
font-size: 0.9em; |
|
transition: background-color 0.2s; |
|
} |
|
|
|
.refresh-btn:hover:not(:disabled) { |
|
filter: brightness(0.9); |
|
} |
|
|
|
.refresh-btn:disabled { |
|
cursor: not-allowed; |
|
opacity: 0.6; |
|
} |
|
|
|
.no-connections { |
|
color: var(--muted-foreground); |
|
text-align: center; |
|
padding: 2rem; |
|
} |
|
|
|
/* Message */ |
|
.message { |
|
padding: 1rem; |
|
border-radius: 4px; |
|
margin-top: 1rem; |
|
background: var(--info-bg, #e7f3ff); |
|
color: var(--info-text, #0066cc); |
|
border: 1px solid var(--info, #0066cc); |
|
} |
|
|
|
.message.error { |
|
background: var(--danger-bg); |
|
color: var(--danger-text); |
|
border-color: var(--danger); |
|
} |
|
|
|
.message.success { |
|
background: var(--success-bg); |
|
color: var(--success-text); |
|
border-color: var(--success); |
|
} |
|
|
|
/* Modal */ |
|
.modal-overlay { |
|
position: fixed; |
|
top: 0; |
|
left: 0; |
|
right: 0; |
|
bottom: 0; |
|
background: rgba(0, 0, 0, 0.6); |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
z-index: 1000; |
|
} |
|
|
|
.modal { |
|
background: var(--card-bg); |
|
border-radius: 8px; |
|
padding: 1.5rem; |
|
max-width: 600px; |
|
width: 90%; |
|
max-height: 80vh; |
|
overflow: auto; |
|
border: 1px solid var(--border-color); |
|
} |
|
|
|
.modal h3 { |
|
margin: 0 0 0.5rem 0; |
|
color: var(--text-color); |
|
} |
|
|
|
.modal-description { |
|
color: var(--muted-foreground); |
|
margin-bottom: 1rem; |
|
font-size: 0.9em; |
|
line-height: 1.5; |
|
} |
|
|
|
.uri-display textarea { |
|
width: 100%; |
|
height: 120px; |
|
padding: 0.75em; |
|
border: 1px solid var(--border-color); |
|
border-radius: 4px; |
|
background: var(--input-bg); |
|
color: var(--input-text-color); |
|
font-family: monospace; |
|
font-size: 0.85em; |
|
resize: none; |
|
word-break: break-all; |
|
} |
|
|
|
.modal-actions { |
|
display: flex; |
|
gap: 0.5rem; |
|
margin-top: 1rem; |
|
justify-content: flex-end; |
|
} |
|
|
|
.copy-btn { |
|
background: var(--primary); |
|
color: var(--text-color); |
|
border: none; |
|
padding: 0.75em 1.5em; |
|
border-radius: 4px; |
|
cursor: pointer; |
|
font-weight: 500; |
|
transition: background-color 0.2s; |
|
} |
|
|
|
.copy-btn:hover { |
|
background: var(--accent-hover-color); |
|
} |
|
|
|
.close-btn { |
|
background: var(--secondary); |
|
color: var(--text-color); |
|
border: none; |
|
padding: 0.75em 1.5em; |
|
border-radius: 4px; |
|
cursor: pointer; |
|
font-weight: 500; |
|
transition: background-color 0.2s; |
|
} |
|
|
|
.close-btn:hover { |
|
filter: brightness(0.9); |
|
} |
|
|
|
/* States */ |
|
.not-enabled, |
|
.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); |
|
} |
|
|
|
.not-enabled p, |
|
.permission-denied p, |
|
.login-prompt p { |
|
margin: 0 0 1rem 0; |
|
line-height: 1.4; |
|
} |
|
|
|
.not-enabled code { |
|
background: var(--code-bg); |
|
padding: 0.2em 0.4em; |
|
border-radius: 0.25rem; |
|
font-family: monospace; |
|
font-size: 0.9em; |
|
} |
|
|
|
.login-btn { |
|
background: var(--primary); |
|
color: var(--text-color); |
|
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 { |
|
background: var(--accent-hover-color); |
|
} |
|
</style>
|
|
|