mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
feat: folder shares apply to hosts added later (#1343)
* feat: folder shares apply to hosts added later Sharing a folder only fanned grants out to the hosts in it at the time. The share is now also kept as a standing rule on the folder, and a host created in or moved into it (or a subfolder) inherits the same access and secret snapshots. Rules follow folder renames and can be stopped from the share dialog. * fix: stabilize folder access migrations
This commit is contained in:
@@ -191,6 +191,42 @@ export async function shareFolder(
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -1611,6 +1611,9 @@
|
||||
"shareWithCount": "Share ({{count}})",
|
||||
"currentAccess": "Current access",
|
||||
"noAccessEntries": "This host has not been shared yet",
|
||||
"folderRulesTitle": "Applies to hosts added later",
|
||||
"folderRulesHint": "These shares stay on the folder: a host created in or moved into it (or a subfolder) gets the same access automatically. Removing a rule only stops future inheritance.",
|
||||
"folderRuleRemove": "Stop inheriting",
|
||||
"folderShareSummary": "Shared {{shared}} of {{total}} host(s) in this folder",
|
||||
"grantedBy": "Granted by",
|
||||
"expires": "Expires",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import {
|
||||
getFolderAccess,
|
||||
revokeFolderAccess,
|
||||
type FolderAccessRule,
|
||||
} from "@/api/rbac-api";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -177,6 +182,19 @@ export function HostShareModal({
|
||||
setAccessList(res.accessList ?? []);
|
||||
}
|
||||
|
||||
const [folderRules, setFolderRules] = useState<FolderAccessRule[]>([]);
|
||||
const refreshFolderRules = useCallback(async () => {
|
||||
if (!isFolderShare || !folder) return;
|
||||
try {
|
||||
setFolderRules((await getFolderAccess(folder)).rules);
|
||||
} catch {
|
||||
setFolderRules([]);
|
||||
}
|
||||
}, [isFolderShare, folder]);
|
||||
useEffect(() => {
|
||||
if (open) void refreshFolderRules();
|
||||
}, [open, refreshFolderRules]);
|
||||
|
||||
async function handleShare() {
|
||||
if ((!host && !folder) || selectedCount === 0) return;
|
||||
const targets: ShareTarget[] = [
|
||||
@@ -196,6 +214,7 @@ export function HostShareModal({
|
||||
permissionLevel,
|
||||
...(durationHours ? { durationHours } : {}),
|
||||
});
|
||||
await refreshFolderRules();
|
||||
setFolderShareSummary({
|
||||
hostsShared: result.hostsShared,
|
||||
hostsTotal: result.hostsTotal,
|
||||
@@ -499,6 +518,42 @@ export function HostShareModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFolderShare && folderRules.length > 0 && (
|
||||
<div className="flex flex-col gap-1 px-3 pb-2 shrink-0">
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.sharing.folderRulesTitle")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.sharing.folderRulesHint")}
|
||||
</span>
|
||||
{folderRules.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center gap-2 text-xs px-1 py-0.5"
|
||||
>
|
||||
<span className="flex-1 truncate">
|
||||
{rule.targetType === "role"
|
||||
? rule.roleDisplayName || rule.roleName
|
||||
: rule.username}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase text-muted-foreground">
|
||||
{rule.permissionLevel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-muted-foreground hover:text-destructive"
|
||||
title={t("hosts.sharing.folderRuleRemove")}
|
||||
onClick={() => {
|
||||
void revokeFolderAccess(rule.id).then(refreshFolderRules);
|
||||
}}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current access: takes remaining space, scrolls independently */}
|
||||
{!isFolderShare && (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
|
||||
Reference in New Issue
Block a user