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,
|
signerApprove,
|
||||||
signerGrantsList,
|
signerGrantsList,
|
||||||
signerGrantRevoke,
|
signerGrantRevoke,
|
||||||
|
signerGrantUpdate,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
const [status, setStatus] = useState<SignerStatus>(EMPTY_STATUS);
|
const [status, setStatus] = useState<SignerStatus>(EMPTY_STATUS);
|
||||||
const [grants, setGrants] = useState<SignerGrant[]>([]);
|
const [grants, setGrants] = useState<SignerGrant[]>([]);
|
||||||
|
|
@ -52,10 +53,15 @@ export function SignerScreen() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Poll so approval requests appear without needing a manual refresh, and so
|
// 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(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
void refresh();
|
void signerStatus()
|
||||||
|
.then(setStatus)
|
||||||
|
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)));
|
||||||
}, 1000);
|
}, 1000);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 = () => {
|
const badge = () => {
|
||||||
switch (status.phase) {
|
switch (status.phase) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
|
|
@ -248,21 +289,50 @@ export function SignerScreen() {
|
||||||
</header>
|
</header>
|
||||||
<div className="card-body signer-pending">
|
<div className="card-body signer-pending">
|
||||||
<p className="hint">
|
<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>
|
</p>
|
||||||
{grants.map((grant) => (
|
{grants.map((grant) => {
|
||||||
<div key={`${grant.app_pubkey}:${grant.method}`} className="signer-pending-item">
|
const key = grantKey(grant);
|
||||||
|
const editing = grantDraft?.key === key;
|
||||||
|
return (
|
||||||
|
<div key={key} className="signer-pending-item">
|
||||||
<div className="signer-pending-info">
|
<div className="signer-pending-info">
|
||||||
<code className="mono signer-pending-method">{grantLabel(grant)}</code>
|
<code className="mono signer-pending-method">{grantLabel(grant)}</code>
|
||||||
<p>for {shortHexId(grant.app_pubkey)}</p>
|
<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>
|
||||||
<div className="settings-inline">
|
<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)}>
|
<Button variant="secondary" onClick={() => void onRevokeGrant(grant)}>
|
||||||
Revoke
|
Revoke
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -176,4 +176,73 @@ describe('SignerScreen', () => {
|
||||||
).toBe(true);
|
).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