mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
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:
1 parent
225f618337
commit
2d6c1cdecb
29 files changed
+1971
-40
No files matched your search
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user