diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e457a6c..e95b3a3 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -74,5 +74,7 @@ export const api = { signerConnect: (uri: string) => call('signer_connect', { uri }), signerDisconnect: () => call('signer_disconnect'), signerStatus: () => call('signer_status'), + signerApprove: (id: string, approved: boolean) => + call('signer_approve', { id, approved }), copyText: (text: string) => window.backend.copyText(text), }; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index c0ac97a..85b4a66 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -3,6 +3,16 @@ export type Theme = 'light' | 'dark' | 'system'; /** Lifecycle of the NIP-46 remote signer. */ export type SignerPhase = 'stopped' | 'connecting' | 'connected'; +/** A NIP-46 request waiting for the user to approve or reject it. */ +export interface PendingApproval { + /** Internal id used to answer this request. */ + id: string; + /** The requested NIP-46 method, e.g. `sign_event`. */ + method: string; + /** A short human-readable description of what will be done. */ + summary: string; +} + /** Non-secret snapshot of the NIP-46 remote signer for display. */ export interface SignerStatus { phase: SignerPhase; @@ -12,6 +22,8 @@ export interface SignerStatus { relays: string[]; /** A user-facing error if the signer stopped because of one. */ error: string | null; + /** Requests currently waiting for the user's approval. */ + pending: PendingApproval[]; } /** A safe view of a profile with no secret key material. */ diff --git a/frontend/src/screens/SignerScreen.tsx b/frontend/src/screens/SignerScreen.tsx index 937e07b..4f548b2 100644 --- a/frontend/src/screens/SignerScreen.tsx +++ b/frontend/src/screens/SignerScreen.tsx @@ -7,7 +7,13 @@ import { Icon } from '../components/Icon'; import type { SignerStatus } from '../lib/types'; import { useApp } from '../state/AppProvider'; -const EMPTY_STATUS: SignerStatus = { phase: 'stopped', peer: null, relays: [], error: null }; +const EMPTY_STATUS: SignerStatus = { + phase: 'stopped', + peer: null, + relays: [], + error: null, + pending: [], +}; /** Shorten a 64-char hex key for display. */ function shortHex(value: string): string { @@ -15,7 +21,7 @@ function shortHex(value: string): string { } export function SignerScreen() { - const { state, signerConnect, signerDisconnect, signerStatus } = useApp(); + const { state, signerConnect, signerDisconnect, signerStatus, signerApprove } = useApp(); const [status, setStatus] = useState(EMPTY_STATUS); const [uri, setUri] = useState(''); const [error, setError] = useState(null); @@ -37,8 +43,20 @@ export function SignerScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Poll so approval requests appear without needing a manual refresh, and so + // approvals/rejections made elsewhere are reflected here. + useEffect(() => { + const timer = window.setInterval(() => { + void refresh(); + }, 1000); + return () => window.clearInterval(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const vaultLocked = state?.vault_locked ?? false; + const isActive = status.phase === 'connected' || status.phase === 'connecting'; + const onConnect = async (event: FormEvent) => { event.preventDefault(); const trimmed = uri.trim(); @@ -67,6 +85,15 @@ export function SignerScreen() { } }; + const onApprove = async (id: string, approved: boolean) => { + setError(null); + try { + setStatus(await signerApprove(id, approved)); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + const badge = () => { switch (status.phase) { case 'connected': @@ -78,8 +105,6 @@ export function SignerScreen() { } }; - const isActive = status.phase === 'connected' || status.phase === 'connecting'; - return (
@@ -87,8 +112,8 @@ export function SignerScreen() {

Signer

- Securely sign for another Nostr app. Paste its nostrconnect:// link to let this app - approve its requests with the active profile's keys. + Securely sign for another Nostr app. Paste its nostrconnect:// link, then approve each + signing or decryption request here.

@@ -142,6 +167,39 @@ export function SignerScreen() {
+ {status.pending.length > 0 && ( +
+
+

Requests waiting for approval

+ {status.pending.length} +
+
+

+ The connected app wants to do the following with the active profile's keys. + Review each one before approving it. +

+ {status.pending.map((request) => ( +
+
+ {request.method} +

{request.summary}

+
+
+ + +
+
+ ))} +
+
+ )} +

Connect a Nostr app

@@ -150,8 +208,8 @@ export function SignerScreen() { {isActive ? (

- The signer is listening. Requests from the connected app are approved - automatically. + The signer is listening. Requests from the connected app appear above and are only + run after you approve them.