feat(signer): inline kind-scope editing for always-allow grants

The 'Always-allow permissions' card on the Signer screen now edits an
existing grant's event-kind scope via the signer_grant_update RPC:
Edit toggles an input (comma-separated kinds, client-validated), Save
applies, Revert restores. Grants list is left alone by the 5s poll so
an open editor is never yanked out from under the user.
This commit is contained in:
Avi 2026-10-01 10:36:30 -05:00
commit e8d2abbd1b
2 changed files with 154 additions and 15 deletions

View file

@ -27,6 +27,7 @@ export function SignerScreen() {
signerApprove,
signerGrantsList,
signerGrantRevoke,
signerGrantUpdate,
} = useApp();
const [status, setStatus] = useState<SignerStatus>(EMPTY_STATUS);
const [grants, setGrants] = useState<SignerGrant[]>([]);
@ -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<string | null>(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() {
</header>
<div className="card-body signer-pending">
<p className="hint">
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.
</p>
{grants.map((grant) => (
<div key={`${grant.app_pubkey}:${grant.method}`} className="signer-pending-item">
{grants.map((grant) => {
const key = grantKey(grant);
const editing = grantDraft?.key === key;
return (
<div key={key} className="signer-pending-item">
<div className="signer-pending-info">
<code className="mono signer-pending-method">{grantLabel(grant)}</code>
<p>for {shortHexId(grant.app_pubkey)}</p>
{editing && (
<div className="settings-inline signer-grant-edit">
<input
className="signer-grant-kinds"
aria-label="Allowed event kinds"
placeholder="e.g. 1, 30023 (empty = all kinds)"
value={grantDraft.kinds}
onChange={(e) => setGrantDraft({ key, kinds: e.target.value })}
/>
<Button variant="primary" onClick={() => void onSaveKinds(grant)}>
<Icon name="check" size={16} />
Save
</Button>
<Button variant="secondary" onClick={() => setGrantDraft(null)}>
Cancel
</Button>
</div>
)}
{editing && kindError && <ErrorText>{kindError}</ErrorText>}
</div>
<div className="settings-inline">
{grant.method === 'sign_event' && !editing && (
<Button variant="secondary" onClick={() => startEditGrant(grant)}>
Edit kinds
</Button>
)}
<Button variant="secondary" onClick={() => void onRevokeGrant(grant)}>
Revoke
</Button>
</div>
</div>
))}
);
})}
</div>
</section>
)}

View file

@ -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(<SignerScreen />);
// 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(<SignerScreen />);
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(<SignerScreen />);
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();
});
});