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:
ZacharyZcR
2026-08-25 04:50:51 +08:00
committed by GitHub
parent 82143946c7
commit 302ac19e6c
24 changed files with 28903 additions and 20 deletions
+36
View File
@@ -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,
+3
View File
@@ -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",
+56 -1
View File
@@ -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">