Enforce tenant member permissions
This commit is contained in:
@@ -33,13 +33,37 @@ type DeviceSetupProps = {
|
||||
onOpenSettings: () => void;
|
||||
};
|
||||
|
||||
function allowPermission(permissions: string[], permission: string): boolean {
|
||||
if (permissions.includes('*') || permissions.includes(permission)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (permission.includes(':')) {
|
||||
const [prefix] = permission.split(':');
|
||||
return permissions.includes(`${prefix}:*`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function MobileDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const { t, i18n } = useTranslation('management');
|
||||
const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext();
|
||||
const { status } = useAuth();
|
||||
const { status, user } = useAuth();
|
||||
const isMember = user?.role === 'member';
|
||||
const memberPermissions = React.useMemo(() => {
|
||||
if (!isMember) {
|
||||
return ['*'];
|
||||
}
|
||||
return Array.isArray(activeEvent?.member_permissions) ? activeEvent?.member_permissions ?? [] : [];
|
||||
}, [activeEvent?.member_permissions, isMember]);
|
||||
const canManageEvents = React.useMemo(
|
||||
() => allowPermission(memberPermissions, 'events:manage'),
|
||||
[memberPermissions]
|
||||
);
|
||||
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
|
||||
const [fallbackLoading, setFallbackLoading] = React.useState(false);
|
||||
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
|
||||
@@ -150,6 +174,7 @@ export default function MobileDashboardPage() {
|
||||
const hasSummaryPackage =
|
||||
Boolean(activePackage?.id && activePackage.id !== summarySeenPackageId);
|
||||
const shouldRedirectToBilling =
|
||||
!isMember &&
|
||||
!packagesLoading &&
|
||||
!packagesError &&
|
||||
!effectiveHasEvents &&
|
||||
@@ -210,7 +235,7 @@ export default function MobileDashboardPage() {
|
||||
closeTour();
|
||||
navigate(adminPath('/mobile/events/new'));
|
||||
},
|
||||
showAction: true,
|
||||
showAction: canManageEvents,
|
||||
},
|
||||
qr: {
|
||||
key: 'qr',
|
||||
@@ -257,7 +282,7 @@ export default function MobileDashboardPage() {
|
||||
};
|
||||
|
||||
return tourStepKeys.map((key) => stepMap[key]);
|
||||
}, [closeTour, navigate, t, tourStepKeys, tourTargetSlug]);
|
||||
}, [canManageEvents, closeTour, navigate, t, tourStepKeys, tourTargetSlug]);
|
||||
|
||||
const activeTourStep = tourSteps[tourStep] ?? tourSteps[0];
|
||||
const totalTourSteps = tourSteps.length;
|
||||
@@ -356,6 +381,7 @@ export default function MobileDashboardPage() {
|
||||
handleSummaryClose();
|
||||
navigate(adminPath('/mobile/events/new'));
|
||||
}}
|
||||
showContinue={canManageEvents}
|
||||
packageName={activePackage.package_name ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary')}
|
||||
packageType={activePackage.package_type ?? null}
|
||||
remainingEvents={remainingEvents}
|
||||
@@ -462,11 +488,18 @@ export default function MobileDashboardPage() {
|
||||
locale={locale}
|
||||
canSwitch={effectiveMultiple}
|
||||
onSwitch={() => setEventSwitcherOpen(true)}
|
||||
onEdit={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/edit`))}
|
||||
canEdit={canManageEvents}
|
||||
onEdit={
|
||||
canManageEvents
|
||||
? () => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/edit`))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<EventManagementGrid
|
||||
event={activeEvent}
|
||||
tasksEnabled={tasksEnabled}
|
||||
isMember={isMember}
|
||||
permissions={memberPermissions}
|
||||
onNavigate={(path) => navigate(path)}
|
||||
/>
|
||||
<KpiStrip
|
||||
@@ -500,6 +533,7 @@ function PackageSummarySheet({
|
||||
open,
|
||||
onClose,
|
||||
onContinue,
|
||||
showContinue,
|
||||
packageName,
|
||||
packageType,
|
||||
remainingEvents,
|
||||
@@ -512,6 +546,7 @@ function PackageSummarySheet({
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onContinue: () => void;
|
||||
showContinue: boolean;
|
||||
packageName: string;
|
||||
packageType: string | null;
|
||||
remainingEvents: number | null | undefined;
|
||||
@@ -600,7 +635,9 @@ function PackageSummarySheet({
|
||||
</MobileCard>
|
||||
|
||||
<XStack space="$2">
|
||||
<CTAButton label={t('mobileDashboard.packageSummary.continue', 'Continue to event setup')} onPress={onContinue} fullWidth={false} />
|
||||
{showContinue ? (
|
||||
<CTAButton label={t('mobileDashboard.packageSummary.continue', 'Continue to event setup')} onPress={onContinue} fullWidth={false} />
|
||||
) : null}
|
||||
<CTAButton label={t('mobileDashboard.packageSummary.dismiss', 'Close')} tone="ghost" onPress={onClose} fullWidth={false} />
|
||||
</XStack>
|
||||
</YStack>
|
||||
@@ -919,12 +956,19 @@ function OnboardingEmptyState({ installPrompt, pushState, devicePermissions, onO
|
||||
<Text fontSize="$sm" color={text} opacity={0.9}>
|
||||
{t('mobileDashboard.emptyBody', 'Print a QR, collect uploads, and start moderating in minutes.')}
|
||||
</Text>
|
||||
<CTAButton label={t('mobileDashboard.ctaCreate', 'Create event')} onPress={() => navigate(adminPath('/mobile/events/new'))} />
|
||||
<CTAButton
|
||||
label={t('mobileDashboard.ctaWelcome', 'Start welcome journey')}
|
||||
tone="ghost"
|
||||
onPress={() => navigate(ADMIN_WELCOME_BASE_PATH)}
|
||||
/>
|
||||
{canManageEvents ? (
|
||||
<>
|
||||
<CTAButton
|
||||
label={t('mobileDashboard.ctaCreate', 'Create event')}
|
||||
onPress={() => navigate(adminPath('/mobile/events/new'))}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('mobileDashboard.ctaWelcome', 'Start welcome journey')}
|
||||
tone="ghost"
|
||||
onPress={() => navigate(ADMIN_WELCOME_BASE_PATH)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
@@ -1156,13 +1200,15 @@ function EventHeaderCard({
|
||||
locale,
|
||||
canSwitch,
|
||||
onSwitch,
|
||||
canEdit,
|
||||
onEdit,
|
||||
}: {
|
||||
event: TenantEvent | null;
|
||||
locale: string;
|
||||
canSwitch: boolean;
|
||||
onSwitch: () => void;
|
||||
onEdit: () => void;
|
||||
canEdit: boolean;
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, muted, border, surface, accentSoft, primary, shadow } = useAdminTheme();
|
||||
@@ -1222,24 +1268,26 @@ function EventHeaderCard({
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
<Pressable
|
||||
aria-label={t('mobileEvents.edit', 'Edit event')}
|
||||
onPress={onEdit}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: 16,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: accentSoft,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 6px 16px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
>
|
||||
<Pencil size={18} color={primary} />
|
||||
</Pressable>
|
||||
{canEdit && onEdit ? (
|
||||
<Pressable
|
||||
aria-label={t('mobileEvents.edit', 'Edit event')}
|
||||
onPress={onEdit}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: 16,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: accentSoft,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 6px 16px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
>
|
||||
<Pencil size={18} color={primary} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1247,10 +1295,14 @@ function EventHeaderCard({
|
||||
function EventManagementGrid({
|
||||
event,
|
||||
tasksEnabled,
|
||||
isMember,
|
||||
permissions,
|
||||
onNavigate,
|
||||
}: {
|
||||
event: TenantEvent | null;
|
||||
tasksEnabled: boolean;
|
||||
isMember: boolean;
|
||||
permissions: string[];
|
||||
onNavigate: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
@@ -1263,81 +1315,103 @@ function EventManagementGrid({
|
||||
}
|
||||
const tiles = [
|
||||
{
|
||||
key: 'settings',
|
||||
icon: Pencil,
|
||||
label: t('mobileDashboard.shortcutSettings', 'Event settings'),
|
||||
color: ADMIN_ACTION_COLORS.settings,
|
||||
requiredPermission: 'events:manage',
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/edit`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
icon: Sparkles,
|
||||
label: tasksEnabled
|
||||
? t('events.quick.tasks', 'Tasks & Checklists')
|
||||
: `${t('events.quick.tasks', 'Tasks & Checklists')} (${t('common:states.disabled', 'Disabled')})`,
|
||||
color: ADMIN_ACTION_COLORS.tasks,
|
||||
requiredPermission: 'tasks:manage',
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/tasks`)) : undefined,
|
||||
disabled: !tasksEnabled || !slug,
|
||||
},
|
||||
{
|
||||
key: 'qr',
|
||||
icon: QrCode,
|
||||
label: t('events.quick.qr', 'QR Code Layouts'),
|
||||
color: ADMIN_ACTION_COLORS.qr,
|
||||
requiredPermission: 'join-tokens:manage',
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/qr`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
key: 'images',
|
||||
icon: ImageIcon,
|
||||
label: t('events.quick.images', 'Image Management'),
|
||||
color: ADMIN_ACTION_COLORS.images,
|
||||
requiredPermission: 'photos:moderate',
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/control-room`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
key: 'controlRoom',
|
||||
icon: Tv,
|
||||
label: t('events.quick.controlRoom', 'Moderation & Live Show'),
|
||||
color: ADMIN_ACTION_COLORS.liveShow,
|
||||
requiredPermission: 'photos:moderate',
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/control-room`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
key: 'liveShowSettings',
|
||||
icon: Settings,
|
||||
label: t('events.quick.liveShowSettings', 'Live Show settings'),
|
||||
color: ADMIN_ACTION_COLORS.liveShowSettings,
|
||||
requiredPermission: 'live-show:manage',
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show/settings`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
icon: Users,
|
||||
label: t('events.quick.guests', 'Guest Management'),
|
||||
color: ADMIN_ACTION_COLORS.guests,
|
||||
requiredPermission: 'members:manage',
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/members`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
key: 'guestMessages',
|
||||
icon: Megaphone,
|
||||
label: t('events.quick.guestMessages', 'Guest messages'),
|
||||
color: ADMIN_ACTION_COLORS.guestMessages,
|
||||
requiredPermission: 'guest-notifications:manage',
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/guest-notifications`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
key: 'branding',
|
||||
icon: Layout,
|
||||
label: t('events.quick.branding', 'Branding & Theme'),
|
||||
color: ADMIN_ACTION_COLORS.branding,
|
||||
requiredPermission: 'events:manage',
|
||||
onPress: slug && brandingAllowed ? () => onNavigate(adminPath(`/mobile/events/${slug}/branding`)) : undefined,
|
||||
disabled: !brandingAllowed || !slug,
|
||||
},
|
||||
{
|
||||
key: 'photobooth',
|
||||
icon: Camera,
|
||||
label: t('events.quick.photobooth', 'Photobooth'),
|
||||
color: ADMIN_ACTION_COLORS.photobooth,
|
||||
requiredPermission: 'events:manage',
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/photobooth`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
key: 'analytics',
|
||||
icon: TrendingUp,
|
||||
label: t('mobileDashboard.shortcutAnalytics', 'Analytics'),
|
||||
color: ADMIN_ACTION_COLORS.analytics,
|
||||
requiredPermission: 'events:manage',
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/analytics`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
@@ -1345,16 +1419,22 @@ function EventManagementGrid({
|
||||
|
||||
if (event && isPastEvent(event.event_date)) {
|
||||
tiles.push({
|
||||
key: 'recap',
|
||||
icon: Sparkles,
|
||||
label: t('events.quick.recap', 'Recap & Archive'),
|
||||
color: ADMIN_ACTION_COLORS.recap,
|
||||
requiredPermission: 'events:manage',
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/recap`)) : undefined,
|
||||
disabled: !slug,
|
||||
});
|
||||
}
|
||||
|
||||
const visibleTiles = isMember
|
||||
? tiles.filter((tile) => !tile.requiredPermission || allowPermission(permissions, tile.requiredPermission))
|
||||
: tiles;
|
||||
|
||||
const rows: typeof tiles[] = [];
|
||||
tiles.forEach((tile, index) => {
|
||||
visibleTiles.forEach((tile, index) => {
|
||||
const rowIndex = Math.floor(index / 2);
|
||||
if (!rows[rowIndex]) {
|
||||
rows[rowIndex] = [];
|
||||
|
||||
Reference in New Issue
Block a user