feat: add a permission-gated UI action registry and action slots for plugins

Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.
This commit is contained in:
LukeGus committed 2026-09-21 16:56:15 -05:00
1 parent 225f618337
commit 2d6c1cdecb
29 files changed
+1971 -40

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
import { useMemo, useSyncExternalStore } from "react";
import {
getSlotContributions,
subscribeToActionRegistry,
getActionPermission,
type SlotContribution,
} from "@/shell/action-registry";
import { usePermissions } from "@/hooks/use-permissions";
/**
* The contributions in a slot that the current user may actually see.
*
* A slot owner calls this without knowing which plugins contributed, or
* whether any did. Contributions the user lacks the permission for are
* dropped entirely rather than disabled, which is how admin-gated UI already
* behaves elsewhere in the app.
*/
export function useActionSlot(slotId: string): SlotContribution[] {
const contributions = useSyncExternalStore(
subscribeToActionRegistry,
() => getSlotContributions(slotId),
() => getSlotContributions(slotId),
);
const { has, loaded } = usePermissions();
return useMemo(() => {
// Render nothing until the grants are known. A button that appears and
// then vanishes is worse than one that appears a moment late.
if (!loaded) return [];
return contributions.filter((contribution) => {
const permission =
getActionPermission(contribution.actionId) ?? undefined;
return !permission || has(permission);
});
}, [contributions, has, loaded]);
}
+104
View File
@@ -0,0 +1,104 @@
import { useCallback, useEffect, useState } from "react";
import { matchesPermission } from "@/lib/permissions";
export interface PermissionsState {
permissions: string[];
isAdmin: boolean;
/** False until the call answers, so gated UI does not flash in or out. */
loaded: boolean;
has: (permission: string) => boolean;
}
/** Fired when a user's grants may have changed, so every surface re-reads them. */
export const PERMISSIONS_CHANGED_EVENT = "permissionsChanged";
interface CachedPermissions {
permissions: string[];
isAdmin: boolean;
}
/**
* Shared across every hook instance. A terminal per split pane would otherwise
* fire one identical request each.
*/
let cache: CachedPermissions | null = null;
let inFlight: Promise<CachedPermissions> | null = null;
function load(): Promise<CachedPermissions> {
if (cache) return Promise.resolve(cache);
if (inFlight) return inFlight;
inFlight = import("@/api/rbac-api")
.then(({ getMyPermissions }) => getMyPermissions())
.then((result) => {
cache = {
permissions: result.permissions ?? [],
isAdmin: !!result.isAdmin,
};
return cache;
})
.catch(() => {
// Deny by default. A failed lookup hides gated UI rather than showing
// something the server would refuse anyway.
cache = { permissions: [], isAdmin: false };
return cache;
})
.finally(() => {
inFlight = null;
});
return inFlight;
}
export function notifyPermissionsChanged(): void {
cache = null;
window.dispatchEvent(new Event(PERMISSIONS_CHANGED_EVENT));
}
/**
* The current user's grants.
*
* Freshness is eventually consistent: the backend caches per user with a TTL
* and this caches until notifyPermissionsChanged fires, so a revoke can leave
* a stale button on screen briefly. That is acceptable because the button is
* not the boundary - clicking it hits a route that checks again and refuses.
*/
export function usePermissions(): PermissionsState {
const [state, setState] = useState<CachedPermissions & { loaded: boolean }>(
() =>
cache
? { ...cache, loaded: true }
: { permissions: [], isAdmin: false, loaded: false },
);
useEffect(() => {
let cancelled = false;
const refresh = () => {
load().then((result) => {
if (!cancelled) setState({ ...result, loaded: true });
});
};
refresh();
window.addEventListener(PERMISSIONS_CHANGED_EVENT, refresh);
return () => {
cancelled = true;
window.removeEventListener(PERMISSIONS_CHANGED_EVENT, refresh);
};
}, []);
const has = useCallback(
(permission: string) =>
matchesPermission(state.permissions, state.isAdmin, permission),
[state.permissions, state.isAdmin],
);
return { ...state, has };
}
/** Test seam. */
export function resetPermissionsCache(): void {
cache = null;
inFlight = null;
}