diff --git a/config-demo/company.json b/config-demo/company.json index a34d61dd..18050956 100644 --- a/config-demo/company.json +++ b/config-demo/company.json @@ -203,13 +203,5 @@ } ] } - ], - "departments": [ - "Engineering", - "Operations", - "Finance", - "Legal", - "Security", - "Marketing" ] } diff --git a/config-demo/modules.json b/config-demo/modules.json index fbe88b5c..2ac1b89f 100644 --- a/config-demo/modules.json +++ b/config-demo/modules.json @@ -18,11 +18,6 @@ "label": "Birthday", "required": false }, - "department": { - "label": "Department", - "placeholder": "Select a department", - "required": true - }, "team": { "label": "Team", "placeholder": "Team name", diff --git a/docs/configuration.md b/docs/configuration.md index 7d86f29b..25c830cd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -19,11 +19,6 @@ e.g "label": "Birthday", "required": false }, - "department": { - "label": "Department", - "placeholder": "Select a department", - "required": true, - }, "team": { "label": "Team", "placeholder": "Team name", diff --git a/scripts/client.build.ts b/scripts/client.build.ts index c2c1356b..5f9d266f 100644 --- a/scripts/client.build.ts +++ b/scripts/client.build.ts @@ -57,12 +57,6 @@ function getBuildConfig(): BuildOptions { ]) ) ), - 'process.env.DIVISIONS': JSON.stringify( - appConfig.config.company.divisions - ), - 'process.env.DEPARTMENTS': JSON.stringify( - appConfig.config.company.departments - ), 'process.env.LAYOUT': JSON.stringify(appConfig.config.application.layout), 'process.env.APP_NAME': JSON.stringify(appConfig.config.application.name), 'process.env.COMPANY_NAME': JSON.stringify(appConfig.config.company.name), @@ -71,10 +65,13 @@ function getBuildConfig(): BuildOptions { 'process.env.AUTH_MESSAGE_TO_SIGN': JSON.stringify( config.authMessageToSign ), - 'process.env.ROLES': JSON.stringify( - appConfig.config.permissions.roles.map((x) => ({ - ...fp.pick(['id', 'name', 'accessByDefault'])(x), - lowPriority: x.id === appConfig.lowPriorityRole, + 'process.env.ROLE_GROUPS': JSON.stringify( + appConfig.config.permissions.roleGroups.map((x) => ({ + ...x, + roles: x.roles.map((r) => ({ + ...fp.pick(['id', 'name', 'accessByDefault'])(r), + lowPriority: r.id === appConfig.lowPriorityRole, + })), })) ), }, diff --git a/src/client/components/AdminHome.tsx b/src/client/components/AdminHome.tsx index 5d0f8036..800b00e8 100644 --- a/src/client/components/AdminHome.tsx +++ b/src/client/components/AdminHome.tsx @@ -2,28 +2,28 @@ import * as React from 'react' import { useStore } from '@nanostores/react' import * as stores from '#client/stores' import config from '#client/config' -import { prop, propEq } from '#shared/utils/fp' +import { + ADMIN_ACCESS_PERMISSION_RE, + ADMIN_ACCESS_PERMISSION_POSTFIX, +} from '#client/constants' import { Button, ComponentWrapper } from '#client/components/ui' import Permissions from '#shared/permissions' -import { DefaultPermissionPostfix } from '#shared/types' import { PermissionsSet } from '#shared/utils' type ModuleWithAdminComponents = { id: string name: string - paths: string[] + routes: string[] } const modulesWithAdminComponents: ModuleWithAdminComponents[] = config.modules.reduce((acc, m) => { if (!m.router.admin) return acc - const adminRoutePaths = Object.keys(m.router.admin) - .map((x) => m.router.admin![x]) - .map(prop('path')) - if (adminRoutePaths.length) { + const routes = Object.keys(m.router.admin) + if (routes.length) { const moduleInfo: ModuleWithAdminComponents = { id: m.id, name: m.name, - paths: adminRoutePaths, + routes, } return [...acc, moduleInfo] } @@ -33,7 +33,7 @@ const modulesWithAdminComponents: ModuleWithAdminComponents[] = type Props = { children: React.ReactNode } const doesUserHaveAdminPermission = (granted: PermissionsSet) => { - return granted.some((x) => x.endsWith(`.${DefaultPermissionPostfix.Admin}`)) + return granted.some((x) => ADMIN_ACCESS_PERMISSION_RE.test(x)) } export const AdminHome: React.FC = (props) => { @@ -50,42 +50,51 @@ export const AdminHome: React.FC = (props) => { const _AdminHome: React.FC = ({ children }) => { const permissions = useStore(stores.permissions) const page = useStore(stores.router) - const moduleVisibilityFilter = React.useCallback( - (m: ModuleWithAdminComponents): boolean => { + const officeId = useStore(stores.officeId) + + const filteredModules = React.useMemo(() => { + return modulesWithAdminComponents.filter((m) => { const modulePermissions: string[] = Object.values( Permissions[m.id as keyof typeof Permissions] || {} ) - const adminPermission = `${m.id}.${DefaultPermissionPostfix.Admin}` + const adminPermission = `${m.id}.${ADMIN_ACCESS_PERMISSION_POSTFIX}` + const adminPermissionPerOffice = `${adminPermission}:${officeId}` return ( - permissions.has(adminPermission) && - modulePermissions.includes(adminPermission) + modulePermissions.includes(adminPermission) && + (permissions.has(adminPermissionPerOffice) || + permissions.has(adminPermission)) ) - }, - [permissions] - ) + }) + }, [permissions, officeId]) return ( -
- {modulesWithAdminComponents.filter(moduleVisibilityFilter).map((x) => { - return ( - - ) - })} -
- {children} + {filteredModules.length ? ( + <> +
+ {filteredModules.map((x) => { + return ( + + ) + })} +
+ {children} + + ) : ( +
Please select an office that you can work with.
+ )}
) } diff --git a/src/client/components/EntityAccessSelector.tsx b/src/client/components/EntityAccessSelector.tsx index 7e0c19f5..ae48c3a1 100644 --- a/src/client/components/EntityAccessSelector.tsx +++ b/src/client/components/EntityAccessSelector.tsx @@ -1,8 +1,15 @@ import { ENTITY_VISIBILITY_LABEL } from '#client/components/EntityVisibilityTag' -import { CheckboxGroup, RadioGroup } from '#client/components/ui' +import { + Button, + CheckboxGroup, + LabelWrapper, + Link, + RadioGroup, +} from '#client/components/ui' import config from '#client/config' import { EntityVisibility } from '#shared/types' import { cn } from '#client/utils' +import { USER_ROLES } from '#client/constants' import { prop } from '#shared/utils/fp' import React from 'react' @@ -44,8 +51,11 @@ export const EntityAccessSelector: React.FC = ({ visibilityTypes, ...props }) => { + const [showAllRoles, setShowAllRoles] = React.useState( + USER_ROLES.length <= 10 + ) const roleIds = React.useMemo( - () => config.roles.filter(prop('accessByDefault')).map(prop('id')), + () => USER_ROLES.filter(prop('accessByDefault')).map(prop('id')), [] ) @@ -122,6 +132,22 @@ export const EntityAccessSelector: React.FC = ({ ] ) + const filteredRoles = React.useMemo< + Array<{ value: string; label: string }> + >(() => { + const availableRoles = USER_ROLES.map(prop('id')) + const unsupportedRoles = (props.value.allowedRoles || []) + .filter((x) => !availableRoles.includes(x)) + .map((x) => ({ value: x, label: `${x} (UNSUPPORTED)` })) + const filteredRoles = USER_ROLES.filter( + (x) => showAllRoles || x.accessByDefault + ).map((x) => ({ + value: x.id, + label: x.name, + })) + return unsupportedRoles.concat(filteredRoles) + }, [showAllRoles, props.value.allowedRoles]) + return (
= ({ name="roles" label="Allowed user roles" value={allowedRolesValue} - options={config.roles.map((x) => ({ - value: x.id, - label: x.name, - }))} + options={filteredRoles} onChange={onChange('allowedRoles')} /> + {!showAllRoles && ( + + setShowAllRoles(true)}>Show all roles + + )}
)} {showOfficesList && ( diff --git a/src/client/components/PermissionsValidator.tsx b/src/client/components/PermissionsValidator.tsx index 7b39644c..a0405a89 100644 --- a/src/client/components/PermissionsValidator.tsx +++ b/src/client/components/PermissionsValidator.tsx @@ -4,21 +4,27 @@ import React from 'react' type Props = { required: string[] + officeId?: string onReject?: () => void + onRejectRender?: React.ReactElement onRejectGoHome?: boolean children: React.ReactNode } export const PermissionsValidator: React.FC = ({ required = [], + officeId, children, onReject, onRejectGoHome = false, + onRejectRender = null, }) => { const permissions = useStore(stores.permissions) - const [isValid, setIsValid] = React.useState(permissions.hasAll(required)) + const [isValid, setIsValid] = React.useState( + permissions.hasAll(required, officeId) + ) React.useEffect(() => { - if (!permissions.hasAll(required)) { + if (!permissions.hasAll(required, officeId)) { if (onRejectGoHome) { setTimeout(() => stores.goTo('home'), 0) } else if (onReject) { @@ -28,6 +34,12 @@ export const PermissionsValidator: React.FC = ({ } else { setIsValid(true) } - }, [required]) - return isValid ? <>{children} : null + }, [required, officeId]) + if (isValid) { + return <>{children} + } + if (onRejectRender) { + return onRejectRender + } + return null } diff --git a/src/client/components/ui/Filters.tsx b/src/client/components/ui/Filters.tsx index 0b4a758c..33beaa22 100644 --- a/src/client/components/ui/Filters.tsx +++ b/src/client/components/ui/Filters.tsx @@ -61,6 +61,7 @@ export const Filters = (props: React.PropsWithChildren>) => { } return ( , 'onChange'> & { const labeledTypes = ['checkbox', 'radio'] type LabelProps = { name?: string | undefined - label: string | undefined + label: string | React.ReactNode | undefined required?: boolean | undefined type?: string | undefined extraLabel?: string | null diff --git a/src/client/components/ui/UserLabel.tsx b/src/client/components/ui/UserLabel.tsx index 45c12a6d..7998d892 100644 --- a/src/client/components/ui/UserLabel.tsx +++ b/src/client/components/ui/UserLabel.tsx @@ -8,9 +8,8 @@ import { USER_ROLE_BY_ID } from '#client/constants' type Props = { user: User | UserCompact - hideRole?: boolean } -export const UserLabel: React.FC = ({ user, hideRole = false }) => { +export const UserLabel: React.FC = ({ user }) => { return user ? ( = ({ user, hideRole = false }) => { href={`/profile/${user.id}`} target="_blank" className={cn(!user.isInitialised && 'opacity-50')} - title={!user.isInitialised ? 'The user has not been onboarded yet' : undefined} + title={ + !user.isInitialised + ? 'The user has not been onboarded yet' + : undefined + } kind="secondary" > {user.fullName} - {!hideRole && ( - - )} ) : null } type UserRoleLabelType = { - role: User['role'] + role: string className?: string } -export const UserRoleLabel: React.FC = ({ role, ...props }) => { +export const UserRoleLabel: React.FC = ({ + role, + ...props +}) => { const roleRecord = USER_ROLE_BY_ID[role] - // TODO: use roleRecord.color ? return ( - + {roleRecord?.name || role} ) diff --git a/src/client/config.ts b/src/client/config.ts index bf465443..3b3f3abe 100644 --- a/src/client/config.ts +++ b/src/client/config.ts @@ -2,7 +2,8 @@ import { Layout, ModuleClientRouter, Office, - AppRole, + UserRole, + UserRoleGroup, AppModule, } from '#shared/types' @@ -26,21 +27,26 @@ export type ClientOfficeConfig = Pick< | 'allowRoomReservation' > -type ClientUserRole = Pick & { +export type ClientUserRole = Pick< + UserRole, + 'id' | 'name' | 'accessByDefault' +> & { lowPriority: boolean } +export type ClientUserRoleGroup = UserRoleGroup & { + roles: ClientUserRole[] +} + type ClientAppConfig = { modules: ClientModuleConfig[] offices: ClientOfficeConfig[] - departments: string[] - divisions: string[] appName: string companyName: string appHost: string mapBoxApiKey: string layout: Layout - roles: ClientUserRole[] + roleGroups: ClientUserRoleGroup[] auth: ClientAuthConfig authMessageToSign: string } @@ -50,14 +56,12 @@ type ClientAuthConfig = { providers: string[] } const config: ClientAppConfig = { modules: process.env.APP_MODULES as unknown as ClientModuleConfig[], offices: process.env.APP_OFFICES as unknown as ClientOfficeConfig[], - departments: process.env.DEPARTMENTS as unknown as string[], - divisions: process.env.DIVISIONS as unknown as string[], appName: process.env.APP_NAME as unknown as string, companyName: process.env.COMPANY_NAME as unknown as string, appHost: process.env.APP_HOST as unknown as string, mapBoxApiKey: process.env.MAPBOX_API_KEY as unknown as string, layout: process.env.LAYOUT as unknown as Layout, - roles: process.env.ROLES as unknown as ClientUserRole[], + roleGroups: process.env.ROLE_GROUPS as unknown as ClientUserRoleGroup[], auth: process.env.AUTH as unknown as ClientAuthConfig, authMessageToSign: process.env.AUTH_MESSAGE_TO_SIGN as unknown as string, } diff --git a/src/client/constants.ts b/src/client/constants.ts index 113211c2..6138cfa0 100644 --- a/src/client/constants.ts +++ b/src/client/constants.ts @@ -1,5 +1,6 @@ import config from '#client/config' +// TODO: implement shared constants and move it there export const DATE_FORMAT = 'YYYY-MM-DD' export const DATE_FORMAT_DAY_NAME = 'ddd, MMMM D' @@ -8,12 +9,22 @@ export const DATE_FORMAT_DAY_NAME_FULL = 'dddd, MMMM D' export const FRIENDLY_DATE_FORMAT = 'MMMM D YYYY' -export const USER_ROLE_BY_ID = config.roles.reduce( +export const USER_ROLES = config.roleGroups.map((x) => x.roles).flat() + +export const USER_ROLE_BY_ID = USER_ROLES.reduce( (acc, x) => ({ ...acc, [x.id]: x }), - {} as Record + {} as Record ) export const OFFICE_BY_ID = config.offices.reduce( (acc, x) => ({ ...acc, [x.id]: x }), {} as Record ) + +// TODO: implement shared constants and move it there +export const ADMIN_ACCESS_PERMISSION_POSTFIX = '__admin' + +// TODO: implement shared constants and move it there +export const ADMIN_ACCESS_PERMISSION_RE = new RegExp( + `^.*\.${ADMIN_ACCESS_PERMISSION_POSTFIX}` +) diff --git a/src/client/utils/index.ts b/src/client/utils/index.ts index 81672075..65709b89 100644 --- a/src/client/utils/index.ts +++ b/src/client/utils/index.ts @@ -4,7 +4,6 @@ import dayjs, { Dayjs } from 'dayjs' export const cn = ( ...chunks: Array ): string => { - // return chunks.map((x) => (typeof x === 'string' ? x : '')).join(' ') return twMerge(...chunks.map((x) => (typeof x === 'string' ? x : ''))) } @@ -12,9 +11,10 @@ export const toggleInArray = ( arr: T[], item: T, keepOne: boolean = false, - maxNumber?: number + maxNumber?: number, + autoDeselect?: boolean ): T[] => { - if (arr.indexOf(item) > -1) { + if (arr.includes(item)) { if (keepOne && arr.length === 1) { return arr } @@ -22,6 +22,9 @@ export const toggleInArray = ( } else { if (maxNumber) { if (arr.length >= maxNumber) { + if (autoDeselect) { + return arr.slice(1).concat(item) + } return arr } } @@ -56,11 +59,13 @@ export const generateId = (length: number = 16, prefix?: string): string => { // window.location.href = url.toString() // } -export const trimString = (str: string, length: number = 32, postfix: string = '...'): string => { +export const trimString = ( + str: string, + length: number = 32, + postfix: string = '...' +): string => { if (!str) return '' - return str.length < length - ? str - : str.slice(0, length) + postfix + return str.length < length ? str : str.slice(0, length) + postfix } export const formatDateRange = ( diff --git a/src/integrations/email-smtp/index.ts b/src/integrations/email-smtp/index.ts index 231b22d4..509aade8 100644 --- a/src/integrations/email-smtp/index.ts +++ b/src/integrations/email-smtp/index.ts @@ -70,11 +70,7 @@ class EmailSMTP extends Integration { }) } - public async sendEmail({ - to, - html, - subject, - }: Email): Promise> { + public async sendEmail({ to, html, subject }: Email): Promise { if (config.debug) { console.log( `Sending email skipped (debug mode).\nemail: ${to}\nsubject: ${subject}\nhtml: ${html}\n` diff --git a/src/integrations/integration.ts b/src/integrations/integration.ts index 889499de..f35f741b 100644 --- a/src/integrations/integration.ts +++ b/src/integrations/integration.ts @@ -20,10 +20,10 @@ export class Integration { return { success: false, error: error as Error } } - success(data?: T): SafeResponse { + success(data?: T): SafeResponse { if (data !== undefined) { return { success: true, data: data } as SafeResponse } - return { success: true } + return { success: true } as SafeResponse } } diff --git a/src/integrations/matrix/index.ts b/src/integrations/matrix/index.ts index 5001f289..57fa18d1 100644 --- a/src/integrations/matrix/index.ts +++ b/src/integrations/matrix/index.ts @@ -1,6 +1,6 @@ import axios from 'axios' import config from '#server/config' -import { escapeRegExpSensitiveCharacters } from '#server/utils' +import { escapeRegExpSensitiveCharacters } from '#shared/utils/fp' import { User } from '#modules/users/server/models/user' import { SafeResponse } from '#server/types' import { Integration } from '../integration' @@ -143,7 +143,7 @@ class Matrix extends Integration { public async inviteUserInRoom( roomId: MatrixRoomId, username: MatrixUsername - ): Promise> { + ): Promise { const usernameFormatted = this.sanitizeUsername(username) if (config.debug) { console.log( @@ -175,10 +175,7 @@ class Matrix extends Integration { process.nextTick(() => this.inviteUserInRoom(roomId, username)) } - async sendMessageToUser( - user: User, - message: string - ): Promise> { + async sendMessageToUser(user: User, message: string): Promise { try { const roomId = await this.ensureUserRoom(user) if (roomId) { @@ -194,9 +191,7 @@ class Matrix extends Integration { process.nextTick(() => this.sendMessageToUser(user, message)) } - public async sendMessageInAdminRoom( - message: string - ): Promise> { + public async sendMessageInAdminRoom(message: string): Promise { try { await this.sendMessageInRoom(this.credentials.adminRoomId, message) return this.success() diff --git a/src/modules/announcements/client/components/AdminAnnouncements.tsx b/src/modules/announcements/client/components/AdminAnnouncements.tsx index 4c56c7a9..bfd1ffdc 100644 --- a/src/modules/announcements/client/components/AdminAnnouncements.tsx +++ b/src/modules/announcements/client/components/AdminAnnouncements.tsx @@ -50,7 +50,7 @@ export const _AdminAnnouncements: React.FC<{}> = () => { Header: 'Creator', accessor: (one: any) => { const user = usersById[one.creatorUserId] - return + return }, }, { diff --git a/src/modules/announcements/server/router.ts b/src/modules/announcements/server/router.ts index 35047f8b..9a8b9c40 100644 --- a/src/modules/announcements/server/router.ts +++ b/src/modules/announcements/server/router.ts @@ -27,7 +27,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { order: [['scheduledAt', 'ASC']], where: { visibility: EntityVisibility.Visible, - allowedRoles: { [Op.contains]: [req.user.role] }, + allowedRoles: { [Op.overlap]: req.user.roles }, offices: { [Op.contains]: [req.office.id] }, scheduledAt: { [Op.lte]: today.toDate() }, expiresAt: { [Op.gt]: today.toDate() }, diff --git a/src/modules/events/client/components/AdminEvents.tsx b/src/modules/events/client/components/AdminEvents.tsx index 419286ad..2b8a5880 100644 --- a/src/modules/events/client/components/AdminEvents.tsx +++ b/src/modules/events/client/components/AdminEvents.tsx @@ -13,6 +13,7 @@ import { Tag, UserLabel, Filters, + UserRoleLabel, } from '#client/components/ui' import config from '#client/config' import { OFFICE_BY_ID, USER_ROLE_BY_ID } from '#client/constants' @@ -141,7 +142,7 @@ export const AdminEvents = () => { Header: 'Creator', accessor: (event: EventAdminResponse) => { const user = usersById[event.creatorUserId] - return + return }, }, { @@ -167,9 +168,7 @@ export const AdminEvents = () => { accessor: (event: EventAdminResponse) => ( {event.allowedRoles.map((x) => ( - - {USER_ROLE_BY_ID[x]?.name || x} - + ))} ), diff --git a/src/modules/events/server/jobs/pull-global-events.ts b/src/modules/events/server/jobs/pull-global-events.ts index 4c40ea1c..f87fdb05 100644 --- a/src/modules/events/server/jobs/pull-global-events.ts +++ b/src/modules/events/server/jobs/pull-global-events.ts @@ -1,6 +1,7 @@ import { UniqueConstraintError } from 'sequelize' import { CronJob, CronJobContext } from '#server/types' import { EntityVisibility } from '#shared/types' +import * as fp from '#shared/utils/fp' import { getGlobalEventDefaultChecklist, getGlobalEventDefaultContent, @@ -96,9 +97,11 @@ export const cronJob: CronJob = { ? EntityVisibility.None : EntityVisibility.Visible - const allowedRoles = ctx.appConfig.config.permissions.roles + const allowedRoles = ctx.appConfig.config.permissions.roleGroups + .map(fp.prop('roles')) + .flat() .filter((x) => (isInternal ? x.accessByDefault : true)) - .map((x) => x.id) + .map(fp.prop('id')) try { await ctx.models.Event.create({ diff --git a/src/modules/events/server/router.ts b/src/modules/events/server/router.ts index 5937a350..450fe872 100644 --- a/src/modules/events/server/router.ts +++ b/src/modules/events/server/router.ts @@ -12,6 +12,7 @@ import { PublicForm, } from '#modules/forms/types' import { EntityVisibility } from '#shared/types' +import * as fp from '#shared/utils/fp' import { Permissions } from '../permissions' import { Event, @@ -58,8 +59,10 @@ const publicRouter: FastifyPluginCallback = async function (fastify, opts) { if (event.visibility === EntityVisibility.None) { return reply.throw.notFound() } - const userRole = req.user ? req.user.role : appConfig.lowPriorityRole - if (!event.allowedRoles.includes(userRole)) { + const userRoles = req.user + ? req.user.roles + : [appConfig.lowPriorityRole] + if (!fp.hasIntersection(event.allowedRoles, userRoles)) { return reply.throw.notFound() } } @@ -100,7 +103,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { if (event.visibility === EntityVisibility.None) { return reply.throw.notFound() } - if (!event.allowedRoles.includes(req.user.role)) { + if (!fp.hasIntersection(event.allowedRoles, req.user.roles)) { return reply.throw.notFound() } } @@ -169,7 +172,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { return reply.throw.notFound() } // Inherit access policy from the parent event - if (!event.allowedRoles.includes(req.user.role)) { + if (!fp.hasIntersection(event.allowedRoles, req.user.roles)) { return reply.throw.notFound() } } @@ -235,7 +238,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { return reply.throw.notFound() } // Inherit access policy from the parent event - if (!event.allowedRoles.includes(req.user.role)) { + if (!fp.hasIntersection(event.allowedRoles, req.user.roles)) { return reply.throw.notFound() } } @@ -444,7 +447,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { [Op.gte]: new Date(), }, visibility: EntityVisibility.Visible, - allowedRoles: { [Op.contains]: [req.user.role] }, + allowedRoles: { [Op.overlap]: req.user.roles }, offices: { [Op.contains]: [req.office.id] }, }, order: ['startDate'], @@ -574,7 +577,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { if (event.visibility === EntityVisibility.None) { return reply.throw.accessDenied() } - if (!event.allowedRoles.includes(req.user.role)) { + if (!fp.hasIntersection(event.allowedRoles, req.user.roles)) { return reply.throw.accessDenied() } } @@ -761,7 +764,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { }, 'metadata.global': true, visibility: EntityVisibility.Visible, - allowedRoles: { [Op.contains]: [req.user.role] }, + allowedRoles: { [Op.overlap]: req.user.roles }, }, ], }, @@ -789,7 +792,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { }, } if (!req.can(Permissions.AdminManage)) { - where.allowedRoles = { [Op.contains]: [req.user.role] } + where.allowedRoles = { [Op.overlap]: req.user.roles } } const event = await fastify.db.Event.findOne({ where }) if (!event) { @@ -1162,7 +1165,7 @@ const adminRouter: FastifyPluginCallback = async function (fastify, opts) { Permissions.AdminReceiveNotifications ) return fastify.db.User.findAllActive({ - where: { role: { [Op.in]: roles } }, + where: { roles: { [Op.overlap]: roles } }, }) }) } diff --git a/src/modules/forms/client/components/AdminForms.tsx b/src/modules/forms/client/components/AdminForms.tsx index 421e7304..c8e2e1ab 100644 --- a/src/modules/forms/client/components/AdminForms.tsx +++ b/src/modules/forms/client/components/AdminForms.tsx @@ -13,6 +13,7 @@ import { UserLabel, Filters, LabelWrapper, + UserRoleLabel, } from '#client/components/ui' import { showNotification } from '#client/components/ui/Notifications' import { USER_ROLE_BY_ID } from '#client/constants' @@ -138,7 +139,7 @@ export const AdminForms = () => { Header: 'Creator', accessor: (form: FormAdminResponse) => { const user = usersById[form.creatorUserId] - return + return }, }, { @@ -152,9 +153,7 @@ export const AdminForms = () => { accessor: (form: FormAdminResponse) => ( {form.allowedRoles.map((x) => ( - - {USER_ROLE_BY_ID[x]?.name || x} - + ))} ), diff --git a/src/modules/forms/server/router.ts b/src/modules/forms/server/router.ts index ab99e864..ae4a0df1 100644 --- a/src/modules/forms/server/router.ts +++ b/src/modules/forms/server/router.ts @@ -10,6 +10,7 @@ import { Op, Filterable } from 'sequelize' import { appConfig } from '#server/app-config' import { getJSONdiff } from '#server/utils' import { EntityVisibility } from '#shared/types' +import * as fp from '#shared/utils/fp' import { User } from '#modules/users/types' import { useRateLimit } from '#server/utils/rate-limit' import { Permissions } from '../permissions' @@ -47,7 +48,7 @@ const publicRouter: FastifyPluginCallback = async (fastify, opts) => { return reply.throw.notFound() } if (form.visibility !== EntityVisibility.UrlPublic) { - if (!form.allowedRoles.includes(req.user.role)) { + if (!fp.hasIntersection(form.allowedRoles, req.user.roles)) { return reply.throw.notFound() } } @@ -97,7 +98,7 @@ const publicRouter: FastifyPluginCallback = async (fastify, opts) => { return reply.throw.gone() } if (form.visibility !== EntityVisibility.UrlPublic) { - if (!form.allowedRoles.includes(req.user.role)) { + if (!fp.hasIntersection(form.allowedRoles, req.user.roles)) { return reply.throw.notFound() } } @@ -383,7 +384,7 @@ const adminRouter: FastifyPluginCallback = async (fastify, opts) => { Permissions.AdminReceiveNotifications ) return fastify.db.User.findAllActive({ - where: { role: { [Op.in]: roles } }, + where: { roles: { [Op.overlap]: roles } }, }) }) diff --git a/src/modules/guest-invites/client/components/AdminGuestInvite.tsx b/src/modules/guest-invites/client/components/AdminGuestInvite.tsx index f5941714..2b4329c3 100644 --- a/src/modules/guest-invites/client/components/AdminGuestInvite.tsx +++ b/src/modules/guest-invites/client/components/AdminGuestInvite.tsx @@ -3,7 +3,7 @@ import dayjs from 'dayjs' import { useStore } from '@nanostores/react' import config from '#client/config' import * as stores from '#client/stores' -import { useUserAdmin } from '#modules/users/client/queries' +import { useUserCompact } from '#modules/users/client/queries' import { useVisitsAreas, useAvailableDesks, @@ -30,7 +30,7 @@ export const AdminGuestInvite: React.FC = () => { const inviteId = page?.route === 'adminGuestInvite' ? page.params.inviteId : null const { data: invite } = useGuestInviteAdmin(inviteId) - const { data: user } = useUserAdmin(invite?.creatorUserId || '', { + const { data: user } = useUserCompact(invite?.creatorUserId || '', { enabled: !!invite?.creatorUserId, }) const office = React.useMemo(() => { @@ -47,10 +47,10 @@ export const AdminGuestInvite: React.FC = () => { } }, [areas]) const area = React.useMemo(() => areas.find((x) => areaId === x.id), [areaId]) - const onAreaChange = React.useCallback( - (areaId: string) => setAreaId(areaId), - [] - ) + const onAreaChange = React.useCallback((areaId: string) => { + setSelectedDeskId(null) + setAreaId(areaId) + }, []) const { data: availableDesks = [] } = useAvailableDesks( office?.id || '', @@ -111,7 +111,7 @@ export const AdminGuestInvite: React.FC = () => {
{invite.dates.map((x, i) => ( - + {dayjs(x, DATE_FORMAT).format('D MMMM, YYYY')} {i !== invite.dates.length - 1 && ', '} @@ -141,6 +141,7 @@ export const AdminGuestInvite: React.FC = () => { inputs={[ areas.length > 1 && ( ({ - label: x.name, - value: x.id, - }))} - /> -
+ setEditedUserId(null)} + onChange={onChangeUserRoles(u.id)} + /> )} - - ), - }, - { - Header: 'Email', - accessor: (x: User) => ( - - {x.email} - + ), }, - { - Header: 'Division', - accessor: (u: User) => - u.division || UNKNOWN, - }, - { - Header: 'Department', - accessor: (u: User) => - u.department || UNKNOWN, - }, { Header: 'Created at', accessor: (x: User) => dayjs(x.createdAt).format('D MMM, YYYY'), }, { - Header: 'Delete', + Header: 'Actions', accessor: (u: User) => { if (u.scheduledToDelete && !u.deletedAt) { const scheduledAt = dayjs(u.scheduledToDelete).startOf('day') @@ -246,25 +229,6 @@ export const UserTable: React.FC = () => { [editedUserId] ) - const onChangeUserRole = React.useCallback( - (userId: string /*, newRole: string*/) => (role: string) => { - const user = users.find(propEq('id', userId)) - if (!user) return - const currentRole = USER_ROLE_BY_ID[user.role] - const targetRole = USER_ROLE_BY_ID[role] - if ( - window.confirm( - `πŸ›‘ Are you sure to change ${user.fullName}'s role from "${ - currentRole?.name || user.role - }" to "${targetRole.name}"?` - ) - ) { - updateUser({ id: user.id, role }) - } - }, - [users] - ) - return (
{showDeleteModal && userForDeletion && ( @@ -276,44 +240,48 @@ export const UserTable: React.FC = () => { )}

Users

+
+ setRoleFilterIsShown((x) => !x)} + className="relative" + > + {!roleFilterIsShown ? 'Show' : 'Hide'} filters + + } + > + {!roleFilterIsShown && !!roleFilter.length && ( + Some filters are applied + )} + +
+
- {!!config.divisions && !!config.divisions.length && ( - - ({ id: x, name: x })), - ]} - value={divisionFilter} - onChange={setDivisionFilter} - multiple - /> - - )} - {!!config.departments && !!config.departments.length && ( - - ({ id: x, name: x })), - ]} - value={departmentFilter} - onChange={setDepartmentFilter} - multiple - /> - + {roleFilterIsShown && ( + <> + + + + {config.roleGroups.map((g) => ( + + + + ))} + )} - - ({ id: x.id, name: x.name })), - ]} - value={roleFilter} - onChange={setRoleFilter} - multiple - /> - +
- {[user.jobTitle, user.team, user.department] - .filter(Boolean) - .map((x, i) => ( - - {!!i && · } - {x} - - ))} + {[user.jobTitle, user.team].filter(Boolean).join(' Β· ')}
diff --git a/src/modules/users/client/components/ProfileCard.tsx b/src/modules/users/client/components/ProfileCard.tsx index 23527406..b332383a 100644 --- a/src/modules/users/client/components/ProfileCard.tsx +++ b/src/modules/users/client/components/ProfileCard.tsx @@ -16,7 +16,7 @@ import { PublicUserProfile, UserMe } from '#shared/types' import dayjs from 'dayjs' import { PermissionsValidator } from '#client/components/PermissionsValidator' import Permissions from '#shared/permissions' -import { USER_ROLE_BY_ID } from '#client/constants' +import { USER_ROLES } from '#client/constants' const ProfileRow = ({ label, @@ -53,8 +53,6 @@ export const Card = ({ fullView?: boolean isMine?: boolean }) => { - const permissions = useStore(stores.permissions) - const location = React.useMemo(() => { if (user && !user.geodata?.doNotShareLocation) { const city = user.city || '' @@ -65,9 +63,11 @@ export const Card = ({ return null }, [user]) - const userRole = React.useMemo(() => { - const role = USER_ROLE_BY_ID[user.role] - return role?.name || user.role + const userRoles = React.useMemo(() => { + return USER_ROLES.reduce((acc, x) => { + if (!user.roles.includes(x.id)) return acc + return acc.concat(x.name) + }, []) }, [user]) return ( @@ -85,20 +85,15 @@ export const Card = ({

{user.fullName}

- {[user.jobTitle, user.team, user.department] - .filter(Boolean) - .map((x, i) => ( - - {!!i && · } - {x} - - ))} + {[user.jobTitle, user.team].filter(Boolean).join(' Β· ')}
-
- - {userRole} - +
+ {userRoles.map((x) => ( + + {x} + + ))}
diff --git a/src/modules/users/client/components/ProfileForm.tsx b/src/modules/users/client/components/ProfileForm.tsx index 48726de2..7f32fbde 100644 --- a/src/modules/users/client/components/ProfileForm.tsx +++ b/src/modules/users/client/components/ProfileForm.tsx @@ -20,6 +20,8 @@ import { RootComponentProps, Tag as TagType, } from '#shared/types' +import { toggleInArray } from '#client/utils' +import * as fp from '#shared/utils/fp' import { useCitySearchSuggestion, useCountries, @@ -28,6 +30,12 @@ import { useUpdateProfile, } from '../queries' +const EDITABLE_ROLE_GROUPS = config.roleGroups.filter( + (x) => x.rules.editableByRoles.length +) + +type RolesByGroupId = Record + export const ProfileForm: React.FC = ({ portals }) => { const me = useStore(stores.me) const [cityQuery, setCityQuery] = React.useState('') @@ -41,7 +49,6 @@ export const ProfileForm: React.FC = ({ portals }) => { const [state, setState] = React.useState({ fullName: me?.fullName || '', birthday: me?.birthday || '', - department: me?.department || null, team: me?.team || '', jobTitle: me?.jobTitle || '', country: me?.country || null, @@ -50,7 +57,21 @@ export const ProfileForm: React.FC = ({ portals }) => { bio: me?.bio || '', geodata: me?.geodata || undefined, defaultLocation: me?.defaultLocation || null, + roles: [], // will be overwritten in the `onSubmitForm` function }) + const [rolesByGroupId, setRolesByGroupId] = React.useState( + (() => { + const result: RolesByGroupId = {} + EDITABLE_ROLE_GROUPS.filter((g) => + g.rules.editableByRoles.some(fp.isIn(me?.roles || [])) + ).forEach((g) => { + result[g.id] = g.roles + .map(fp.prop('id')) + .filter(fp.isIn(me?.roles || [])) + }) + return result + })() + ) const { data: countries } = useCountries() const { data: citySuggestions = [] } = useCitySearchSuggestion( @@ -133,16 +154,16 @@ export const ProfileForm: React.FC = ({ portals }) => { const c = cityValue[0] save({ ...state, - department: state.department || '', country: state.country || '', city: c ? c.label : '', birthday: !state.birthday ? null : state.birthday, geodata: { doNotShareLocation: state.geodata?.doNotShareLocation || false, }, + roles: Object.values(rolesByGroupId).flat(), }) }, - [state, cityValue] + [state, cityValue, rolesByGroupId] ) const onShareLocationChange = () => { setState({ @@ -171,6 +192,24 @@ export const ProfileForm: React.FC = ({ portals }) => { setIsChanged(true) } + const onToggleRole = React.useCallback( + (groupId: string, roleId: string) => (ev: React.MouseEvent) => { + const group = EDITABLE_ROLE_GROUPS.find(fp.propEq('id', groupId))! + setRolesByGroupId((value) => ({ + ...value, + [groupId]: toggleInArray( + value[groupId], + roleId, + true, + group.rules.max, + true + ), + })) + setIsChanged(true) + }, + [] + ) + React.useEffect(() => { if ( REQUIRED_FIELDS.every( @@ -200,6 +239,31 @@ export const ProfileForm: React.FC = ({ portals }) => { containerClassName="w-full" required={isRequired('fullName')} /> + {EDITABLE_ROLE_GROUPS.filter((g) => + g.rules.editableByRoles.some(fp.isIn(me?.roles || [])) + ).map((g) => ( +
+ +
+
+ {g.roles.map((x) => ( + + {x.name} + + ))} +
+
+
+
+ ))} {metadata?.birthday && ( = ({ portals }) => { containerClassName="w-full" /> )} - {metadata?.department && ( - + +export const UserRolesEditorModal: React.FC<{ + user: User + onClose: () => void + onChange: (roles: string[]) => void +}> = ({ user, ...props }) => { + const [changed, setChanged] = React.useState(false) + const [unsupportedRoleIds, setUnsupportedRoleIds] = React.useState( + user.roles.filter((x) => !USER_ROLE_BY_ID[x]) + ) + const [groupedRoleIds, setGroupedRoleIds] = React.useState( + (() => { + const res: GroupedRoleIds = {} + config.roleGroups.forEach((g) => { + res[g.id] = g.roles + .filter(fp.propIn('id', user.roles)) + .map(fp.prop('id')) + }) + return res + })() + ) + + const unsupportedUserRoles = React.useMemo(() => { + return unsupportedRoleIds.map((x) => ({ + id: x, + name: x, + accessByDefault: false, + lowPriority: false, + })) + }, [unsupportedRoleIds]) + + const onToggleRole = React.useCallback( + (groupId: string, roleId: string) => (ev: React.MouseEvent) => { + ev.preventDefault() + setChanged(true) + const rules = config.roleGroups.find(fp.propEq('id', groupId))!.rules + setGroupedRoleIds((value) => { + return { + ...value, + [groupId]: toggleInArray( + value[groupId], + roleId, + false, + rules.max || undefined, + true + ), + } + }) + }, + [] + ) + + const onToggleUnsupportedRole = React.useCallback( + (roleId: string) => (ev: React.MouseEvent) => { + ev.preventDefault() + setChanged(true) + setUnsupportedRoleIds((ids) => toggleInArray(ids, roleId)) + }, + [] + ) + + const onCloseSafe = React.useCallback(() => { + if (changed) { + if (window.confirm('You have unsaved changes. Close anyway?')) { + props.onClose() + } + return + } + props.onClose() + }, [props.onClose, changed]) + + const onSave = React.useCallback(() => { + props.onChange([ + ...unsupportedRoleIds, + ...Object.values(groupedRoleIds).flat(), + ]) + props.onClose() + }, [props.onChange, props.onClose, groupedRoleIds, unsupportedRoleIds]) + + return ( + +
+ +
+ + {!!unsupportedUserRoles.length && ( +
+
+
Unsupported roles
+
+ The following roles are not supported by the current configuration + of the app. You can only deselect them. Make sure that these roles + are not used in other parts of the app. +
+
+
+ {unsupportedUserRoles.map((x) => ( + + {x.name}{' '} + UNSUPPORTED + + ))} +
+
+ )} + + {config.roleGroups.map((g, i) => ( +
+ {!!i &&
} +
+
{g.name}
+
+ {!!g.rules.max && ( + + Only {g.rules.max} role{g.rules.max > 1 && 's'} can be + selected.{' '} + + )} + {!!g.rules.unique && ( + + Unique role per user.{' '} + + )} + {!!g.rules.editableByRoles.length && ( +
+ Users with the following roles can change these roles in the + profile form:{' '} + {g.rules.editableByRoles + .map((x) => USER_ROLE_BY_ID[x].name) + .join(', ')} + . +
+ )} +
+
+
+ {g.roles.map((x) => ( + + {x.name} + + ))} +
+
+ ))} + + + Cancel + , + + Save + , + ]} + /> +
+ ) +} diff --git a/src/modules/users/client/components/UsersMap.tsx b/src/modules/users/client/components/UsersMap.tsx index eebec13e..44d84960 100644 --- a/src/modules/users/client/components/UsersMap.tsx +++ b/src/modules/users/client/components/UsersMap.tsx @@ -46,10 +46,7 @@ const PersonRow: React.FC<{ person: UserMapPin }> = ({ person }) => ( > {person.fullName} -

- {person.jobTitle ?? ''} {person.team ? `@ ${person.team}` : ''}{' '} - {person.department ? `- ${person.department}` : ' '} -

+

{[person.jobTitle, person.team].filter(Boolean).join(' Β· ')}

) diff --git a/src/modules/users/client/components/Welcome.tsx b/src/modules/users/client/components/Welcome.tsx index 3aabe3c9..de29be28 100644 --- a/src/modules/users/client/components/Welcome.tsx +++ b/src/modules/users/client/components/Welcome.tsx @@ -22,7 +22,7 @@ import config from '#client/config' import * as stores from '#client/stores' import { ProfileField, OnboardingProfileRequest, Tag } from '#shared/types' import { toggleInArray } from '#client/utils' -import { groupBy, pick, prop } from '#shared/utils/fp' +import * as fp from '#shared/utils/fp' import { PermissionsValidator } from '#client/components/PermissionsValidator' import Permissions from '#shared/permissions' import { @@ -34,6 +34,12 @@ import { useTags, } from '../queries' +const EDITABLE_ROLE_GROUPS = config.roleGroups.filter( + (x) => x.rules.editableByRoles.length +) + +type RolesByGroupId = Record + enum ScreenName { General = 'General', Contacts = 'Contacts', @@ -53,13 +59,13 @@ export const _Welcome: React.FC = () => { const me = useStore(stores.me) const [state, setState] = React.useState({ - department: me?.department || '', team: me?.team || '', jobTitle: me?.jobTitle || '', country: me?.country || '', city: me?.city || '', tagIds: [], contacts: me?.contacts ?? {}, + roles: me?.roles || [], // will be overwritten in the `onSubmitForm` function }) const { data: metadata = null, isFetched: isMetadataFetched } = useMetadata() @@ -109,7 +115,7 @@ export const _Welcome: React.FC = () => { const stepComponents = { [ScreenName.General]: ( { ) } -type GeneralInfoFormData = Pick & - Partial> +type GeneralInfoFormData = Pick< + OnboardingProfileRequest, + 'city' | 'country' | 'roles' +> & + Partial> const GeneralInfo: React.FC<{ formData: GeneralInfoFormData onSubmit: (value: GeneralInfoFormData) => void }> = ({ formData, onSubmit }) => { + const me = useStore(stores.me) const [state, setState] = React.useState(formData) + const [rolesByGroupId, setRolesByGroupId] = React.useState( + (() => { + const result: RolesByGroupId = {} + EDITABLE_ROLE_GROUPS.filter((g) => + g.rules.editableByRoles.some(fp.isIn(me?.roles || [])) + ).forEach((g) => { + result[g.id] = g.roles + .map(fp.prop('id')) + .filter(fp.isIn(formData.roles)) + }) + return result + })() + ) const [cityQuery, setCityQuery] = React.useState('') const [cityOption, setCityOption] = React.useState([]) @@ -200,9 +223,25 @@ const GeneralInfo: React.FC<{ const onSubmitClick = React.useCallback( (ev: React.MouseEvent) => { ev.preventDefault() - onSubmit(state) + onSubmit({ ...state, roles: Object.values(rolesByGroupId).flat() }) }, - [state] + [state, rolesByGroupId] + ) + const onToggleRole = React.useCallback( + (groupId: string, roleId: string) => (ev: React.MouseEvent) => { + const group = EDITABLE_ROLE_GROUPS.find(fp.propEq('id', groupId))! + setRolesByGroupId((value) => ({ + ...value, + [groupId]: toggleInArray( + value[groupId], + roleId, + true, + group.rules.max, + true + ), + })) + }, + [] ) React.useEffect(() => { @@ -218,37 +257,49 @@ const GeneralInfo: React.FC<{ }, []) const generalInfoConfigured = - !!metadata && (metadata.department || metadata.team || metadata.jobTitle) + !!metadata && (metadata.team || metadata.jobTitle) return (

Complete Setting Up Your Profile

{generalInfoConfigured && (
General Information -

+

Specify what you're working on for hub members who'll be interested in collaborating on new projects

- {!!metadata.department && ( - What is you permanent Location? -

+

By knowing your timezone hub members would better adjust teamwork and meetings time

@@ -329,13 +381,19 @@ const Contacts: React.FC<{ onSubmit: (value: { contacts: Record }) => void onMoveBack: () => void }> = ({ metadata, onSubmit, onMoveBack }) => { + const me = useStore(stores.me) const metadataFields = Object.keys(metadata) const [state, setState] = React.useState>({}) const [isValid, setIsValid] = React.useState(false) - const requiredFieldsIds: string[] = metadataFields.filter( - (contactId) => metadata[contactId].required - ) + const requiredFieldsIds: string[] = metadataFields.filter((contactId) => { + const contactField = metadata[contactId] + const userRoles = me?.roles || [] + return ( + contactField.required || + fp.hasIntersection(contactField.requiredForRoles, userRoles) + ) + }) const [selectedFieldIds, setSelectedFieldIds] = React.useState(requiredFieldsIds) @@ -410,7 +468,7 @@ const Contacts: React.FC<{ } label={x.label} containerClassName="w-full mb-4" - required={x.required} + required={requiredFieldsIds.includes(x.id)} /> ) })} @@ -469,7 +527,7 @@ const Tags: React.FC<{ React.useState(presavedTagIds) const groupedTags = React.useMemo(() => { - const tagsByCategory = (tags || []).reduce(groupBy('category'), {}) + const tagsByCategory = (tags || []).reduce(fp.groupBy('category'), {}) return Object.keys(tagsByCategory).map((x) => ({ category: x, tags: tagsByCategory[x], @@ -500,7 +558,7 @@ const Tags: React.FC<{ // DELETE: ? React.useEffect(() => { if (userTags.length) { - setSelectedTagIds(userTags.map(prop('id'))) + setSelectedTagIds(userTags.map(fp.prop('id'))) } }, [userTags]) diff --git a/src/modules/users/client/components/index.ts b/src/modules/users/client/components/index.ts index 3a2e9ba8..c91dba8c 100644 --- a/src/modules/users/client/components/index.ts +++ b/src/modules/users/client/components/index.ts @@ -15,3 +15,4 @@ export { UsersMap } from './UsersMap' export { UsersMapPage } from './UsersMapPage' export { UsersMapWidget } from './UsersMapWidget' export { Welcome } from './Welcome' +export { UserRolesEditorModal } from './UserRolesEditorModal' diff --git a/src/modules/users/client/queries.ts b/src/modules/users/client/queries.ts index 260dd1b2..adff98d1 100644 --- a/src/modules/users/client/queries.ts +++ b/src/modules/users/client/queries.ts @@ -79,20 +79,8 @@ export const useMapStats = () => { ) } -export const useUserAdmin = ( - userId: string, - { enabled } = { enabled: true } -) => { - const path = `/admin-api/users/users/${userId}` - return useQuery( - path, - async () => (await api.get(path)).data, - { enabled } - ) -} - export const useUpdateUserAdmin = (cb: () => void) => - useMutation>( + useMutation>( ({ id, ...data }) => api.put(`/admin-api/users/user/${id}`, data), { onSuccess: cb } ) @@ -123,7 +111,7 @@ export const useSubmitOnboarding = (cb: () => void) => ) export const usePublicProfile = (userId: string | null) => { - const path = `/user-api/users/user/${userId}` + const path = `/user-api/users/profile/${userId}` return useQuery( path, async () => (await api.get(path)).data, @@ -131,6 +119,18 @@ export const usePublicProfile = (userId: string | null) => { ) } +export const useUserCompact = ( + userId: string | null, + { enabled } = { enabled: true } +) => { + const path = `/user-api/users/user/${userId}` + return useQuery( + path, + async () => (await api.get(path)).data, + { enabled } + ) +} + export const useCountries = () => { const path = '/user-api/users/countries' return useQuery( diff --git a/src/modules/users/metadata-schema.ts b/src/modules/users/metadata-schema.ts index 72251b04..ffbc253c 100644 --- a/src/modules/users/metadata-schema.ts +++ b/src/modules/users/metadata-schema.ts @@ -4,6 +4,7 @@ const contactFieldSchema = z.object({ label: z.string().optional(), placeholder: z.string().optional(), required: z.boolean().optional(), + requiredForRoles: z.array(z.string()).default([]), prefix: z.string().optional(), // Optional because not all fields have it }) @@ -19,7 +20,6 @@ export const schema = z .object({ profileFields: z.object({ birthday: profileFieldSchema.optional(), - department: profileFieldSchema.optional(), team: profileFieldSchema.optional(), jobTitle: profileFieldSchema.optional(), bio: profileFieldSchema.optional(), diff --git a/src/modules/users/server/migrations/20231206175252_users_add-roles-list.js b/src/modules/users/server/migrations/20231206175252_users_add-roles-list.js new file mode 100644 index 00000000..709fdcfd --- /dev/null +++ b/src/modules/users/server/migrations/20231206175252_users_add-roles-list.js @@ -0,0 +1,39 @@ +// @ts-check +const { Sequelize, DataTypes } = require('sequelize') + +module.exports = { + async up({ context: queryInterface, appConfig }) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addColumn( + 'users', + 'roles', + { + type: DataTypes.ARRAY(DataTypes.STRING), + allowNull: false, + defaultValue: [], + }, + { transaction } + ) + await queryInterface.sequelize.query( + ` + UPDATE users + SET roles = ARRAY[role]::varchar[] + WHERE role IS NOT NULL; + `, + { transaction } + ) + await queryInterface.changeColumn( + 'users', + 'role', + { + type: DataTypes.STRING, + allowNull: true, + }, + { transaction } + ) + }) + }, + async down({ context: queryInterface, appConfig }) { + await queryInterface.removeColumn('users', 'roles') + }, +} diff --git a/src/modules/users/server/models/user.ts b/src/modules/users/server/models/user.ts index 4d786423..64a71e3c 100644 --- a/src/modules/users/server/models/user.ts +++ b/src/modules/users/server/models/user.ts @@ -21,7 +21,7 @@ import { Tag } from './tag' import { appConfig } from '#server/app-config' import dayjs from 'dayjs' -type UserCreateFields = Pick & +type UserCreateFields = Pick & Partial export class User @@ -29,16 +29,16 @@ export class User implements UserModel { declare id: CreationOptional - declare role: UserModel['role'] + // declare role: UserModel['role'] TODO: migration: delete column + declare roles: UserModel['roles'] declare fullName: UserModel['fullName'] declare birthday: UserModel['birthday'] declare email: UserModel['email'] declare stealthMode: UserModel['stealthMode'] declare avatar: UserModel['avatar'] - declare department: UserModel['department'] + // declare department: UserModel['department'] TODO: migration: delete column declare team: UserModel['team'] declare jobTitle: UserModel['jobTitle'] - declare division: UserModel['division'] declare country: UserModel['country'] declare city: UserModel['city'] declare contacts: UserModel['contacts'] @@ -70,12 +70,22 @@ export class User 'avatar', 'email', 'isInitialised', - 'role', - 'division', + 'roles', ], }) } + useCompactView(): UserCompact { + return { + id: this.id, + fullName: this.fullName, + avatar: this.avatar, + email: this.email, + isInitialised: this.isInitialised, + roles: this.roles, + } + } + useMeView(): UserMe { const country = COUNTRIES.find((x) => x.code === this.country) || null return { ...this.toJSON(), countryName: country?.name || null } @@ -107,7 +117,6 @@ export class User birthday: this.birthday, email: this.email, avatar: this.avatar, - department: this.department, team: this.team, jobTitle: this.jobTitle, country: hideGeoData ? null : this.country, @@ -118,7 +127,7 @@ export class User geodata: this.geodata, defaultLocation: hideGeoData ? null : this.defaultLocation, tags, - role: this.role, + roles: this.roles, } } @@ -179,7 +188,7 @@ export class User async anonymize(this: User): Promise { const shortId = this.id.split('-').reverse()[0] return this.set({ - role: appConfig.lowPriorityRole, + roles: [appConfig.lowPriorityRole], fullName: shortId, birthday: null, email: `${shortId}@delet.ed`, @@ -208,9 +217,10 @@ User.init( allowNull: false, primaryKey: true, }, - role: { - type: DataTypes.STRING, + roles: { + type: DataTypes.ARRAY(DataTypes.STRING), allowNull: false, + defaultValue: [], }, fullName: { type: DataTypes.STRING, @@ -236,14 +246,6 @@ User.init( type: DataTypes.STRING, allowNull: true, }, - division: { - type: DataTypes.STRING, - allowNull: true, - }, - department: { - type: DataTypes.STRING, - allowNull: true, - }, team: { type: DataTypes.STRING, allowNull: true, diff --git a/src/modules/users/server/router.ts b/src/modules/users/server/router.ts index 387368b0..a32ad6b0 100644 --- a/src/modules/users/server/router.ts +++ b/src/modules/users/server/router.ts @@ -1,13 +1,19 @@ +import dayjs from 'dayjs' import { FastifyPluginCallback, FastifyRequest } from 'fastify' import { Filterable, Op } from 'sequelize' import { appConfig } from '#server/app-config' import config from '#server/config' -import { COUNTRIES, COUNTRY_COORDINATES, DATE_FORMAT } from '#server/constants' -import { AuthAccount, DefaultPermissionPostfix } from '#shared/types' +import { + COUNTRIES, + COUNTRY_COORDINATES, + DATE_FORMAT, + ADMIN_ACCESS_PERMISSION_RE, +} from '#server/constants' +import { AuthAccount } from '#shared/types' +import * as fp from '#shared/utils/fp' import { Permissions } from '../permissions' import { AuthProvider, - ProfileField, GeoData, ImportedTag, ImportedTagGroup, @@ -23,7 +29,7 @@ import { getUserProviderQuery, removeAuthId, } from './helpers' -import dayjs from 'dayjs' + import { Metadata } from '../metadata-schema' const ROLES_ALLOWED_TO_BE_ON_MAP = appConfig.getRolesByPermission( @@ -38,9 +44,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { fastify.get('/me', async (req, reply) => { return { - isAdmin: req.permissions.some((x) => - x.endsWith(DefaultPermissionPostfix.Admin) - ), + isAdmin: req.permissions.some((x) => ADMIN_ACCESS_PERMISSION_RE.test(x)), user: req.user.useMeView(), // FIXME: store countryName in the database (geodata) permissions: Array.from(req.permissions), } @@ -87,11 +91,20 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { ) } + // process roles + const mergeRolesRequest = appConfig.validateAndMergeEditableRoles( + req.user.roles, + req.body.roles + ) + if (!mergeRolesRequest.success) { + return reply.throw.badParams(mergeRolesRequest.error.message) + } + const roles = mergeRolesRequest.data + await req.user .set({ fullName: req.body.fullName, birthday: req.body.birthday, - department: req.body.department, team: req.body.team, jobTitle: req.body.jobTitle, country: req.body.country, @@ -109,6 +122,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { appConfig.offices[0].id, appConfig.offices ), + roles, }) .save() return reply.ok() @@ -125,8 +139,15 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { if (req.user.isInitialised) { return reply.throw.rejected() } + const mergeRolesRequest = appConfig.validateAndMergeEditableRoles( + req.user.roles, + req.body.roles || [] + ) + if (!mergeRolesRequest.success) { + return reply.throw.badParams(mergeRolesRequest.error.message) + } + const roles = mergeRolesRequest.data const userData: Partial = { - department: req.body.department || null, team: req.body.team || null, jobTitle: req.body.jobTitle || null, country: req.body.country || null, @@ -138,6 +159,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { appConfig.offices ), contacts: req.body.contacts, + roles, } // build `geodata` field @@ -177,10 +199,10 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { // NOTE: temporary route for the onboarding demo fastify.get('/me/reset', async (req, reply) => { - req.check(Permissions.UseOnboarding, Permissions.AdminManage) + req.check(Permissions.UseOnboarding) + req.check(Permissions.AdminManage) await req.user .set({ - department: null, team: null, jobTitle: null, country: null, @@ -232,7 +254,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { ) fastify.get( - '/user/:userId', + '/profile/:userId', async (req: FastifyRequest<{ Params: { userId: string } }>, reply) => { req.check(Permissions.ListProfiles) const user = await fastify.db.User.findByPkActive(req.params.userId, { @@ -251,6 +273,18 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { } ) + fastify.get( + '/user/:userId', + async (req: FastifyRequest<{ Params: { userId: string } }>, reply) => { + req.check(Permissions.ListProfiles) + const user = await fastify.db.User.findByPkActive(req.params.userId) + if (!user) { + return reply.throw.notFound() + } + return user.useCompactView() + } + ) + fastify.post( '/user/batch', async (req: FastifyRequest<{ Body: string[] }>, reply) => { @@ -356,7 +390,8 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { ) fastify.get('/me/tags', async (req, reply) => { - req.check(Permissions.ManageProfile, Permissions.ListProfiles) + req.check(Permissions.ManageProfile) + req.check(Permissions.ListProfiles) // FIXME: missed types for sequelize lazy loading methods (many-to-many relation) // @ts-ignore const tags = (await req.user.getTags()) as Tag[] @@ -382,7 +417,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { return fastify.db.User.findAllActive({ where: { [Op.and]: [ - { role: { [Op.in]: ROLES_ALLOWED_TO_BE_ON_MAP } }, + { roles: { [Op.overlap]: ROLES_ALLOWED_TO_BE_ON_MAP } }, { 'geodata.doNotShareLocation': 'false' }, { 'geodata.coordinates': { [Op.ne]: '[0, 0]' } }, ], @@ -395,7 +430,6 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { 'email', 'avatar', 'city', - 'department', 'team', ], }) @@ -406,7 +440,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { const users = await fastify.db.User.findAllActive({ where: { [Op.and]: [ - { role: { [Op.in]: ROLES_ALLOWED_TO_BE_ON_MAP } }, + { roles: { [Op.overlap]: ROLES_ALLOWED_TO_BE_ON_MAP } }, { 'geodata.doNotShareLocation': 'false' }, { country: { [Op.ne]: null } }, ], @@ -581,49 +615,71 @@ const adminRouter: FastifyPluginCallback = async function (fastify, opts) { } ) - fastify.get( - '/users/:userId', - async (req: FastifyRequest<{ Params: { userId: string } }>, reply) => { - return fastify.db.User.findByPkActive(req.params.userId) - } - ) - fastify.put( '/user/:userId', async ( req: FastifyRequest<{ Params: { userId: string } - Body: Pick + Body: Pick }>, reply ) => { req.check(Permissions.AdminAssignRoles) - if ( - !appConfig.config.permissions.roles.some((x) => x.id === req.body.role) - ) { - return reply.throw.badParams('Invalid role') + + const newRoleIds = req.body.roles + const roleGroups = appConfig.config.permissions.roleGroups + const availableRoles = roleGroups.map(fp.prop('roles')).flat() + + // check for conflicts + for (const roleGroup of roleGroups) { + if (roleGroup.rules.unique) { + const availableRoles = roleGroup.roles.map(fp.prop('id')) + const roles = newRoleIds.filter(fp.isIn(availableRoles)) + if (!roles.length) continue + const users = await fastify.db.User.findAll({ + where: { + id: { [Op.not]: req.params.userId }, + roles: { + [Op.overlap]: roles, + }, + }, + }) + if (users.length) { + return reply.throw.badParams( + `Roles from the ${ + roleGroup.name + } group should be unique. Conflicts with users: ${users + .map(fp.prop('email')) + .join(', ')}` + ) + } + } } + + // sort roles array before saving + const roles = appConfig.sortRoles(newRoleIds) + const user = await fastify.db.User.findByPkActive(req.params.userId) if (!user) { return reply.throw.notFound() } - const previousRole = user.role - await user.set({ role: req.body.role }).save() + const previousRoles = [...user.roles] + await user.set({ roles }).save() + if (fastify.integrations.Matrix) { - const rolesById = appConfig.config.permissions.roles.reduce( - (acc, x) => ({ ...acc, [x.id]: x }), - {} as Record + const rolesById = availableRoles.reduce(fp.by('id'), {}) + const previousRoleNames = previousRoles.map( + (x) => rolesById[x]?.name || x ) - const previousRoleName = rolesById[previousRole]?.name || previousRole - const targetRoleName = rolesById[req.body.role]?.name || req.body.role + const targetRoleNames = newRoleIds.map((x) => rolesById[x]?.name || x) const message = appConfig.templates.notification( 'users', 'roleChanged', { admin: req.user.usePublicProfileView(), user: user.usePublicProfileView(), - previousRoleName, - targetRoleName, + previousRoleNames, + targetRoleNames, } ) if (message) { diff --git a/src/modules/users/templates/notification.yaml b/src/modules/users/templates/notification.yaml index 029d030c..d8155523 100644 --- a/src/modules/users/templates/notification.yaml +++ b/src/modules/users/templates/notification.yaml @@ -1,2 +1,12 @@ roleChanged: | - {{ admin.fullName }} ({{ admin.email }}) assigned {{ user.fullName }}'s ({{ user.email }}) role: "{{ previousRoleName }}" -> "{{ targetRoleName }}" + {{ admin.fullName }} ({{ admin.email }}) updated {{ user.fullName }}'s ({{ user.email }}) roles set: +
+ Previous: + {{#previousRoleNames}} + {{.}} + {{/previousRoleNames}} +
+ New: + {{#targetRoleNames}} + {{.}} + {{/targetRoleNames}} diff --git a/src/modules/users/types.ts b/src/modules/users/types.ts index 6b629cd4..b3d12c8b 100644 --- a/src/modules/users/types.ts +++ b/src/modules/users/types.ts @@ -1,15 +1,13 @@ export interface User { id: string - role: string + roles: string[] fullName: string birthday: string | null email: string stealthMode: boolean avatar: string | null - department: string | null team: string | null jobTitle: string | null - division: string | null country: string | null city: string | null contacts: Record @@ -56,19 +54,18 @@ export interface Session { } export type OnboardingProfileRequest = { - department: string | null team: string | null jobTitle: string | null country: string | null city: string | null contacts: Record tagIds: string[] + roles: string[] } export type ProfileRequest = { fullName: string birthday: string | null - department: string team: string jobTitle: string country: string @@ -77,10 +74,10 @@ export type ProfileRequest = { geodata?: GeoData defaultLocation: string | null contacts: Record | null + roles: string[] } -export type ProfileFormData = Omit & { - department: string | null +export type ProfileFormData = Omit & { country: string | null contacts: Record | null } @@ -92,7 +89,7 @@ export type UserMe = User & { export type UserCompact = Pick< User, - 'id' | 'fullName' | 'email' | 'avatar' | 'isInitialised' | 'role' | 'division' + 'id' | 'fullName' | 'email' | 'avatar' | 'isInitialised' | 'roles' > export type PublicUserProfile = Pick< @@ -102,7 +99,6 @@ export type PublicUserProfile = Pick< | 'birthday' | 'email' | 'avatar' - | 'department' | 'team' | 'jobTitle' | 'country' @@ -111,7 +107,7 @@ export type PublicUserProfile = Pick< | 'bio' | 'geodata' | 'defaultLocation' - | 'role' + | 'roles' > & { countryName: string | null tags: Tag[] @@ -130,7 +126,6 @@ export type UserMapPin = Pick< | 'id' | 'fullName' | 'avatar' - | 'department' | 'team' | 'jobTitle' | 'country' @@ -203,6 +198,7 @@ export type ProfileField = { required: boolean placeholder?: string prefix?: string + requiredForRoles: string[] } export type ProfileFieldsMetadata = { @@ -211,7 +207,6 @@ export type ProfileFieldsMetadata = { export type ProfileMetadata = { birthday: ProfileField - department: ProfileField team: ProfileField jobTitle: ProfileField bio: ProfileField diff --git a/src/modules/visits/client/components/AdminVisits.tsx b/src/modules/visits/client/components/AdminVisits.tsx index f3438c69..b8cd8159 100644 --- a/src/modules/visits/client/components/AdminVisits.tsx +++ b/src/modules/visits/client/components/AdminVisits.tsx @@ -14,14 +14,18 @@ import { useUsersCompact } from '#modules/users/client/queries' import { useUpdateVisitAdmin, useVisitsAdmin } from '../queries' import { VisitStatusTag } from './VisitStatusTag' -export const AdminVisits: React.FC = ({ portals }) => ( - - <_AdminVisits portals={portals} /> - -) +export const AdminVisits: React.FC = ({ portals }) => { + const officeId = useStore(stores.officeId) + return ( + + <_AdminVisits portals={portals} /> + + ) +} export const _AdminVisits: React.FC = ({ portals }) => { useDocumentTitle('Visits') diff --git a/src/modules/visits/client/components/VisitDetail.tsx b/src/modules/visits/client/components/VisitDetail.tsx index 1a299ce0..ab18f58c 100644 --- a/src/modules/visits/client/components/VisitDetail.tsx +++ b/src/modules/visits/client/components/VisitDetail.tsx @@ -16,7 +16,7 @@ import { propEq } from '#shared/utils/fp' import { useStore } from '@nanostores/react' import dayjs from 'dayjs' import React from 'react' -import { useUserAdmin } from '#modules/users/client/queries' +import { useUserCompact } from '#modules/users/client/queries' import { useUpdateVisit, useUpdateVisitAdmin, @@ -38,7 +38,7 @@ export const _VisitDetail = () => { const page = useStore(stores.router) const visitId = page?.route === 'visit' ? page.params.visitId : '' const { data: visit = null, refetch: refetchVisit } = useVisit(visitId) - const { data: user = null } = useUserAdmin(visit?.userId || '', { + const { data: user = null } = useUserCompact(visit?.userId || '', { enabled: !!visit && visit.userId !== me?.id, }) const { data: areas = [] } = useVisitsAreas(visit?.officeId || '', { diff --git a/src/modules/visits/client/components/VisitRequestForm.tsx b/src/modules/visits/client/components/VisitRequestForm.tsx index 04ab9b90..605162d5 100644 --- a/src/modules/visits/client/components/VisitRequestForm.tsx +++ b/src/modules/visits/client/components/VisitRequestForm.tsx @@ -24,11 +24,18 @@ type VisitRequestStep = | 'wait-for-confirmation' | 'needs_confirmation' -export const VisitRequestForm = () => ( - - <_VisitRequestForm /> - -) +export const VisitRequestForm = () => { + const officeId = useStore(stores.officeId) + return ( + + <_VisitRequestForm /> + + ) +} export const _VisitRequestForm: React.FC = () => { useDocumentTitle('Office visit request') diff --git a/src/modules/visits/server/helpers/index.ts b/src/modules/visits/server/helpers/index.ts index 43321272..c925e361 100644 --- a/src/modules/visits/server/helpers/index.ts +++ b/src/modules/visits/server/helpers/index.ts @@ -56,7 +56,7 @@ export const getFullBookableAreas = ( areas .filter((a) => a.bookable) .map((a) => { - const desk = a.desks.find((d) => d.type === 'full_area') + const desk = a.desks.find((d) => d.fullAreaBooking) return desk ? { areaId: a.id, deskId: desk.id } : null }) .filter(Boolean) diff --git a/src/modules/visits/server/router.ts b/src/modules/visits/server/router.ts index 6e43b170..32fd4c9f 100644 --- a/src/modules/visits/server/router.ts +++ b/src/modules/visits/server/router.ts @@ -11,6 +11,7 @@ import { import config from '#server/config' import { OfficeArea, Office } from '#server/app-config/types' import { appEvents } from '#server/utils/app-events' +import * as fp from '#shared/utils/fp' import { getConflictedVisits, getFullBookableAreas, @@ -49,10 +50,10 @@ const publicRouter: FastifyPluginCallback = async (fastify, opts) => { const userRouter: FastifyPluginCallback = async (fastify, opts) => { fastify.get('/notice', async (req: FastifyRequest, reply) => { - req.check(Permissions.Create) if (!req.office) { return reply.throw.badParams('Missing office ID') } + req.check(Permissions.Create, req.office.id) return appConfig.templates.text(mId, 'visitNotice', { officeId: req.office, }) @@ -67,10 +68,10 @@ const userRouter: FastifyPluginCallback = async (fastify, opts) => { }>, reply ) => { - req.check(Permissions.Create) if (!req.office) { return reply.throw.badParams('Missing office ID') } + req.check(Permissions.Create, req.office.id) if (!req.office.allowDeskReservation) { return reply.throw.misconfigured( `The ${req.office.name} office doesn't support desk reservation` @@ -102,18 +103,20 @@ const userRouter: FastifyPluginCallback = async (fastify, opts) => { }>, reply ) => { - req.check(Permissions.Create) if (!req.params.visitId) { return reply.throw.badParams() } - const where: Filterable['where'] = { id: req.params.visitId } - if (!req.can(Permissions.AdminList)) { - where.userId = req.user.id - } - const visit = await fastify.db.Visit.findOne({ where }) + const visit = await fastify.db.Visit.findByPk(req.params.visitId) if (!visit) { return reply.throw.notFound() } + req.check(Permissions.Create, visit.officeId) + if ( + req.user.id !== visit.id && + !req.can(Permissions.AdminManage, visit.officeId) + ) { + return reply.throw.accessDenied() + } return reply.send(visit) } ) @@ -127,20 +130,21 @@ const userRouter: FastifyPluginCallback = async (fastify, opts) => { }>, reply ) => { - req.check(Permissions.Create) const visitId = req.params.visitId const status = req.body.status - - const where: Filterable['where'] = { id: req.params.visitId } - if (!req.can(Permissions.AdminManage)) { - where.userId = req.user.id - } - const visit = await fastify.db.Visit.findOne({ where }) + const visit = await fastify.db.Visit.findByPk(visitId) if (!visit) { return reply.throw.notFound() } + req.check(Permissions.Create, visit.officeId) + if ( + req.user.id !== visit.userId && + !req.can(Permissions.AdminManage, visit.officeId) + ) { + return reply.throw.accessDenied() + } - if (visit.status !== 'confirmed' && req.body.status !== 'cancelled') { + if (visit.status !== 'confirmed' && status !== 'cancelled') { return reply.throw.rejected() } await fastify.db.Visit.update({ status }, { where: { id: visitId } }) @@ -190,10 +194,10 @@ const userRouter: FastifyPluginCallback = async (fastify, opts) => { fastify.get( '/occupancy', async (req: FastifyRequest<{ Reply: VisitsOccupancy }>, reply) => { - req.check(Permissions.Create) if (!req.office) { return reply.throw.badParams('Missing office ID') } + req.check(Permissions.Create, req.office.id) if (!req.office.allowDeskReservation) { return reply.throw.misconfigured( `The ${req.office.name} office doesn't support desk reservation` @@ -247,10 +251,10 @@ const userRouter: FastifyPluginCallback = async (fastify, opts) => { fastify.get( '/areas', async (req: FastifyRequest<{ Reply: OfficeArea[] }>, reply) => { - req.check(Permissions.Create) if (!req.office) { return reply.throw.badParams('Missing office ID') } + req.check(Permissions.Create, req.office.id) if (!req.office.allowDeskReservation) { return reply.throw.misconfigured( `The ${req.office.name} office doesn't support desk reservation` @@ -263,7 +267,10 @@ const userRouter: FastifyPluginCallback = async (fastify, opts) => { fastify.post( '/visit', async (req: FastifyRequest<{ Body: VisitsCreationRequest }>, reply) => { - req.check(Permissions.Create) + if (!req.body.officeId) { + return reply.throw.badParams() + } + req.check(Permissions.Create, req.body.officeId) let visits: Array = [] for (const visitsGroup of req.body.visits) { for (const date of visitsGroup.dates) { @@ -326,7 +333,7 @@ const userRouter: FastifyPluginCallback = async (fastify, opts) => { ) } - // "full_area" areaId + deskId pairs + // "fullAreaBooking" areaId + deskId pairs const bookableAreaDeskPairs = getFullBookableAreas(office.areas!) // check whether the requested desks are in fully booked areas @@ -443,10 +450,10 @@ const userRouter: FastifyPluginCallback = async (fastify, opts) => { }>, reply ) => { - req.check(Permissions.Create) if (!req.office) { return reply.throw.badParams('Missing office ID') } + // req.check(Permissions.Create, req.office.id) if (!req.office.allowDeskReservation) { return reply.throw.misconfigured( `The ${req.office.name} office doesn't support desk reservation` @@ -477,7 +484,7 @@ const userRouter: FastifyPluginCallback = async (fastify, opts) => { fullBookableAreas ) - const desks = req.office + const allDesks = req.office .areas!.filter((a) => a.available) .map((a) => a.desks.map((d) => ({ @@ -486,14 +493,40 @@ const userRouter: FastifyPluginCallback = async (fastify, opts) => { type: d.type, user: d.user, multiple: d.allowMultipleBookings, + permittedRoles: d.permittedRoles, })) ) .flat() + + const deskRoles = Array.from( + new Set( + allDesks + .filter((x) => x.permittedRoles.length) + .map((x) => x.permittedRoles) + .flat() + ) + ) + const usersWithDeskRoles = await fastify.db.User.findAll({ + where: { roles: { [Op.overlap]: deskRoles } }, + }) + const deskRolesPresented = deskRoles.filter((x) => + usersWithDeskRoles.some((u) => u.roles.includes(x)) + ) + + const desks = allDesks .filter((x) => { // desk is in the fully reserved area if (reservedAreaIds.includes(x.areaId)) { return false } + // desk is only available for specified list of roles + if (x.permittedRoles.length) { + if (x.permittedRoles.some((x) => deskRolesPresented.includes(x))) { + if (!x.permittedRoles.some((x) => req.user.roles.includes(x))) { + return false + } + } + } // Desk can be booked multiple times if (x.multiple) { return true @@ -518,6 +551,7 @@ const userRouter: FastifyPluginCallback = async (fastify, opts) => { ) }) .map((x) => ({ areaId: x.areaId, deskId: x.deskId })) + return desks } ) @@ -531,10 +565,10 @@ const userRouter: FastifyPluginCallback = async (fastify, opts) => { }>, reply ) => { - req.check(Permissions.ListVisitors) if (!req.office) { return reply.throw.badParams('Missing office ID') } + req.check(Permissions.ListVisitors, req.office.id) if (!req.office.allowDeskReservation) { return reply.throw.misconfigured( `The ${req.office.name} office doesn't support desk reservation` @@ -571,7 +605,6 @@ const userRouter: FastifyPluginCallback = async (fastify, opts) => { fastify.post( '/stealth', async (req: FastifyRequest<{ Body: { stealthMode: boolean } }>, reply) => { - req.check(Permissions.ListVisitors) await fastify.db.User.update( { stealthMode: req.body.stealthMode }, { where: { id: req.user.id } } @@ -591,10 +624,10 @@ const adminRouter: FastifyPluginCallback = async (fastify, opts) => { }>, reply ) => { - req.check(Permissions.AdminList) if (!req.office) { return reply.throw.badParams('Missing office ID') } + req.check(Permissions.AdminList, req.office.id) const dates = req.query['dates[]'] const where: Filterable['where'] = { officeId: req.office.id } if (dates?.length) { @@ -616,13 +649,13 @@ const adminRouter: FastifyPluginCallback = async (fastify, opts) => { }>, reply ) => { - req.check(Permissions.AdminList, Permissions.AdminManage) const visitId = req.params.visitId const status = req.body.status const visit = await fastify.db.Visit.findByPk(req.params.visitId) if (!visit) { return reply.throw.notFound() } + req.check(Permissions.AdminManage, visit.officeId) await fastify.db.Visit.update({ status }, { where: { id: visitId } }) // Send user notification to the user via Matrix if (fastify.integrations.Matrix) { @@ -665,7 +698,7 @@ const adminRouter: FastifyPluginCallback = async (fastify, opts) => { ) } } - appEvents.useModule('admin').emit('update_counters') + // appEvents.useModule('admin').emit('update_counters') return reply.code(200).send() } ) diff --git a/src/modules/working-hours/client/components/AdminWorkingHours.tsx b/src/modules/working-hours/client/components/AdminWorkingHours.tsx index 92d4e49d..f964efc5 100644 --- a/src/modules/working-hours/client/components/AdminWorkingHours.tsx +++ b/src/modules/working-hours/client/components/AdminWorkingHours.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import dayjs, { Dayjs } from 'dayjs' +import config from '#client/config' import { Button, H1, @@ -10,9 +11,9 @@ import { Tag, UserLabel, } from '#client/components/ui' -import { by, groupBy, propEq, sortBy } from '#shared/utils/fp' +import { by, groupBy, pick, propEq, propIn, sortBy } from '#shared/utils/fp' import { formatDateRange } from '#client/utils' -import { DATE_FORMAT } from '#client/constants' +import { DATE_FORMAT, USER_ROLES } from '#client/constants' import { useUsersCompact } from '#modules/users/client/queries' import { useAdminEntries, @@ -53,7 +54,7 @@ type Unit = 'week' | 'month' export const AdminWorkingHours: React.FC = () => { const [offset, setOffset] = React.useState(0) const [unit, setUnit] = React.useState('week') - const [division, setDivision] = React.useState('') + const [role, setRole] = React.useState('') const [showExportModal, setShowExportModal] = React.useState(false) const [shownUser, setShownUser] = React.useState(null) @@ -65,7 +66,7 @@ export const AdminWorkingHours: React.FC = () => { return [start, end] }, [offset, unit]) - const { data: configByDivision = {} } = useAdminConfig() + const { data: configByRole = {} } = useAdminConfig() const { data: entries = [] } = useAdminEntries( period[0].format(DATE_FORMAT), period[1].format(DATE_FORMAT), @@ -77,7 +78,7 @@ export const AdminWorkingHours: React.FC = () => { null ) const { data: userConfigs = [] } = useAdminUserConfigs({ - division, + role, }) const userConfigByUserId = React.useMemo( @@ -85,14 +86,16 @@ export const AdminWorkingHours: React.FC = () => { [userConfigs] ) - const divisions = React.useMemo( - () => Object.keys(configByDivision), - [configByDivision] - ) + const roles = React.useMemo(() => { + const allowedRoles = Object.keys(configByRole) + return USER_ROLES.filter((x) => allowedRoles.includes(x.id)).map( + pick(['id', 'name']) + ) + }, [configByRole]) const moduleConfig = React.useMemo( - () => (division ? configByDivision[division] : null), - [configByDivision, division] + () => (role ? configByRole[role] : null), + [configByRole, role] ) const { data: users = [] } = useUsersCompact(undefined, { @@ -124,7 +127,10 @@ export const AdminWorkingHours: React.FC = () => { const userWorkingHours = React.useMemo(() => { return users - .filter((user) => user.division === division) + .filter((user) => { + const userRole = roles.find(propIn('id', user.roles)) + return userRole?.id === role + }) .map((user) => { const workingHours = calculateTotalWorkingHours( entriesByUser[user.id] || [] @@ -164,7 +170,8 @@ export const AdminWorkingHours: React.FC = () => { }, [ entriesByUser, users, - division, + role, + roles, moduleConfig, unit, period, @@ -184,14 +191,9 @@ export const AdminWorkingHours: React.FC = () => { { Header: 'User', accessor: (x: UserWorkingHours) => { - return + return }, }, - { - Header: 'Division', - accessor: (x: UserWorkingHours) => - x.user.division || UNKNOWN, - }, { Header: 'Working hours', accessor: (x: UserWorkingHours) => { @@ -261,10 +263,10 @@ export const AdminWorkingHours: React.FC = () => { ) React.useEffect(() => { - if (divisions.length) { - setDivision(divisions[0]) + if (roles.length) { + setRole(roles[0].id) } - }, [divisions]) + }, [roles]) const isQueryParamsHandled = React.useRef(false) @@ -355,14 +357,14 @@ export const AdminWorkingHours: React.FC = () => { containerClassName="mr-2" /> - {/* division picker */} + {/* role picker */} ({ value: x, label: x }))} - value={division} - onChange={setDivision} + options={roles.map((x) => ({ value: x.id, label: x.name }))} + value={role} + onChange={setRole} />
diff --git a/src/modules/working-hours/client/components/WorkingHoursUserModal.tsx b/src/modules/working-hours/client/components/WorkingHoursUserModal.tsx index 34e39b35..cb69b225 100644 --- a/src/modules/working-hours/client/components/WorkingHoursUserModal.tsx +++ b/src/modules/working-hours/client/components/WorkingHoursUserModal.tsx @@ -245,7 +245,7 @@ export const WorkingHoursUserModal: React.FC<{ return (
- +
Agreed working week: {mergedModuleConfig.weeklyWorkingHours}h
diff --git a/src/modules/working-hours/client/queries.ts b/src/modules/working-hours/client/queries.ts index b40918fc..d5899d1a 100644 --- a/src/modules/working-hours/client/queries.ts +++ b/src/modules/working-hours/client/queries.ts @@ -194,21 +194,21 @@ export const useAdminUserConfigs = ( | { userId: string } - | { division: string } + | { role: string } ) => { const path = '/admin-api/working-hours/user-configs' - const query: { userId?: string; division?: string } = {} + const query: { userId?: string; role?: string } = {} if ('userId' in opts) { query.userId = opts.userId } - if ('division' in opts) { - query.division = opts.division + if ('role' in opts) { + query.role = opts.role } return useQuery( [path, query], async ({ queryKey }) => (await api.get(path, { params: queryKey[1] })) .data, - { enabled: !!query.userId || !!query.division } + { enabled: !!query.userId || !!query.role } ) } diff --git a/src/modules/working-hours/metadata-schema.ts b/src/modules/working-hours/metadata-schema.ts index 13466010..62ca7aa9 100644 --- a/src/modules/working-hours/metadata-schema.ts +++ b/src/modules/working-hours/metadata-schema.ts @@ -4,7 +4,7 @@ const timeSchema = z.string().regex(/^([0-1][0-9]|2[0-3]):[0-5][0-9]$/) export const schema = z .object({ - configByDivision: z.record( + configByRole: z.record( z .object({ workingDays: z.array(z.number().min(0).max(6)).min(1), diff --git a/src/modules/working-hours/server/jobs/fetch-default-working-hours.ts b/src/modules/working-hours/server/jobs/fetch-default-working-hours.ts index 8b2e1dc7..baf408c4 100644 --- a/src/modules/working-hours/server/jobs/fetch-default-working-hours.ts +++ b/src/modules/working-hours/server/jobs/fetch-default-working-hours.ts @@ -8,31 +8,23 @@ export const cronJob: CronJob = { name: 'fetch-default-hours-reminder', cron: '0 21 * * 0-4', // daily Sun-Thu at 9PM fn: async (ctx: CronJobContext) => { - if (!ctx.integrations.Matrix) { - ctx.log.error( - 'Cannot send working hours reminders: disabled Matrix integration.' - ) - return - } if (!ctx.integrations.BambooHR) { - ctx.log.error( - 'Cannot send working hours reminders: disabled Matrix integration.' - ) + ctx.log.error('Cannot run the job: disabled BambooHR integration.') return } const moduleMetadata = ctx.appConfig.getModuleMetadata( 'working-hours' ) as Metadata - const configByDivision = moduleMetadata?.configByDivision as Record< + const configByRole = moduleMetadata?.configByRole as Record< string, WorkingHoursConfig > - const divisions = Object.keys(configByDivision) + const allowedRoles = Object.keys(configByRole) const users = await ctx.models.User.findAllActive({ where: { - division: { [Op.in]: divisions }, + roles: { [Op.overlap]: allowedRoles }, isInitialised: true, }, }) diff --git a/src/modules/working-hours/server/jobs/working-hours-reminder.ts b/src/modules/working-hours/server/jobs/working-hours-reminder.ts index 60517c66..ac2e13c8 100644 --- a/src/modules/working-hours/server/jobs/working-hours-reminder.ts +++ b/src/modules/working-hours/server/jobs/working-hours-reminder.ts @@ -20,11 +20,11 @@ export const cronJob: CronJob = { const moduleMetadata = ctx.appConfig.getModuleMetadata( 'working-hours' ) as Metadata - const configByDivision = moduleMetadata?.configByDivision as Record< + const configByRole = moduleMetadata?.configByRole as Record< string, WorkingHoursConfig > - const divisions = Object.keys(configByDivision) + const allowedRoles = Object.keys(configByRole) const lastWorkingDay = getPreviousWeekday() const today = dayjs() @@ -55,7 +55,7 @@ export const cronJob: CronJob = { const users = await ctx.models.User.findAllActive({ where: { id: { [Op.notIn]: excludedUserIds }, - division: { [Op.in]: divisions }, + roles: { [Op.overlap]: allowedRoles }, isInitialised: true, // NOTE: test mode email: { [Op.in]: config.workingHoursTestGroup }, @@ -82,7 +82,8 @@ export const cronJob: CronJob = { const report = { succeeded: 0, failed: 0 } for (const user of users) { - const config = configByDivision[user.division || ''] + const userRole = user.roles.find((x) => allowedRoles.includes(x)) + const config = configByRole[userRole || ''] if (!config) continue const response = await ctx.integrations.Matrix.sendMessageToUser( user, diff --git a/src/modules/working-hours/server/router.ts b/src/modules/working-hours/server/router.ts index 625ced63..62030063 100644 --- a/src/modules/working-hours/server/router.ts +++ b/src/modules/working-hours/server/router.ts @@ -51,11 +51,16 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { return null } const metadata = appConfig.getModuleMetadata('working-hours') as Metadata - if (!metadata || !req.user.division) return null - const divisionConfig = metadata.configByDivision[req.user.division] || null - if (!divisionConfig) return null + if (!metadata) return null + + const allowedRoles = Object.keys(metadata.configByRole) + const userRole = req.user.roles.find((x) => allowedRoles.includes(x)) + if (!userRole) return null + + const roleConfig = metadata.configByRole[userRole] || null + if (!roleConfig) return null const result: WorkingHoursConfig = { - ...divisionConfig, + ...roleConfig, personalDefaultEntries: [], } @@ -376,7 +381,7 @@ const adminRouter: FastifyPluginCallback = async function (fastify, opts) { fastify.get('/config', async (req: FastifyRequest, reply) => { req.check(Permissions.AdminList) const metadata = appConfig.getModuleMetadata('working-hours') as Metadata - return metadata.configByDivision + return metadata.configByRole }) fastify.get( @@ -448,7 +453,7 @@ const adminRouter: FastifyPluginCallback = async function (fastify, opts) { async ( req: FastifyRequest<{ Querystring: { - division?: string + role?: string userId?: string } }>, @@ -456,19 +461,17 @@ const adminRouter: FastifyPluginCallback = async function (fastify, opts) { ) => { req.check(Permissions.AdminList) const metadata = appConfig.getModuleMetadata('working-hours') as Metadata - const divisions = Object.keys(metadata.configByDivision) + const allowedRoles = Object.keys(metadata.configByRole) const where: WhereOptions = {} if (req.query.userId) { where['userId'] = req.query.userId - } else if (req.query.division) { - if (!divisions.includes(req.query.division)) { - return reply.throw.badParams( - `Unknown division "${req.query.division}"` - ) + } else if (req.query.role) { + if (!allowedRoles.includes(req.query.role)) { + return reply.throw.badParams(`Unknown role "${req.query.role}"`) } const userIds = await fastify.db.User.findAllActive({ - where: { division: req.query.division }, + where: { roles: { [Op.contains]: [req.query.role] } }, attributes: ['id'], }).then(map(prop('id'))) where['userId'] = { [Op.in]: userIds } @@ -486,7 +489,7 @@ const adminRouter: FastifyPluginCallback = async function (fastify, opts) { async ( req: FastifyRequest<{ Querystring: { - division: string + role: string from: string to: string roundUp?: string @@ -495,19 +498,19 @@ const adminRouter: FastifyPluginCallback = async function (fastify, opts) { reply ) => { req.check(Permissions.AdminList) - if (!req.query.division || !req.query.from || !req.query.to) { + if (!req.query.role || !req.query.from || !req.query.to) { return reply.throw.badParams('Missing parameters') } const metadata = appConfig.getModuleMetadata('working-hours') as Metadata if ( !metadata || - !req.query.division || - !metadata.configByDivision[req.query.division] + !req.query.role || + !metadata.configByRole[req.query.role] ) { - return reply.throw.misconfigured('Unsuported division') + return reply.throw.misconfigured('Unsuported role') } - const moduleConfig = metadata.configByDivision[ - req.query.division + const moduleConfig = metadata.configByRole[ + req.query.role ] as WorkingHoursConfig const roundUp = !!req.query.roundUp @@ -523,7 +526,7 @@ const adminRouter: FastifyPluginCallback = async function (fastify, opts) { ) const users = await fastify.db.User.findAll({ - where: { division: req.query.division }, + where: { roles: { [Op.contains]: [req.query.role] } }, }) const userConfigs = await fastify.db.WorkingHoursUserConfig.findAll({ where: { userId: { [Op.in]: users.map(prop('id')) } }, @@ -717,7 +720,7 @@ const adminRouter: FastifyPluginCallback = async function (fastify, opts) { const csvContent = csvParser.unparse(csvRows) reply.headers({ 'Content-Type': 'text/csv', - 'Content-Disposition': `attachment; filename=time-tracking-${req.query.division}-${req.query.from}-${req.query.to}.csv`, + 'Content-Disposition': `attachment; filename=time-tracking-${req.query.role}-${req.query.from}-${req.query.to}.csv`, Pragma: 'no-cache', }) return reply.code(200).send(csvContent) @@ -739,16 +742,12 @@ const adminRouter: FastifyPluginCallback = async function (fastify, opts) { } const metadata = appConfig.getModuleMetadata('working-hours') as Metadata - if ( - !metadata || - !req.user.division || - !metadata.configByDivision[req.user.division] - ) { - return reply.throw.misconfigured('Unsuported division') + const allowedRoles = Object.keys(metadata.configByRole) + const userRole = user.roles.find((x) => allowedRoles.includes(x)) + if (!userRole) { + return reply.throw.misconfigured('Unsuported role') } - const moduleConfig = metadata.configByDivision[ - req.user.division - ] as WorkingHoursConfig + const moduleConfig = metadata.configByRole[userRole] as WorkingHoursConfig const userConfig = await fastify.db.WorkingHoursUserConfig.findOne({ where: { userId: user.id }, diff --git a/src/server/app-config/index.ts b/src/server/app-config/index.ts index d8f75b8b..16619cea 100644 --- a/src/server/app-config/index.ts +++ b/src/server/app-config/index.ts @@ -6,6 +6,7 @@ import { safeRequire, getFilePath } from '#server/utils' import { PermissionsSet } from '#shared/utils' import * as fp from '#shared/utils/fp' import { log } from '#server/utils/log' +import { SafeResponse } from '#server/types' import { AppTemplates } from './templates' import { AppModule, @@ -13,6 +14,7 @@ import { IntegrationManifest, Office, AppConfigJson, + UserRole, } from './types' import * as schemas from './schemas' @@ -28,6 +30,8 @@ class AppError extends Error { export class AppConfig { private superusers: Set = new Set(config.superusers) private permissionsByRole!: Record + private allRoles!: UserRole[] + private allRoleIds!: string[] private allPermissions!: string[] public config!: AppConfigJson @@ -180,8 +184,31 @@ export class AppConfig { } this.integrations = integrations + // validate the `permittedRoles` lists for each desk + this.allRoles = this.config.permissions.roleGroups + .map(fp.prop('roles')) + .flat() + this.allRoleIds = this.allRoles.map(fp.prop('id')) + this.config.company.offices.forEach((o) => { + o.areas?.forEach((a) => { + a.desks.forEach((d) => { + if (d.permittedRoles.length) { + const unsupportedRole = d.permittedRoles.find( + (x) => !this.allRoleIds.includes(x) + ) + if (unsupportedRole) { + throw new AppError( + `There is an unsupported role assigned to the "${o.id} ${a.id} ${d.id}" desk. Please change it to one of the roles listed in the "./config/permissions.json" file.`, + unsupportedRole + ) + } + } + }) + }) + }) + // store permissions - this.permissionsByRole = this.config.permissions.roles.reduce((acc, x) => { + this.permissionsByRole = this.allRoles.reduce((acc, x) => { return { ...acc, [x.id]: x.permissions } }, {}) this.allPermissions = appModules @@ -231,7 +258,7 @@ export class AppConfig { getUserPermissions( email: string | null, authAddresses: string[], - role: string + roles: string[] ): PermissionsSet { if (email && this.superusers.has(email)) { return new PermissionsSet(this.allPermissions) @@ -243,7 +270,9 @@ export class AppConfig { } } } - return new PermissionsSet(this.permissionsByRole[role] || []) + return new PermissionsSet( + roles.map((x) => this.permissionsByRole[x] || []).flat() + ) } getModuleMetadata(moduleId: string): unknown | null { @@ -262,10 +291,37 @@ export class AppConfig { } getRolesByPermission(permission: string): string[] { - return this.config.permissions.roles + return this.allRoles .filter((x) => x.permissions.includes(permission)) .map((x) => x.id) } + + sortRoles(roleIds: string[]): string[] { + const unsupportedRoles = roleIds.filter(fp.isNotIn(this.allRoleIds)) + const allowedRoles = this.allRoleIds.filter(fp.isIn(roleIds)) + return unsupportedRoles.concat(allowedRoles) + } + + validateAndMergeEditableRoles( + userRoles: string[], + editedRoles: string[] + ): SafeResponse { + const editableRoleGroups = appConfig.config.permissions.roleGroups.filter( + (x) => + x.rules.editableByRoles.length && + x.rules.editableByRoles.some(fp.isIn(userRoles)) + ) + const editableRoles = editableRoleGroups + .map(fp.prop('roles')) + .flat() + .map(fp.prop('id')) + if (editedRoles.some(fp.isNotIn(editableRoles))) { + return { success: false, error: new Error('Invalid roles') } + } + let roles = userRoles.filter(fp.isNotIn(editableRoles)) // non-editable roles + roles = roles.concat(editedRoles) // concat them with editable roles from the request + return { success: true, data: this.sortRoles(roles) } + } } export const appConfig = new AppConfig() diff --git a/src/server/app-config/schemas.ts b/src/server/app-config/schemas.ts index 2c2e3c8a..51edb932 100644 --- a/src/server/app-config/schemas.ts +++ b/src/server/app-config/schemas.ts @@ -52,9 +52,11 @@ export const officeAreaDesk = z y: z.number().min(0).max(100), }), allowMultipleBookings: z.boolean().default(false).optional(), - user: z.string().email().optional(), + fullAreaBooking: z.boolean().default(false), + permittedRoles: z.array(z.string()).default([]), }) .and( + // TODO: delete `type` & `user` fields z.union([ z.object({ type: z.literal('personal'), @@ -155,19 +157,38 @@ export const office = z export const companyConfig = z.object({ name: z.string().nonempty(), offices: z.array(office).min(1), - departments: z.array(z.string()).min(1).optional(), - divisions: z.array(z.string()).min(1).optional(), }) -export const appRole = z.object({ +export const userRole = z.object({ id: z.string(), name: z.string(), - permissions: z.array(z.string()), + permissions: z.array(z.string()).default([]), accessByDefault: z.boolean().default(false).optional(), }) +export const userRoleGroup = z.object({ + id: z.string(), + name: z.string(), + rules: z + .object({ + max: z.number().min(1).optional(), + unique: z.boolean().default(false), + editableByRoles: z.array(z.string()).default([]), + }) + .superRefine((rules, ctx) => { + if (rules.unique && rules.editableByRoles.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Unique fields cannot be editable by users', + }) + } + }) + .default({ max: undefined, unique: false }), + roles: z.array(userRole).min(1), +}) + export const permissionsConfig = z.object({ - roles: z.array(appRole).min(1), + roleGroups: z.array(userRoleGroup).min(1), defaultRoleByEmailDomain: z .record(z.string()) .and(z.object({ __default: z.string() })), diff --git a/src/server/app-config/types.ts b/src/server/app-config/types.ts index e8a33088..301feeee 100644 --- a/src/server/app-config/types.ts +++ b/src/server/app-config/types.ts @@ -10,7 +10,8 @@ export type OfficeArea = z.infer export type OfficeRoom = z.infer export type Office = z.infer export type CompanyConfig = z.infer -export type AppRole = z.infer +export type UserRole = z.infer +export type UserRoleGroup = z.infer export type PermissionsConfig = z.infer export type ModuleConfig = z.infer export type ModulesConfig = z.infer diff --git a/src/server/auth/providers/google/index.ts b/src/server/auth/providers/google/index.ts index 719039c7..9fe90d0c 100644 --- a/src/server/auth/providers/google/index.ts +++ b/src/server/auth/providers/google/index.ts @@ -72,7 +72,7 @@ export const plugin: FastifyPluginCallback = async ( fullName: data.name, email: data.email, avatar: data.picture, - role: appConfig.getDefaultUserRoleByEmail(data.email), + roles: [appConfig.getDefaultUserRoleByEmail(data.email)], }) } else { await user.set({ avatar: data.picture }).save() diff --git a/src/server/auth/providers/polkadot/index.ts b/src/server/auth/providers/polkadot/index.ts index 8a330c7b..af7663bc 100644 --- a/src/server/auth/providers/polkadot/index.ts +++ b/src/server/auth/providers/polkadot/index.ts @@ -118,7 +118,7 @@ export const plugin: FastifyPluginCallback = async ( const newUser = await fastify.db.User.create({ authIds: authIds as AuthIds, fullName: '', - role: appConfig.lowPriorityRole, + roles: [appConfig.lowPriorityRole], isInitialised: false, email: '', }) diff --git a/src/server/constants.ts b/src/server/constants.ts index e6f5bcff..d0c80593 100644 --- a/src/server/constants.ts +++ b/src/server/constants.ts @@ -2,12 +2,21 @@ export const ROBOT_USER_ID = '00000000-0000-0000-0000-000000000000' export const SESSION_TOKEN_COOKIE_NAME = 'hqappjwt' +// TODO: implement shared constants and move it there export const DATE_FORMAT = 'YYYY-MM-DD' export const FRIENDLY_DATE_FORMAT = 'MMMM D YYYY' export const DATE_FORMAT_DAY_NAME = 'ddd, MMMM D' +// TODO: implement shared constants and move it there +export const ADMIN_ACCESS_PERMISSION_POSTFIX = '__admin' + +// TODO: implement shared constants and move it there +export const ADMIN_ACCESS_PERMISSION_RE = new RegExp( + `^.*\.${ADMIN_ACCESS_PERMISSION_POSTFIX}` +) + export const COUNTRIES = [ { name: 'Afghanistan', code: 'AF', emoji: 'πŸ‡¦πŸ‡«' }, { name: 'Γ…land Islands', code: 'AX', emoji: 'πŸ‡¦πŸ‡½' }, diff --git a/src/server/module-router-plugin.ts b/src/server/module-router-plugin.ts index 3b1f8637..ce50ffad 100644 --- a/src/server/module-router-plugin.ts +++ b/src/server/module-router-plugin.ts @@ -1,9 +1,12 @@ -import path from 'path' import Bottleneck from 'bottleneck' import { FastifyPluginCallback, FastifyRequest } from 'fastify' import nodeCron from 'node-cron' -import { SESSION_TOKEN_COOKIE_NAME } from '#server/constants' -import { appConfig, AppConfig } from '#server/app-config' +import { + SESSION_TOKEN_COOKIE_NAME, + ADMIN_ACCESS_PERMISSION_POSTFIX, + ADMIN_ACCESS_PERMISSION_RE, +} from '#server/constants' +import { appConfig } from '#server/app-config' import config from '#server/config' import { sequelize } from '#server/db' import { safeRequire, getFilePath } from '#server/utils' @@ -13,7 +16,6 @@ import { ConnectedModels, ConnectedIntegrations, } from '#server/types' -import { DefaultPermissionPostfix } from '#shared/types' import { PermissionsSet } from '#shared/utils' import * as fp from '#shared/utils/fp' @@ -125,16 +127,16 @@ export const moduleRouterPlugin = req.permissions = appConfig.getUserPermissions( user.email, user.getAuthAddresses(), - user.role + user.roles ) } } } - req.can = (permission: string) => { - return req.permissions.has(permission) + req.can = (permission: string, officeId?: string) => { + return req.permissions.has(permission, officeId) } - req.check = (...permissions: string[]) => { - if (!req.permissions.hasAll(permissions)) { + req.check = (permission: string, officeId?: string) => { + if (!req.can(permission, officeId)) { reply.status(403) throw new Error('Access denied') } @@ -169,9 +171,11 @@ export const moduleRouterPlugin = if (moduleRouters.adminRouter) { fastify.register(async (fastify) => { fastify.addHook('onRequest', async (req, reply) => { - if ( - !req.can(`${module.id}.${DefaultPermissionPostfix.Admin}`) - ) { + const adminAccessPermission = `${module.id}.${ADMIN_ACCESS_PERMISSION_POSTFIX}` + const canAccess = req.permissions.some((x) => + x.startsWith(adminAccessPermission) + ) + if (!canAccess) { return reply.throw.accessDenied() } }) @@ -223,9 +227,9 @@ export const moduleRouterPlugin = fastify.decorate('sequelize', sequelize) } -const initialiseIntegrations = async (): Promise< +async function initialiseIntegrations(): Promise< Record -> => { +> { const integrationById: Record = {} for (const appIntegration of appConfig.integrations) { const { default: Integration } = require(getFilePath( diff --git a/src/server/types/index.ts b/src/server/types/index.ts index af6e7448..c964e650 100644 --- a/src/server/types/index.ts +++ b/src/server/types/index.ts @@ -24,8 +24,8 @@ declare module 'fastify' { interface FastifyRequest { user: User permissions: PermissionsSet - can: (permission: string) => boolean - check: (...permissions: string[]) => void | never + can: (permission: string, officeId?: string) => boolean + check: (permission: string, officeId?: string) => void | never office?: Office } interface FastifyReply { @@ -66,7 +66,6 @@ export type GoogleParsedStateQueryParam = Record< string | null > -export type SafeResponse = - | { success: true; data: T extends undefined ? never : T } - | { success: false; error: Error } - | { success: true } +export type SafeResponse = T extends undefined + ? { success: true } | { success: false; error: Error } + : { success: true; data: T } | { success: false; error: Error } diff --git a/src/server/utils/index.ts b/src/server/utils/index.ts index 3e7d9de7..7af7c210 100644 --- a/src/server/utils/index.ts +++ b/src/server/utils/index.ts @@ -54,9 +54,6 @@ export const getUtcHourForTimezone = ( return result } -export const escapeRegExpSensitiveCharacters = (str: string): string => - str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - export const cloneRegExp = (input: RegExp, injectFlags: string = '') => { const pattern = input.source let flags = '' diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index c4eec800..e73fc237 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -7,7 +7,3 @@ export enum EntityVisibility { Visible = 'visible', UrlPublic = 'url_public', } - -export enum DefaultPermissionPostfix { - Admin = '__admin', -} diff --git a/src/shared/utils/fp.ts b/src/shared/utils/fp.ts index 65905fc6..809ce146 100644 --- a/src/shared/utils/fp.ts +++ b/src/shared/utils/fp.ts @@ -18,6 +18,16 @@ export const propNotEq = (obj: O): boolean => obj[field] !== ref +export const isIn = + (refArray: E[]) => + (el: E): boolean => + refArray.includes(el) + +export const isNotIn = + (refArray: E[]) => + (el: E): boolean => + !refArray.includes(el) + export const propIn = (field: keyof O, refArray: any[]) => (obj: O): boolean => @@ -161,3 +171,11 @@ export function camelcasify(text: string): string { .map((x: string) => capitalize(x)) .join('') } + +export function escapeRegExpSensitiveCharacters(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +export function hasIntersection(arr1: T[], arr2: T[]): boolean { + return arr1.some((x) => arr2.includes(x)) +} diff --git a/src/shared/utils/permissions-set.ts b/src/shared/utils/permissions-set.ts index f0fc77ba..e88dcacc 100644 --- a/src/shared/utils/permissions-set.ts +++ b/src/shared/utils/permissions-set.ts @@ -1,6 +1,19 @@ +import { escapeRegExpSensitiveCharacters, last } from './fp' + type PermissionsSetMapFn = (value: string) => boolean export class PermissionsSet extends Set { + has(permission: string, officeId?: string): boolean { + if (!officeId) { + return super.has(permission) + } + return super.has(permission + ':' + officeId) || super.has(permission) + } + hasRoot(permissionRoot: string): boolean { + const escaped = escapeRegExpSensitiveCharacters(permissionRoot) + const re = new RegExp(`^${escaped}(:.*)?$`) + return this.some((x) => re.test(x)) + } some(fn: PermissionsSetMapFn, thisArg?: any): boolean { for (const value of this) { if (fn.call(thisArg, value)) { @@ -9,18 +22,21 @@ export class PermissionsSet extends Set { } return false } - every(fn: PermissionsSetMapFn, thisArg?: any): boolean { - for (const value of this) { - if (!fn.call(thisArg, value)) { - return false - } - } - return true + hasAll(values: string[], officeId?: string): boolean { + return values.every((x) => this.has(x, officeId)) } - hasAll(values: string[]): boolean { - return values.every((x) => this.has(x)) + hasAnyOf(values: string[], officeId?: string): boolean { + return values.some((x) => this.has(x, officeId)) } - hasAnyOf(values: string[]): boolean { - return values.some((x) => this.has(x)) + extractOfficeIds(permissionRoot: string): string[] | null { + if (this.has(permissionRoot)) { + return [] + } + const escaped = escapeRegExpSensitiveCharacters(permissionRoot) + const re = new RegExp(`^${escaped}(:.*)?$`) + const officeIds = Array.from(this) + .filter((x) => re.test(x)) + .map((x) => last(x.split(':'))) + return officeIds.length ? officeIds : null } }