fix
This commit is contained in:
@@ -6,12 +6,18 @@
|
||||
import { sectionLabel } from "$lib/plan-feature-catalog";
|
||||
import { Badge, Button, Checkbox, Dialog, Input, Spinner } from "$lib/components/ui";
|
||||
import Alert from "$lib/components/Alert.svelte";
|
||||
import { ChevronDown, ChevronRight, Check } from "@lucide/svelte";
|
||||
import {
|
||||
CUSTOM_ROLE_ID,
|
||||
PERMISSION_ROLES,
|
||||
catalogKeys,
|
||||
denyAll,
|
||||
detectRole,
|
||||
filterCatalog,
|
||||
isDenied,
|
||||
permissionSummary,
|
||||
roleById,
|
||||
rolePresetDenied,
|
||||
samePermissions,
|
||||
setPermissionAllowed,
|
||||
type MemberPermissionsView,
|
||||
@@ -40,11 +46,28 @@
|
||||
let loadError = $state<string | null>(null);
|
||||
let search = $state("");
|
||||
let isOwnerMember = $state(false);
|
||||
/** Raw per-key checkboxes stay collapsed — the role picker is the primary control. */
|
||||
let advancedOpen = $state(false);
|
||||
|
||||
const allKeys = $derived(catalogKeys(catalog));
|
||||
const summary = $derived(permissionSummary(catalog, denied));
|
||||
const dirty = $derived(!samePermissions(denied, savedDenied));
|
||||
const readOnly = $derived(!canEdit || isOwnerMember);
|
||||
/** Which preset the current selection matches, or "custom" after fine-tuning. */
|
||||
const activeRole = $derived(detectRole(catalog, denied));
|
||||
|
||||
function roleLabel(id: string): string {
|
||||
return i18n.t(`settings.permissions.role.${id}`);
|
||||
}
|
||||
|
||||
function roleDescription(id: string): string {
|
||||
return i18n.t(`settings.permissions.role.${id}.desc`);
|
||||
}
|
||||
|
||||
function applyRole(id: string) {
|
||||
if (readOnly || id === CUSTOM_ROLE_ID) return;
|
||||
denied = rolePresetDenied(catalog, roleById(id));
|
||||
}
|
||||
|
||||
function labelFor(key: string): string {
|
||||
const translated = i18n.t(`plan.feature.${key}`);
|
||||
@@ -62,6 +85,7 @@
|
||||
loading = true;
|
||||
loadError = null;
|
||||
search = "";
|
||||
advancedOpen = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const [catalogPayload, view] = await Promise.all([
|
||||
@@ -147,76 +171,137 @@
|
||||
<Alert tone="info" message={i18n.t("settings.permissions.ownerOnly")} />
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-sm text-muted-foreground" data-testid="permissions-summary">
|
||||
{i18n.t("settings.permissions.summary", {
|
||||
allowed: summary.allowed,
|
||||
total: summary.total
|
||||
})}
|
||||
<fieldset class="space-y-2" disabled={readOnly}>
|
||||
<legend class="text-sm font-medium">{i18n.t("settings.permissions.roleHeading")}</legend>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{i18n.t("settings.permissions.roleHint")}
|
||||
</p>
|
||||
{#if !readOnly}
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" data-testid="permissions-allow-all" onclick={allowEverything}>
|
||||
{i18n.t("settings.permissions.allowAll")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" data-testid="permissions-deny-all" onclick={denyEverything}>
|
||||
{i18n.t("settings.permissions.denyAll")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
placeholder={i18n.t("settings.permissions.searchPlaceholder")}
|
||||
aria-label={i18n.t("settings.permissions.searchPlaceholder")}
|
||||
/>
|
||||
|
||||
<div class="max-h-[24rem] space-y-5 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")}
|
||||
<div class="grid gap-2 sm:grid-cols-2" role="radiogroup" aria-label={i18n.t("settings.permissions.roleHeading")}>
|
||||
{#each PERMISSION_ROLES as role (role.id)}
|
||||
{@const selected = activeRole === role.id}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
disabled={readOnly}
|
||||
data-testid={`perm-role-${role.id}`}
|
||||
class="flex items-start gap-2 rounded-md border px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60 {selected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:bg-muted/40'}"
|
||||
onclick={() => applyRole(role.id)}
|
||||
>
|
||||
<span
|
||||
class="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border {selected
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-muted-foreground/40'}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if selected}<Check class="h-3 w-3" />{/if}
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-sm font-medium">{roleLabel(role.id)}</span>
|
||||
<span class="block text-xs text-muted-foreground">{roleDescription(role.id)}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if activeRole === CUSTOM_ROLE_ID}
|
||||
<p class="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground" data-testid="perm-role-custom">
|
||||
<span class="font-medium text-foreground">{roleLabel(CUSTOM_ROLE_ID)}</span>
|
||||
— {roleDescription(CUSTOM_ROLE_ID)}
|
||||
</p>
|
||||
{/if}
|
||||
{#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"
|
||||
>
|
||||
{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}
|
||||
</fieldset>
|
||||
|
||||
<div class="border-t border-border pt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 text-left text-sm font-medium hover:text-primary"
|
||||
aria-expanded={advancedOpen}
|
||||
data-testid="permissions-advanced-toggle"
|
||||
onclick={() => (advancedOpen = !advancedOpen)}
|
||||
>
|
||||
{#if advancedOpen}
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
{:else}
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
{/if}
|
||||
<span>{i18n.t("settings.permissions.advanced")}</span>
|
||||
<span class="ml-auto text-xs font-normal text-muted-foreground" data-testid="permissions-summary">
|
||||
{i18n.t("settings.permissions.summary", {
|
||||
allowed: summary.allowed,
|
||||
total: summary.total
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if advancedOpen}
|
||||
<div class="mt-3 space-y-3">
|
||||
{#if !readOnly}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" data-testid="permissions-allow-all" onclick={allowEverything}>
|
||||
{i18n.t("settings.permissions.allowAll")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" data-testid="permissions-deny-all" onclick={denyEverything}>
|
||||
{i18n.t("settings.permissions.denyAll")}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
placeholder={i18n.t("settings.permissions.searchPlaceholder")}
|
||||
aria-label={i18n.t("settings.permissions.searchPlaceholder")}
|
||||
/>
|
||||
|
||||
<div class="max-h-[20rem] space-y-5 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")}
|
||||
</p>
|
||||
{/if}
|
||||
{#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"
|
||||
>
|
||||
{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}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user