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:
parent
8070dfc0a2
commit
e8d2abbd1b
2 changed files with 154 additions and 15 deletions
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue