feat: nwc connections settings page with one-time connection strings and service lifecycle

This commit is contained in:
2026-08-20 01:20:41 +02:00
parent 33318a5150
commit 52211273b1
8 changed files with 727 additions and 45 deletions
+334
View File
@@ -0,0 +1,334 @@
<template>
<q-page class="q-pa-md">
<div class="row items-center q-mb-md">
<q-btn
flat
dense
round
color="primary"
icon="arrow_back"
aria-label="Back"
@click="router.push('/settings')"
/>
<div class="text-h5 text-weight-bold text-primary q-ml-sm">Nostr Wallet Connect</div>
</div>
<q-card v-if="wallet.state !== 'unlocked'" class="sattle-card q-pa-lg">
<div class="text-body1 text-grey-4">
Unlock your wallet first - NWC connections need the wallet's key in memory.
</div>
</q-card>
<template v-else>
<!-- the honest explainer: foreground-only by design (see the nwc.ts
façade) - this wallet is not an always-on NWC service -->
<q-list class="sattle-card q-mb-md" bordered>
<q-item-label header class="text-primary text-weight-bold"> How this works </q-item-label>
<div class="q-pa-md q-pt-sm text-body2 text-grey-4">
Nostr Wallet Connect lets other apps (like Alby) pay and receive through this wallet over
public nostr relays. This wallet answers their requests
<strong>only while it is open and unlocked</strong> - requests sent while it is closed
wait on the relay and are dropped if they are more than ten minutes old when it next
opens, never executed late. Every connection has its own spending budget.
</div>
</q-list>
<!-- master switch + service state -->
<q-list class="sattle-card q-mb-md" bordered>
<q-item-label header class="text-primary text-weight-bold"> Service </q-item-label>
<div class="q-pa-md q-pt-sm">
<div class="row items-center justify-between">
<div class="text-body2 text-grey-4 col q-pr-md">
Answer requests from your connected apps.
</div>
<q-toggle
:model-value="nwc.enabled"
color="primary"
aria-label="Enable Nostr Wallet Connect"
@update:model-value="nwc.setEnabled"
/>
</div>
<div v-if="nwc.enabled" class="text-caption q-mt-sm" data-nwc-status>
<span v-if="nwc.running" class="text-positive">
Service running - answering requests for
{{ nwc.connections.length }} connection(s).
</span>
<span v-else class="text-grey-5">Starting the service…</span>
</div>
<div v-if="nwc.lastError" class="text-caption text-negative q-mt-sm">
{{ nwc.lastError }}
</div>
</div>
</q-list>
<!-- the one-time connection string, straight after creation - the
client secret inside it is never stored, so this is the only
chance to copy or scan it -->
<q-list v-if="createdString" class="sattle-card q-mb-md created-card" bordered>
<q-item-label header class="text-primary text-weight-bold">
Connection created
</q-item-label>
<div class="q-pa-md q-pt-sm">
<q-banner dense rounded class="sattle-card text-warning q-mb-md">
<template #avatar>
<q-icon name="warning" color="warning" />
</template>
This connection string is shown only once and cannot be recovered - copy it or scan it
into your app now. Anyone holding it can spend within the budget you set.
</q-banner>
<div class="row justify-center q-mb-md">
<QrCode :value="createdString" />
</div>
<div class="row items-center no-wrap q-mb-md">
<code class="nwc-connection-string text-grey-4 ellipsis">{{ createdString }}</code>
<q-btn
flat
dense
round
size="sm"
color="primary"
icon="content_copy"
aria-label="Copy connection string"
@click="copyConnectionString"
/>
</div>
<q-btn
unelevated
color="primary"
text-color="dark"
label="Done - I've saved it"
class="full-width"
@click="createdString = ''"
/>
</div>
</q-list>
<!-- connections -->
<q-list class="sattle-card q-mb-md" bordered separator>
<q-item-label header class="text-primary text-weight-bold"> Connections </q-item-label>
<q-item v-if="!nwc.connections.length">
<q-item-section class="text-grey-5">
No connections yet - create one below and paste its string into your app.
</q-item-section>
</q-item>
<q-item v-for="connection in nwc.connections" :key="connection.clientPubkey">
<q-item-section>
<q-item-label class="text-grey-3">
Client {{ fingerprint(connection.clientPubkey) }}
</q-item-label>
<q-item-label caption class="text-grey-5">
{{ connection.relays.join(', ') }}
</q-item-label>
<q-item-label caption class="text-grey-5">
{{ budgetLabel(connection) }} &middot; spent {{ spentLabel(connection) }} this period
</q-item-label>
<q-item-label caption class="text-grey-5">
Created {{ new Date(connection.createdAt).toLocaleDateString() }}
</q-item-label>
</q-item-section>
<q-item-section side>
<div class="column q-gutter-xs">
<q-btn
flat
dense
no-caps
color="primary"
label="Edit budget"
@click="askEditBudget(connection)"
/>
<q-btn
flat
dense
no-caps
color="negative"
label="Revoke"
@click="askRevoke(connection)"
/>
</div>
</q-item-section>
</q-item>
</q-list>
<!-- create -->
<q-list v-if="!createdString" class="sattle-card q-mb-md" bordered>
<q-item-label header class="text-primary text-weight-bold"> New connection </q-item-label>
<div class="q-pa-md q-pt-sm">
<div class="text-caption text-grey-5 q-mb-xs">Relays</div>
<RelaysEditor v-model="newRelays" />
<div class="text-caption text-grey-5 q-mb-xs q-mt-md">Spending budget</div>
<NwcBudgetPicker v-model="newBudget" />
<q-btn
unelevated
color="primary"
text-color="dark"
label="Create connection"
class="full-width q-mt-md"
:disable="newRelays.length === 0"
@click="createNewConnection"
/>
</div>
</q-list>
<q-banner v-if="banner" dense class="bg-negative text-white rounded-borders q-mb-md">
{{ banner }}
</q-banner>
</template>
<!-- edit budget -->
<q-dialog v-model="editingBudget">
<q-card class="sattle-card q-pa-lg">
<div class="text-h6 text-primary q-mb-sm">Edit budget</div>
<div class="text-body2 text-grey-4 q-mb-md">
New limit for client {{ fingerprint(editTarget?.clientPubkey ?? '') }}.
</div>
<NwcBudgetPicker v-model="editBudget" />
<div class="row q-gutter-sm justify-end q-mt-md">
<q-btn v-close-popup flat no-caps color="grey-5" label="Cancel" />
<q-btn
unelevated
no-caps
color="primary"
text-color="dark"
label="Save"
@click="saveBudget"
/>
</div>
</q-card>
</q-dialog>
<!-- revoke confirmation -->
<q-dialog v-model="confirmingRevoke">
<q-card class="sattle-card q-pa-lg">
<div class="text-h6 text-primary q-mb-sm">Revoke connection</div>
<div class="text-body2 text-grey-4 q-mb-md">
Revoke client {{ fingerprint(revokeTarget?.clientPubkey ?? '') }}? The service stops
answering it immediately, and its connection string stops working. The app can only
reconnect with a new connection.
</div>
<div class="row q-gutter-sm justify-end">
<q-btn v-close-popup flat no-caps color="grey-5" label="Cancel" />
<q-btn
unelevated
no-caps
color="negative"
text-color="white"
label="Revoke"
@click="doRevoke"
/>
</div>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { copyToClipboard, useQuasar } from 'quasar';
import type { NwcBudget, NwcConnectionRecord } from '@/lnurlcash/nwc';
import { msatToSats } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet';
import { DEFAULT_NOSTR_RELAYS } from '@/stores/nostrBackup';
import { NWC_DEFAULT_BUDGET, NWC_PERIOD_WEEK_MS, useNwcStore } from '@/stores/nwc';
import RelaysEditor from '@/components/RelaysEditor.vue';
import QrCode from '@/components/QrCode.vue';
import NwcBudgetPicker from '@/components/NwcBudgetPicker.vue';
const router = useRouter();
const $q = useQuasar();
const wallet = useWalletStore();
const nwc = useNwcStore();
const toast = (type: 'positive' | 'negative', message: string): void => {
if (typeof $q.notify === 'function') {
$q.notify({ type, message, position: 'top', timeout: 3000 });
}
};
const fingerprint = (pubkey: string): string =>
pubkey.length > 18 ? `${pubkey.slice(0, 10)}…${pubkey.slice(-8)}` : pubkey;
const formatSats = (msat: number): string =>
msatToSats(msat).toLocaleString(undefined, { maximumFractionDigits: 3 });
const budgetLabel = (connection: NwcConnectionRecord): string =>
`${formatSats(connection.budget.maxMsat)} sats per ${
connection.budget.periodMs === NWC_PERIOD_WEEK_MS ? 'week' : 'day'
}`;
// the period may already have rolled over - the engine treats an expired
// period as a full allowance again, so the display does too
const spentLabel = (connection: NwcConnectionRecord): string => {
const expired = Date.now() - connection.spent.periodStart >= connection.budget.periodMs;
return formatSats(expired ? 0 : connection.spent.msat);
};
// ---- create ----
const newRelays = ref<string[]>([...DEFAULT_NOSTR_RELAYS]);
const newBudget = ref<NwcBudget>({ ...NWC_DEFAULT_BUDGET });
// the one-time connection string - held only in this page's local state,
// cleared on "Done" and never re-rendered from any store
const createdString = ref('');
const banner = ref('');
const createNewConnection = (): void => {
banner.value = '';
try {
createdString.value = nwc.create(newRelays.value, newBudget.value).connectionString;
} catch (err) {
banner.value = err instanceof Error ? err.message : 'Could not create the connection.';
}
};
const copyConnectionString = (): void => {
void copyToClipboard(createdString.value).then(() =>
toast('positive', 'Connection string copied.'),
);
};
// ---- edit budget ----
const editingBudget = ref(false);
const editTarget = ref<NwcConnectionRecord | null>(null);
const editBudget = ref<NwcBudget>({ ...NWC_DEFAULT_BUDGET });
const askEditBudget = (connection: NwcConnectionRecord): void => {
editTarget.value = connection;
editBudget.value = { ...connection.budget };
editingBudget.value = true;
};
const saveBudget = (): void => {
editingBudget.value = false;
if (!editTarget.value) return;
nwc.updateBudget(editTarget.value.clientPubkey, editBudget.value);
toast('positive', 'Budget updated.');
};
// ---- revoke ----
const confirmingRevoke = ref(false);
const revokeTarget = ref<NwcConnectionRecord | null>(null);
const askRevoke = (connection: NwcConnectionRecord): void => {
revokeTarget.value = connection;
confirmingRevoke.value = true;
};
const doRevoke = (): void => {
confirmingRevoke.value = false;
if (!revokeTarget.value) return;
nwc.revoke(revokeTarget.value.clientPubkey);
toast('positive', 'Connection revoked.');
};
</script>
<style lang="scss" scoped>
.nwc-connection-string {
font-size: 0.75rem;
word-break: break-all;
}
.created-card {
border: 1px solid var(--q-primary);
}
</style>
+9 -3
View File
@@ -14,8 +14,8 @@
</div>
<!-- group shells - entries are placeholders until their milestone
lands (see project plan: M2 flows, M4 backup/security, M5 NWC);
the Mints group (M3) is live -->
lands (see project plan); Wallet (M4), Connections (M5) and the
Mints group (M3) are live -->
<q-list
v-for="group in groups"
:key="group.label"
@@ -57,7 +57,13 @@ const groups: { label: string; items: SettingsItem[] }[] = [
{ label: 'Security', to: '/settings/security' },
],
},
{ label: 'Connections', items: [{ label: 'Nostr Wallet Connect' }, { label: 'Nostr' }] },
{
label: 'Connections',
items: [
{ label: 'Nostr Wallet Connect', to: '/settings/nwc' },
{ label: 'Nostr backup & relays', to: '/settings/backup' },
],
},
{
label: 'Mints',
items: [