feat(permissions): granular permission gating + role editor & presets (#747, phase 1 of #743) (#1045)
* feat(permissions): granular permission gating + role editor & presets Make every admin feature permission-gateable so multi-user studios can split capability across roles (#747, and phase 1 of #743). - Split the catch-all settings.edit into dedicated dangerous-config perms (banking / domains / security / integrations / features): a team member can no longer change IBAN, domains, SSO, webhooks, API tokens or feature flags. Reads keep an OR with settings.view so existing roles keep visibility. The site-URL write inside /general is change-gated on settings.domains. - Add dedicated perms for admin surfaces miscategorised under settings.* (whatsapp, event_types, image_security, notifications, system) plus roles.manage and vat_codes.view; gate the previously-ungated VAT read. - Boot self-heal (_permissionsBoot.js): super_admin always holds every permission (tracks-all) so new perms never need a compensation migration; all other roles stay frozen (no silent escalation on upgrade). - Seed two presets: Solo Photographer (full operator) and Team Photographer (contributor — view events + manage photos + read-only CRM; no settings/users/billing edits, no events.edit). - Role editor: adminRoles CRUD (create/edit/clone/delete + permission matrix; system roles protected, super_admin immutable) and a Roles tab with a category-grouped matrix and preset cloning. - Settings page tabs are permission-gated with snap-back; i18n en/de. Migration 174. Backward-compatible: admin/editor/viewer unchanged. * feat(permissions): hide in-page action buttons a role can't use Wrap mutating controls on the surfaces restricted roles actually reach (Events list, Archives, gallery photo grid, event detail) in PermissionGate so they are HIDDEN when the user lacks the permission, rather than shown-then-403: - Events list: create / bulk archive / bulk delete / row archive / row delete / download-archive. - Archives: restore / download / delete. - Photo grid: single + bulk delete (photos.delete), per-photo download (photos.download), bulk move/hide/show (photos.edit). - Event detail: edit / rename / publish (events.edit), duplicate (events.create), archive (events.archive), create-invoice (bills.manage); the Actions card is hidden entirely for view-only roles. - Photos tab: upload / external import (photos.upload), export menu (photos.download). Backend already enforces these with 403; this is the matching UX so a Team Photographer never sees delete/settings controls. * fix(permissions): close settings-split bypass via generic settings writers Security review found the settings.edit split was bypassable: the generic settings writers (/general, /analytics, /seo, /security) upsert arbitrary setting_keys, so a role holding only settings.edit (or settings.security) could write keys owned by a narrower permission — repointing the public site URL (settings.domains), security policy (settings.security) or VAT/accounting config (settings.banking) via the wrong endpoint. Add stripUnauthorizedProtectedKeys(): before every generic upsert, drop any protected key the caller isn't permitted to write (general_site_url → settings.domains, security_* → settings.security, accounting_* → settings.banking). Dedicated routes still work because their caller holds the matching perm. Replaces the narrower in-handler site-URL guard. Also fix two tests affected by the RBAC changes: - authzPermissionGaps: API-token management moved to settings.integrations, so grant that (not settings.edit) to exercise the ownership 404. - AdminPhotoGrid.viewToggle: stub PermissionGate (its buttons are now gated and the test renders without a PermissionsProvider). * fix(permissions): address upstream review (#1045) - Renumber migration 174 -> 175 (174 now taken by 174_sqlite_nullable_event_dates from #1035; the collision made picpeakImportService's forward-only restore guard treat both as order 174 and accept a newer .picpeak onto an older schema). - Contain the roles.manage blast radius (delegation, not root escalation): a non-super_admin can no longer edit their own role, nor grant any permission their own role doesn't already hold (createRole + updateRole). - Protected-key denial now 403s (naming the keys + required perms) instead of silently stripping and reporting "saved" (adminSettings generic writers). - Reserve team_photographer so a custom role can't squat the preset name. - Boot self-heal: per-step try/catch so a role_permissions insert race on one replica doesn't skip preset seeding. - Forward-project the feature .manage perms that also replaced settings.edit gates (whatsapp/event_types/image_security/notifications/system), matching the settings.* split projection so the pattern is symmetric for phase-2. - Guard exports.down's roles/admin_users queries with hasTable. * fix(permissions): change-detection on protected-key 403 + commit guard tests (#1045) Round-2 review: - The protected-key 403 fired on key PRESENCE. The General tab re-posts general_site_url on every save, so a settings.edit-only role (the office manager this PR enables) got 403'd on every General save even when the URL was unchanged. Restore change-detection: compare the incoming value against the stored one and 403 only on an actual change; unchanged protected keys are dropped so the rest of the save proceeds. Only /general is affected. - Commit the self-amplification guard test (was run locally, never staged): adminRolesGuards.test.js — non-super can't grant perms it lacks, can't edit its own role, can't escalate another role; super_admin bypasses; team_photographer name reserved. - Add adminSettingsProtectedKeys.test.js pinning the change-detection: an unchanged general_site_url saves, an actual change 403s, super_admin changes it.
This commit is contained in:
@@ -10,6 +10,7 @@ import { uploadsService } from '../../services/uploads.service';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { getPhotoViewMode, setPhotoViewMode, type PhotoViewMode } from '../../utils/photoViewPrefs';
|
||||
import { Button } from '../common';
|
||||
import { PermissionGate } from './PermissionGate';
|
||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
import { BulkCategoryModal } from './BulkCategoryModal';
|
||||
|
||||
@@ -209,50 +210,54 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('gallery.photosSelected', { count: selectedPhotos.size })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsCategoryModalOpen(true)}
|
||||
leftIcon={<FolderOpen className="w-4 h-4" />}
|
||||
>
|
||||
{t('photos.moveToCategory', 'Move to Category')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await photosService.bulkUpdatePhotos(eventId, Array.from(selectedPhotos), { visibility: 'hidden' });
|
||||
toast.success(t('admin.photos.hiddenSuccess', 'Photos hidden'));
|
||||
onPhotosDeleted();
|
||||
} catch { toast.error(t('common.error')); }
|
||||
}}
|
||||
leftIcon={<EyeOff className="w-4 h-4" />}
|
||||
>
|
||||
{t('admin.photos.hideSelected', 'Hide')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await photosService.bulkUpdatePhotos(eventId, Array.from(selectedPhotos), { visibility: 'visible' });
|
||||
toast.success(t('admin.photos.visibleSuccess', 'Photos visible'));
|
||||
onPhotosDeleted();
|
||||
} catch { toast.error(t('common.error')); }
|
||||
}}
|
||||
leftIcon={<Eye className="w-4 h-4" />}
|
||||
>
|
||||
{t('admin.photos.showSelected', 'Show')}
|
||||
</Button>
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={isDeleting}
|
||||
className="px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('gallery.deleteSelected', 'Delete Selected')}
|
||||
</button>
|
||||
<PermissionGate permission="photos.edit">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsCategoryModalOpen(true)}
|
||||
leftIcon={<FolderOpen className="w-4 h-4" />}
|
||||
>
|
||||
{t('photos.moveToCategory', 'Move to Category')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await photosService.bulkUpdatePhotos(eventId, Array.from(selectedPhotos), { visibility: 'hidden' });
|
||||
toast.success(t('admin.photos.hiddenSuccess', 'Photos hidden'));
|
||||
onPhotosDeleted();
|
||||
} catch { toast.error(t('common.error')); }
|
||||
}}
|
||||
leftIcon={<EyeOff className="w-4 h-4" />}
|
||||
>
|
||||
{t('admin.photos.hideSelected', 'Hide')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await photosService.bulkUpdatePhotos(eventId, Array.from(selectedPhotos), { visibility: 'visible' });
|
||||
toast.success(t('admin.photos.visibleSuccess', 'Photos visible'));
|
||||
onPhotosDeleted();
|
||||
} catch { toast.error(t('common.error')); }
|
||||
}}
|
||||
leftIcon={<Eye className="w-4 h-4" />}
|
||||
>
|
||||
{t('admin.photos.showSelected', 'Show')}
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
<PermissionGate permission="photos.delete">
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={isDeleting}
|
||||
className="px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('gallery.deleteSelected', 'Delete Selected')}
|
||||
</button>
|
||||
</PermissionGate>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -420,19 +425,23 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
|
||||
{!isSelectionMode && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={(e) => handleDownload(photo, e)}
|
||||
className="p-1 text-white hover:bg-white/20 rounded"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleDeleteSingle(photo, e)}
|
||||
className="p-1 text-white hover:bg-white/20 rounded disabled:opacity-50"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
<PermissionGate permission="photos.download">
|
||||
<button
|
||||
onClick={(e) => handleDownload(photo, e)}
|
||||
className="p-1 text-white hover:bg-white/20 rounded"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
</button>
|
||||
</PermissionGate>
|
||||
<PermissionGate permission="photos.delete">
|
||||
<button
|
||||
onClick={(e) => handleDeleteSingle(photo, e)}
|
||||
className="p-1 text-white hover:bg-white/20 rounded disabled:opacity-50"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -666,21 +675,25 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
||||
{!isSelectionMode && (
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => handleDownload(photo, e)}
|
||||
className="p-1.5 text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-600 rounded"
|
||||
title={t('common.download', 'Download')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleDeleteSingle(photo, e)}
|
||||
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/30 rounded disabled:opacity-50"
|
||||
disabled={isRowDeleting}
|
||||
title={t('common.delete', 'Delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<PermissionGate permission="photos.download">
|
||||
<button
|
||||
onClick={(e) => handleDownload(photo, e)}
|
||||
className="p-1.5 text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-600 rounded"
|
||||
title={t('common.download', 'Download')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</PermissionGate>
|
||||
<PermissionGate permission="photos.delete">
|
||||
<button
|
||||
onClick={(e) => handleDeleteSingle(photo, e)}
|
||||
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/30 rounded disabled:opacity-50"
|
||||
disabled={isRowDeleting}
|
||||
title={t('common.delete', 'Delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Shield, Lock, Save } from 'lucide-react';
|
||||
|
||||
import { Button, Input, Card } from '../common';
|
||||
import type { PermissionDef, RoleWithPermissions } from '../../services/roles.service';
|
||||
|
||||
export interface RoleEditorSave {
|
||||
name?: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
interface RoleEditorModalProps {
|
||||
isOpen: boolean;
|
||||
mode: 'create' | 'edit';
|
||||
role: RoleWithPermissions | null; // null for create
|
||||
catalog: PermissionDef[];
|
||||
isLoading: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (payload: RoleEditorSave) => void;
|
||||
}
|
||||
|
||||
// Friendly labels for permission categories (fallback: the raw category name).
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
events: 'Events',
|
||||
photos: 'Photos',
|
||||
archives: 'Archives',
|
||||
analytics: 'Analytics',
|
||||
email: 'Email',
|
||||
branding: 'Branding',
|
||||
cms: 'CMS Pages',
|
||||
settings: 'Settings & Config',
|
||||
backup: 'Backup & Restore',
|
||||
users: 'Users & Roles',
|
||||
activity: 'Activity Logs',
|
||||
customers: 'Customers',
|
||||
quotes: 'Quotes',
|
||||
billing: 'Invoices',
|
||||
contracts: 'Contracts',
|
||||
accounting: 'Accounting',
|
||||
workflows: 'Workflows',
|
||||
whatsapp: 'WhatsApp',
|
||||
system: 'System',
|
||||
};
|
||||
|
||||
function categoryLabel(cat: string): string {
|
||||
return CATEGORY_LABELS[cat] || cat.charAt(0).toUpperCase() + cat.slice(1);
|
||||
}
|
||||
|
||||
export const RoleEditorModal: React.FC<RoleEditorModalProps> = ({
|
||||
isOpen,
|
||||
mode,
|
||||
role,
|
||||
catalog,
|
||||
isLoading,
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isSuperAdmin = role?.name === 'super_admin';
|
||||
const readOnly = isSuperAdmin; // super_admin's permission set is immutable
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [nameError, setNameError] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setName(mode === 'edit' ? (role?.name ?? '') : '');
|
||||
setDisplayName(role?.displayName ?? '');
|
||||
setDescription(role?.description ?? '');
|
||||
setSelected(new Set(role?.permissions ?? []));
|
||||
setNameError(undefined);
|
||||
}, [isOpen, mode, role]);
|
||||
|
||||
// Group the catalog by category, preserving a stable order.
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, PermissionDef[]>();
|
||||
for (const p of catalog) {
|
||||
if (!map.has(p.category)) map.set(p.category, []);
|
||||
map.get(p.category)!.push(p);
|
||||
}
|
||||
return Array.from(map.entries()).sort((a, b) => categoryLabel(a[0]).localeCompare(categoryLabel(b[0])));
|
||||
}, [catalog]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const togglePerm = (permName: string) => {
|
||||
if (readOnly) return;
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(permName)) next.delete(permName);
|
||||
else next.add(permName);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCategory = (perms: PermissionDef[], allSelected: boolean) => {
|
||||
if (readOnly) return;
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const p of perms) {
|
||||
if (allSelected) next.delete(p.name);
|
||||
else next.add(p.name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (mode === 'create') {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
if (!/^[a-z][a-z0-9_]{1,48}$/.test(normalized)) {
|
||||
setNameError(t('roleEditor.nameError', 'Use lowercase letters, numbers and underscores (2–49 chars, starting with a letter).'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
onSave({
|
||||
name: mode === 'create' ? name.trim().toLowerCase() : undefined,
|
||||
displayName: displayName.trim() || name.trim(),
|
||||
description: description.trim(),
|
||||
permissions: Array.from(selected),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<Card className="w-full max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<div className="p-6 flex-1 overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-accent" />
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{mode === 'create'
|
||||
? t('roleEditor.createTitle', 'Create role')
|
||||
: t('roleEditor.editTitle', 'Edit role: {{name}}', { name: role?.displayName })}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{readOnly && (
|
||||
<div className="mb-4 flex items-start gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800">
|
||||
<Lock className="w-4 h-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
{t('roleEditor.superAdminLocked', 'Super Admin always holds every permission and cannot be edited. It automatically gains new permissions as features are added.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Identity fields */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('roleEditor.displayName', 'Display name')}
|
||||
</label>
|
||||
<Input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder={t('roleEditor.displayNamePlaceholder', 'e.g. Photographer')}
|
||||
disabled={isLoading || readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('roleEditor.key', 'Key (identifier)')}
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setNameError(undefined); }}
|
||||
placeholder="photographer"
|
||||
disabled={isLoading || mode === 'edit'}
|
||||
/>
|
||||
{nameError && <p className="mt-1 text-sm text-red-600">{nameError}</p>}
|
||||
{mode === 'edit' && (
|
||||
<p className="mt-1 text-xs text-neutral-400 dark:text-neutral-500">
|
||||
{t('roleEditor.keyLocked', 'The key is fixed once a role is created.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('roleEditor.description', 'Description')}
|
||||
</label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t('roleEditor.descriptionPlaceholder', 'What this role is for')}
|
||||
disabled={isLoading || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Permission matrix */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
{t('roleEditor.permissions', 'Permissions')}
|
||||
</h3>
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('roleEditor.selectedCount', '{{count}} selected', { count: selected.size })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{grouped.map(([category, perms]) => {
|
||||
const selectedInCat = perms.filter((p) => selected.has(p.name)).length;
|
||||
const allSelected = selectedInCat === perms.length;
|
||||
return (
|
||||
<div key={category} className="border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-200">{categoryLabel(category)}</span>
|
||||
<span className="text-xs text-neutral-400 dark:text-neutral-500">{selectedInCat}/{perms.length}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCategory(perms, allSelected)}
|
||||
disabled={readOnly}
|
||||
className="text-xs font-medium text-accent hover:underline disabled:opacity-40 disabled:no-underline"
|
||||
>
|
||||
{allSelected ? t('roleEditor.clearAll', 'Clear all') : t('roleEditor.selectAll', 'Select all')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4">
|
||||
{perms.map((p) => {
|
||||
const checked = selected.has(p.name);
|
||||
return (
|
||||
<label
|
||||
key={p.name}
|
||||
className={`flex items-start gap-2 px-3 py-2 border-t border-neutral-100 dark:border-neutral-700/60 ${readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-700/40'}`}
|
||||
title={p.description || undefined}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => togglePerm(p.name)}
|
||||
disabled={readOnly}
|
||||
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-accent"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm text-neutral-800 dark:text-neutral-200">{p.display_name}</span>
|
||||
<span className="block text-[11px] text-neutral-400 dark:text-neutral-500 font-mono truncate">{p.name}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 p-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
isLoading={isLoading}
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
{mode === 'create' ? t('roleEditor.create', 'Create role') : t('common.save', 'Save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RoleEditorModal.displayName = 'RoleEditorModal';
|
||||
@@ -0,0 +1,229 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Shield, Plus, Edit, Copy, Trash2, Lock, Users as UsersIcon, AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { Button, Card, Loading } from '../common';
|
||||
import { useMutationWithToast } from '../../hooks';
|
||||
import { rolesService, type RoleWithPermissions } from '../../services/roles.service';
|
||||
import { RoleEditorModal, type RoleEditorSave } from './RoleEditorModal';
|
||||
|
||||
const getRoleBadgeColor = (roleName: string): string => {
|
||||
switch (roleName?.toLowerCase()) {
|
||||
case 'super_admin':
|
||||
return 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800';
|
||||
case 'admin':
|
||||
case 'solo_photographer':
|
||||
return 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800';
|
||||
case 'editor':
|
||||
return 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800';
|
||||
default:
|
||||
return 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 border-neutral-200 dark:border-neutral-600';
|
||||
}
|
||||
};
|
||||
|
||||
export const RoleManagementTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [editor, setEditor] = useState<{ mode: 'create' | 'edit'; role: RoleWithPermissions | null } | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RoleWithPermissions | null>(null);
|
||||
|
||||
const { data: roles, isLoading: rolesLoading } = useQuery({
|
||||
queryKey: ['admin-roles-full'],
|
||||
queryFn: rolesService.getRoles,
|
||||
});
|
||||
const { data: catalog, isLoading: catalogLoading } = useQuery({
|
||||
queryKey: ['admin-permission-catalog'],
|
||||
queryFn: rolesService.getPermissionCatalog,
|
||||
});
|
||||
|
||||
const invalidate: string[][] = [['admin-roles-full'], ['admin-roles']];
|
||||
|
||||
const createMutation = useMutationWithToast({
|
||||
mutationFn: (payload: RoleEditorSave) =>
|
||||
rolesService.createRole({
|
||||
name: payload.name!,
|
||||
displayName: payload.displayName,
|
||||
description: payload.description,
|
||||
permissions: payload.permissions,
|
||||
}),
|
||||
invalidateKeys: invalidate,
|
||||
successMessage: t('roleEditor.created', 'Role created'),
|
||||
errorMessage: (e: Error) => e.message || t('roleEditor.saveError', 'Failed to save role'),
|
||||
onSuccess: () => setEditor(null),
|
||||
});
|
||||
|
||||
const updateMutation = useMutationWithToast({
|
||||
mutationFn: ({ id, payload }: { id: number; payload: RoleEditorSave }) =>
|
||||
rolesService.updateRole(id, {
|
||||
displayName: payload.displayName,
|
||||
description: payload.description,
|
||||
permissions: payload.permissions,
|
||||
}),
|
||||
invalidateKeys: invalidate,
|
||||
successMessage: t('roleEditor.updated', 'Role updated'),
|
||||
errorMessage: (e: Error) => e.message || t('roleEditor.saveError', 'Failed to save role'),
|
||||
onSuccess: () => setEditor(null),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutationWithToast({
|
||||
mutationFn: (id: number) => rolesService.deleteRole(id),
|
||||
invalidateKeys: invalidate,
|
||||
successMessage: t('roleEditor.deleted', 'Role deleted'),
|
||||
errorMessage: (e: Error) => e.message || t('roleEditor.deleteError', 'Failed to delete role'),
|
||||
onSuccess: () => setDeleteTarget(null),
|
||||
});
|
||||
|
||||
const handleSave = (payload: RoleEditorSave) => {
|
||||
if (editor?.mode === 'edit' && editor.role) {
|
||||
updateMutation.mutate({ id: editor.role.id, payload });
|
||||
} else {
|
||||
createMutation.mutate(payload);
|
||||
}
|
||||
};
|
||||
|
||||
if (rolesLoading || catalogLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[300px]">
|
||||
<Loading size="lg" text={t('roleEditor.loading', 'Loading roles…')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('roleEditor.subtitle', 'Define what each role can do. Start from a preset by cloning it, then trim or extend the permissions.')}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Plus className="w-4 h-4" />}
|
||||
onClick={() => setEditor({ mode: 'create', role: null })}
|
||||
>
|
||||
{t('roleEditor.newRole', 'New role')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{(roles || []).map((role) => {
|
||||
const isSuperAdmin = role.name === 'super_admin';
|
||||
return (
|
||||
<Card key={role.id} padding="sm" className="flex flex-col">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium border ${getRoleBadgeColor(role.name)}`}>
|
||||
<Shield className="w-3 h-3" />
|
||||
{role.displayName}
|
||||
</span>
|
||||
{role.isSystem && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||
<Lock className="w-3 h-3" />
|
||||
{t('roleEditor.systemRole', 'System')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-mono text-neutral-400 dark:text-neutral-500">{role.name}</p>
|
||||
{role.description && (
|
||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400 line-clamp-2">{role.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-3 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<span>{t('roleEditor.permCount', '{{count}} permissions', { count: role.permissions.length })}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<UsersIcon className="w-3.5 h-3.5" />
|
||||
{t('roleEditor.userCount', '{{count}} users', { count: role.userCount })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-neutral-100 dark:border-neutral-700">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Edit className="w-3.5 h-3.5" />}
|
||||
onClick={() => setEditor({ mode: 'edit', role })}
|
||||
>
|
||||
{isSuperAdmin ? t('roleEditor.view', 'View') : t('common.edit', 'Edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Copy className="w-3.5 h-3.5" />}
|
||||
onClick={() => setEditor({ mode: 'create', role: { ...role, displayName: `${role.displayName} copy` } })}
|
||||
>
|
||||
{t('roleEditor.clone', 'Clone')}
|
||||
</Button>
|
||||
{!role.isSystem && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Trash2 className="w-3.5 h-3.5" />}
|
||||
onClick={() => setDeleteTarget(role)}
|
||||
className="text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30"
|
||||
>
|
||||
{t('common.delete', 'Delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{editor && (
|
||||
<RoleEditorModal
|
||||
isOpen
|
||||
mode={editor.mode}
|
||||
role={editor.role}
|
||||
catalog={catalog || []}
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
onClose={() => setEditor(null)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="p-2 rounded-full bg-red-100 dark:bg-red-900/40">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('roleEditor.confirmDelete.title', 'Delete role?')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
{deleteTarget.userCount > 0
|
||||
? t('roleEditor.confirmDelete.hasUsers', 'Reassign the {{count}} user(s) holding "{{name}}" before deleting it.', { count: deleteTarget.userCount, name: deleteTarget.displayName })
|
||||
: t('roleEditor.confirmDelete.message', 'Permanently delete the "{{name}}" role? This cannot be undone.', { name: deleteTarget.displayName })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleteMutation.isPending}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => deleteMutation.mutate(deleteTarget.id)}
|
||||
isLoading={deleteMutation.isPending}
|
||||
disabled={deleteTarget.userCount > 0}
|
||||
className="bg-red-600 hover:bg-red-700 focus:ring-red-500"
|
||||
>
|
||||
{t('common.delete', 'Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RoleManagementTab.displayName = 'RoleManagementTab';
|
||||
@@ -10,7 +10,7 @@ import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
|
||||
import { AdminPhotoGrid } from '../AdminPhotoGrid';
|
||||
import type { AdminPhoto } from '../../../services/photos.service';
|
||||
@@ -39,6 +39,13 @@ vi.mock('../../../services/photos.service', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
// AdminPhotoGrid now wraps its action buttons in PermissionGate (which needs a
|
||||
// PermissionsProvider). This test is about the layout toggle, not gating, so
|
||||
// stub the gate to a passthrough that always renders its children.
|
||||
vi.mock('../PermissionGate', () => ({
|
||||
PermissionGate: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
}));
|
||||
|
||||
const renderWithQueryClient = (ui: ReactElement) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } }
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"cancel": "Abbrechen",
|
||||
"tabs": {
|
||||
"users": "Benutzer",
|
||||
"invitations": "Einladungen"
|
||||
"invitations": "Einladungen",
|
||||
"roles": "Rollen"
|
||||
},
|
||||
"stats": {
|
||||
"totalUsers": "Benutzer gesamt",
|
||||
@@ -5980,5 +5981,40 @@
|
||||
"rebills": "offene Weiterverrechnungen",
|
||||
"hoursOnly": "Nur die Stunden",
|
||||
"rebillsOnly": "Nur die Weiterverrechnungen"
|
||||
},
|
||||
"roleEditor": {
|
||||
"loading": "Rollen werden geladen…",
|
||||
"subtitle": "Lege fest, was jede Rolle darf. Beginne mit einer Vorlage, indem du sie duplizierst, und passe die Berechtigungen an.",
|
||||
"newRole": "Neue Rolle",
|
||||
"systemRole": "System",
|
||||
"permCount": "{{count}} Berechtigungen",
|
||||
"userCount": "{{count}} Benutzer",
|
||||
"view": "Ansehen",
|
||||
"clone": "Duplizieren",
|
||||
"create": "Rolle erstellen",
|
||||
"createTitle": "Rolle erstellen",
|
||||
"editTitle": "Rolle bearbeiten: {{name}}",
|
||||
"superAdminLocked": "Super Admin besitzt immer alle Berechtigungen und kann nicht bearbeitet werden. Neue Berechtigungen werden automatisch übernommen.",
|
||||
"displayName": "Anzeigename",
|
||||
"displayNamePlaceholder": "z. B. Fotograf",
|
||||
"key": "Schlüssel (Kennung)",
|
||||
"keyLocked": "Der Schlüssel ist nach dem Erstellen fest.",
|
||||
"description": "Beschreibung",
|
||||
"descriptionPlaceholder": "Wofür diese Rolle gedacht ist",
|
||||
"permissions": "Berechtigungen",
|
||||
"selectedCount": "{{count}} ausgewählt",
|
||||
"selectAll": "Alle auswählen",
|
||||
"clearAll": "Alle abwählen",
|
||||
"nameError": "Nur Kleinbuchstaben, Zahlen und Unterstriche (2–49 Zeichen, beginnend mit einem Buchstaben).",
|
||||
"created": "Rolle erstellt",
|
||||
"updated": "Rolle aktualisiert",
|
||||
"deleted": "Rolle gelöscht",
|
||||
"saveError": "Rolle konnte nicht gespeichert werden",
|
||||
"deleteError": "Rolle konnte nicht gelöscht werden",
|
||||
"confirmDelete": {
|
||||
"title": "Rolle löschen?",
|
||||
"message": "Die Rolle \"{{name}}\" endgültig löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"hasUsers": "Weise die {{count}} Benutzer der Rolle \"{{name}}\" neu zu, bevor du sie löschst."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"cancel": "Cancel",
|
||||
"tabs": {
|
||||
"users": "Users",
|
||||
"invitations": "Invitations"
|
||||
"invitations": "Invitations",
|
||||
"roles": "Roles"
|
||||
},
|
||||
"stats": {
|
||||
"totalUsers": "Total Users",
|
||||
@@ -5978,5 +5979,40 @@
|
||||
"rebills": "open re-bills",
|
||||
"hoursOnly": "Just the hours",
|
||||
"rebillsOnly": "Just the re-bills"
|
||||
},
|
||||
"roleEditor": {
|
||||
"loading": "Loading roles…",
|
||||
"subtitle": "Define what each role can do. Start from a preset by cloning it, then trim or extend the permissions.",
|
||||
"newRole": "New role",
|
||||
"systemRole": "System",
|
||||
"permCount": "{{count}} permissions",
|
||||
"userCount": "{{count}} users",
|
||||
"view": "View",
|
||||
"clone": "Clone",
|
||||
"create": "Create role",
|
||||
"createTitle": "Create role",
|
||||
"editTitle": "Edit role: {{name}}",
|
||||
"superAdminLocked": "Super Admin always holds every permission and cannot be edited. It automatically gains new permissions as features are added.",
|
||||
"displayName": "Display name",
|
||||
"displayNamePlaceholder": "e.g. Photographer",
|
||||
"key": "Key (identifier)",
|
||||
"keyLocked": "The key is fixed once a role is created.",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "What this role is for",
|
||||
"permissions": "Permissions",
|
||||
"selectedCount": "{{count}} selected",
|
||||
"selectAll": "Select all",
|
||||
"clearAll": "Clear all",
|
||||
"nameError": "Use lowercase letters, numbers and underscores (2–49 chars, starting with a letter).",
|
||||
"created": "Role created",
|
||||
"updated": "Role updated",
|
||||
"deleted": "Role deleted",
|
||||
"saveError": "Failed to save role",
|
||||
"deleteError": "Failed to delete role",
|
||||
"confirmDelete": {
|
||||
"title": "Delete role?",
|
||||
"message": "Permanently delete the \"{{name}}\" role? This cannot be undone.",
|
||||
"hasUsers": "Reassign the {{count}} user(s) holding \"{{name}}\" before deleting it."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { format, parseISO, isValid } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { PermissionGate } from '../../components/admin/PermissionGate';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { archiveService } from '../../services/archive.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -302,34 +303,40 @@ export const ArchivesPage: React.FC = () => {
|
||||
Details
|
||||
</Button>
|
||||
*/}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(archive)}
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
disabled={!archive.archivePath}
|
||||
>
|
||||
{t('archives.download')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRestore(archive)}
|
||||
leftIcon={<RotateCcw className="w-4 h-4" />}
|
||||
disabled={restoreMutation.isPending}
|
||||
>
|
||||
{t('archives.restore')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(archive)}
|
||||
leftIcon={<Trash2 className="w-4 h-4" />}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{t('archives.delete')}
|
||||
</Button>
|
||||
<PermissionGate permission="archives.download">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(archive)}
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
disabled={!archive.archivePath}
|
||||
>
|
||||
{t('archives.download')}
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
<PermissionGate permission="archives.restore">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRestore(archive)}
|
||||
leftIcon={<RotateCcw className="w-4 h-4" />}
|
||||
disabled={restoreMutation.isPending}
|
||||
>
|
||||
{t('archives.restore')}
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
<PermissionGate permission="archives.delete">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(archive)}
|
||||
leftIcon={<Trash2 className="w-4 h-4" />}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{t('archives.delete')}
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common';
|
||||
import { BulkArchiveModal, BulkDeleteModal } from '../../components/admin';
|
||||
import { PermissionGate } from '../../components/admin/PermissionGate';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService, type EventStatusFilter } from '../../services/events.service';
|
||||
import { adminService } from '../../services/admin.service';
|
||||
@@ -314,13 +315,15 @@ export const EventsListPage: React.FC = () => {
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('events.title')}</h1>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('events.subtitle')}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Plus className="w-5 h-5" />}
|
||||
onClick={() => navigate('/admin/events/new')}
|
||||
>
|
||||
{t('events.createEvent')}
|
||||
</Button>
|
||||
<PermissionGate permission="events.create">
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Plus className="w-5 h-5" />}
|
||||
onClick={() => navigate('/admin/events/new')}
|
||||
>
|
||||
{t('events.createEvent')}
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
|
||||
{/* Statistics Cards — fed from /admin/dashboard/stats so the totals
|
||||
@@ -442,21 +445,25 @@ export const EventsListPage: React.FC = () => {
|
||||
<Button variant="outline" size="sm" onClick={() => setSelectedEvents([])}>
|
||||
{t('events.clear')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => bulkArchiveModal.open()}
|
||||
>
|
||||
{t('events.archiveSelected')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => bulkDeleteModal.open()}
|
||||
className="border-red-300 text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/30"
|
||||
>
|
||||
{t('events.deleteSelected', 'Delete Selected')}
|
||||
</Button>
|
||||
<PermissionGate permission="events.archive">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => bulkArchiveModal.open()}
|
||||
>
|
||||
{t('events.archiveSelected')}
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
<PermissionGate permission="events.delete">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => bulkDeleteModal.open()}
|
||||
className="border-red-300 text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/30"
|
||||
>
|
||||
{t('events.deleteSelected', 'Delete Selected')}
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -665,44 +672,50 @@ export const EventsListPage: React.FC = () => {
|
||||
</button>
|
||||
) : null}
|
||||
{!event.is_archived ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
archiveMutation.mutate(event.id);
|
||||
setActiveDropdown(null);
|
||||
setDropdownPosition(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
{t('events.archiveEventAction')}
|
||||
</button>
|
||||
<PermissionGate permission="events.archive">
|
||||
<button
|
||||
onClick={() => {
|
||||
archiveMutation.mutate(event.id);
|
||||
setActiveDropdown(null);
|
||||
setDropdownPosition(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
{t('events.archiveEventAction')}
|
||||
</button>
|
||||
</PermissionGate>
|
||||
) : null}
|
||||
{event.is_archived ? (
|
||||
<PermissionGate permission="archives.download">
|
||||
<button
|
||||
onClick={() => {
|
||||
toast.info(t('events.downloadArchiveSoon'));
|
||||
setActiveDropdown(null);
|
||||
setDropdownPosition(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t('events.downloadArchiveAction')}
|
||||
</button>
|
||||
</PermissionGate>
|
||||
) : null}
|
||||
<PermissionGate permission="events.delete">
|
||||
<button
|
||||
onClick={() => {
|
||||
toast.info(t('events.downloadArchiveSoon'));
|
||||
setActiveDropdown(null);
|
||||
setDropdownPosition(null);
|
||||
if (confirm(t('events.deleteEventConfirm'))) {
|
||||
deleteMutation.mutate(event.id);
|
||||
setActiveDropdown(null);
|
||||
setDropdownPosition(null);
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 flex items-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t('events.downloadArchiveAction')}
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('events.deleteEvent')}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(t('events.deleteEventConfirm'))) {
|
||||
deleteMutation.mutate(event.id);
|
||||
setActiveDropdown(null);
|
||||
setDropdownPosition(null);
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('events.deleteEvent')}
|
||||
</button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -58,6 +58,7 @@ import { CrmSettingsPage } from './settings/CrmSettingsPage';
|
||||
import { ReminderTemplatesPage } from './settings/ReminderTemplatesPage';
|
||||
import { BlockLibraryPage } from './contracts/BlockLibraryPage';
|
||||
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
||||
import { usePermissions } from '../../contexts/PermissionsContext';
|
||||
import { Briefcase, Receipt, ScrollText, Landmark, Smartphone, MonitorPlay } from 'lucide-react';
|
||||
|
||||
// Tab keys driving the inner-nav. Must include every key used in
|
||||
@@ -120,10 +121,56 @@ function isValidTab(value: string | null): value is TabType {
|
||||
return value !== null && (ALL_TAB_KEYS as string[]).includes(value);
|
||||
}
|
||||
|
||||
// Per-tab permission gating (multi-photographer permission project). Each tab is
|
||||
// shown when the user holds ANY of the listed permissions. `settings.view` is in
|
||||
// every set as the baseline "can read settings" grant, so admin/super_admin (who
|
||||
// hold it) keep seeing every tab — no regression. A specialised role WITHOUT
|
||||
// settings.view (e.g. a bookkeeper granted only settings.banking) reaches
|
||||
// Settings via the broadened sidebar gate and sees only the tabs whose specific
|
||||
// permission it holds. Backend routes enforce the same perms regardless of UI.
|
||||
const TAB_PERMISSIONS: Record<TabType, string[]> = {
|
||||
features: ['settings.view', 'settings.features'],
|
||||
general: ['settings.view', 'settings.domains'],
|
||||
events: ['settings.view'],
|
||||
eventTypes: ['settings.view', 'event_types.view', 'event_types.manage'],
|
||||
branding: ['settings.view', 'branding.view', 'branding.edit'],
|
||||
categories: ['settings.view'],
|
||||
thumbnails: ['settings.view'],
|
||||
downloads: ['settings.view'],
|
||||
styling: ['settings.view', 'branding.edit'],
|
||||
cms: ['settings.view', 'cms.view', 'cms.edit'],
|
||||
email: ['settings.view', 'email.view', 'email.edit'],
|
||||
moderation: ['settings.view'],
|
||||
security: ['settings.view', 'settings.security'],
|
||||
sso: ['settings.view', 'settings.security'],
|
||||
imageSecurity: ['settings.view', 'image_security.view', 'image_security.manage'],
|
||||
seo: ['settings.view'],
|
||||
apiTokens: ['settings.view', 'settings.integrations'],
|
||||
webhooks: ['settings.view', 'settings.integrations'],
|
||||
status: ['settings.view', 'system.view', 'system.manage'],
|
||||
analytics: ['settings.view', 'analytics.view'],
|
||||
backup: ['settings.view', 'backup.view'],
|
||||
businessProfile: ['settings.view', 'settings.banking'],
|
||||
crm: ['settings.view'],
|
||||
contracts: ['settings.view', 'contracts.view', 'contracts.manage'],
|
||||
reminderTemplates: ['settings.view', 'email.view', 'email.edit'],
|
||||
accounting: ['settings.view', 'settings.banking', 'accounting.view', 'accounting.manage'],
|
||||
whatsapp: ['settings.view', 'whatsapp.view', 'whatsapp.manage'],
|
||||
slideshow: ['settings.view'],
|
||||
};
|
||||
|
||||
// The union of every settings-tab permission — used to decide whether to show
|
||||
// the Settings entry in the sidebar for a specialised role that lacks the
|
||||
// general settings.view read but holds one specific config permission.
|
||||
export const SETTINGS_TAB_PERMISSIONS: string[] = Array.from(
|
||||
new Set(Object.values(TAB_PERMISSIONS).flat())
|
||||
);
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { flags, isLoading: flagsLoading } = useFeatureFlags();
|
||||
const { hasAnyPermission } = usePermissions();
|
||||
|
||||
// Read ?tab=… on mount; default to Features per the redesign.
|
||||
const initialTab: TabType = isValidTab(searchParams.get('tab'))
|
||||
@@ -219,6 +266,28 @@ export const SettingsPage: React.FC = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [flagsLoading, flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, flags.accounting, flags.whatsapp, flags.slideshow, activeTab]);
|
||||
|
||||
// Permission snap-back: if the active tab isn't permitted for this role (e.g.
|
||||
// a deep-linked ?tab=security a photographer can't access), move to the first
|
||||
// tab that is both permitted and not feature-flag-gated-off. Sits above the
|
||||
// isLoading early return to keep hook ordering stable.
|
||||
useEffect(() => {
|
||||
if (flagsLoading) return;
|
||||
if (hasAnyPermission(TAB_PERMISSIONS[activeTab] ?? ['settings.view'])) return;
|
||||
const flagOff: Partial<Record<TabType, boolean>> = {
|
||||
crm: !(flags.quotes || flags.bills || flags.contracts),
|
||||
contracts: !flags.contracts,
|
||||
reminderTemplates: !flags.reminderEmails,
|
||||
accounting: !flags.accounting,
|
||||
whatsapp: !flags.whatsapp,
|
||||
slideshow: !flags.slideshow,
|
||||
};
|
||||
const firstVisible = ALL_TAB_KEYS.find(
|
||||
(k) => !flagOff[k] && hasAnyPermission(TAB_PERMISSIONS[k] ?? ['settings.view'])
|
||||
);
|
||||
if (firstVisible && firstVisible !== activeTab) setActiveTab(firstVisible);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [flagsLoading, activeTab, flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, flags.accounting, flags.whatsapp, flags.slideshow]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -314,7 +383,14 @@ export const SettingsPage: React.FC = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const allItems = navGroups.flatMap((g) => g.items);
|
||||
// Permission-filter each group's items, then drop groups left empty. A tab is
|
||||
// shown when the user holds any of its TAB_PERMISSIONS (super_admin bypasses
|
||||
// in the context). See TAB_PERMISSIONS above.
|
||||
const visibleGroups = navGroups
|
||||
.map((g) => ({ ...g, items: g.items.filter((i) => hasAnyPermission(TAB_PERMISSIONS[i.key] ?? ['settings.view'])) }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
|
||||
const allItems = visibleGroups.flatMap((g) => g.items);
|
||||
const activeItem = allItems.find((i) => i.key === activeTab) ?? allItems[0];
|
||||
// (Visibility snap-back is handled in the useEffect above, which sits
|
||||
// before the isLoading early return to keep hook ordering stable.)
|
||||
@@ -345,7 +421,7 @@ export const SettingsPage: React.FC = () => {
|
||||
onChange={(e) => setActiveTab(e.target.value as TabType)}
|
||||
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm font-medium text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
{navGroups.map((group) => (
|
||||
{visibleGroups.map((group) => (
|
||||
<optgroup key={group.label} label={group.label}>
|
||||
{group.items.map((item) => (
|
||||
<option key={item.key} value={item.key}>
|
||||
@@ -363,7 +439,7 @@ export const SettingsPage: React.FC = () => {
|
||||
aria-label={t('settings.navAriaLabel', 'Settings navigation')}
|
||||
className="sticky top-6 space-y-6"
|
||||
>
|
||||
{navGroups.map((group) => (
|
||||
{visibleGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<h3 className="px-3 mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
|
||||
{group.label}
|
||||
|
||||
@@ -23,8 +23,10 @@ import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { userManagementService } from '../../services/userManagement.service';
|
||||
import type { AdminUser, AdminRole, AdminInvitation } from '../../types';
|
||||
import { useLocalizedDate, useModal, useMutationWithToast } from "../../hooks";
|
||||
import { usePermissions } from '../../contexts/PermissionsContext';
|
||||
import { RoleManagementTab } from '../../components/admin/RoleManagementTab';
|
||||
|
||||
type TabType = 'users' | 'invitations';
|
||||
type TabType = 'users' | 'invitations' | 'roles';
|
||||
|
||||
// Role badge colors
|
||||
const getRoleBadgeColor = (roleName: string): string => {
|
||||
@@ -369,6 +371,8 @@ const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
export const UserManagementPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { formatDistanceToNow } = useLocalizedDate()
|
||||
const { hasAnyPermission } = usePermissions();
|
||||
const canManageRoles = hasAnyPermission(['roles.manage', 'users.view']);
|
||||
|
||||
// State
|
||||
const [activeTab, setActiveTab] = useState<TabType>('users');
|
||||
@@ -612,6 +616,9 @@ export const UserManagementPage: React.FC = () => {
|
||||
label: t('userManagement.tabs.invitations'),
|
||||
count: invitations?.length || 0,
|
||||
},
|
||||
...(canManageRoles
|
||||
? [{ key: 'roles' as TabType, label: t('userManagement.tabs.roles', 'Roles'), count: roles?.length || 0 }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -721,23 +728,25 @@ export const UserManagementPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<Card padding="sm" className="mb-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={
|
||||
activeTab === 'users'
|
||||
? t('userManagement.searchUsersPlaceholder')
|
||||
: t('userManagement.searchInvitationsPlaceholder')
|
||||
}
|
||||
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
{activeTab !== 'roles' && (
|
||||
<Card padding="sm" className="mb-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={
|
||||
activeTab === 'users'
|
||||
? t('userManagement.searchUsersPlaceholder')
|
||||
: t('userManagement.searchInvitationsPlaceholder')
|
||||
}
|
||||
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Users Tab Content */}
|
||||
{activeTab === 'users' && (
|
||||
@@ -967,6 +976,9 @@ export const UserManagementPage: React.FC = () => {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Roles Tab Content */}
|
||||
{activeTab === 'roles' && canManageRoles && <RoleManagementTab />}
|
||||
|
||||
{/* Create Invitation Modal */}
|
||||
<CreateInvitationModal
|
||||
isOpen={createInvitationModal.isOpen}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Archive, Send, Copy } from 'lucide-react';
|
||||
import type { Event } from '../../../types';
|
||||
import { Button, Card } from '../../../components/common';
|
||||
import { PermissionGate } from '../../../components/admin/PermissionGate';
|
||||
|
||||
interface EventActionsCardProps {
|
||||
event: Event;
|
||||
@@ -31,7 +32,7 @@ export const EventActionsCard: React.FC<EventActionsCardProps> = ({
|
||||
|
||||
<div className="space-y-3">
|
||||
{event.is_draft ? (
|
||||
<>
|
||||
<PermissionGate permission="events.edit">
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Send className="w-4 h-4" />}
|
||||
@@ -44,9 +45,9 @@ export const EventActionsCard: React.FC<EventActionsCardProps> = ({
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center">
|
||||
{t('events.draftBanner')}
|
||||
</p>
|
||||
</>
|
||||
</PermissionGate>
|
||||
) : (
|
||||
<>
|
||||
<PermissionGate permission="events.archive">
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<Archive className="w-4 h-4" />}
|
||||
@@ -63,19 +64,21 @@ export const EventActionsCard: React.FC<EventActionsCardProps> = ({
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center">
|
||||
{t('events.archivingInfo')}
|
||||
</p>
|
||||
</>
|
||||
</PermissionGate>
|
||||
)}
|
||||
{/* Duplicate (#626) — visible in both draft and live mode.
|
||||
Creates a new draft inheriting this gallery's config. */}
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<Copy className="w-4 h-4" />}
|
||||
onClick={() => setShowDuplicateDialog(true)}
|
||||
isLoading={isDuplicating}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{t('events.duplicateEvent', 'Duplicate gallery')}
|
||||
</Button>
|
||||
<PermissionGate permission="events.create">
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<Copy className="w-4 h-4" />}
|
||||
onClick={() => setShowDuplicateDialog(true)}
|
||||
isLoading={isDuplicating}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{t('events.duplicateEvent', 'Duplicate gallery')}
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { Event } from '../../../types';
|
||||
import { Button, Card } from '../../../components/common';
|
||||
import { PermissionGate } from '../../../components/admin/PermissionGate';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
||||
import { buildShareLinkUrl } from '../../../utils/url';
|
||||
@@ -137,22 +138,24 @@ export const EventDetailsHeader: React.FC<EventDetailsHeaderProps> = ({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Edit2 className="w-4 h-4" />}
|
||||
onClick={handleStartEdit}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Type className="w-4 h-4" />}
|
||||
onClick={() => setShowRenameDialog(true)}
|
||||
>
|
||||
{t('events.rename.button', 'Rename')}
|
||||
</Button>
|
||||
<PermissionGate permission="events.edit">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Edit2 className="w-4 h-4" />}
|
||||
onClick={handleStartEdit}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Type className="w-4 h-4" />}
|
||||
onClick={() => setShowRenameDialog(true)}
|
||||
>
|
||||
{t('events.rename.button', 'Rename')}
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
{feedbackSettings?.feedback_enabled && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -167,21 +170,23 @@ export const EventDetailsHeader: React.FC<EventDetailsHeaderProps> = ({
|
||||
bill editor with the event snapshot + (when exactly
|
||||
one is linked) the customer. Gated on the bills flag. */}
|
||||
{flags.bills && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Receipt className="w-4 h-4" />}
|
||||
onClick={() => {
|
||||
const accts = ((event as { customer_accounts?: Array<{ id: number }> }).customer_accounts) || [];
|
||||
const params = new URLSearchParams({ eventId: String(event.id) });
|
||||
if (event.event_name) params.set('eventName', event.event_name);
|
||||
if (event.event_date) params.set('eventDate', String(event.event_date).slice(0, 10));
|
||||
if (accts.length === 1) params.set('customerAccountId', String(accts[0].id));
|
||||
navigate(`/admin/clients/bills/new?${params.toString()}`);
|
||||
}}
|
||||
>
|
||||
{t('events.createInvoice', 'Create invoice')}
|
||||
</Button>
|
||||
<PermissionGate permission="bills.manage">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Receipt className="w-4 h-4" />}
|
||||
onClick={() => {
|
||||
const accts = ((event as { customer_accounts?: Array<{ id: number }> }).customer_accounts) || [];
|
||||
const params = new URLSearchParams({ eventId: String(event.id) });
|
||||
if (event.event_name) params.set('eventName', event.event_name);
|
||||
if (event.event_date) params.set('eventDate', String(event.event_date).slice(0, 10));
|
||||
if (accts.length === 1) params.set('customerAccountId', String(accts[0].id));
|
||||
navigate(`/admin/clients/bills/new?${params.toString()}`);
|
||||
}}
|
||||
>
|
||||
{t('events.createInvoice', 'Create invoice')}
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -222,15 +227,17 @@ export const EventDetailsHeader: React.FC<EventDetailsHeaderProps> = ({
|
||||
{t('events.draftBanner')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Send className="w-4 h-4" />}
|
||||
onClick={() => setShowPublishDialog(true)}
|
||||
isLoading={isPublishing}
|
||||
>
|
||||
{t('events.publishAndNotify')}
|
||||
</Button>
|
||||
<PermissionGate permission="events.edit">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Send className="w-4 h-4" />}
|
||||
onClick={() => setShowPublishDialog(true)}
|
||||
isLoading={isPublishing}
|
||||
>
|
||||
{t('events.publishAndNotify')}
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import type { Event } from '../../../types';
|
||||
import { FeedbackModerationPanel } from '../../../components/admin';
|
||||
import { PermissionGate } from '../../../components/admin/PermissionGate';
|
||||
import { EventReminderOverrideCard } from '../../../components/admin/EventReminderOverrideCard';
|
||||
import { SlideshowSettingsCard } from '../../../components/admin/SlideshowSettingsCard';
|
||||
import { DownloadResolutionCard } from '../../../components/admin/DownloadResolutionCard';
|
||||
@@ -158,15 +159,17 @@ export const OverviewTab: React.FC<OverviewTabProps> = ({
|
||||
|
||||
{/* Actions */}
|
||||
{!event.is_archived && (
|
||||
<EventActionsCard
|
||||
event={event}
|
||||
onArchive={onArchive}
|
||||
isArchiving={isArchiving}
|
||||
setShowPublishDialog={setShowPublishDialog}
|
||||
isPublishing={isPublishing}
|
||||
setShowDuplicateDialog={setShowDuplicateDialog}
|
||||
isDuplicating={isDuplicating}
|
||||
/>
|
||||
<PermissionGate permissions={['events.edit', 'events.archive', 'events.create']}>
|
||||
<EventActionsCard
|
||||
event={event}
|
||||
onArchive={onArchive}
|
||||
isArchiving={isArchiving}
|
||||
setShowPublishDialog={setShowPublishDialog}
|
||||
isPublishing={isPublishing}
|
||||
setShowDuplicateDialog={setShowDuplicateDialog}
|
||||
isDuplicating={isDuplicating}
|
||||
/>
|
||||
</PermissionGate>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Upload, X } from 'lucide-react';
|
||||
import type { Event } from '../../../types';
|
||||
import { Button, Card, Loading } from '../../../components/common';
|
||||
import { AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PhotoUploadModal, PhotoFilterPanel, PhotoExportMenu } from '../../../components/admin';
|
||||
import { PermissionGate } from '../../../components/admin/PermissionGate';
|
||||
import { externalMediaService } from '../../../services/externalMedia.service';
|
||||
import { AdminPhoto, type PhotoFilters as PhotoFilterParams, type FeedbackFilters, type FilterSummary } from '../../../services/photos.service';
|
||||
import { ExternalFolderPicker } from './ExternalFolderPicker';
|
||||
@@ -93,29 +94,35 @@ export const PhotosTab: React.FC<PhotosTabProps> = ({
|
||||
{/* Actions Bar */}
|
||||
<div className="mb-4 flex flex-wrap justify-between items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
onClick={() => setShowPhotoUpload(true)}
|
||||
>
|
||||
{t('events.uploadPhotos')}
|
||||
</Button>
|
||||
{event.source_mode === 'reference' && (
|
||||
<PermissionGate permission="photos.upload">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setShowExternalImport(true)}
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
onClick={() => setShowPhotoUpload(true)}
|
||||
>
|
||||
{t('events.importExternal', 'Import from External Folder')}
|
||||
{t('events.uploadPhotos')}
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
{event.source_mode === 'reference' && (
|
||||
<PermissionGate permission="photos.upload">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowExternalImport(true)}
|
||||
>
|
||||
{t('events.importExternal', 'Import from External Folder')}
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
)}
|
||||
</div>
|
||||
<PhotoExportMenu
|
||||
eventId={parseInt(id!)}
|
||||
selectedPhotoIds={selectedPhotoIds}
|
||||
filters={feedbackFilters}
|
||||
/>
|
||||
<PermissionGate permission="photos.download">
|
||||
<PhotoExportMenu
|
||||
eventId={parseInt(id!)}
|
||||
selectedPhotoIds={selectedPhotoIds}
|
||||
filters={feedbackFilters}
|
||||
/>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
|
||||
{/* Photo Grid */}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
/**
|
||||
* Role-editor API client (backend: adminRoles.js). Lets an owner create custom
|
||||
* roles, edit any role's permission set, clone a preset, and delete custom
|
||||
* roles. Every mutation is gated by `roles.manage` on the backend.
|
||||
*/
|
||||
|
||||
export interface PermissionDef {
|
||||
id: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
category: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface RoleWithPermissions {
|
||||
id: number;
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string | null;
|
||||
isSystem: boolean;
|
||||
priority?: number;
|
||||
userCount: number;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
interface RolesResponse { roles: RoleWithPermissions[] }
|
||||
interface RoleResponse { role: RoleWithPermissions }
|
||||
interface PermissionsResponse { permissions: PermissionDef[] }
|
||||
|
||||
export interface CreateRolePayload {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
description?: string | null;
|
||||
priority?: number;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateRolePayload {
|
||||
displayName?: string;
|
||||
description?: string | null;
|
||||
priority?: number;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export const rolesService = {
|
||||
async getRoles(): Promise<RoleWithPermissions[]> {
|
||||
const res = await api.get<RolesResponse>('/admin/roles');
|
||||
return res.data.roles;
|
||||
},
|
||||
|
||||
async getPermissionCatalog(): Promise<PermissionDef[]> {
|
||||
const res = await api.get<PermissionsResponse>('/admin/roles/permissions');
|
||||
return res.data.permissions;
|
||||
},
|
||||
|
||||
async createRole(payload: CreateRolePayload): Promise<RoleWithPermissions> {
|
||||
const res = await api.post<RoleResponse>('/admin/roles', payload);
|
||||
return res.data.role;
|
||||
},
|
||||
|
||||
async updateRole(id: number, payload: UpdateRolePayload): Promise<RoleWithPermissions> {
|
||||
const res = await api.put<RoleResponse>(`/admin/roles/${id}`, payload);
|
||||
return res.data.role;
|
||||
},
|
||||
|
||||
async cloneRole(id: number, payload: { name: string; displayName?: string; description?: string | null }): Promise<RoleWithPermissions> {
|
||||
const res = await api.post<RoleResponse>(`/admin/roles/${id}/clone`, payload);
|
||||
return res.data.role;
|
||||
},
|
||||
|
||||
async deleteRole(id: number): Promise<void> {
|
||||
await api.delete(`/admin/roles/${id}`);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user