diff --git a/app/admin/components/SectionHeader.tsx b/app/admin/components/SectionHeader.tsx index 0f7fe70b..f7832ba0 100644 --- a/app/admin/components/SectionHeader.tsx +++ b/app/admin/components/SectionHeader.tsx @@ -1,6 +1,7 @@ // Copyright 2024 Peter Beverloo & AnimeCon. All rights reserved. // Use of this source code is governed by a MIT license that can be found in the LICENSE file. +import type { BooleanPermission, CRUDPermission } from '@lib/auth/Access'; import type { Privilege } from '@lib/auth/Privileges'; import Stack from '@mui/material/Stack'; import Typography, { type TypographyProps } from '@mui/material/Typography'; @@ -20,10 +21,9 @@ export interface SectionHeaderProps { icon?: React.ReactNode; /** - * Privilege behind which availability of this section is gated, to inform the volunteer that - * not everyone has access to this information. + * The permission behind which this feature has been gated. */ - privilege?: Privilege; + permission?: BooleanPermission | CRUDPermission; /** * Title of this section. Required. @@ -39,6 +39,11 @@ export interface SectionHeaderProps { * The system prop that allows defining system overrides as well as additional CSS styles. */ sx?: TypographyProps['sx'], + + /** + * @todo Remove + */ + privilege?: Privilege, } /** diff --git a/app/admin/events/[event]/[team]/schedule/page.tsx b/app/admin/events/[event]/[team]/schedule/page.tsx index ba3f8683..2ecf42e4 100644 --- a/app/admin/events/[event]/[team]/schedule/page.tsx +++ b/app/admin/events/[event]/[team]/schedule/page.tsx @@ -1,6 +1,7 @@ // Copyright 2024 Peter Beverloo & AnimeCon. All rights reserved. // Use of this source code is governed by a MIT license that can be found in the LICENSE file. +import { notFound } from 'next/navigation'; import { z } from 'zod'; import Alert from '@mui/material/Alert'; @@ -26,7 +27,9 @@ const kExpandSection = z.record(z.string(), z.boolean()); * relies on the timeline component as well as data from many different sources. */ export default async function EventTeamSchedulePage(props: NextPageParams<'event' | 'team'>) { - const { event, team, user } = await verifyAccessAndFetchPageInfo(props.params); + const { access, event, team, user } = await verifyAccessAndFetchPageInfo(props.params); + if (!access.can('event.schedules', 'read', { event: event.slug, team: team.slug })) + notFound(); const userSettings = await readUserSettings(user.userId, [ 'user-admin-schedule-date', @@ -40,7 +43,8 @@ export default async function EventTeamSchedulePage(props: NextPageParams<'event inclusiveShifts: userSettings['user-admin-schedule-inclusive-shifts'] ?? false, }; - const readOnly = !can(user, Privilege.EventScheduleManagement); + const readOnly = + !access.can('event.schedules', 'update', { event: event.slug, team: team.slug }); let sections: z.infer = {}; try { diff --git a/app/admin/events/[event]/[team]/shifts/ShiftTable.tsx b/app/admin/events/[event]/[team]/shifts/ShiftTable.tsx index 95208f02..6300f75c 100644 --- a/app/admin/events/[event]/[team]/shifts/ShiftTable.tsx +++ b/app/admin/events/[event]/[team]/shifts/ShiftTable.tsx @@ -34,9 +34,9 @@ function formatMinutes(minutes: number): string { */ export type ShiftTableProps = EventShiftContext['context'] & { /** - * Whether the shift table should be shown in read only mode. + * Whether shifts displayed in this table can be deleted. */ - readOnly?: boolean; + canDeleteShifts: boolean; }; /** @@ -44,10 +44,10 @@ export type ShiftTableProps = EventShiftContext['context'] & { * displays the shifts that exist for a particular { event, team } pair. */ export function ShiftTable(props: ShiftTableProps) { - const { readOnly, ...context } = props; + const { canDeleteShifts, ...context } = props; const deleteColumn: RemoteDataTableColumn[] = []; - if (!readOnly) { + if (canDeleteShifts) { deleteColumn.push({ field: 'id', headerName: /* no header= */ '', @@ -227,6 +227,6 @@ export function ShiftTable(props: ShiftTableProps) { return ( + enableDelete={canDeleteShifts} pageSize={100} disableFooter /> ); } diff --git a/app/admin/events/[event]/[team]/shifts/[id]/page.tsx b/app/admin/events/[event]/[team]/shifts/[id]/page.tsx index b9e24989..d0a1d8d0 100644 --- a/app/admin/events/[event]/[team]/shifts/[id]/page.tsx +++ b/app/admin/events/[event]/[team]/shifts/[id]/page.tsx @@ -7,7 +7,6 @@ import type { NextPageParams } from '@lib/NextRouterParams'; import type { ShiftDemandTeamInfo } from './ShiftDemandTimeline'; import type { TimelineEvent } from '@beverloo/volunteer-manager-timeline'; import { CollapsableSection } from '@app/admin/components/CollapsableSection'; -import { Privilege, can } from '@lib/auth/Privileges'; import { RegistrationStatus } from '@lib/database/Types'; import { ScheduledShiftsSection } from './ScheduledShiftsSection'; import { Section } from '@app/admin/components/Section'; @@ -67,9 +66,12 @@ function demandToTimelineEvents( * actual volunteers cannot be adjusted; this is something that has to be done on the schedule page. */ export default async function EventTeamShiftPage(props: NextPageParams<'event' | 'team' | 'id'>) { - const { event, team, user } = await verifyAccessAndFetchPageInfo(props.params); + const { access, event, team, user } = await verifyAccessAndFetchPageInfo(props.params); - const readOnly = !can(user, Privilege.EventShiftManagement); + if (!access.can('event.shifts', 'read', { event: event.slug, team: team.slug })) + notFound(); + + const readOnly = !access.can('event.shifts', 'update', { event: event.slug, team: team.slug }); const warnings: any[] = [ ]; const step = await readSetting('schedule-time-step-minutes'); diff --git a/app/admin/events/[event]/[team]/shifts/page.tsx b/app/admin/events/[event]/[team]/shifts/page.tsx index ca3e493f..6efc4fe1 100644 --- a/app/admin/events/[event]/[team]/shifts/page.tsx +++ b/app/admin/events/[event]/[team]/shifts/page.tsx @@ -1,12 +1,13 @@ // Copyright 2024 Peter Beverloo & AnimeCon. All rights reserved. // Use of this source code is governed by a MIT license that can be found in the LICENSE file. +import { notFound } from 'next/navigation'; + import Alert from '@mui/material/Alert'; import Paper from '@mui/material/Paper'; import type { NextPageParams } from '@lib/NextRouterParams'; import { CollapsableSection } from '@app/admin/components/CollapsableSection'; -import { Privilege, can } from '@lib/auth/Privileges'; import { Section } from '@app/admin/components/Section'; import { SectionIntroduction } from '@app/admin/components/SectionIntroduction'; import { ShiftCreateSection } from './ShiftCreateSection'; @@ -21,35 +22,42 @@ import { verifyAccessAndFetchPageInfo } from '@app/admin/events/verifyAccessAndF * end up out of sync. */ export default async function EventTeamShiftsPage(props: NextPageParams<'event' | 'team'>) { - const { event, team, user } = await verifyAccessAndFetchPageInfo(props.params); + const { access, event, team, user } = await verifyAccessAndFetchPageInfo(props.params); - // TODO: Box with warnings regarding the shifts (e.g. out-of-sync entries). + const accessScope = { event: event.slug, team: team.slug }; + + const canCreateShifts = access.can('event.shifts', 'create', accessScope); + const canReadShifts = access.can('event.shifts', 'read', accessScope); + const canUpdateShifts = access.can('event.shifts', 'update', accessScope); + const canDeleteShifts = access.can('event.shifts', 'delete', accessScope); + + if (!canReadShifts) + notFound(); const { activities, categories, locations } = await getShiftMetadata(event.festivalId); - const readOnly = !can(user, Privilege.EventShiftManagement); + // TODO: Box with warnings regarding the shifts (e.g. out-of-sync entries). const warnings: any[] = [ ]; return ( <> - { !!readOnly && + { (!canCreateShifts && !canUpdateShifts && !canDeleteShifts) && Please ask your Staff member to add you to the scheduling team if you would like to be able to make any changes. }
- +
The shifts tool has not been implemented yet. - { !readOnly && -
+ { canCreateShifts && +
+ locations={locations} event={event.slug} team={team.slug} />
} ); diff --git a/app/api/admin/event/schedule/createScheduleEntry.ts b/app/api/admin/event/schedule/createScheduleEntry.ts index 12ecfb02..bb5f0b54 100644 --- a/app/api/admin/event/schedule/createScheduleEntry.ts +++ b/app/api/admin/event/schedule/createScheduleEntry.ts @@ -71,8 +71,13 @@ type Response = ApiResponse; * API that allows leaders to create a new schedule entry. */ export async function createScheduleEntry(request: Request, props: ActionProps): Promise { - if (!props.user || !can(props.user, Privilege.EventScheduleManagement)) + if ( + !props.user || + !props.access.can( + 'event.schedules', 'update', { event: request.event, team: request.team })) + { notFound(); + } const event = await getEventBySlug(request.event); if (!event) diff --git a/app/api/admin/event/schedule/deleteScheduleEntry.ts b/app/api/admin/event/schedule/deleteScheduleEntry.ts index 7e6d0dc8..d64c477b 100644 --- a/app/api/admin/event/schedule/deleteScheduleEntry.ts +++ b/app/api/admin/event/schedule/deleteScheduleEntry.ts @@ -52,8 +52,13 @@ type Response = ApiResponse; * API that allows leaders to delete a schedule entry. */ export async function deleteScheduleEntry(request: Request, props: ActionProps): Promise { - if (!props.user || !can(props.user, Privilege.EventScheduleManagement)) + if ( + !props.user || + !props.access.can( + 'event.schedules', 'update', { event: request.event, team: request.team })) + { notFound(); + } if (request.id.length !== 1) notFound(); // invalid request diff --git a/app/api/admin/event/schedule/updateScheduleEntry.ts b/app/api/admin/event/schedule/updateScheduleEntry.ts index c960091b..bc447241 100644 --- a/app/api/admin/event/schedule/updateScheduleEntry.ts +++ b/app/api/admin/event/schedule/updateScheduleEntry.ts @@ -83,8 +83,13 @@ type Response = ApiResponse; * API that allows leaders to update a schedule entry. */ export async function updateScheduleEntry(request: Request, props: ActionProps): Promise { - if (!props.user || !can(props.user, Privilege.EventScheduleManagement)) + if ( + !props.user || + !props.access.can( + 'event.schedules', 'update', { event: request.event, team: request.team })) + { notFound(); + } if (request.id.length !== 1) notFound(); // invalid request diff --git a/app/api/admin/event/shifts/[[...id]]/route.ts b/app/api/admin/event/shifts/[[...id]]/route.ts index 6476d846..216888fb 100644 --- a/app/api/admin/event/shifts/[[...id]]/route.ts +++ b/app/api/admin/event/shifts/[[...id]]/route.ts @@ -7,7 +7,7 @@ import { z } from 'zod'; import { type DataTableEndpoints, createDataTableApi } from '../../../../createDataTableApi'; import { Log, LogSeverity, LogType } from '@lib/Log'; import { Privilege } from '@lib/auth/Privileges'; -import { executeAccessCheck } from '@lib/auth/AuthenticationContext'; +import { executeAccessCheck, type PermissionAccessCheck } from '@lib/auth/AuthenticationContext'; import { getShiftsForEvent } from '@app/admin/lib/getShiftsForEvent'; import { validateContext } from '../../validateContext'; import db, { tActivities, tSchedule, tShifts, tShiftsCategories } from '@lib/database'; @@ -145,16 +145,25 @@ export type EventShiftContext = z.infer; export const { DELETE, GET, POST, PUT } = createDataTableApi(kEventShiftRowModel, kEventShiftContext, { async accessCheck({ context }, action, props) { - let privilege: Privilege | undefined; + const permission: PermissionAccessCheck = { + permission: 'event.shifts', + operation: 'read', // to be updated + options: { + event: context.event, + team: context.team, + }, + }; + switch (action) { case 'get': case 'list': - break; // no additional privilege necessary + permission.operation = 'read'; + break; case 'create': case 'delete': case 'update': - privilege = Privilege.EventShiftManagement; + permission.operation = action; break; default: @@ -164,7 +173,7 @@ createDataTableApi(kEventShiftRowModel, kEventShiftContext, { executeAccessCheck(props.authenticationContext, { check: 'admin-event', event: context.event, - privilege, + permission, }); }, diff --git a/app/lib/auth/Access.ts b/app/lib/auth/Access.ts index 2be2f02e..b3cc4193 100644 --- a/app/lib/auth/Access.ts +++ b/app/lib/auth/Access.ts @@ -67,6 +67,29 @@ export const kPermissions = { type: 'boolean', }, + 'event.schedules': { + name: 'Event schedule planning', + description: + 'This permission decides whether they have the ability to read or update event ' + + 'scheduling, which is specific to a given event and team. The schedules are the ' + + 'rosters shared with volunteers, telling them when they have to be where, doing what.', + hide: [ 'create', 'delete'], // schedules are either in read-only more, or fully mutable + requireEvent: true, + requireTeam: true, + type: 'crud', + }, + + 'event.shifts': { + name: 'Event shift planning', + description: + 'This permission decides whether they have the ability to see or manage the shifts ' + + 'that are planned for a particular event and team. The shifts decide what it is that ' + + 'we expect volunteers to do.', + requireEvent: true, + requireTeam: true, + type: 'crud', + }, + 'event.retention': { name: 'Retention management', description: @@ -338,11 +361,12 @@ export const kPermissionGroups: Record = { ], staff: [ - 'event.applications:read', - 'event.applications:update', + 'event.applications', 'event.help-requests', 'event.requests', 'event.retention', + 'event.schedules', + 'event.shifts', 'event.vendors', 'event.visible', 'volunteer.avatars', @@ -350,6 +374,8 @@ export const kPermissionGroups: Record = { senior: [ 'event.applications:read', + 'event.schedules:read', + 'event.shifts:read', 'event.vendors:read', 'event.visible', 'volunteer.avatars', diff --git a/app/lib/auth/Privileges.ts b/app/lib/auth/Privileges.ts index a94cc014..c44ec0db 100644 --- a/app/lib/auth/Privileges.ts +++ b/app/lib/auth/Privileges.ts @@ -22,9 +22,7 @@ export enum Privilege { EventApplicationOverride = 1 << 3, EventContentOverride = 1 << 2, EventHotelManagement = 1 << 12, - EventScheduleManagement = 1 << 29, EventScheduleOverride = 1 << 4, - EventShiftManagement = 1 << 26, EventTrainingManagement = 1 << 13, EventVolunteerApplicationOverrides = 1 << 14, @@ -65,9 +63,7 @@ const PrivilegeExpansion: { [key in Privilege]?: Privilege[] } = { Privilege.EventApplicationOverride, Privilege.EventContentOverride, Privilege.EventHotelManagement, - Privilege.EventScheduleManagement, Privilege.EventScheduleOverride, - Privilege.EventShiftManagement, Privilege.EventTrainingManagement, Privilege.EventVolunteerApplicationOverrides, ], @@ -106,15 +102,12 @@ export function expand(privileges: Privileges): Privileges { export const PrivilegeGroups: { [key in Privilege]: string } = { [Privilege.Administrator]: 'Special access', [Privilege.Refunds]: 'Special access', - //[Privilege.Statistics]: 'Special access', [Privilege.EventAdministrator]: 'Special access', [Privilege.EventApplicationOverride]: 'Event access', [Privilege.EventContentOverride]: 'Event access', [Privilege.EventHotelManagement]: 'Event access', - [Privilege.EventScheduleManagement]: 'Event access', [Privilege.EventScheduleOverride]: 'Event access', - [Privilege.EventShiftManagement]: 'Event access', [Privilege.EventTrainingManagement]: 'Event access', [Privilege.EventVolunteerApplicationOverrides]: 'Event access', @@ -130,15 +123,12 @@ export const PrivilegeGroups: { [key in Privilege]: string } = { export const PrivilegeNames: { [key in Privilege]: string } = { [Privilege.Administrator]: 'Administrator', [Privilege.Refunds]: 'Refund requests', - //[Privilege.Statistics]: 'Statistics', [Privilege.EventAdministrator]: 'Event administrator', [Privilege.EventApplicationOverride]: 'Always accept their applications', [Privilege.EventContentOverride]: 'Always allow access to event content', [Privilege.EventHotelManagement]: 'Manage hotel rooms', - [Privilege.EventScheduleManagement]: 'Manage schedules', [Privilege.EventScheduleOverride]: 'Always allow access to the volunteer portal', - [Privilege.EventShiftManagement]: 'Manage shifts', [Privilege.EventTrainingManagement]: 'Manage trainings', [Privilege.EventVolunteerApplicationOverrides]: 'Manage application overrides',