fix
This commit is contained in:
@@ -19,9 +19,11 @@
|
||||
roleById,
|
||||
rolePresetDenied,
|
||||
samePermissions,
|
||||
sectionPermissionTree,
|
||||
setPermissionAllowed,
|
||||
type MemberPermissionsView,
|
||||
type PermissionCatalog
|
||||
type PermissionCatalog,
|
||||
type PermissionCatalogEntry
|
||||
} from "$lib/member-permissions";
|
||||
|
||||
let {
|
||||
@@ -48,6 +50,8 @@
|
||||
let isOwnerMember = $state(false);
|
||||
/** Raw per-key checkboxes stay collapsed — the role picker is the primary control. */
|
||||
let advancedOpen = $state(false);
|
||||
/** Which root keys have their fine-tune children expanded. */
|
||||
let fineTuneOpen = $state<Record<string, boolean>>({});
|
||||
|
||||
const allKeys = $derived(catalogKeys(catalog));
|
||||
const summary = $derived(permissionSummary(catalog, denied));
|
||||
@@ -76,6 +80,7 @@
|
||||
}
|
||||
|
||||
const visibleSections = $derived(filterCatalog(catalog, search, labelFor));
|
||||
const searching = $derived(search.trim().length > 0);
|
||||
|
||||
/** Reload whenever the dialog opens for a member (never on every keystroke). */
|
||||
$effect(() => {
|
||||
@@ -86,6 +91,7 @@
|
||||
loadError = null;
|
||||
search = "";
|
||||
advancedOpen = false;
|
||||
fineTuneOpen = {};
|
||||
void (async () => {
|
||||
try {
|
||||
const [catalogPayload, view] = await Promise.all([
|
||||
@@ -121,6 +127,14 @@
|
||||
denied = denyAll(catalog);
|
||||
}
|
||||
|
||||
function toggleFineTune(key: string) {
|
||||
fineTuneOpen = { ...fineTuneOpen, [key]: !fineTuneOpen[key] };
|
||||
}
|
||||
|
||||
function entryAllowed(entry: PermissionCatalogEntry): boolean {
|
||||
return entry.plan_allowed && !isDenied(denied, entry.key);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!member || readOnly || saving) return;
|
||||
saving = true;
|
||||
@@ -142,13 +156,6 @@
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Indent nested keys so "Products - Error tab" reads as a child of "Products". */
|
||||
function indentClass(key: string): string {
|
||||
const depth = key.split(".").length - 2;
|
||||
if (depth <= 0) return "";
|
||||
return depth === 1 ? "pl-6" : "pl-12";
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog
|
||||
@@ -237,6 +244,7 @@
|
||||
|
||||
{#if advancedOpen}
|
||||
<div class="mt-3 space-y-3">
|
||||
<p class="text-xs text-muted-foreground">{i18n.t("settings.permissions.fineTuneHint")}</p>
|
||||
{#if !readOnly}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" data-testid="permissions-allow-all" onclick={allowEverything}>
|
||||
@@ -255,7 +263,7 @@
|
||||
aria-label={i18n.t("settings.permissions.searchPlaceholder")}
|
||||
/>
|
||||
|
||||
<div class="max-h-[20rem] space-y-5 overflow-y-auto pr-1">
|
||||
<div class="max-h-[22rem] space-y-4 overflow-y-auto pr-1">
|
||||
{#if visibleSections.length === 0}
|
||||
<p class="py-8 text-center text-sm text-muted-foreground">
|
||||
{i18n.t("settings.permissions.noMatches")}
|
||||
@@ -264,39 +272,114 @@
|
||||
{#each visibleSections as section (section.id)}
|
||||
<section class="space-y-1">
|
||||
<h3
|
||||
class="sticky top-0 bg-background py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
class="sticky top-0 z-10 bg-background py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{sectionLabel(section.id)}
|
||||
</h3>
|
||||
{#each section.entries as entry (entry.key)}
|
||||
{@const allowed = entry.plan_allowed && !isDenied(denied, entry.key)}
|
||||
{@const locked = !entry.plan_allowed}
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/40 {indentClass(
|
||||
entry.key
|
||||
)}"
|
||||
>
|
||||
<Checkbox
|
||||
id={`perm-${entry.key}`}
|
||||
checked={allowed}
|
||||
disabled={readOnly || locked}
|
||||
aria-label={labelFor(entry.key)}
|
||||
data-testid={`perm-${entry.key}`}
|
||||
onchange={() => toggle(entry.key, !allowed)}
|
||||
/>
|
||||
<label
|
||||
for={`perm-${entry.key}`}
|
||||
class="min-w-0 flex-1 cursor-pointer text-sm {locked
|
||||
? 'text-muted-foreground'
|
||||
: 'text-foreground'}"
|
||||
>
|
||||
{labelFor(entry.key)}
|
||||
</label>
|
||||
{#if locked}
|
||||
<Badge variant="secondary">{i18n.t("settings.permissions.planLocked")}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if searching}
|
||||
{#each section.entries as entry (entry.key)}
|
||||
{@const allowed = entryAllowed(entry)}
|
||||
{@const locked = !entry.plan_allowed}
|
||||
<div class="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/40">
|
||||
<Checkbox
|
||||
id={`perm-${entry.key}`}
|
||||
checked={allowed}
|
||||
disabled={readOnly || locked}
|
||||
aria-label={labelFor(entry.key)}
|
||||
data-testid={`perm-${entry.key}`}
|
||||
onchange={() => toggle(entry.key, !allowed)}
|
||||
/>
|
||||
<label
|
||||
for={`perm-${entry.key}`}
|
||||
class="min-w-0 flex-1 cursor-pointer text-sm {locked
|
||||
? 'text-muted-foreground'
|
||||
: 'text-foreground'}"
|
||||
>
|
||||
{labelFor(entry.key)}
|
||||
</label>
|
||||
{#if locked}
|
||||
<Badge variant="secondary">{i18n.t("settings.permissions.planLocked")}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each sectionPermissionTree(section) as node (node.root.key)}
|
||||
{@const root = node.root}
|
||||
{@const allowed = entryAllowed(root)}
|
||||
{@const locked = !root.plan_allowed}
|
||||
{@const hasChildren = node.children.length > 0}
|
||||
{@const childrenOpen = Boolean(fineTuneOpen[root.key])}
|
||||
<div class="rounded-md border border-transparent hover:border-border/60">
|
||||
<div class="flex items-center gap-2 px-2 py-1.5">
|
||||
<Checkbox
|
||||
id={`perm-${root.key}`}
|
||||
checked={allowed}
|
||||
disabled={readOnly || locked}
|
||||
aria-label={labelFor(root.key)}
|
||||
data-testid={`perm-${root.key}`}
|
||||
onchange={() => toggle(root.key, !allowed)}
|
||||
/>
|
||||
<label
|
||||
for={`perm-${root.key}`}
|
||||
class="min-w-0 flex-1 cursor-pointer text-sm font-medium {locked
|
||||
? 'text-muted-foreground'
|
||||
: 'text-foreground'}"
|
||||
>
|
||||
{labelFor(root.key)}
|
||||
</label>
|
||||
{#if locked}
|
||||
<Badge variant="secondary">{i18n.t("settings.permissions.planLocked")}</Badge>
|
||||
{/if}
|
||||
{#if hasChildren}
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-expanded={childrenOpen}
|
||||
data-testid={`perm-fine-${root.key}`}
|
||||
onclick={() => toggleFineTune(root.key)}
|
||||
>
|
||||
{childrenOpen
|
||||
? i18n.t("settings.permissions.hideFineTune")
|
||||
: i18n.t("settings.permissions.showFineTune", {
|
||||
count: node.children.length
|
||||
})}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if hasChildren && childrenOpen}
|
||||
<div class="mb-1 ml-4 space-y-0.5 border-l border-border/70 pl-3">
|
||||
{#each node.children as entry (entry.key)}
|
||||
{@const childAllowed = entryAllowed(entry)}
|
||||
{@const childLocked = !entry.plan_allowed}
|
||||
<div class="flex items-center gap-3 rounded-md px-1 py-1 hover:bg-muted/40">
|
||||
<Checkbox
|
||||
id={`perm-${entry.key}`}
|
||||
checked={childAllowed}
|
||||
disabled={readOnly || childLocked || isDenied(denied, root.key)}
|
||||
aria-label={labelFor(entry.key)}
|
||||
data-testid={`perm-${entry.key}`}
|
||||
onchange={() => toggle(entry.key, !childAllowed)}
|
||||
/>
|
||||
<label
|
||||
for={`perm-${entry.key}`}
|
||||
class="min-w-0 flex-1 cursor-pointer text-sm {childLocked
|
||||
? 'text-muted-foreground'
|
||||
: 'text-foreground'}"
|
||||
>
|
||||
{labelFor(entry.key)}
|
||||
</label>
|
||||
{#if childLocked}
|
||||
<Badge variant="secondary"
|
||||
>{i18n.t("settings.permissions.planLocked")}</Badge
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user