fix(admin): let the plugin consent, signature and dependency dialogs scroll

This commit is contained in:
jubnl
2026-08-30 19:27:01 +02:00
parent f341efccc9
commit e70d7397dd
3 changed files with 106 additions and 93 deletions
@@ -281,6 +281,10 @@ describe('AdminPluginsPanel — a refused update', () => {
expect(await screen.findByText(/key it was installed with/i)).toBeInTheDocument() expect(await screen.findByText(/key it was installed with/i)).toBeInTheDocument()
expect(screen.getByText(/key it is offering now/i)).toBeInTheDocument() expect(screen.getByText(/key it is offering now/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /trust the new key/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /trust the new key/i })).toBeInTheDocument()
// The dialog body scrolls; the decision buttons sit outside it, so they never leave the viewport (#2159).
const body = screen.getByText(/key it is offering now/i).closest('.overflow-y-auto')
expect(body).not.toBeNull()
expect(body).not.toContainElement(screen.getByRole('button', { name: /trust the new key/i }))
}) })
// D2, at the UI. An invalid signature means the bytes are not what the author signed. // D2, at the UI. An invalid signature means the bytes are not what the author signed.
@@ -947,7 +951,11 @@ describe('AdminPluginsPanel — enabling a plugin', () => {
await screen.findByText('Gotify') await screen.findByText('Gotify')
fireEvent.click(rowToggle('Gotify')) fireEvent.click(rowToggle('Gotify'))
fireEvent.click(await screen.findByRole('button', { name: /^cancel$/i })) // The dependency list scrolls; Cancel sits outside it, so it never leaves the viewport (#2159).
const body = (await screen.findByText('trek-core')).closest('.overflow-y-auto')
expect(body).not.toBeNull()
expect(body).not.toContainElement(screen.getByRole('button', { name: /^cancel$/i }))
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }))
await waitFor(() => expect(screen.queryByText('Missing dependencies')).not.toBeInTheDocument()) await waitFor(() => expect(screen.queryByText('Missing dependencies')).not.toBeInTheDocument())
expect(installs).toBe(0) expect(installs).toBe(0)
@@ -967,6 +975,11 @@ describe('AdminPluginsPanel — enabling a plugin', () => {
// An unknown permission is still shown — as its raw code // An unknown permission is still shown — as its raw code
expect(screen.getByText('weird:perm')).toBeInTheDocument() expect(screen.getByText('weird:perm')).toBeInTheDocument()
expect(screen.getByText('api.acme.io')).toBeInTheDocument() expect(screen.getByText('api.acme.io')).toBeInTheDocument()
// A long permission list scrolls inside the dialog; the decision buttons sit outside
// the scroll container, so they never leave the viewport (#2159).
const body = screen.getByText('Read trips the acting user can access').closest('.overflow-y-auto')
expect(body).not.toBeNull()
expect(body).not.toContainElement(screen.getByRole('button', { name: /keep off for now/i }))
fireEvent.click(screen.getByRole('button', { name: /keep off for now/i })) fireEvent.click(screen.getByRole('button', { name: /keep off for now/i }))
expect(await screen.findByText(/left off until you approve/i)).toBeInTheDocument() expect(await screen.findByText(/left off until you approve/i)).toBeInTheDocument()
@@ -1793,15 +1793,15 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
return ( return (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}> <div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}> <div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3"> <div className="shrink-0 px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldAlert size={18} className="text-warning" /></div> <div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldAlert size={18} className="text-warning" /></div>
<div> <div>
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.sig.title', { name: data.subject.name })}</h3> <h3 className="text-sm font-semibold text-content">{t('admin.plugins.sig.title', { name: data.subject.name })}</h3>
<p className="text-xs text-content-muted mt-1">{t(bodyKey as never)}</p> <p className="text-xs text-content-muted mt-1">{t(bodyKey as never)}</p>
</div> </div>
</div> </div>
<div className="p-5 space-y-4"> <div className="p-5 space-y-4 min-h-0 flex-1 overflow-y-auto">
{/* Fingerprints, not full keys: these exist to be COMPARED by a human — read the {/* Fingerprints, not full keys: these exist to be COMPARED by a human — read the
new one back to the author over the phone. The full key travels in the request. */} new one back to the author over the phone. The full key travels in the request. */}
{canRetrust && ( {canRetrust && (
@@ -1815,7 +1815,7 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
<p className="text-xs text-content-faint font-mono break-all bg-surface-tertiary rounded-lg px-3 py-2">{data.detail}</p> <p className="text-xs text-content-faint font-mono break-all bg-surface-tertiary rounded-lg px-3 py-2">{data.detail}</p>
)} )}
</div> </div>
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2"> <div className="shrink-0 px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
<button type="button" onClick={onClose} <button type="button" onClick={onClose}
className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors"> className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">
{offerRetrust ? t('admin.plugins.sig.cancel') : t('common.close')} {offerRetrust ? t('admin.plugins.sig.cancel') : t('common.close')}
@@ -1857,15 +1857,15 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
}) { }) {
return ( return (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onLater}> <div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onLater}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}> <div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3"> <div className="shrink-0 px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldCheck size={18} className="text-warning" /></div> <div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldCheck size={18} className="text-warning" /></div>
<div> <div>
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.updateConsentTitle')}</h3> <h3 className="text-sm font-semibold text-content">{t('admin.plugins.updateConsentTitle')}</h3>
<p className="text-xs text-content-muted mt-1">{t('admin.plugins.updateConsentBody', { name: data.plugin.name, version: data.version })}</p> <p className="text-xs text-content-muted mt-1">{t('admin.plugins.updateConsentBody', { name: data.plugin.name, version: data.version })}</p>
</div> </div>
</div> </div>
<div className="p-5 space-y-4"> <div className="p-5 space-y-4 min-h-0 flex-1 overflow-y-auto">
{/* The admin is about to widen what this code may do — so say, right here, that {/* The admin is about to widen what this code may do — so say, right here, that
nothing ties this code to its author. One line, no checkbox, no extra click: nothing ties this code to its author. One line, no checkbox, no extra click:
this informs, it does not block. */} this informs, it does not block. */}
@@ -1894,7 +1894,7 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
</div> </div>
)} )}
</div> </div>
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2"> <div className="shrink-0 px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
<button type="button" onClick={onLater} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('admin.plugins.updateLater')}</button> <button type="button" onClick={onLater} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('admin.plugins.updateLater')}</button>
<button type="button" onClick={onApprove} className="text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover transition-colors">{t('admin.plugins.updateApprove')}</button> <button type="button" onClick={onApprove} className="text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover transition-colors">{t('admin.plugins.updateApprove')}</button>
</div> </div>
@@ -1917,15 +1917,15 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
] ]
return ( return (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}> <div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}> <div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3"> <div className="shrink-0 px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><Puzzle size={18} className="text-warning" /></div> <div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><Puzzle size={18} className="text-warning" /></div>
<div> <div>
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.dep.resolveTitle')}</h3> <h3 className="text-sm font-semibold text-content">{t('admin.plugins.dep.resolveTitle')}</h3>
<p className="text-xs text-content-muted mt-1">{t('admin.plugins.dep.resolveBody', { name: data.plugin.name })}</p> <p className="text-xs text-content-muted mt-1">{t('admin.plugins.dep.resolveBody', { name: data.plugin.name })}</p>
</div> </div>
</div> </div>
<div className="p-5 space-y-2.5"> <div className="p-5 space-y-2.5 min-h-0 flex-1 overflow-y-auto">
{rows.map(r => ( {rows.map(r => (
<div key={r.id} className="flex items-center gap-3 p-3 rounded-xl border border-edge-secondary bg-surface-tertiary"> <div key={r.id} className="flex items-center gap-3 p-3 rounded-xl border border-edge-secondary bg-surface-tertiary">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -1946,7 +1946,7 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
<p className="text-[11.5px] text-content-faint pt-1">{t('admin.plugins.dep.resolveHint')}</p> <p className="text-[11.5px] text-content-faint pt-1">{t('admin.plugins.dep.resolveHint')}</p>
)} )}
</div> </div>
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end"> <div className="shrink-0 px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end">
<button type="button" onClick={onClose} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('common.cancel')}</button> <button type="button" onClick={onClose} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('common.cancel')}</button>
</div> </div>
</div> </div>
@@ -1784,7 +1784,7 @@ function SignatureBlockSheet({ data, entry, busy, t, onRetrust, onClose }: {
return ( return (
<MSheet open onClose={onClose} variant="card" material="opaque" ariaLabel={t('admin.plugins.sig.title', { name: data.subject.name })}> <MSheet open onClose={onClose} variant="card" material="opaque" ariaLabel={t('admin.plugins.sig.title', { name: data.subject.name })}>
<div className="p-[18px]"> <div className="flex-none px-[18px] pt-[18px]">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${CHIP_PENDING}`}><ShieldAlert size={18} /></div> <div className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${CHIP_PENDING}`}><ShieldAlert size={18} /></div>
<div> <div>
@@ -1792,30 +1792,30 @@ function SignatureBlockSheet({ data, entry, busy, t, onRetrust, onClose }: {
<p className="mt-1 text-xs text-m-muted">{t(bodyKey as never)}</p> <p className="mt-1 text-xs text-m-muted">{t(bodyKey as never)}</p>
</div> </div>
</div> </div>
<div className="mt-4 space-y-4"> </div>
{/* Fingerprints, not full keys: these exist to be COMPARED by a human — read the <div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-4 space-y-4">
new one back to the author over the phone. The full key travels in the request. */} {/* Fingerprints, not full keys: these exist to be COMPARED by a human — read the
{canRetrust && ( new one back to the author over the phone. The full key travels in the request. */}
<div className="space-y-2"> {canRetrust && (
<KeyRow label={t('admin.plugins.sig.pinnedKey')} value={data.subject.keyFingerprint ?? '—'} /> <div className="space-y-2">
<KeyRow label={t('admin.plugins.sig.newKey')} value={fingerprint(newKey) ?? '—'} highlight /> <KeyRow label={t('admin.plugins.sig.pinnedKey')} value={data.subject.keyFingerprint ?? '—'} />
<p className="pt-1 text-[11.5px] leading-relaxed text-m-muted">{t('admin.plugins.sig.confirmOutOfBand')}</p> <KeyRow label={t('admin.plugins.sig.newKey')} value={fingerprint(newKey) ?? '—'} highlight />
</div> <p className="pt-1 text-[11.5px] leading-relaxed text-m-muted">{t('admin.plugins.sig.confirmOutOfBand')}</p>
)} </div>
{!canRetrust && data.detail && ( )}
<p className="break-all rounded-lg bg-[color:var(--m-ic)] px-3 py-2 font-mono text-xs text-m-faint">{data.detail}</p> {!canRetrust && data.detail && (
)} <p className="break-all rounded-lg bg-[color:var(--m-ic)] px-3 py-2 font-mono text-xs text-m-faint">{data.detail}</p>
</div> )}
<div className="mt-5 flex items-center justify-end gap-2"> </div>
<MAdminButton variant="ghost" onClick={onClose}> <div className="flex-none px-[18px] pb-[18px] flex items-center justify-end gap-2">
{offerRetrust ? t('admin.plugins.sig.cancel') : t('common.close')} <MAdminButton variant="ghost" onClick={onClose}>
{offerRetrust ? t('admin.plugins.sig.cancel') : t('common.close')}
</MAdminButton>
{offerRetrust && (
<MAdminButton variant="danger" busy={busy} onClick={() => onRetrust(version, newKey)}>
{t('admin.plugins.sig.retrustConfirm')}
</MAdminButton> </MAdminButton>
{offerRetrust && ( )}
<MAdminButton variant="danger" busy={busy} onClick={() => onRetrust(version, newKey)}>
{t('admin.plugins.sig.retrustConfirm')}
</MAdminButton>
)}
</div>
</div> </div>
</MSheet> </MSheet>
) )
@@ -1846,7 +1846,7 @@ function UpdateConsentSheet({ data, unsigned, t, onApprove, onLater }: {
}) { }) {
return ( return (
<MSheet open onClose={onLater} variant="card" material="opaque" ariaLabel={t('admin.plugins.updateConsentTitle')}> <MSheet open onClose={onLater} variant="card" material="opaque" ariaLabel={t('admin.plugins.updateConsentTitle')}>
<div className="p-[18px]"> <div className="flex-none px-[18px] pt-[18px]">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${CHIP_PENDING}`}><ShieldCheck size={18} /></div> <div className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${CHIP_PENDING}`}><ShieldCheck size={18} /></div>
<div> <div>
@@ -1854,39 +1854,39 @@ function UpdateConsentSheet({ data, unsigned, t, onApprove, onLater }: {
<p className="mt-1 text-xs text-m-muted">{t('admin.plugins.updateConsentBody', { name: data.plugin.name, version: data.version })}</p> <p className="mt-1 text-xs text-m-muted">{t('admin.plugins.updateConsentBody', { name: data.plugin.name, version: data.version })}</p>
</div> </div>
</div> </div>
<div className="mt-4 space-y-4"> </div>
{/* The admin is about to widen what this code may do — so say, right here, that <div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-4 space-y-4">
nothing ties this code to its author. One line, no checkbox, no extra click: {/* The admin is about to widen what this code may do — so say, right here, that
this informs, it does not block. */} nothing ties this code to its author. One line, no checkbox, no extra click:
{unsigned && ( this informs, it does not block. */}
<p className={`flex items-start gap-2 rounded-lg border px-3 py-2 text-xs ${CHIP_PENDING}`}> {unsigned && (
<ShieldAlert size={13} className="mt-0.5 shrink-0" /> <p className={`flex items-start gap-2 rounded-lg border px-3 py-2 text-xs ${CHIP_PENDING}`}>
<span>{t('admin.plugins.sig.consentUnsigned')}</span> <ShieldAlert size={13} className="mt-0.5 shrink-0" />
</p> <span>{t('admin.plugins.sig.consentUnsigned')}</span>
)} </p>
{data.newPermissions.length > 0 && ( )}
<div> {data.newPermissions.length > 0 && (
<h4 className="text-xs font-semibold uppercase tracking-wider text-m-muted">{t('admin.plugins.updateNewPermissions')}</h4> <div>
<ul className="mt-2 space-y-1.5"> <h4 className="text-xs font-semibold uppercase tracking-wider text-m-muted">{t('admin.plugins.updateNewPermissions')}</h4>
{data.newPermissions.map(perm => ( <ul className="mt-2 space-y-1.5">
<li key={perm} className="flex items-start gap-2 text-xs text-m-muted"><Check size={13} className="mt-0.5 shrink-0 text-[color:var(--m-st-pending)]" /><PermLabel perm={perm} t={t} /></li> {data.newPermissions.map(perm => (
))} <li key={perm} className="flex items-start gap-2 text-xs text-m-muted"><Check size={13} className="mt-0.5 shrink-0 text-[color:var(--m-st-pending)]" /><PermLabel perm={perm} t={t} /></li>
</ul> ))}
</ul>
</div>
)}
{data.newEgress.length > 0 && (
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-m-muted">{t('admin.plugins.updateNewEgress')}</h4>
<div className="mt-2 flex flex-wrap gap-1.5">
{data.newEgress.map(host => <code key={host} className="rounded bg-[color:var(--m-ic)] px-1.5 py-0.5 font-mono text-[11px] text-m-muted">{host}</code>)}
</div> </div>
)} </div>
{data.newEgress.length > 0 && ( )}
<div> </div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-m-muted">{t('admin.plugins.updateNewEgress')}</h4> <div className="flex-none px-[18px] pb-[18px] flex items-center justify-end gap-2">
<div className="mt-2 flex flex-wrap gap-1.5"> <MAdminButton variant="ghost" onClick={onLater}>{t('admin.plugins.updateLater')}</MAdminButton>
{data.newEgress.map(host => <code key={host} className="rounded bg-[color:var(--m-ic)] px-1.5 py-0.5 font-mono text-[11px] text-m-muted">{host}</code>)} <MAdminButton onClick={onApprove}>{t('admin.plugins.updateApprove')}</MAdminButton>
</div>
</div>
)}
</div>
<div className="mt-5 flex items-center justify-end gap-2">
<MAdminButton variant="ghost" onClick={onLater}>{t('admin.plugins.updateLater')}</MAdminButton>
<MAdminButton onClick={onApprove}>{t('admin.plugins.updateApprove')}</MAdminButton>
</div>
</div> </div>
</MSheet> </MSheet>
) )
@@ -1906,7 +1906,7 @@ function DependencyResolveSheet({ data, t, busy, installedIds, onDownload, onClo
] ]
return ( return (
<MSheet open onClose={onClose} variant="card" material="opaque" ariaLabel={t('admin.plugins.dep.resolveTitle')}> <MSheet open onClose={onClose} variant="card" material="opaque" ariaLabel={t('admin.plugins.dep.resolveTitle')}>
<div className="p-[18px]"> <div className="flex-none px-[18px] pt-[18px]">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${CHIP_PENDING}`}><Puzzle size={18} /></div> <div className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${CHIP_PENDING}`}><Puzzle size={18} /></div>
<div> <div>
@@ -1914,30 +1914,30 @@ function DependencyResolveSheet({ data, t, busy, installedIds, onDownload, onClo
<p className="mt-1 text-xs text-m-muted">{t('admin.plugins.dep.resolveBody', { name: data.plugin.name })}</p> <p className="mt-1 text-xs text-m-muted">{t('admin.plugins.dep.resolveBody', { name: data.plugin.name })}</p>
</div> </div>
</div> </div>
<div className="mt-4 space-y-2.5"> </div>
{rows.map(r => ( <div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-4 space-y-2.5">
<div key={r.id} className="flex items-center gap-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-3"> {rows.map(r => (
<div className="min-w-0 flex-1"> <div key={r.id} className="flex items-center gap-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-3">
<div className="truncate text-[13px] font-semibold text-m-ink">{r.id}</div> <div className="min-w-0 flex-1">
<div className="mt-0.5 text-[11.5px] text-m-muted"> <div className="truncate text-[13px] font-semibold text-m-ink">{r.id}</div>
{r.installed <div className="mt-0.5 text-[11.5px] text-m-muted">
? t('admin.plugins.dep.mismatch', { wanted: r.constraint, installed: r.installed }) {r.installed
: t('admin.plugins.dep.requires', { version: r.constraint })} ? t('admin.plugins.dep.mismatch', { wanted: r.constraint, installed: r.installed })
</div> : t('admin.plugins.dep.requires', { version: r.constraint })}
</div> </div>
<button type="button" onClick={() => onDownload(r.id, r.constraint)} disabled={busy}
className={`shrink-0 ${ACT_PILL}`}>
<Download size={13} /> {r.installed ? t('admin.plugins.dep.update') : t('admin.plugins.dep.download')}
</button>
</div> </div>
))} <button type="button" onClick={() => onDownload(r.id, r.constraint)} disabled={busy}
{rows.some(r => !installedIds.has(r.id)) && ( className={`shrink-0 ${ACT_PILL}`}>
<p className="pt-1 text-[11.5px] text-m-faint">{t('admin.plugins.dep.resolveHint')}</p> <Download size={13} /> {r.installed ? t('admin.plugins.dep.update') : t('admin.plugins.dep.download')}
)} </button>
</div> </div>
<div className="mt-5 flex items-center justify-end"> ))}
<MAdminButton variant="ghost" onClick={onClose}>{t('common.cancel')}</MAdminButton> {rows.some(r => !installedIds.has(r.id)) && (
</div> <p className="pt-1 text-[11.5px] text-m-faint">{t('admin.plugins.dep.resolveHint')}</p>
)}
</div>
<div className="flex-none px-[18px] pb-[18px] flex items-center justify-end">
<MAdminButton variant="ghost" onClick={onClose}>{t('common.cancel')}</MAdminButton>
</div> </div>
</MSheet> </MSheet>
) )