diff --git a/frontend/src/screens/SignerScreen.tsx b/frontend/src/screens/SignerScreen.tsx index 94a1fe3..1248028 100644 --- a/frontend/src/screens/SignerScreen.tsx +++ b/frontend/src/screens/SignerScreen.tsx @@ -27,6 +27,7 @@ export function SignerScreen() { signerApprove, signerGrantsList, signerGrantRevoke, + signerGrantUpdate, } = useApp(); const [status, setStatus] = useState(EMPTY_STATUS); const [grants, setGrants] = useState([]); @@ -52,10 +53,15 @@ export function SignerScreen() { }, []); // Poll so approval requests appear without needing a manual refresh, and so - // approvals/rejections made elsewhere are reflected here. + // approvals/rejections made elsewhere are reflected here. The grants list is + // loaded on mount and refreshed after our own grant actions; the 1 s poll + // only touches the request queue so the grants card (with its open kind + // editor) stays stable while typing. useEffect(() => { const timer = window.setInterval(() => { - void refresh(); + void signerStatus() + .then(setStatus) + .catch((err: unknown) => setError(err instanceof Error ? err.message : String(err))); }, 1000); return () => window.clearInterval(timer); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -113,6 +119,41 @@ export function SignerScreen() { } }; + // Grant kind editing: an "edit" draft keyed by app:method, holding the + // comma-separated kind list being typed. Empty input means "all kinds" + // (the same explicit broadening the backend uses). + const [grantDraft, setGrantDraft] = useState<{ key: string; kinds: string } | null>(null); + const [kindError, setKindError] = useState(null); + + const grantKey = (g: SignerGrant) => `${g.app_pubkey}:${g.method}`; + + const startEditGrant = (grant: SignerGrant) => { + setKindError(null); + setGrantDraft({ key: grantKey(grant), kinds: (grant.allowed_kinds ?? []).join(', ') }); + }; + + const onSaveKinds = async (grant: SignerGrant) => { + if (!grantDraft) return; + const parts = grantDraft.kinds + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + const bad = parts.find((s) => !/^\d+$/.test(s)); + if (bad !== undefined) { + setKindError(`“${bad}” is not an event kind number.`); + return; + } + setError(null); + try { + await signerGrantUpdate(grant.app_pubkey, grant.method, parts.map(Number)); + setGrants(await signerGrantsList()); + setGrantDraft(null); + setKindError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + const badge = () => { switch (status.phase) { case 'connected': @@ -248,21 +289,50 @@ export function SignerScreen() {

- These requests run without asking. Revoke one to go back to approving it every time. + These requests run without asking. Edit an event-kind scope or revoke one to go back + to approving it every time.

- {grants.map((grant) => ( -
-
- {grantLabel(grant)} -

for {shortHexId(grant.app_pubkey)}

+ {grants.map((grant) => { + const key = grantKey(grant); + const editing = grantDraft?.key === key; + return ( +
+
+ {grantLabel(grant)} +

for {shortHexId(grant.app_pubkey)}

+ {editing && ( +
+ setGrantDraft({ key, kinds: e.target.value })} + /> + + +
+ )} + {editing && kindError && {kindError}} +
+
+ {grant.method === 'sign_event' && !editing && ( + + )} + +
-
- -
-
- ))} + ); + })}
)} diff --git a/frontend/src/test/SignerScreen.test.tsx b/frontend/src/test/SignerScreen.test.tsx index 45345c6..bad7146 100644 --- a/frontend/src/test/SignerScreen.test.tsx +++ b/frontend/src/test/SignerScreen.test.tsx @@ -176,4 +176,73 @@ describe('SignerScreen', () => { ).toBe(true); }); }); + + it('edits the kind scope of a sign_event grant', async () => { + const backend = createFakeBackend(); + backend.signerGrants = [{ app_pubkey: 'aa11', method: 'sign_event', allowed_kinds: [1] }]; + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + // The grants list arrives on the initial load poll. + expect( + await screen.findByText('Sign events — kinds 1', {}, { timeout: 3000 }), + ).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Edit kinds' })); + const input = screen.getByLabelText('Allowed event kinds'); + await user.clear(input); + await user.type(input, '1, 30023'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect( + backend.requests.some( + (r) => + r.method === 'signer_grant_update' && + r.params?.app_pubkey === 'aa11' && + r.params?.grant_method === 'sign_event' && + JSON.stringify(r.params?.grant_kinds) === '[1,30023]', + ), + ).toBe(true); + }); + expect(backend.signerGrants[0].allowed_kinds).toEqual([1, 30023]); + expect(await screen.findByText('Sign events — kinds 1, 30023')).toBeInTheDocument(); + }); + + it('rejects a non-numeric kind before calling the backend', async () => { + const backend = createFakeBackend(); + backend.signerGrants = [{ app_pubkey: 'aa11', method: 'sign_event', allowed_kinds: [1] }]; + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await screen.findByText('Sign events — kinds 1', {}, { timeout: 3000 }); + await user.click(screen.getByRole('button', { name: 'Edit kinds' })); + const input = screen.getByLabelText('Allowed event kinds'); + await user.clear(input); + await user.type(input, '1, hello'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByText(/hello.*not an event kind/)).toBeInTheDocument(); + expect(backend.requests.some((r) => r.method === 'signer_grant_update')).toBe(false); + }); + + it('empty kind list broadens the grant to all kinds', async () => { + const backend = createFakeBackend(); + backend.signerGrants = [{ app_pubkey: 'aa11', method: 'sign_event', allowed_kinds: [1] }]; + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await screen.findByText('Sign events — kinds 1', {}, { timeout: 3000 }); + await user.click(screen.getByRole('button', { name: 'Edit kinds' })); + await user.clear(screen.getByLabelText('Allowed event kinds')); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(backend.signerGrants[0].allowed_kinds).toEqual([]); + }); + expect(await screen.findByText('Sign events — all kinds')).toBeInTheDocument(); + }); });