Files
Termix/src/ui/api/rbac-api.ts
T
LukeGus 2d6c1cdecb 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.
2026-09-21 16:56:15 -05:00

512 lines
13 KiB
TypeScript

import {
handleApiError,
rbacApi,
type AccessRecord,
type Role,
type UserRole,
} from "@/main-axios";
import type { AuthOverrideProtocol } from "@/types/auth-protocols";
import {
getConnectedRemoteApi,
resolveRemoteHostId,
} from "@/lib/remote-server-api";
async function getSharingTarget(hostId: number, syncId?: string | null) {
const api = await getConnectedRemoteApi();
if (!api || !syncId) return { api: rbacApi, hostId };
const remoteHostId = await resolveRemoteHostId(syncId);
if (remoteHostId === null) {
throw new Error("The synced host does not exist on the remote server");
}
return { api, hostId: remoteHostId };
}
export async function getRoles(): Promise<{ roles: Role[] }> {
try {
const api = (await getConnectedRemoteApi()) ?? rbacApi;
const response = await api.get("/rbac/roles");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch roles");
}
}
export async function createRole(roleData: {
name: string;
displayName: string;
description?: string | null;
}): Promise<{ role: Role }> {
try {
const response = await rbacApi.post("/rbac/roles", roleData);
return response.data;
} catch (error) {
throw handleApiError(error, "create role");
}
}
export async function updateRole(
roleId: number,
roleData: {
displayName?: string;
description?: string | null;
permissions?: string[];
},
): Promise<{ role: Role }> {
try {
const response = await rbacApi.put(`/rbac/roles/${roleId}`, roleData);
if (roleData.permissions) {
// The editing admin may hold this role, so re-read our own grants
// rather than leaving gated UI stale until the next reload.
const { notifyPermissionsChanged } =
await import("@/hooks/use-permissions");
notifyPermissionsChanged();
}
return response.data;
} catch (error) {
throw handleApiError(error, "update role");
}
}
export async function deleteRole(
roleId: number,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.delete(`/rbac/roles/${roleId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete role");
}
}
export async function getUserRoles(
userId: string,
): Promise<{ roles: UserRole[] }> {
try {
const response = await rbacApi.get(`/rbac/users/${userId}/roles`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch user roles");
}
}
export async function assignRoleToUser(
userId: string,
roleId: number,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.post(`/rbac/users/${userId}/roles`, {
roleId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "assign role to user");
}
}
export async function removeRoleFromUser(
userId: string,
roleId: number,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.delete(
`/rbac/users/${userId}/roles/${roleId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "remove role from user");
}
}
export interface RoleMember {
userId: string;
username: string;
grantedAt: string;
grantedBy: string | null;
}
export async function getRoleMembers(
roleId: number,
): Promise<{ members: RoleMember[] }> {
try {
const response = await rbacApi.get(`/rbac/roles/${roleId}/members`);
return response.data;
} catch (error) {
throw handleApiError(error, "get role members");
}
}
export type SharePermissionLevel = "connect" | "view" | "edit" | "manage";
export interface ShareTarget {
type: "user" | "role";
id: string | number;
}
export async function shareHost(
hostId: number,
shareData: {
targets: ShareTarget[];
permissionLevel: SharePermissionLevel;
durationHours?: number;
},
syncId?: string | null,
): Promise<{
success: boolean;
expiresAt: string | null;
results: Array<{
type: "user" | "role";
id: string | number;
accessId: number;
created: boolean;
}>;
}> {
try {
const target = await getSharingTarget(hostId, syncId);
const response = await target.api.post(
`/rbac/host/${target.hostId}/share`,
shareData,
);
return response.data;
} catch (error) {
throw handleApiError(error, "share host");
}
}
export async function shareFolder(
folder: string,
shareData: {
targets: ShareTarget[];
permissionLevel: SharePermissionLevel;
durationHours?: number;
},
): Promise<{
success: boolean;
expiresAt: string | null;
hostsShared: number;
hostsTotal: number;
hostResults: Array<{ hostId: number; shared: boolean; reason?: string }>;
}> {
try {
const api = (await getConnectedRemoteApi()) ?? rbacApi;
const response = await api.post("/rbac/folder/share", {
folder,
...shareData,
});
return response.data;
} catch (error) {
throw handleApiError(error, "share folder");
}
}
export interface FolderAccessRule {
id: number;
folder: string;
targetType: "user" | "role";
userId: string | null;
roleId: number | null;
username: string | null;
roleName: string | null;
roleDisplayName: string | null;
permissionLevel: SharePermissionLevel;
expiresAt: string | null;
createdAt: string;
}
/** Standing shares on a folder - what hosts added to it later inherit. */
export async function getFolderAccess(
folder: string,
): Promise<{ rules: FolderAccessRule[] }> {
try {
const response = await rbacApi.get("/rbac/folder/access", {
params: { folder },
});
return response.data;
} catch (error) {
throw handleApiError(error, "get folder access");
}
}
export async function revokeFolderAccess(ruleId: number): Promise<void> {
try {
await rbacApi.delete(`/rbac/folder/access/${ruleId}`);
} catch (error) {
throw handleApiError(error, "revoke folder access");
}
}
export async function updateHostAccess(
hostId: number,
accessId: number,
update: {
permissionLevel?: SharePermissionLevel;
durationHours?: number | null;
},
syncId?: string | null,
): Promise<{ success: boolean; expiresAt: string | null }> {
try {
const target = await getSharingTarget(hostId, syncId);
const response = await target.api.patch(
`/rbac/host/${target.hostId}/access/${accessId}`,
update,
);
return response.data;
} catch (error) {
throw handleApiError(error, "update host access");
}
}
export async function getHostAccess(
hostId: number,
syncId?: string | null,
): Promise<{ accessList: AccessRecord[]; isOwner?: boolean }> {
try {
const target = await getSharingTarget(hostId, syncId);
const response = await target.api.get(`/rbac/host/${target.hostId}/access`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch host access");
}
}
export interface PermissionCatalogEntry {
group: string;
permissions: string[];
}
export async function getPermissionsCatalog(): Promise<{
catalog: PermissionCatalogEntry[];
}> {
try {
const response = await rbacApi.get("/rbac/permissions/catalog");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch permissions catalog");
}
}
export interface MyPermissions {
permissions: string[];
isAdmin: boolean;
}
/** The current user's own grants, for hiding UI they could not use. */
export async function getMyPermissions(): Promise<MyPermissions> {
try {
const response = await rbacApi.get("/rbac/permissions/me");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch permissions");
}
}
export async function getSharedHosts(): Promise<{
sharedHosts: Array<{
id: number;
name: string | null;
ip: string;
port: number;
username: string;
folder: string | null;
tags: string | null;
permissionLevel: SharePermissionLevel;
expiresAt: string | null;
grantedBy: string;
ownerUsername: string;
}>;
}> {
try {
const api = (await getConnectedRemoteApi()) ?? rbacApi;
const response = await api.get("/rbac/shared-hosts");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch shared hosts");
}
}
export async function revokeHostAccess(
hostId: number,
accessId: number,
syncId?: string | null,
): Promise<{ success: boolean }> {
try {
const target = await getSharingTarget(hostId, syncId);
const response = await target.api.delete(
`/rbac/host/${target.hostId}/access/${accessId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "revoke host access");
}
}
export async function getHostAuthOverride(
hostId: number,
protocol: AuthOverrideProtocol,
remoteShared = false,
): Promise<{ protocol: AuthOverrideProtocol; credentialId: number | null }> {
try {
const api = remoteShared ? await getConnectedRemoteApi() : rbacApi;
if (!api) throw new Error("Remote server is not connected");
const targetHostId = remoteShared ? Math.abs(hostId) : hostId;
const response = await api.get(
`/rbac/host-access/${targetHostId}/auth/${protocol}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch host authentication override");
}
}
export async function setHostAuthOverride(
hostId: number,
protocol: AuthOverrideProtocol,
credentialId: number | null,
remoteShared = false,
): Promise<{
success: boolean;
protocol: AuthOverrideProtocol;
credentialId: number | null;
}> {
try {
const api = remoteShared ? await getConnectedRemoteApi() : rbacApi;
if (!api) throw new Error("Remote server is not connected");
const targetHostId = remoteShared ? Math.abs(hostId) : hostId;
const response = await api.put(
`/rbac/host-access/${targetHostId}/auth/${protocol}`,
{ credentialId },
);
return response.data;
} catch (error) {
throw handleApiError(error, "update host authentication override");
}
}
// ============================================================================
// SNIPPET SHARING
// ============================================================================
export async function shareSnippet(
snippetId: number,
shareData: {
targetType: "user" | "role";
targetUserId?: string;
targetRoleId?: number;
durationHours?: number;
},
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.post(
`/rbac/snippet/${snippetId}/share`,
shareData,
);
return response.data;
} catch (error) {
throw handleApiError(error, "share snippet");
}
}
export async function shareSnippetFolder(
folder: string,
targets: ShareTarget[],
durationHours?: number,
): Promise<{ success: boolean; snippetsShared: number }> {
try {
const response = await rbacApi.post("/rbac/snippet-folder/share", {
folder,
targets,
durationHours,
});
return response.data;
} catch (error) {
throw handleApiError(error, "share snippet folder");
}
}
export type CredentialPermissionLevel = "use" | "manage";
export async function shareCredential(
credentialId: number,
targets: ShareTarget[],
permissionLevel: CredentialPermissionLevel,
durationHours?: number,
): Promise<{ success: boolean; expiresAt: string | null }> {
try {
const response = await rbacApi.post(
`/rbac/credential/${credentialId}/share`,
{ targets, permissionLevel, durationHours },
);
return response.data;
} catch (error) {
throw handleApiError(error, "share credential");
}
}
export async function getCredentialAccess(
credentialId: number,
): Promise<{ access: AccessRecord[] }> {
try {
const response = await rbacApi.get(
`/rbac/credential/${credentialId}/access`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "get credential access");
}
}
export async function revokeCredentialAccess(
credentialId: number,
accessId: number,
): Promise<void> {
try {
await rbacApi.delete(`/rbac/credential/${credentialId}/access/${accessId}`);
} catch (error) {
throw handleApiError(error, "revoke credential access");
}
}
export async function getSnippetAccess(
snippetId: number,
): Promise<{ accessList: AccessRecord[] }> {
try {
const response = await rbacApi.get(`/rbac/snippet/${snippetId}/access`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch snippet access");
}
}
export async function revokeSnippetAccess(
snippetId: number,
accessId: number,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.delete(
`/rbac/snippet/${snippetId}/access/${accessId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "revoke snippet access");
}
}
export async function getSharedSnippets(): Promise<{
sharedSnippets: Array<{
id: number;
name: string;
content: string;
description: string | null;
folder: string | null;
ownerUsername: string;
permissionLevel: string;
expiresAt: string | null;
}>;
}> {
try {
const response = await rbacApi.get("/rbac/shared-snippets");
return response.data;
} catch (error) {
handleApiError(error, "fetch shared snippets");
}
}