Files
descrybe/apps/web/src/lib/components/settings/MemberPermissionsDialog.svelte
T
2026-08-24 04:31:49 +02:00

408 lines
14 KiB
Svelte

<script lang="ts">
import { api } from "$lib/api";
import { i18n } from "$lib/i18n";
import { notifyApiError } from "$lib/notify";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
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,
sectionPermissionTree,
setPermissionAllowed,
type MemberPermissionsView,
type PermissionCatalog,
type PermissionCatalogEntry
} from "$lib/member-permissions";
let {
open = $bindable(false),
member,
canEdit = false,
onSaved = undefined
}: {
open?: boolean;
/** The teammate being edited — null while the dialog is closed. */
member: { user_id: string; email: string; is_owner?: boolean } | null;
/** Only the company owner may change permissions; others get a read-only view. */
canEdit?: boolean;
onSaved?: (view: MemberPermissionsView) => void;
} = $props();
let catalog = $state<PermissionCatalog | null>(null);
let denied = $state<string[]>([]);
let savedDenied = $state<string[]>([]);
let loading = $state(false);
let saving = $state(false);
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);
/** 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));
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}`);
// Untranslated keys fall back to the raw key rather than an empty cell.
return translated === `plan.feature.${key}` ? key : translated;
}
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(() => {
if (!open || !member) return;
const userId = member.user_id;
const ac = new AbortController();
loading = true;
loadError = null;
search = "";
advancedOpen = false;
fineTuneOpen = {};
void (async () => {
try {
const [catalogPayload, view] = await Promise.all([
api<PermissionCatalog>("/api/team/permission-catalog", { signal: ac.signal }),
api<MemberPermissionsView>(`/api/team/${userId}/permissions`, { signal: ac.signal })
]);
catalog = catalogPayload;
denied = [...(view.denied ?? [])];
savedDenied = [...(view.denied ?? [])];
isOwnerMember = Boolean(view.is_owner);
} catch (err) {
if ((err as { name?: string })?.name === "AbortError") return;
loadError = i18n.t("settings.permissions.loadFailed");
} finally {
loading = false;
}
})();
return () => ac.abort();
});
function toggle(key: string, nextAllowed: boolean) {
if (readOnly) return;
denied = setPermissionAllowed(denied, key, nextAllowed, allKeys);
}
function allowEverything() {
if (readOnly) return;
denied = [];
}
function denyEverything() {
if (readOnly) return;
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;
try {
const view = await api<MemberPermissionsView>(`/api/team/${member.user_id}/permissions`, {
method: "PUT",
body: { denied }
});
savedDenied = [...(view.denied ?? [])];
denied = [...(view.denied ?? [])];
// The editor may have just restricted themselves out of a section elsewhere in
// this tab — refetch so the sidebar and route guard agree with the server.
await planCapabilities.refresh(undefined, true);
onSaved?.(view);
open = false;
} catch (err) {
notifyApiError(err, i18n.t("settings.permissions.saveFailed"));
} finally {
saving = false;
}
}
</script>
<Dialog
bind:open
title={i18n.t("settings.permissions.title", { email: member?.email ?? "" })}
description={i18n.t("settings.permissions.description")}
class="sm:max-w-2xl sm:min-w-0"
>
<div class="space-y-4 py-2">
{#if loading}
<div class="flex justify-center py-10">
<Spinner />
</div>
{:else if loadError}
<Alert tone="error" message={loadError} />
{:else}
{#if isOwnerMember}
<Alert tone="info" message={i18n.t("settings.permissions.ownerNotice")} />
{:else if !canEdit}
<Alert tone="info" message={i18n.t("settings.permissions.ownerOnly")} />
{/if}
<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>
<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>
&mdash; {roleDescription(CUSTOM_ROLE_ID)}
</p>
{/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">
<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}>
{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}
<Input
type="search"
bind:value={search}
placeholder={i18n.t("settings.permissions.searchPlaceholder")}
aria-label={i18n.t("settings.permissions.searchPlaceholder")}
/>
<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")}
</p>
{/if}
{#each visibleSections as section (section.id)}
<section class="space-y-1">
<h3
class="sticky top-0 z-10 bg-background py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{sectionLabel(section.id)}
</h3>
{#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>
</div>
{/if}
</div>
{/if}
</div>
{#snippet footer()}
<Button variant="outline" onclick={() => (open = false)}>
{readOnly ? i18n.t("common.close") : i18n.t("common.cancel")}
</Button>
{#if !readOnly}
<Button
data-testid="permissions-save"
disabled={saving || loading || !dirty}
loading={saving}
onclick={save}
>
{i18n.t("settings.permissions.save")}
</Button>
{/if}
{/snippet}
</Dialog>