240 lines
7.3 KiB
Svelte
240 lines
7.3 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 {
|
||
|
|
catalogKeys,
|
||
|
|
denyAll,
|
||
|
|
filterCatalog,
|
||
|
|
isDenied,
|
||
|
|
permissionSummary,
|
||
|
|
samePermissions,
|
||
|
|
setPermissionAllowed,
|
||
|
|
type MemberPermissionsView,
|
||
|
|
type PermissionCatalog
|
||
|
|
} 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);
|
||
|
|
|
||
|
|
const allKeys = $derived(catalogKeys(catalog));
|
||
|
|
const summary = $derived(permissionSummary(catalog, denied));
|
||
|
|
const dirty = $derived(!samePermissions(denied, savedDenied));
|
||
|
|
const readOnly = $derived(!canEdit || isOwnerMember);
|
||
|
|
|
||
|
|
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));
|
||
|
|
|
||
|
|
/** 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 = "";
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 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
|
||
|
|
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}
|
||
|
|
|
||
|
|
<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
|
||
|
|
})}
|
||
|
|
</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")}
|
||
|
|
</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>
|
||
|
|
{/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>
|