'use client' /** * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * * Copyright (c) Infonomic Company Limited */ /** * Inline per-role ability editor. Renders below the role-details * header on the role detail page — full-width, no drawer. * * Mode-switched: defaults to **view** (checkboxes disabled, no edit * affordances). Click Edit to switch to **edit** mode — checkboxes * become interactive, per-group "Select all / Clear" buttons appear, * and a Save button materialises once the selection diverges from the * stored set. Cancel reverts the local set and returns to view mode. * * The View toggle is disabled while there are unsaved changes — this * is the explicit-intent equivalent of a confirm dialog and keeps the * UX honest about whether changes have been persisted. * * Vid-less by design (see Phase B notes): abilities live in * `byline_admin_permissions`, not on the role row, so editing them * does not bump the role's `vid`. Last-writer-wins on a per-role basis. * * Stable override handles: see `permissions.module.css`. */ import { useMemo, useState } from 'react' import { useTranslation } from '@byline/i18n/react' import { Alert, Button, Checkbox, LoaderEllipsis } from '@byline/ui/react' import cx from 'clsx' import { useBylineAdminServices } from '../../../services/admin-services-context.js' import styles from './permissions.module.css' import type { AbilityDescriptorResponse, AbilityGroupResponse, ListRegisteredAbilitiesResponse, SetRoleAbilitiesResponse, } from '../../admin-permissions/index.js' import type { AdminRoleResponse } from '../index.js' type Mode = 'view' | 'edit' interface RolePermissionsProps { role: AdminRoleResponse registered: ListRegisteredAbilitiesResponse initialAbilities: string[] onSaved?: (response: SetRoleAbilitiesResponse) => void } function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { if (a.size !== b.size) return false for (const item of a) if (!b.has(item)) return false return true } interface GroupSectionProps { group: AbilityGroupResponse selected: ReadonlySet mode: Mode saving: boolean onToggle: (key: string, checked: boolean) => void onSelectAll: (groupKeys: readonly string[]) => void onClearAll: (groupKeys: readonly string[]) => void } function GroupSection({ group, selected, mode, saving, onToggle, onSelectAll, onClearAll, }: GroupSectionProps) { const { t } = useTranslation('byline-admin') const groupKeys = useMemo(() => group.abilities.map((a) => a.key), [group.abilities]) const selectedInGroup = groupKeys.filter((key) => selected.has(key)).length const isEdit = mode === 'edit' return (
{group.group} {t('adminRoles.permissions.groupCount', { selected: selectedInGroup, total: group.abilities.length, mode, })}
{isEdit ? (
) : null}
{group.abilities.map((ability: AbilityDescriptorResponse) => (
onToggle(ability.key, checked === true)} // Override the uikit Checkbox container's `width: 100%` so it // shrinks to its button width — otherwise the external label // is pushed away by an empty 100%-wide container. containerClasses={cx( 'byline-role-permissions-checkbox-auto', styles['checkbox-auto'] )} componentClasses={cx( 'byline-role-permissions-checkbox-auto', styles['checkbox-auto'] )} />
))}
) } export function RolePermissions({ role, registered, initialAbilities, onSaved, }: RolePermissionsProps) { const { setRoleAbilities } = useBylineAdminServices() const { t } = useTranslation('byline-admin') const [mode, setMode] = useState('view') const [initialSet, setInitialSet] = useState>(() => new Set(initialAbilities)) const [selected, setSelected] = useState>(() => new Set(initialAbilities)) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const isDirty = !setsEqual(selected, initialSet) const totalSelected = selected.size function handleToggle(key: string, checked: boolean): void { if (mode !== 'edit') return setSelected((current) => { const next = new Set(current) if (checked) next.add(key) else next.delete(key) return next }) } function handleSelectAll(groupKeys: readonly string[]): void { setSelected((current) => { const next = new Set(current) for (const key of groupKeys) next.add(key) return next }) } function handleClearAll(groupKeys: readonly string[]): void { setSelected((current) => { const next = new Set(current) for (const key of groupKeys) next.delete(key) return next }) } function handleCancel(): void { setSelected(new Set(initialSet)) setError(null) setMode('view') } function handleEnterEdit(): void { setError(null) setMode('edit') } function handleEnterView(): void { // Disabled while dirty (the toggle's `disabled` prop guards this) — // belt-and-suspenders so a stray click can't slip through. if (isDirty) return setError(null) setMode('view') } async function handleSave(): Promise { if (saving) return setSaving(true) setError(null) try { const response = await setRoleAbilities({ data: { id: role.id, abilities: Array.from(selected) }, }) // Reset baseline to the authoritative stored set — guards against // any dedupe/normalisation the server might apply. const storedSet = new Set(response.abilities) setInitialSet(storedSet) setSelected(new Set(storedSet)) onSaved?.(response) } catch (err) { const code = getErrorCode(err) if (code === 'admin.permissions.roleNotFound') { setError(t('adminRoles.permissions.errors.roleNotFound')) } else if (code === 'admin.permissions.abilityUnregistered') { setError(t('adminRoles.permissions.errors.abilityUnregistered')) } else { setError(t('adminRoles.permissions.errors.fallback')) } } finally { setSaving(false) } } const isEdit = mode === 'edit' return (

{t('adminRoles.permissions.counter', { selected: totalSelected, total: registered.total, mode, role: role.name, })}

{isEdit && isDirty ? (
) : null}
{error ? {error} : null}
{registered.groups.map((group) => ( ))}
) } interface ModeToggleProps { mode: Mode dirty: boolean saving: boolean onView: () => void onEdit: () => void } function ModeToggle({ mode, dirty, saving, onView, onEdit }: ModeToggleProps) { const { t } = useTranslation('byline-admin') // Segmented two-state toggle. View is disabled while dirty so the // user has to commit to Save or Cancel — avoids accidentally // discarding a draft selection. const isView = mode === 'view' const isEdit = mode === 'edit' const viewDisabled = dirty || saving const editDisabled = saving return (
) } function getErrorCode(err: unknown): string | null { return typeof (err as { code?: unknown })?.code === 'string' ? (err as { code: string }).code : null }