├── .dockerignore ├── .example.env ├── .github └── workflows │ └── docker-build.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── Dockerfile ├── LICENSE ├── README.md ├── config-demo ├── application-2col-sample.json ├── application-3col-sample.json ├── application.json ├── company.json ├── modules.json ├── permissions.json ├── public │ ├── manifest.webmanifest │ ├── maps │ │ └── berlin │ │ │ ├── area-1.png │ │ │ └── none.png │ └── meeting-rooms │ │ └── berlin │ │ └── room-1.jpg └── templates │ └── visits │ └── text.yaml ├── docker-compose-demo.yml ├── docker-compose-dev.yml ├── docker-compose.yml ├── docs ├── configuration.md ├── create-new-office.md ├── functionality.md ├── images │ ├── about.png │ ├── admin.png │ ├── app.png │ ├── desk.png │ ├── events.png │ ├── flow.png │ ├── login.png │ ├── map.png │ ├── meeting.png │ ├── nfts.png │ ├── payment.png │ ├── profile.png │ ├── screen-global.png │ └── wallets.png ├── nft-membership.md ├── quickstart.md └── tablet-setup.md ├── documentation ├── next.config.js ├── package.json ├── pages │ ├── _meta.json │ ├── commonErrors.md │ ├── development.md │ ├── development │ │ ├── create-a-module.md │ │ └── new-route.md │ ├── framework.md │ ├── framework │ │ ├── authentication.md │ │ ├── configuration.md │ │ ├── configuration │ │ │ ├── application.md │ │ │ ├── company.md │ │ │ ├── modules.md │ │ │ ├── permissions.md │ │ │ └── templates.md │ │ └── integrations.md │ ├── index.md │ ├── modules.md │ ├── modules │ │ ├── about.md │ │ ├── admin-dash.md │ │ ├── announcements.md │ │ ├── checklist.md │ │ ├── events.md │ │ ├── forms.md │ │ ├── guest-invites.md │ │ ├── hub-map.md │ │ ├── news.md │ │ ├── office-visits.md │ │ ├── profile-questions.md │ │ ├── quick-navigation.md │ │ ├── room-reservation.md │ │ ├── search.md │ │ ├── users.md │ │ └── visits.md │ └── quickStart.md ├── public │ ├── Polkadot-Hubs-Logotype-BL.png │ ├── about1.png │ ├── aboutpage1.png │ ├── aboutpage2.png │ ├── announcement.png │ ├── desktopLayout.png │ ├── layout-2col.png │ ├── layout-3col.png │ ├── logo.png │ ├── mobileEvents.png │ ├── mobileNews.png │ ├── mobileOffice.png │ ├── modules │ │ ├── about.png │ │ ├── aboutpage1.png │ │ ├── aboutpage2.png │ │ ├── dash.png │ │ ├── dash2.png │ │ ├── dash3.png │ │ ├── dash4.png │ │ ├── dash5.png │ │ ├── dash6.png │ │ ├── eventsEventSample.png │ │ ├── eventsUncompletedActions.png │ │ ├── eventsUpcoming.png │ │ ├── eventsUser.png │ │ ├── guestInviteAdmin.png │ │ ├── guestInviteAdmin2.png │ │ ├── guestInviteForm.png │ │ ├── guestInviteFormGuest.png │ │ ├── officeVisits.png │ │ ├── officeVisitsActions.png │ │ ├── roomReservationApp.png │ │ ├── roomReservationApp2.png │ │ ├── roomReservationTablet1.png │ │ ├── roomReservationTablet2.png │ │ ├── usersMap.png │ │ ├── usersMapPage.png │ │ ├── usersOnboarding1.png │ │ ├── usersOnboarding2.png │ │ ├── usersOnboarding3.png │ │ ├── usersProfileCard.png │ │ └── visitsWhoIsInOffice.png │ ├── screen-global.png │ ├── search.png │ ├── search1.png │ ├── search2.png │ ├── search3.png │ └── search4.png ├── theme.config.jsx └── yarn.lock ├── jsconfig.json ├── nodemon.json ├── package.json ├── public ├── app.html ├── apple-touch-icon.png ├── enkrypt-icon.svg ├── favicon.ico ├── favicon.svg ├── novawallet-icon.svg ├── polkadot-js-icon.svg ├── polkadot-token-icon.svg ├── subwallet-js-icon.svg ├── talisman-icon.svg └── wallet-connect-icon.svg ├── scripts ├── clear.ts ├── client.build.ts ├── migrations.ts └── server.postbuild.ts ├── src ├── client │ ├── App.tsx │ ├── components │ │ ├── AdminHome.tsx │ │ ├── Container.tsx │ │ ├── DatePicker.tsx │ │ ├── DynamicForm.tsx │ │ ├── EntityAccessSelector.tsx │ │ ├── EntityVisibilityTag.tsx │ │ ├── Header.tsx │ │ ├── Home.tsx │ │ ├── Layout.tsx │ │ ├── NotFound.tsx │ │ ├── OfficeFloorMap.tsx │ │ ├── PermissionsValidator.tsx │ │ ├── RouteValidator.tsx │ │ ├── auth │ │ │ ├── Login.tsx │ │ │ ├── LoginButton.tsx │ │ │ ├── PolkadotProvider.tsx │ │ │ ├── WarningModal.tsx │ │ │ ├── config.ts │ │ │ ├── helper.tsx │ │ │ └── steps.tsx │ │ ├── charts │ │ │ ├── Card.tsx │ │ │ ├── StackedBarChart.tsx │ │ │ ├── YearCalendar.tsx │ │ │ └── index.tsx │ │ └── ui │ │ │ ├── Accordion.tsx │ │ │ ├── Avatar.tsx │ │ │ ├── Background.tsx │ │ │ ├── Breadcrumbs.tsx │ │ │ ├── Button.tsx │ │ │ ├── CTA.tsx │ │ │ ├── CopyToClipboard.tsx │ │ │ ├── Counter.tsx │ │ │ ├── DaySlider.tsx │ │ │ ├── DropDown.tsx │ │ │ ├── Filters.tsx │ │ │ ├── HeaderWrapper.tsx │ │ │ ├── Icons.tsx │ │ │ ├── ImageWithPanZoom.tsx │ │ │ ├── Input.tsx │ │ │ ├── Link.tsx │ │ │ ├── Loader.tsx │ │ │ ├── LoadingPolkadot.tsx │ │ │ ├── LoadingPolkadotWithText.tsx │ │ │ ├── LocationMenu.tsx │ │ │ ├── Map │ │ │ ├── index.tsx │ │ │ └── mapbox │ │ │ │ ├── config.tsx │ │ │ │ ├── index.ts │ │ │ │ └── popup.tsx │ │ │ ├── MarkdownTextarea.tsx │ │ │ ├── Modal.tsx │ │ │ ├── Notifications.tsx │ │ │ ├── Panel.tsx │ │ │ ├── Placeholder.tsx │ │ │ ├── PlaceholderCard.tsx │ │ │ ├── ProgressBar.tsx │ │ │ ├── ProgressDots.tsx │ │ │ ├── StealthMode.tsx │ │ │ ├── TabSlider.tsx │ │ │ ├── Table.tsx │ │ │ ├── Tag.tsx │ │ │ ├── Text.tsx │ │ │ ├── TimeLabel.tsx │ │ │ ├── TimeRangePicker.tsx │ │ │ ├── UserLabel.tsx │ │ │ ├── Warning.tsx │ │ │ ├── Wrappers.tsx │ │ │ └── index.tsx │ ├── config.ts │ ├── constants.ts │ ├── index.css │ ├── index.tsx │ ├── stores │ │ ├── index.ts │ │ ├── router.ts │ │ └── state.ts │ └── utils │ │ ├── api.ts │ │ ├── hooks.ts │ │ ├── index.ts │ │ ├── markdown.tsx │ │ ├── polkadot.ts │ │ └── portal.tsx ├── integrations │ ├── _template │ │ ├── index.ts │ │ ├── manifest.json │ │ ├── router.ts │ │ └── types.ts │ ├── bamboohr │ │ ├── index.ts │ │ ├── manifest.json │ │ └── types.ts │ ├── email-smtp │ │ ├── README.md │ │ ├── index.ts │ │ ├── manifest.json │ │ └── types.ts │ ├── humaans │ │ ├── index.ts │ │ ├── manifest.json │ │ └── types.ts │ ├── integration.ts │ ├── mapbox │ │ ├── index.ts │ │ └── manifest.json │ ├── matrix │ │ ├── README.md │ │ ├── index.ts │ │ ├── manifest.json │ │ └── types.ts │ └── notion │ │ ├── index.ts │ │ ├── manifest.json │ │ └── types.ts ├── modules │ ├── _template │ │ ├── client │ │ │ ├── components │ │ │ │ └── index.ts │ │ │ └── queries.ts │ │ ├── manifest.json │ │ ├── metadata-schema.ts │ │ ├── permissions.ts │ │ ├── server │ │ │ ├── jobs │ │ │ │ └── index.ts │ │ │ ├── models │ │ │ │ └── index.ts │ │ │ └── router.ts │ │ └── types.ts │ ├── about │ │ ├── client │ │ │ ├── components │ │ │ │ ├── AboutPage.tsx │ │ │ │ ├── AboutWidget.tsx │ │ │ │ └── index.ts │ │ │ └── queries.ts │ │ ├── manifest.json │ │ ├── metadata-schema.ts │ │ ├── permissions.ts │ │ ├── server │ │ │ ├── jobs │ │ │ │ └── index.ts │ │ │ ├── models │ │ │ │ └── index.ts │ │ │ └── router.ts │ │ └── types.ts │ ├── admin-dashboard │ │ ├── client │ │ │ └── components │ │ │ │ ├── AdminDashboard.tsx │ │ │ │ └── index.ts │ │ ├── manifest.json │ │ ├── metadata-schema.ts │ │ ├── permissions.ts │ │ └── types.ts │ ├── announcements │ │ ├── client │ │ │ ├── components │ │ │ │ ├── AdminAnnouncements.tsx │ │ │ │ ├── AdminAnnouncementsEditor.tsx │ │ │ │ ├── Announcement.tsx │ │ │ │ └── index.ts │ │ │ ├── helpers │ │ │ │ └── index.ts │ │ │ └── queries.ts │ │ ├── manifest.json │ │ ├── permissions.ts │ │ ├── server │ │ │ ├── migrations │ │ │ │ └── 20230727121511_announcements_init.js │ │ │ ├── models │ │ │ │ ├── announcement.ts │ │ │ │ └── index.ts │ │ │ └── router.ts │ │ └── types.ts │ ├── checklists │ │ ├── client │ │ │ ├── components │ │ │ │ ├── AdminChecklist.tsx │ │ │ │ ├── AdminChecklistEditor.tsx │ │ │ │ ├── Checklist.tsx │ │ │ │ └── index.ts │ │ │ └── queries.ts │ │ ├── manifest.json │ │ ├── permissions.ts │ │ ├── server │ │ │ ├── jobs │ │ │ │ ├── checklist-answer-delete-data.ts │ │ │ │ └── index.ts │ │ │ ├── migrations │ │ │ │ └── 20230504151731_checklist_checklist-init.js │ │ │ ├── models │ │ │ │ ├── checklist-answer.ts │ │ │ │ ├── checklist.ts │ │ │ │ └── index.ts │ │ │ └── router.ts │ │ └── types.ts │ ├── events │ │ ├── client │ │ │ ├── components │ │ │ │ ├── AdminEventApplicationEditor.tsx │ │ │ │ ├── AdminEventApplications.tsx │ │ │ │ ├── AdminEventEditor.tsx │ │ │ │ ├── AdminEventFormSubmissionsBadge.tsx │ │ │ │ ├── AdminEvents.tsx │ │ │ │ ├── EventBadge.tsx │ │ │ │ ├── EventForm.tsx │ │ │ │ ├── EventPage.tsx │ │ │ │ ├── EventPublicFormDetector.tsx │ │ │ │ ├── EventsPage.tsx │ │ │ │ ├── GlobalEvents.tsx │ │ │ │ ├── MyEvents.tsx │ │ │ │ ├── UncompletedActions.tsx │ │ │ │ ├── UpcomingEvents.tsx │ │ │ │ └── index.ts │ │ │ ├── helpers │ │ │ │ └── index.tsx │ │ │ └── queries.ts │ │ ├── manifest.json │ │ ├── metadata-schema.ts │ │ ├── permissions.ts │ │ ├── server │ │ │ ├── helpers │ │ │ │ ├── checklists.ts │ │ │ │ ├── global-event-templates.ts │ │ │ │ └── index.ts │ │ │ ├── jobs │ │ │ │ ├── event-checklist-reminder.ts │ │ │ │ ├── event-delete-data.ts │ │ │ │ ├── index.ts │ │ │ │ └── pull-global-events.ts │ │ │ ├── migrations │ │ │ │ └── 20220830154031_events_initial.js │ │ │ ├── models │ │ │ │ ├── event-application.ts │ │ │ │ ├── event-checklist-reminder-job.ts │ │ │ │ ├── event-checkmark.ts │ │ │ │ ├── event.ts │ │ │ │ └── index.ts │ │ │ └── router.ts │ │ ├── templates │ │ │ ├── email.yaml │ │ │ └── notification.yaml │ │ └── types.ts │ ├── forms │ │ ├── client │ │ │ ├── components │ │ │ │ ├── AdminFormEditor │ │ │ │ │ ├── FormBuilder.tsx │ │ │ │ │ ├── helpers.ts │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── types.ts │ │ │ │ ├── AdminFormSubmissionEditor.tsx │ │ │ │ ├── AdminFormSubmissions.tsx │ │ │ │ ├── AdminForms.tsx │ │ │ │ ├── PublicForm.tsx │ │ │ │ └── index.ts │ │ │ └── queries.ts │ │ ├── manifest.json │ │ ├── permissions.ts │ │ ├── server │ │ │ ├── helpers │ │ │ │ └── index.ts │ │ │ ├── jobs │ │ │ │ ├── forms-delete-data.ts │ │ │ │ ├── index.ts │ │ │ │ └── purge-form-submissions.ts │ │ │ ├── migrations │ │ │ │ ├── 20220819170047_forms_initial.js │ │ │ │ └── 20240228150156_forms_purge-submissions-after-days.js │ │ │ ├── models │ │ │ │ ├── form-submission.ts │ │ │ │ ├── form.ts │ │ │ │ └── index.ts │ │ │ └── router.ts │ │ ├── templates │ │ │ ├── email.yaml │ │ │ └── notification.yaml │ │ └── types.ts │ ├── guest-invites │ │ ├── client │ │ │ ├── components │ │ │ │ ├── AdminDashboardStats.tsx │ │ │ │ ├── AdminGuestInvite.tsx │ │ │ │ ├── AdminGuestInviteEditor.tsx │ │ │ │ ├── AdminGuestInvites.tsx │ │ │ │ ├── GuestInviteDetail.tsx │ │ │ │ ├── GuestInviteForm.tsx │ │ │ │ ├── GuestInviteRequestForm.tsx │ │ │ │ ├── GuestInviteStatusTag.tsx │ │ │ │ └── index.ts │ │ │ ├── helpers │ │ │ │ └── index.ts │ │ │ └── queries.ts │ │ ├── manifest.json │ │ ├── metadata-schema.ts │ │ ├── permissions.ts │ │ ├── server │ │ │ ├── helpers │ │ │ │ └── index.ts │ │ │ ├── migrations │ │ │ │ └── 20221003113746_guest-invites_initial.js │ │ │ ├── models │ │ │ │ ├── guest-invite.ts │ │ │ │ └── index.ts │ │ │ └── router.ts │ │ ├── templates │ │ │ ├── email.yaml │ │ │ └── notification.yaml │ │ └── types.ts │ ├── hub-map │ │ ├── client │ │ │ ├── components │ │ │ │ ├── HubMap.tsx │ │ │ │ ├── ScheduledItem.tsx │ │ │ │ ├── ScheduledItemsList.tsx │ │ │ │ └── index.ts │ │ │ ├── helpers │ │ │ │ └── index.ts │ │ │ └── queries.ts │ │ ├── manifest.json │ │ ├── metadata-schema.ts │ │ ├── permissions.ts │ │ ├── server │ │ │ ├── helpers │ │ │ │ └── index.ts │ │ │ ├── models │ │ │ │ └── index.ts │ │ │ └── router.ts │ │ └── types.ts │ ├── news │ │ ├── client │ │ │ ├── components │ │ │ │ ├── AdminNews.tsx │ │ │ │ ├── AdminNewsEditor.tsx │ │ │ │ ├── LatestNews.tsx │ │ │ │ ├── NewsListPage.tsx │ │ │ │ ├── NewsPage.tsx │ │ │ │ └── index.ts │ │ │ ├── helpers │ │ │ │ └── index.ts │ │ │ └── queries.ts │ │ ├── manifest.json │ │ ├── permissions.ts │ │ ├── server │ │ │ ├── migrations │ │ │ │ └── 20230219200202_news_create-news-table.js │ │ │ ├── models │ │ │ │ ├── index.ts │ │ │ │ └── news.ts │ │ │ └── router.ts │ │ └── types.ts │ ├── office-visits │ │ ├── client │ │ │ ├── components │ │ │ │ ├── Actions.tsx │ │ │ │ ├── MyOfficeVisits.tsx │ │ │ │ ├── Visit.tsx │ │ │ │ ├── VisitsStats.tsx │ │ │ │ └── index.ts │ │ │ ├── helpers │ │ │ │ └── index.ts │ │ │ └── queries.ts │ │ ├── manifest.json │ │ ├── metadata-schema.ts │ │ ├── permissions.ts │ │ ├── server │ │ │ ├── helpers │ │ │ │ └── index.ts │ │ │ └── router.ts │ │ └── types.ts │ ├── profile-questions │ │ ├── client │ │ │ ├── components │ │ │ │ ├── ProfileQuestionForm.tsx │ │ │ │ ├── ProfileQuestionsProgress.tsx │ │ │ │ ├── ProfileQuestionsWidget.tsx │ │ │ │ └── index.ts │ │ │ └── queries.ts │ │ ├── manifest.json │ │ ├── metadata-schema.ts │ │ ├── permissions.ts │ │ ├── server │ │ │ ├── jobs │ │ │ │ ├── index.ts │ │ │ │ └── profile-questions-delete-data.ts │ │ │ ├── migrations │ │ │ │ └── 20230514165149_profile-questions_initial.js.js │ │ │ ├── models │ │ │ │ ├── index.ts │ │ │ │ └── profile-question-answer.ts │ │ │ └── router.ts │ │ └── types.ts │ ├── quick-navigation │ │ ├── client │ │ │ ├── components │ │ │ │ ├── QuickNav.tsx │ │ │ │ └── index.ts │ │ │ └── queries.ts │ │ ├── manifest.json │ │ ├── metadata-schema.ts │ │ ├── permissions.ts │ │ ├── server │ │ │ └── router.ts │ │ └── types.ts │ ├── room-reservation │ │ ├── client │ │ │ ├── components │ │ │ │ ├── AdminDashboardStats.tsx │ │ │ │ ├── AdminRoomReservations.tsx │ │ │ │ ├── DeviceRoomReservation.tsx │ │ │ │ ├── MeetingRoomBookingModal.tsx │ │ │ │ ├── NextMeetings.tsx │ │ │ │ ├── RoomDisplay.tsx │ │ │ │ ├── RoomDisplayDevice.tsx │ │ │ │ ├── RoomListing.tsx │ │ │ │ ├── RoomReservationDetail.tsx │ │ │ │ ├── RoomReservationRequest.tsx │ │ │ │ ├── RoomReservationStatusTag.tsx │ │ │ │ ├── TimeSlotsListing.tsx │ │ │ │ └── index.ts │ │ │ ├── helpers │ │ │ │ └── index.ts │ │ │ └── queries.ts │ │ ├── manifest.json │ │ ├── permissions.ts │ │ ├── server │ │ │ ├── helpers │ │ │ │ ├── index.ts │ │ │ │ └── messages.ts │ │ │ ├── migrations │ │ │ │ └── 20221014192919_room-reservation_initial.js │ │ │ ├── models │ │ │ │ ├── index.ts │ │ │ │ ├── room-display-device.ts │ │ │ │ └── room-reservation.ts │ │ │ └── router.ts │ │ ├── shared-helpers │ │ │ └── index.ts │ │ ├── templates │ │ │ └── notification.yaml │ │ └── types.ts │ ├── search │ │ ├── client │ │ │ ├── components │ │ │ │ ├── SearchBar.tsx │ │ │ │ ├── SearchResults.tsx │ │ │ │ └── index.ts │ │ │ ├── helpers │ │ │ │ └── index.ts │ │ │ └── queries.ts │ │ ├── manifest.json │ │ ├── permissions.ts │ │ ├── server │ │ │ └── router.ts │ │ ├── shared-helpers │ │ │ └── index.ts │ │ └── types.ts │ ├── time-off │ │ ├── manifest.json │ │ ├── metadata-schema.ts │ │ ├── server │ │ │ ├── jobs │ │ │ │ ├── fetch-public-holidays.ts │ │ │ │ ├── fetch-time-off-requests.ts │ │ │ │ └── index.ts │ │ │ ├── migrations │ │ │ │ ├── 20230926152156_time-off_initial.js │ │ │ │ └── 20240510172800_time-off_add-public-holidays.js │ │ │ └── models │ │ │ │ ├── index.ts │ │ │ │ ├── public-holiday.ts │ │ │ │ └── time-off-request.ts │ │ └── types.ts │ ├── users │ │ ├── client │ │ │ ├── components │ │ │ │ ├── AdminDashboardStats.tsx │ │ │ │ ├── AdminUsers.tsx │ │ │ │ ├── AuthAccount.tsx │ │ │ │ ├── AuthAccountsLinkModal.tsx │ │ │ │ ├── DeleteUserModal.tsx │ │ │ │ ├── MySettings.tsx │ │ │ │ ├── NewjoinerDetector.tsx │ │ │ │ ├── Profile.tsx │ │ │ │ ├── ProfileCard.tsx │ │ │ │ ├── ProfileForm.tsx │ │ │ │ ├── PublicProfile.tsx │ │ │ │ ├── TagSelection.tsx │ │ │ │ ├── UserMap.tsx │ │ │ │ ├── UserRolesEditorModal.tsx │ │ │ │ ├── UserStatus.tsx │ │ │ │ ├── UsersMap.tsx │ │ │ │ ├── UsersMapPage.tsx │ │ │ │ ├── UsersMapWidget.tsx │ │ │ │ ├── Welcome.tsx │ │ │ │ └── index.ts │ │ │ ├── helpers │ │ │ │ └── index.ts │ │ │ └── queries.ts │ │ ├── manifest.json │ │ ├── metadata-schema.ts │ │ ├── permissions.ts │ │ ├── server │ │ │ ├── helpers │ │ │ │ └── index.ts │ │ │ ├── jobs │ │ │ │ ├── index.ts │ │ │ │ └── users-delete-users-data.ts │ │ │ ├── migrations │ │ │ │ ├── 20220725132325_users_initial.js │ │ │ │ ├── 20221130182800_users_import-cities.js │ │ │ │ ├── 20221227153751_users_add-user-tags.js │ │ │ │ ├── 20230419141425_users_ghost_user.js │ │ │ │ ├── 20231206175252_users_add-roles-list.js │ │ │ │ ├── 20240827193202_users_update-avatar-url-length.js │ │ │ │ ├── 20241029121408_users_users-email-lowercase.js │ │ │ │ └── default-tags.json │ │ │ ├── models │ │ │ │ ├── city.ts │ │ │ │ ├── index.ts │ │ │ │ ├── session.ts │ │ │ │ ├── tag.ts │ │ │ │ ├── user-tag.ts │ │ │ │ └── user.ts │ │ │ └── router.ts │ │ ├── shared-helpers │ │ │ └── index.ts │ │ ├── templates │ │ │ └── notification.yaml │ │ └── types.ts │ ├── visits │ │ ├── client │ │ │ ├── components │ │ │ │ ├── AdminDashboardStats.tsx │ │ │ │ ├── AdminVisits.tsx │ │ │ │ ├── DateDeskPicker.tsx │ │ │ │ ├── DeskPicker.tsx │ │ │ │ ├── VisitDetail.tsx │ │ │ │ ├── VisitNotice.tsx │ │ │ │ ├── VisitRequestForm.tsx │ │ │ │ ├── VisitStatusTag.tsx │ │ │ │ ├── WhoIsInOffice.tsx │ │ │ │ └── index.ts │ │ │ └── queries.ts │ │ ├── manifest.json │ │ ├── permissions.ts │ │ ├── server │ │ │ ├── helpers │ │ │ │ └── index.ts │ │ │ ├── jobs │ │ │ │ ├── index.ts │ │ │ │ ├── visit-delete-data.ts │ │ │ │ └── visit-reminder.ts │ │ │ ├── migrations │ │ │ │ └── 20220725133440_visits_initial.js │ │ │ ├── models │ │ │ │ ├── index.ts │ │ │ │ ├── visit-reminder-job.ts │ │ │ │ └── visit.ts │ │ │ └── router.ts │ │ ├── templates │ │ │ ├── error.yaml │ │ │ └── notification.yaml │ │ └── types.ts │ └── working-hours │ │ ├── client │ │ ├── components │ │ │ ├── AdminWorkingHours.tsx │ │ │ ├── DefaultEntriesModal.tsx │ │ │ ├── EntryRow.tsx │ │ │ ├── MaxConsecutiveHoursWarning.tsx │ │ │ ├── WorkingHoursEditor.tsx │ │ │ ├── WorkingHoursEditorMonth.tsx │ │ │ ├── WorkingHoursEditorWeek.tsx │ │ │ ├── WorkingHoursExportModal.tsx │ │ │ ├── WorkingHoursUserModal.tsx │ │ │ ├── WorkingHoursWidget.tsx │ │ │ └── index.ts │ │ ├── helpers │ │ │ └── index.ts │ │ └── queries.ts │ │ ├── manifest.json │ │ ├── metadata-schema.ts │ │ ├── permissions.ts │ │ ├── server │ │ ├── helpers │ │ │ └── index.ts │ │ ├── jobs │ │ │ ├── fetch-default-working-hours.ts │ │ │ ├── index.ts │ │ │ └── working-hours-reminder.ts │ │ ├── migrations │ │ │ ├── 20230724145732_working-hours_initial.js │ │ │ ├── 20240808181550_working-hours_add-working-hours-entry-metadata.js │ │ │ └── 20241028153105_working-hours_add-working-hours-entry-auto.js │ │ ├── models │ │ │ ├── default-working-hours-entry.ts │ │ │ ├── index.ts │ │ │ ├── working-hours-entry.ts │ │ │ └── working-hours-user-config.ts │ │ └── router.ts │ │ ├── shared-helpers │ │ └── index.ts │ │ ├── templates │ │ └── notification.yaml │ │ └── types.ts ├── server │ ├── app-config │ │ ├── index.ts │ │ ├── schemas.ts │ │ ├── templates.ts │ │ └── types.ts │ ├── auth │ │ ├── auth-plugin.ts │ │ └── providers │ │ │ ├── google │ │ │ ├── helper.ts │ │ │ ├── index.ts │ │ │ └── manifest.json │ │ │ ├── helper.ts │ │ │ └── polkadot │ │ │ ├── index.ts │ │ │ └── manifest.json │ ├── config.ts │ ├── constants.ts │ ├── db.ts │ ├── index.ts │ ├── module-router-plugin.ts │ ├── server.ts │ ├── types │ │ └── index.ts │ └── utils │ │ ├── app-events.ts │ │ ├── custom-paths.ts │ │ ├── error-template.ts │ │ ├── exceptions.ts │ │ ├── index.ts │ │ ├── log.ts │ │ ├── rate-limit.ts │ │ └── ws.ts └── shared │ ├── permissions │ └── index.ts │ ├── types │ └── index.ts │ └── utils │ ├── fp.ts │ ├── index.ts │ └── permissions-set.ts ├── start.sh ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | 4 | dist_client 5 | dist_server 6 | 7 | .vscode 8 | .DS_Store 9 | 10 | config 11 | config-demo 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .AppleDouble 3 | .LSOverride 4 | .vscode 5 | .idea 6 | 7 | node_modules 8 | dist 9 | dist_client 10 | dist_server 11 | drafts 12 | config 13 | 14 | .env 15 | log.txt 16 | postgres_data/ 17 | 18 | src/shared/types/__import-types.ts 19 | src/shared/permissions/__import-permissions.ts 20 | src/server/types/__import-models-integrations.ts 21 | src/client/stores/__import-stores.tsx 22 | src/client/components/__import-components.tsx 23 | 24 | yalc.lock 25 | .yalc 26 | 27 | documentation/.next/ 28 | documentation/node_modules/ 29 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | src/shared/types/__import-types.ts 2 | src/shared/permissions/__import-permissions.ts 3 | src/server/types/__import-models-integrations.ts 4 | src/client/stores/__import-stores.ts 5 | src/client/components/__import-components.tsx 6 | helm/ 7 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'es5', 3 | semi: false, 4 | singleQuote: true, 5 | jsxSingleQuote: false, 6 | arrowParens: 'always', 7 | tabWidth: 2, 8 | bracketSpacing: true, 9 | bracketSameLine: false, 10 | } 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | 3 | # metadata 4 | ARG VCS_REF 5 | ARG BUILD_DATE 6 | ARG PROJECT_NAME="polkadot-hub-app" 7 | 8 | LABEL io.parity.image.authors="cicd-team@parity.io" \ 9 | io.parity.image.vendor="Parity Technologies" \ 10 | io.parity.image.title="${PROJECT_NAME}" \ 11 | io.parity.image.description="Polkadot Hub App is a self-hosted web app for managing offices, meeting rooms, events, and people profiles." \ 12 | io.parity.image.source="https://github.com/paritytech/${PROJECT_NAME}/blob/${VCS_REF}/Dockerfile" \ 13 | io.parity.image.documentation="https://github.com/paritytech/${PROJECT_NAME}/blob/${VCS_REF}/README.md" \ 14 | io.parity.image.revision="${VCS_REF}" \ 15 | io.parity.image.created="${BUILD_DATE}" 16 | 17 | # Working directory in the container 18 | WORKDIR /app 19 | 20 | COPY package.json ./ 21 | COPY yarn.lock ./ 22 | 23 | RUN yarn install --frozen-lockfile 24 | 25 | COPY . ./ 26 | 27 | EXPOSE 3000 28 | 29 | CMD ["yarn", "production:run"] 30 | -------------------------------------------------------------------------------- /config-demo/public/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Polkadot Hub Sample", 3 | "short_name": "PH Sample", 4 | "description": "Sample Polkadot Hub config", 5 | "display": "fullscreen", 6 | "theme_color": "#f9f9f9", 7 | "start_url": "/" 8 | } 9 | -------------------------------------------------------------------------------- /config-demo/public/maps/berlin/area-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/config-demo/public/maps/berlin/area-1.png -------------------------------------------------------------------------------- /config-demo/public/maps/berlin/none.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/config-demo/public/maps/berlin/none.png -------------------------------------------------------------------------------- /config-demo/public/meeting-rooms/berlin/room-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/config-demo/public/meeting-rooms/berlin/room-1.jpg -------------------------------------------------------------------------------- /config-demo/templates/visits/text.yaml: -------------------------------------------------------------------------------- 1 | visitNotice: | 2 | **Health & Safety Standards** 3 | 4 | We want to keep health and safety standards in our office on a very high level. 5 | 6 | On April 7th, the legal framework for Corona protection measures expired. This means that the last remaining measures have been discontinued. 7 | 8 | Wearing masks, self-testing and social distancing are no longer mandatory. For your comfort and safety though, we are still providing tests and masks should you want to use them. 9 | -------------------------------------------------------------------------------- /docker-compose-demo.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | app: 4 | image: paritytech/polkadot-hub-app:latest 5 | volumes: 6 | - ./config-demo:/app/config/ 7 | - ./src:/app/src 8 | env_file: 9 | - .env 10 | command: yarn production:run 11 | container_name: app-demo 12 | restart: always 13 | depends_on: 14 | postgres: 15 | condition: service_healthy 16 | ports: 17 | - "3000:3000" 18 | postgres: 19 | image: postgres:14 20 | volumes: 21 | - ./postgres_data:/var/lib/postgresql/data 22 | container_name: postgres 23 | restart: always 24 | healthcheck: 25 | test: ["CMD-SHELL", "pg_isready -U test"] 26 | interval: 10s 27 | timeout: 5s 28 | retries: 5 29 | environment: 30 | POSTGRES_USER: test 31 | POSTGRES_PASSWORD: test 32 | POSTGRES_DB: test 33 | ports: 34 | - "5432:5432" 35 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | app: 4 | image: paritytech/polkadot-hub-app:latest 5 | volumes: 6 | - ./config:/app/config/ 7 | env_file: 8 | - .env 9 | command: yarn production:run 10 | container_name: app 11 | restart: always 12 | depends_on: 13 | postgres: 14 | condition: service_healthy 15 | ports: 16 | - "3000:3000" 17 | logging: 18 | driver: "json-file" 19 | options: 20 | max-size: "200k" 21 | max-file: "10" 22 | postgres: 23 | image: postgres:14 24 | volumes: 25 | - ./postgres_data:/var/lib/postgresql/data 26 | container_name: postgres 27 | restart: always 28 | healthcheck: 29 | test: ["CMD-SHELL", "pg_isready -U test"] 30 | interval: 10s 31 | timeout: 5s 32 | retries: 5 33 | environment: 34 | POSTGRES_USER: test 35 | POSTGRES_PASSWORD: test 36 | POSTGRES_DB: test 37 | ports: 38 | - "5432:5432" 39 | -------------------------------------------------------------------------------- /docs/images/about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/docs/images/about.png -------------------------------------------------------------------------------- /docs/images/admin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/docs/images/admin.png -------------------------------------------------------------------------------- /docs/images/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/docs/images/app.png -------------------------------------------------------------------------------- /docs/images/desk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/docs/images/desk.png -------------------------------------------------------------------------------- /docs/images/events.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/docs/images/events.png -------------------------------------------------------------------------------- /docs/images/flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/docs/images/flow.png -------------------------------------------------------------------------------- /docs/images/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/docs/images/login.png -------------------------------------------------------------------------------- /docs/images/map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/docs/images/map.png -------------------------------------------------------------------------------- /docs/images/meeting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/docs/images/meeting.png -------------------------------------------------------------------------------- /docs/images/nfts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/docs/images/nfts.png -------------------------------------------------------------------------------- /docs/images/payment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/docs/images/payment.png -------------------------------------------------------------------------------- /docs/images/profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/docs/images/profile.png -------------------------------------------------------------------------------- /docs/images/screen-global.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/docs/images/screen-global.png -------------------------------------------------------------------------------- /docs/images/wallets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/docs/images/wallets.png -------------------------------------------------------------------------------- /documentation/next.config.js: -------------------------------------------------------------------------------- 1 | const withNextra = require('nextra')({ 2 | theme: 'nextra-theme-docs', 3 | themeConfig: './theme.config.jsx' 4 | }) 5 | 6 | module.exports = withNextra() 7 | -------------------------------------------------------------------------------- /documentation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "documentation", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "devDependencies": {}, 7 | "scripts": { 8 | "dev": "next", 9 | "build": "next build", 10 | "start": "next start" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "next": "^14.1.4", 17 | "nextra": "^2.13.4", 18 | "nextra-theme-docs": "^2.13.4" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /documentation/pages/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": "Hub App Documentation", 3 | "quickStart": "Quick start", 4 | "framework": "Framework", 5 | "modules": "Modules", 6 | "commonErrors": "Common errors" 7 | } 8 | -------------------------------------------------------------------------------- /documentation/pages/commonErrors.md: -------------------------------------------------------------------------------- 1 | ## Common errors/issues 2 | 3 | -------------------------------------------------------------------------------- /documentation/pages/development.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | TBA 4 | -------------------------------------------------------------------------------- /documentation/pages/development/create-a-module.md: -------------------------------------------------------------------------------- 1 | # Create a module 2 | 3 | TBA 4 | -------------------------------------------------------------------------------- /documentation/pages/development/new-route.md: -------------------------------------------------------------------------------- 1 | # How to create a new route 2 | 3 | 1. Open the router.ts file of your module. If you haven't created your module yet see [Create a module Guide](./create-a-module.md) 4 | 5 | 2. There are 3 types of routes that can be created. 6 | 7 | publicApi - `/public-api/{module-name}/` 8 | 9 | userApi - `/user-api/{module-name}/` 10 | 11 | adminApi - `/admin-api/{module-name}/` 12 | -------------------------------------------------------------------------------- /documentation/pages/framework.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/pages/framework.md -------------------------------------------------------------------------------- /documentation/pages/framework/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Configuration for the project lives in a `./config` directory. We suggest keeping that directory in a separate repository and create symbolic link it for local development. You can find a sample folder called `config-demo` [here](https://github.com/paritytech/polkadot-hub-app/tree/master/config-demo) 4 | 5 | ``` 6 | ./config 7 | ├── application.json app name, auth, and homepage layout 8 | ├── company.json company name, offices, departments, divisions 9 | ├── modules.json list of enabled modules with their metadata and enabled integrations 10 | ├── permissions.json list of roles with their permissions, default role by email domain 11 | ├── modules custom modules 12 | │ ├── division-sync 13 | │ ├── help-center 14 | ├── public custom static files 15 | │ ├── manifest.webmanifest custom manifest (as it can contain custom app name, icons set, etc) 16 | │ ├── images 17 | │ │ └── ... 18 | └── templates custom text templates 19 | ├── guest-invites 20 | │ └── email.yaml 21 | └── visits 22 | └── notification.yaml 23 | ``` 24 | -------------------------------------------------------------------------------- /documentation/pages/framework/configuration/modules.md: -------------------------------------------------------------------------------- 1 | # Modules.json 2 | 3 | This is where you turn on/off app modules, set their integrations, metadata and other configuration. 4 | 5 | ```json 6 | { 7 | "id": "announcements", 8 | "enabled": true, 9 | "enabledIntegrations": [] 10 | } 11 | ``` 12 | 13 | To enable a module it has to have `enabled` set to `true`. 14 | 15 | Some modules have metadata that need to be configured in order for the module to function properly. You can see specification for each module on its module page under [/modules](/modules) 16 | 17 | ## Portals 18 | 19 | You can inject modules inside of other modules using portals. Portals are not enabled on all modules, but some use it. See specific module page for reference on its usage of portals. 20 | -------------------------------------------------------------------------------- /documentation/pages/framework/configuration/templates.md: -------------------------------------------------------------------------------- 1 | # Text templates 2 | 3 | Depending on the configuration of your project you might have certain integrations turned on, e.g. email. 4 | The app sends default texts when emails are sent. You can ovewrite these texts with your customs ones. 5 | 6 | 1. Create a folder with the module name in the templates folder , e.g. `./config/templates/guest-invites` 7 | 2. Create a YAML file for the text message. We have 3 types of messages: notification (matrix), email, text (error messages). E.g. `email.yml` 8 | 3. Look up the email message key [here](https://github.com/paritytech/polkadot-hub-app/blob/master/src/integrations/email-smtp/README.md#guest-invites). 9 | 4. Add your custom message to yml file. [See example here](https://github.com/paritytech/polkadot-hub-app/blob/master/src/modules/guest-invites/templates/email.yaml) 10 | -------------------------------------------------------------------------------- /documentation/pages/framework/integrations.md: -------------------------------------------------------------------------------- 1 | # Integrations 2 | 3 | ## Matrix 4 | 5 | Matrix notifications are supported. In order to send the notifications one has to make sure users are providing matrix handles in their profiles. 6 | 7 | See the [List of all notification types](https://github.com/paritytech/polkadot-hub-app/blob/3589ba1e06265d93597d37e01ad74d508bd63816/src/integrations/matrix/README.md#notifications) for more information. 8 | 9 | The profile fields can be configured in metadata section of the "users" module in modules.json. See [users module](/modules/users) for more information. 10 | 11 | ### Mapbox 12 | 13 | Support for maps. Activates if `MAPBOX_API_KEY` is set in your `.env` file. 14 | 15 | `Mapbox` is used in a few places: 16 | 17 | - All users map at `/map`. 18 | - Map of the hub location on the about page `/about/` 19 | - User location on their profile if they specify that they want to share. `/profile/` 20 | 21 | ### Email-smtp 22 | 23 | Set the following variables in your `.env` file. 24 | 25 | ``` 26 | SMTP_ENDPOINT="" 27 | 28 | SMTP_PORT="" 29 | 30 | SMTP_USERNAME="" 31 | 32 | SMTP_PASSWORD="" 33 | 34 | SMTP_FROM_NAME="" 35 | 36 | SMTP_FROM_EMAIL="" 37 | ``` 38 | -------------------------------------------------------------------------------- /documentation/pages/index.md: -------------------------------------------------------------------------------- 1 | # Polkadot Hub App 2 | 3 | Polkadot Hub App is a self-hosted web app for managing offices, meeting rooms, events, and people profiles. It's an opinionated hackspace-like approach for hybrid teams distributed across many continents and working on multiple projects. The app is written in React, Node.js and Postgres. 4 | 5 | Main features: 6 | 7 | - Multiple office locations + "Global" page for remote workers 8 | - Internal user profiles with custom tags, locations, timezones and onboarding 9 | - Flexible role system for access management 10 | - Google authentication 11 | - Authentication using Polkadot 12 | - Modular architecture that allows you to expand the app with new widgets and integrations with external APIs 13 | 14 | Polkadot Hub app 21 | -------------------------------------------------------------------------------- /documentation/pages/modules/about.md: -------------------------------------------------------------------------------- 1 | # About module 2 | 3 | ## Manifest.json 4 | 5 | | key | value | 6 | | ----------------------- | --------------- | 7 | | id | admin-dashboard | 8 | | name | Dashboard | 9 | | dependencies | [] | 10 | | requiredIntegrations | [] | 11 | | recommendedIntegrations | [] | 12 | 13 | ## Available Widgets 14 | 15 | ### About widget 16 | 17 | The widget shows information that is configured in [`company.json file`](../framework/configuration/company.md): 18 | 19 | - address 20 | 21 | - location directions 22 | 23 | - hub opening hours 24 | 25 | - hub opening days 26 | 27 | The image on the widget can be replaced in the public folder in the configuration of the project. Simply add a file to `/config/public/maps/[hubID.png]` 28 | 29 | about widget 36 | 37 | ## About page 38 | 39 | The about page shows more information than the widget, including an interactive map, floor plans and whether desk reservations or room reservations are enabled, floor maps. 40 | 41 | About page 1 45 | About page 2 49 | -------------------------------------------------------------------------------- /documentation/pages/modules/admin-dash.md: -------------------------------------------------------------------------------- 1 | # Admin Dashboard 2 | 3 | Dashboard module shows hub occupancy statistics. The data is useful to get deeper insight on how your hub is used. 4 | 5 | ## Manifest.json 6 | 7 | | key | value | 8 | | ----------------------- | --------------- | 9 | | id | admin-dashboard | 10 | | name | Dashboard | 11 | | dependencies | [] | 12 | | requiredIntegrations | [] | 13 | | recommendedIntegrations | [] | 14 | 15 | dashboard 20 | 21 | dashboard2 26 | 27 | dashboard3 32 | 33 | dashboard4 38 | 39 | dashboard5 44 | 45 | dashboard6 50 | -------------------------------------------------------------------------------- /documentation/pages/modules/announcements.md: -------------------------------------------------------------------------------- 1 | # Announcement module 2 | 3 | The Announcement Module is designed to broadcast important updates, notifications, and reminders to all users. 4 | Announcements are displayed at the top of the user interface, ensuring maximum visibility to all users upon logging in to the platform. 5 | 6 | Each announcement is assigned an expiration date, ensuring that outdated information is automatically removed from the display. 7 | 8 | Announcement 9 | 10 | ## Create 11 | 12 | Announcements are managed through the administration panel accessible to authorized personnel. 13 | 14 | Admins can create, edit, and delete announcements as needed, providing them with full control over the content displayed to users. Admins can choose which users see which announcements using the in-built permissions system. 15 | 16 | Announcements support markdown text. 17 | 18 | Additionally, admins can set expiration dates for announcements, defining the duration of their visibility on the platform. 19 | -------------------------------------------------------------------------------- /documentation/pages/modules/checklist.md: -------------------------------------------------------------------------------- 1 | # Checklist module 2 | 3 | TBA 4 | -------------------------------------------------------------------------------- /documentation/pages/modules/forms.md: -------------------------------------------------------------------------------- 1 | # Forms module 2 | 3 | Self-hosted Google Forms replacement that allows users to create feedback forms and other types of forms. It also supports exporting data to .CSV files. 4 | 5 | ## Manifest.json 6 | 7 | | key | value | 8 | | ----------------------- | ------------------ | 9 | | id | forms | 10 | | name | Forms | 11 | | dependencies | users | 12 | | requiredIntegrations | [] | 13 | | recommendedIntegrations | email-smtp, matrix | 14 | 15 | ## Available Widgets 16 | -------------------------------------------------------------------------------- /documentation/pages/modules/hub-map.md: -------------------------------------------------------------------------------- 1 | # Hub Map 2 | 3 | TBA 4 | -------------------------------------------------------------------------------- /documentation/pages/modules/news.md: -------------------------------------------------------------------------------- 1 | # News 2 | 3 | TBA 4 | -------------------------------------------------------------------------------- /documentation/pages/modules/profile-questions.md: -------------------------------------------------------------------------------- 1 | # Profile Questions 2 | 3 | TBA 4 | -------------------------------------------------------------------------------- /documentation/pages/modules/quick-navigation.md: -------------------------------------------------------------------------------- 1 | # Quick navigation 2 | 3 | TBA 4 | -------------------------------------------------------------------------------- /documentation/pages/modules/search.md: -------------------------------------------------------------------------------- 1 | # Search module 2 | 3 | The Search Module, located at the top of the page, serves as a powerful tool for users to quickly locate others within the organization. 4 | 5 | Announcement 6 | 7 | Search results: 8 | 9 | Announcement 10 | 11 | Users can search using: 12 | 13 | - Names: Search by first name, last name, or both. 14 | 15 | - Tags: Users can add personalized tags to their profiles, enabling searches based on specific interests, skills, or attributes. 16 | 17 | - Other Fields: The search functionality extends to other fields filled in by users on their profiles, such as their social media handles. 18 | 19 | A great example is searching for people using their github handles. 20 | 21 | Announcement 22 | -------------------------------------------------------------------------------- /documentation/pages/quickStart.md: -------------------------------------------------------------------------------- 1 | # Quick start 2 | 3 | Refer to [QuickStart](https://github.com/paritytech/polkadot-hub-app/blob/master/docs/quickstart.md) on github. 4 | -------------------------------------------------------------------------------- /documentation/public/Polkadot-Hubs-Logotype-BL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/Polkadot-Hubs-Logotype-BL.png -------------------------------------------------------------------------------- /documentation/public/about1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/about1.png -------------------------------------------------------------------------------- /documentation/public/aboutpage1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/aboutpage1.png -------------------------------------------------------------------------------- /documentation/public/aboutpage2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/aboutpage2.png -------------------------------------------------------------------------------- /documentation/public/announcement.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/announcement.png -------------------------------------------------------------------------------- /documentation/public/desktopLayout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/desktopLayout.png -------------------------------------------------------------------------------- /documentation/public/layout-2col.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/layout-2col.png -------------------------------------------------------------------------------- /documentation/public/layout-3col.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/layout-3col.png -------------------------------------------------------------------------------- /documentation/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/logo.png -------------------------------------------------------------------------------- /documentation/public/mobileEvents.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/mobileEvents.png -------------------------------------------------------------------------------- /documentation/public/mobileNews.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/mobileNews.png -------------------------------------------------------------------------------- /documentation/public/mobileOffice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/mobileOffice.png -------------------------------------------------------------------------------- /documentation/public/modules/about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/about.png -------------------------------------------------------------------------------- /documentation/public/modules/aboutpage1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/aboutpage1.png -------------------------------------------------------------------------------- /documentation/public/modules/aboutpage2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/aboutpage2.png -------------------------------------------------------------------------------- /documentation/public/modules/dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/dash.png -------------------------------------------------------------------------------- /documentation/public/modules/dash2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/dash2.png -------------------------------------------------------------------------------- /documentation/public/modules/dash3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/dash3.png -------------------------------------------------------------------------------- /documentation/public/modules/dash4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/dash4.png -------------------------------------------------------------------------------- /documentation/public/modules/dash5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/dash5.png -------------------------------------------------------------------------------- /documentation/public/modules/dash6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/dash6.png -------------------------------------------------------------------------------- /documentation/public/modules/eventsEventSample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/eventsEventSample.png -------------------------------------------------------------------------------- /documentation/public/modules/eventsUncompletedActions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/eventsUncompletedActions.png -------------------------------------------------------------------------------- /documentation/public/modules/eventsUpcoming.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/eventsUpcoming.png -------------------------------------------------------------------------------- /documentation/public/modules/eventsUser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/eventsUser.png -------------------------------------------------------------------------------- /documentation/public/modules/guestInviteAdmin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/guestInviteAdmin.png -------------------------------------------------------------------------------- /documentation/public/modules/guestInviteAdmin2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/guestInviteAdmin2.png -------------------------------------------------------------------------------- /documentation/public/modules/guestInviteForm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/guestInviteForm.png -------------------------------------------------------------------------------- /documentation/public/modules/guestInviteFormGuest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/guestInviteFormGuest.png -------------------------------------------------------------------------------- /documentation/public/modules/officeVisits.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/officeVisits.png -------------------------------------------------------------------------------- /documentation/public/modules/officeVisitsActions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/officeVisitsActions.png -------------------------------------------------------------------------------- /documentation/public/modules/roomReservationApp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/roomReservationApp.png -------------------------------------------------------------------------------- /documentation/public/modules/roomReservationApp2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/roomReservationApp2.png -------------------------------------------------------------------------------- /documentation/public/modules/roomReservationTablet1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/roomReservationTablet1.png -------------------------------------------------------------------------------- /documentation/public/modules/roomReservationTablet2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/roomReservationTablet2.png -------------------------------------------------------------------------------- /documentation/public/modules/usersMap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/usersMap.png -------------------------------------------------------------------------------- /documentation/public/modules/usersMapPage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/usersMapPage.png -------------------------------------------------------------------------------- /documentation/public/modules/usersOnboarding1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/usersOnboarding1.png -------------------------------------------------------------------------------- /documentation/public/modules/usersOnboarding2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/usersOnboarding2.png -------------------------------------------------------------------------------- /documentation/public/modules/usersOnboarding3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/usersOnboarding3.png -------------------------------------------------------------------------------- /documentation/public/modules/usersProfileCard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/usersProfileCard.png -------------------------------------------------------------------------------- /documentation/public/modules/visitsWhoIsInOffice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/modules/visitsWhoIsInOffice.png -------------------------------------------------------------------------------- /documentation/public/screen-global.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/screen-global.png -------------------------------------------------------------------------------- /documentation/public/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/search.png -------------------------------------------------------------------------------- /documentation/public/search1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/search1.png -------------------------------------------------------------------------------- /documentation/public/search2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/search2.png -------------------------------------------------------------------------------- /documentation/public/search3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/search3.png -------------------------------------------------------------------------------- /documentation/public/search4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/documentation/public/search4.png -------------------------------------------------------------------------------- /documentation/theme.config.jsx: -------------------------------------------------------------------------------- 1 | export default { 2 | logo: , 3 | darkMode: true, 4 | docsRepositoryBase: 'https://github.com/paritytech/polkadot-hub-app', 5 | project: { 6 | link: 'https://github.com/paritytech/polkadot-hub-app' 7 | }, 8 | sidebar: { 9 | defaultMenuCollapseLevel: 1 10 | } 11 | } -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ES6", 4 | "jsx": "preserve", 5 | "checkJs": true 6 | }, 7 | "exclude": ["node_modules", "**/node_modules/*"] 8 | } -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts,json", 4 | "ignore": [ 5 | "src/client", 6 | "src/modules/*/client", 7 | "src/modules/*/server/migrations", 8 | "*.tsx" 9 | ], 10 | "exec": "yarn server:build && yarn server:start", 11 | "env": { 12 | "NODE_DEBUG": 9229, 13 | "NODE_ENV": "development" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /public/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Loading... 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/public/favicon.ico -------------------------------------------------------------------------------- /public/polkadot-js-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/polkadot-token-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 15 | -------------------------------------------------------------------------------- /public/wallet-connect-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/clear.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | 4 | const cwd = process.cwd() 5 | 6 | const dynamicFiles = [ 7 | 'src/shared/types/__import-types.ts', 8 | 'src/shared/permissions/__import-permissions.ts', 9 | 'src/server/types/__import-models-integrations.ts', 10 | 'src/client/stores/__import-stores.tsx', 11 | 'src/client/components/__import-components.tsx', 12 | ].map((x) => path.join(cwd, x)) 13 | 14 | for (const file of dynamicFiles) { 15 | try { 16 | fs.unlinkSync(file) 17 | } catch (err) { 18 | console.error(`Can't delete file ${file}`, err) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /scripts/server.postbuild.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { join } from 'path' 3 | 4 | /* 5 | This script ensures a consistent structure in the `dist_server` directory after the build process. 6 | The build output's structure depends on the presence of custom modules in the `config/modules`. 7 | The script moves all build outputs into a new `src` subdirectory within `dist_server` if needed. 8 | */ 9 | 10 | const cwd = process.cwd() 11 | const distPath = join(cwd, 'dist_server') 12 | const distSrcPath = join(distPath, 'src') 13 | const tempDistSrcPath = join(cwd, 'src_temp') 14 | 15 | // there are no custom modules in `config/modules` 16 | if (!fs.existsSync(join(distPath, 'config'))) { 17 | // delete `dist_server/src` if present (previous build output) 18 | if (fs.existsSync(distSrcPath)) { 19 | fs.rmSync(distSrcPath, { recursive: true }) 20 | } 21 | 22 | // rename `dist_server` to `src_temp` 23 | fs.renameSync(distPath, tempDistSrcPath) 24 | 25 | // create new empty folder `dist_server` 26 | fs.mkdirSync(distPath) 27 | 28 | // rename `src_temp` to `dist_server/src` 29 | fs.renameSync(tempDistSrcPath, join(distPath, 'src')) 30 | } 31 | -------------------------------------------------------------------------------- /src/client/components/Container.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { cn } from '#client/utils' 3 | 4 | type Props = { 5 | className?: string 6 | children: React.ReactNode 7 | } 8 | export const Container: React.FC = ({ className = '', children }) => ( 9 |
10 | {children} 11 |
12 | ) 13 | -------------------------------------------------------------------------------- /src/client/components/EntityVisibilityTag.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Tag } from '#client/components/ui' 3 | import { EntityVisibility } from '#shared/types' 4 | 5 | export const ENTITY_VISIBILITY_LABEL = { 6 | [EntityVisibility.None]: 'Nobody (draft)', 7 | [EntityVisibility.Url]: 'Registered users with a link', 8 | [EntityVisibility.Visible]: 'Registered users', 9 | [EntityVisibility.UrlPublic]: 'Everyone (public)', 10 | } 11 | 12 | const COLOR = { 13 | [EntityVisibility.None]: 'gray', 14 | [EntityVisibility.Url]: 'yellow', 15 | [EntityVisibility.Visible]: 'green', 16 | [EntityVisibility.UrlPublic]: 'blue', 17 | } 18 | 19 | export const EntityVisibilityTag: React.FC<{ 20 | visibility: EntityVisibility 21 | }> = ({ visibility, ...props }) => ( 22 | // @ts-ignore FIXME: define common client Color enum 23 | 24 | {ENTITY_VISIBILITY_LABEL[visibility]} 25 | 26 | ) 27 | -------------------------------------------------------------------------------- /src/client/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Header } from '#client/components/Header' 3 | import { Container } from '#client/components/Container' 4 | import { Background } from '#client/components/ui/Background' 5 | 6 | type Props = { 7 | children: React.ReactNode 8 | } 9 | 10 | export const Layout: React.FC = ({ children }) => { 11 | return ( 12 | 13 |
14 | {children} 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/client/components/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { FButton, H1, P, ComponentWrapper } from '#client/components/ui' 3 | 4 | export const NotFound: React.FC = () => { 5 | return ( 6 | 7 |

404

8 |

Page not found

9 | Go to the home page 10 |
11 | ) 12 | } -------------------------------------------------------------------------------- /src/client/components/PermissionsValidator.tsx: -------------------------------------------------------------------------------- 1 | import * as stores from '#client/stores' 2 | import { useStore } from '@nanostores/react' 3 | import React from 'react' 4 | 5 | type Props = { 6 | required: string[] 7 | officeId?: string 8 | onReject?: () => void 9 | onRejectRender?: React.ReactElement 10 | onRejectGoHome?: boolean 11 | children: React.ReactNode 12 | } 13 | 14 | export const PermissionsValidator: React.FC = ({ 15 | required = [], 16 | officeId, 17 | children, 18 | onReject, 19 | onRejectGoHome = false, 20 | onRejectRender = null, 21 | }) => { 22 | const permissions = useStore(stores.permissions) 23 | const [isValid, setIsValid] = React.useState( 24 | permissions.hasAll(required, officeId) 25 | ) 26 | React.useEffect(() => { 27 | if (!permissions.hasAll(required, officeId)) { 28 | if (onRejectGoHome) { 29 | setTimeout(() => stores.goTo('home'), 0) 30 | } else if (onReject) { 31 | setTimeout(onReject, 0) 32 | } 33 | setIsValid(false) 34 | } else { 35 | setIsValid(true) 36 | } 37 | }, [required, officeId]) 38 | if (isValid) { 39 | return <>{children} 40 | } 41 | if (onRejectRender) { 42 | return onRejectRender 43 | } 44 | return null 45 | } 46 | -------------------------------------------------------------------------------- /src/client/components/RouteValidator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useStore } from '@nanostores/react' 3 | import * as stores from '#client/stores' 4 | import { notEq } from '#shared/utils/fp' 5 | 6 | type Props = { 7 | children: React.ReactNode 8 | allowedRoutes?: string[] | undefined 9 | disallowedRoutes?: string[] | undefined 10 | } 11 | 12 | export const RouteValidator: React.FC = ({ children, allowedRoutes = [], disallowedRoutes = []}) => { 13 | const page = useStore(stores.router) 14 | if (!page) return null 15 | let match = false 16 | if (allowedRoutes.length && !disallowedRoutes.length) { 17 | match = allowedRoutes.includes(page.route) 18 | } 19 | if (!allowedRoutes.length && disallowedRoutes.length) { 20 | match = disallowedRoutes.every(notEq(page.route)) 21 | } 22 | return match ? ( 23 | <> 24 | {children} 25 | 26 | ) : null 27 | } 28 | -------------------------------------------------------------------------------- /src/client/components/auth/LoginButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react' 2 | import { FButton } from '#client/components/ui' 3 | import { cn } from '#client/utils' 4 | import { LoginIcons, providerUrls } from './helper' 5 | 6 | type Props = { 7 | label?: string 8 | className?: string 9 | size?: 'normal' | 'small' 10 | callbackPath?: string 11 | provider?: string 12 | icon?: string 13 | currentState?: string 14 | } 15 | 16 | export const LoginButton: React.FC = ({ 17 | className = '', 18 | size = 'normal', 19 | label = 'Login with Google', 20 | provider = 'google', 21 | icon = 'google', 22 | currentState = 'Login', 23 | ...props 24 | }) => { 25 | const loginUrl = useMemo(() => { 26 | if (!props.callbackPath) { 27 | return providerUrls[provider] 28 | } 29 | const url = new URL(providerUrls[provider]) 30 | url.searchParams.append('callbackPath', props.callbackPath) 31 | return url.toString() 32 | }, [provider]) 33 | 34 | return ( 35 |
36 | 48 | {icon &&
{LoginIcons[icon]}
} 49 | {label} 50 |
51 |
52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/client/components/auth/WarningModal.tsx: -------------------------------------------------------------------------------- 1 | import { FButton, Modal, P } from '../ui' 2 | 3 | type ModalProps = { 4 | onConfirm: () => void 5 | onCancel: () => void 6 | className?: string 7 | } 8 | 9 | export const WarningModal: React.FC = ({ onConfirm, onCancel }) => { 10 | return ( 11 | 12 |
13 |
14 |

Please be patient

15 |

16 | Signature/sign requests to your wallet might take a while to 17 | propagate (2-5 seconds). 18 |

19 |
20 | 21 |
22 |

No redirect back from wallet

23 |

24 | After your sign the request in you wallet, you will need to manually 25 | return back to the browser to continue. 26 |

27 |
28 | 29 | Noted 30 | 31 |
32 |
33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /src/client/components/charts/Card.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { WidgetWrapper } from '#client/components/ui' 3 | 4 | export const Card: React.FC<{ 5 | title: string | React.ReactNode 6 | subtitle: string | React.ReactNode 7 | }> = (props) => { 8 | return ( 9 | 10 |
{props.title}
11 |
12 | {props.subtitle} 13 |
14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/client/components/charts/index.tsx: -------------------------------------------------------------------------------- 1 | import D3 from 'd3' 2 | 3 | declare global { 4 | interface Window { 5 | d3?: typeof D3 6 | } 7 | } 8 | 9 | export { StackedBarChart } from './StackedBarChart' 10 | export { YearCalendar } from './YearCalendar' 11 | export { Card } from './Card' 12 | -------------------------------------------------------------------------------- /src/client/components/ui/Background.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { cn } from '#client/utils' 3 | 4 | type Props = { 5 | color?: string 6 | children: React.ReactNode 7 | className?: string 8 | } 9 | 10 | export const Background: React.FC = ({ color, children, className }) => ( 11 |
15 | {children} 16 |
17 | ) 18 | -------------------------------------------------------------------------------- /src/client/components/ui/Breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from '#client/components/ui' 3 | import { cn } from '#client/utils' 4 | 5 | type Props = { 6 | className?: string 7 | items: Array<{ 8 | label: string 9 | href?: string 10 | }> 11 | } 12 | export const Breadcrumbs: React.FC = ({ items, className }) => 13 | items.length ? ( 14 |
15 | {items.map((item, i) => ( 16 | 17 | {!!i && /} 18 | {item.href ? ( 19 | 23 | {item.label} 24 | 25 | ) : ( 26 | {item.label} 27 | )} 28 | 29 | ))} 30 |
31 | ) : null 32 | -------------------------------------------------------------------------------- /src/client/components/ui/CopyToClipboard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import { Icons } from './Icons' 3 | import { showNotification } from './Notifications' 4 | 5 | export const CopyToClipboard: React.FC<{ text: string }> = ({ text }) => { 6 | const clipboardRef = useRef(null) 7 | 8 | const handleCopy = async () => { 9 | if (clipboardRef.current) { 10 | try { 11 | await navigator.clipboard.writeText(text) 12 | showNotification('Copied to clipboard', 'success') 13 | } catch (error) { 14 | console.error('Failed to copy text:', error) 15 | showNotification('Failed to copy', 'error') 16 | } 17 | } 18 | } 19 | 20 | return ( 21 |
22 | 29 | 35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/client/components/ui/HeaderWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '#client/utils' 2 | import { BackButton } from './Button' 3 | import { H1, H2, P } from './Text' 4 | 5 | export const HeaderWrapper: React.FC<{ 6 | children: React.ReactNode 7 | title?: string 8 | secondTitle?: string 9 | subtitle?: string | Array 10 | backButton?: boolean 11 | }> = ({ title, secondTitle, children, subtitle, backButton = true }) => ( 12 |
13 | {backButton && } 14 | {title && ( 15 |

{title}

16 | )} 17 | {secondTitle &&

{secondTitle}

} 18 | {subtitle && ( 19 |

23 | {subtitle} 24 |

25 | )} 26 | {children} 27 |
28 | ) 29 | -------------------------------------------------------------------------------- /src/client/components/ui/Link.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '#client/utils' 2 | import React from 'react' 3 | 4 | type Props = React.AnchorHTMLAttributes & { 5 | kind?: 'primary' | 'secondary' 6 | } 7 | 8 | export const Link: React.FC = ({ 9 | className, 10 | kind = 'primary', 11 | ...rest 12 | }) => ( 13 | 23 | ) 24 | -------------------------------------------------------------------------------- /src/client/components/ui/Loader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { cn } from '#client/utils' 3 | 4 | type LoaderSpinnerProps = { 5 | // size: 'small' | 'medium' 6 | // color: 'purple' | 'gray' 7 | className?: string 8 | } 9 | export const LoaderSpinner: React.FC = ({ className = '' }) => ( 10 |
11 |
12 |
13 | ) -------------------------------------------------------------------------------- /src/client/components/ui/LoadingPolkadot.tsx: -------------------------------------------------------------------------------- 1 | import { Icons } from './Icons' 2 | 3 | export const LoadingPolkadot = () => ( 4 | 5 | ) 6 | -------------------------------------------------------------------------------- /src/client/components/ui/LoadingPolkadotWithText.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingPolkadot } from './LoadingPolkadot' 2 | import { H3 } from './Text' 3 | 4 | export const LoadingPolkadotWithText = ({ 5 | text = 'Connecting', 6 | }: { 7 | text?: string 8 | }) => ( 9 |
10 |

{text}

11 | 12 |
13 | ) 14 | -------------------------------------------------------------------------------- /src/client/components/ui/Map/mapbox/popup.tsx: -------------------------------------------------------------------------------- 1 | import config from '#client/config' 2 | import { UserMapPin } from '../../../../../modules/users/types' 3 | 4 | const getUserPopupHtml = (props: UserMapPin) => 5 | ` 6 |
10 | 13 |

${props.fullName}

14 |

${props.jobTitle ?? ''}

15 |
16 | ` 17 | 18 | export const createPopup = ( 19 | mapboxgl: any, 20 | map: mapboxgl.Map | null, 21 | e: any 22 | ) => { 23 | if (!map) { 24 | return 25 | } 26 | const coordinates = e.features[0].geometry.coordinates.slice() 27 | 28 | // Ensure that if the map is zoomed out such that 29 | // multiple copies of the feature are visible, the 30 | // popup appears over the copy being pointed to. 31 | while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { 32 | coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360 33 | } 34 | new mapboxgl.Popup() 35 | .setLngLat(coordinates) 36 | .setHTML(getUserPopupHtml(e.features[0].properties)) 37 | .addTo(map) 38 | } 39 | -------------------------------------------------------------------------------- /src/client/components/ui/Placeholder.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { cn } from '#client/utils' 3 | 4 | type Props = { 5 | children: React.ReactNode 6 | className?: string 7 | } 8 | 9 | export const Placeholder: React.FC = (props) => { 10 | return ( 11 |
12 | {props.children} 13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/client/components/ui/PlaceholderCard.tsx: -------------------------------------------------------------------------------- 1 | export const PlaceholderCard = () => ( 2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | ) 11 | -------------------------------------------------------------------------------- /src/client/components/ui/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '#client/utils/index' 2 | import * as React from 'react' 3 | 4 | interface ProgressBarProps { 5 | progress: number 6 | activateAnimation?: boolean 7 | animationTime?: number 8 | className?: string 9 | } 10 | 11 | export const ProgressBar: React.FC = ({ 12 | progress, 13 | activateAnimation = false, 14 | animationTime = 2000, 15 | className, 16 | }) => { 17 | const [showAnimation, setShowAnimation] = React.useState(false) 18 | const [barProgress, setBarProgress] = React.useState(progress) 19 | 20 | const commonStyle = 'h-3 rounded-full' 21 | const barStyle = cn( 22 | `bg-accents-pink transition-width duration-500`, 23 | commonStyle, 24 | showAnimation ? 'progress-infinite' : '' 25 | ) 26 | const container = cn( 27 | 'w-full bg-pink-100 ', 28 | commonStyle, 29 | showAnimation ? 'progress-infinite' : '', 30 | className 31 | ) 32 | 33 | React.useEffect(() => { 34 | if (progress === 100 && activateAnimation) { 35 | setShowAnimation(true) 36 | setTimeout(() => setShowAnimation(false), animationTime) 37 | } 38 | setBarProgress(progress) 39 | }, [progress]) 40 | 41 | return ( 42 |
43 |
44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/client/components/ui/ProgressDots.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '#client/utils/index' 2 | import * as React from 'react' 3 | 4 | export const ProgressDots: React.FC<{ value: number; total: number }> = ({ 5 | value, 6 | total, 7 | }) => { 8 | return ( 9 |
10 | {Array.from(Array(total)).map((x, i) => ( 11 | i ? 'bg-accents-pink' : 'bg-pink-50' 16 | )} 17 | /> 18 | ))} 19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/client/components/ui/StealthMode.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { SwitchButton } from './Button' 3 | import { P } from './Text' 4 | 5 | type Props = { 6 | title?: string 7 | subtitle?: string 8 | originalValue: boolean 9 | onToggle: (value: boolean) => void 10 | } 11 | 12 | export const StealthMode: React.FC = ({ 13 | title = "I'm in stealth mode", 14 | subtitle = "Don't show me on this list", 15 | originalValue, 16 | onToggle, 17 | }) => { 18 | const [stealthMode, setStealthMode] = React.useState(originalValue) 19 | const onToggleStealthMode = React.useCallback((value: boolean) => { 20 | setStealthMode(value) 21 | onToggle(value) 22 | }, []) 23 | 24 | return ( 25 |
26 |
27 |

28 | {title}
29 | {subtitle} 30 |

31 |
32 |
33 | 38 |
39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/client/components/ui/TimeLabel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import dayjs from 'dayjs' 3 | 4 | type Props = { 5 | value?: Date | string 6 | format: string 7 | interval?: number 8 | } 9 | 10 | export const TimeLabel: React.FC = ({ 11 | value = undefined, 12 | format, 13 | interval = 1e3, 14 | }) => { 15 | const [state, setState] = React.useState(dayjs(value).format(format)) 16 | React.useEffect(() => { 17 | if (!value) { 18 | const loop = setInterval(() => setState(dayjs().format(format)), interval) 19 | return () => clearInterval(loop) 20 | } 21 | }, []) 22 | return {state} 23 | } 24 | -------------------------------------------------------------------------------- /src/client/components/ui/TimeRangePicker.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Input } from './Input' 3 | import { cn } from '#client/utils' 4 | 5 | type TimeRangePickerProps = { 6 | from: string 7 | to: string 8 | onChange?: (from: string, to: string) => void 9 | inputClassName?: string 10 | } 11 | 12 | export const TimeRangePicker: React.FC = ({ 13 | from, 14 | to, 15 | onChange, 16 | inputClassName, 17 | }) => { 18 | const onTimeChange = React.useCallback( 19 | (order: 0 | 1) => (value: string) => { 20 | if (onChange) { 21 | const range: [string, string] = [from, to] 22 | range[order] = value 23 | onChange(range[0], range[1]) 24 | } 25 | }, 26 | [onChange, from, to] 27 | ) 28 | return ( 29 |
30 | 36 |
37 | 43 |
44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/client/components/ui/Warning.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '#client/utils/index' 2 | import * as React from 'react' 3 | import { Icons } from './Icons' 4 | import { P } from './Text' 5 | 6 | type Props = { 7 | text: string 8 | className?: string 9 | children: React.ReactNode 10 | } 11 | 12 | export const Warning: React.FC = ({ text, className, children }) => ( 13 |
19 | 20 |
21 |

{text}

22 | {children} 23 |
24 |
25 | ) 26 | -------------------------------------------------------------------------------- /src/client/constants.ts: -------------------------------------------------------------------------------- 1 | import config from '#client/config' 2 | 3 | // TODO: implement shared constants and move it there 4 | export const DATE_FORMAT = 'YYYY-MM-DD' 5 | 6 | export const DATE_FORMAT_DAY_NAME = 'ddd, MMMM D' 7 | 8 | export const DATE_FORMAT_DAY_NAME_FULL = 'dddd, MMMM D' 9 | 10 | export const FRIENDLY_DATE_FORMAT = 'MMMM D YYYY' 11 | 12 | export const USER_ROLES = config.roleGroups.map((x) => x.roles).flat() 13 | 14 | export const USER_ROLE_BY_ID = USER_ROLES.reduce( 15 | (acc, x) => ({ ...acc, [x.id]: x }), 16 | {} as Record 17 | ) 18 | 19 | export const OFFICE_BY_ID = config.offices.reduce( 20 | (acc, x) => ({ ...acc, [x.id]: x }), 21 | {} as Record 22 | ) 23 | 24 | // TODO: implement shared constants and move it there 25 | export const ADMIN_ACCESS_PERMISSION_POSTFIX = '__admin' 26 | 27 | // TODO: implement shared constants and move it there 28 | export const ADMIN_ACCESS_PERMISSION_RE = new RegExp( 29 | `^.*\.${ADMIN_ACCESS_PERMISSION_POSTFIX}` 30 | ) 31 | 32 | export const ROBOT_USER_ID = '00000000-0000-0000-0000-000000000000' 33 | -------------------------------------------------------------------------------- /src/client/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { QueryClientProvider, QueryClient } from 'react-query' 4 | import { App } from './App' 5 | import { Notifications } from '#client/components/ui' 6 | 7 | const container = document.createElement('div') 8 | document.body.appendChild(container) 9 | const root = createRoot(container!) 10 | 11 | const queryClient = new QueryClient() 12 | queryClient.setDefaultOptions({ 13 | queries: { 14 | retry: false 15 | } 16 | }) 17 | 18 | root.render( 19 | 20 | 21 | 22 | 23 | ) 24 | -------------------------------------------------------------------------------- /src/client/stores/index.ts: -------------------------------------------------------------------------------- 1 | export * from './state' 2 | export { router, goTo, openPage } from './router' 3 | -------------------------------------------------------------------------------- /src/client/stores/router.ts: -------------------------------------------------------------------------------- 1 | import { openPage as _openPage } from '@nanostores/router' 2 | import { router as _router, Route } from './__import-stores' 3 | 4 | export const router = _router 5 | 6 | // @todo replace it with `stores.openPage(stores.router, '...', {...}) 7 | export const goTo =

( 8 | page: P, 9 | params: Record = {} 10 | // @ts-ignore @fixme 11 | ) => openPage(router, page, params) 12 | 13 | export const openPage = _openPage 14 | -------------------------------------------------------------------------------- /src/client/utils/api.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from 'axios' 2 | import { showNotification } from '#client/components/ui/Notifications' 3 | import config from '#client/config' 4 | import * as stores from '#client/stores' 5 | 6 | export const api = axios.create({ 7 | baseURL: config.appHost, 8 | withCredentials: true, 9 | }) 10 | 11 | const DEFAULT_PUBLIC_ROUTES = ['/login', '/polkadot'] 12 | 13 | const publicRouteIds = config.modules 14 | .map((m) => { 15 | const publicRoutes = m.router?.public || {} 16 | return Object.keys(publicRoutes) 17 | }) 18 | .flat() 19 | 20 | api.interceptors.response.use( 21 | (res) => res, 22 | (err: AxiosError<{ statusCode: number; message: string }>) => { 23 | if (err.response?.status === 401) { 24 | const router = stores.router.get() 25 | if (DEFAULT_PUBLIC_ROUTES.includes(router!.path)) { 26 | return 27 | } 28 | if (publicRouteIds.includes(router!.route)) { 29 | return 30 | } 31 | setTimeout(() => { 32 | showNotification('Your session has expired.', 'info', { 33 | text: 'Login', 34 | url: '/auth/logout', 35 | }) 36 | }, 1e3) 37 | } else { 38 | if (err.code === 'ERR_NETWORK') { 39 | showNotification('No internet connection.', 'warning') 40 | } else { 41 | const message = err.response?.data?.message || 'Something went wrong.' 42 | showNotification(message, 'error') 43 | } 44 | } 45 | throw err 46 | } 47 | ) 48 | -------------------------------------------------------------------------------- /src/client/utils/portal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import MC from '#client/components/__import-components' 3 | import { ComponentRef } from '#shared/types' 4 | 5 | export const getComponentInstance = ( 6 | cr: ComponentRef 7 | ): React.FC | null => { 8 | const moduleId = cr[0] as keyof typeof MC 9 | const moduleComponents = MC[moduleId] 10 | if (!moduleComponents) return null 11 | 12 | const componentId = cr[1] as keyof typeof moduleComponents 13 | const Component = MC[moduleId][componentId] as React.FC 14 | if (!Component) return null 15 | 16 | return Component 17 | } 18 | 19 | export const renderComponent = 20 | (props: Record = {}, officeId?: string | null) => 21 | (cr: ComponentRef): React.ReactElement | null => { 22 | const Component = getComponentInstance(cr) 23 | if (!Component) return null 24 | const componentConfig = cr[2] 25 | const offices = componentConfig?.offices || [] 26 | if (offices.length && !offices.includes(officeId!)) { 27 | return null 28 | } 29 | return 30 | } 31 | -------------------------------------------------------------------------------- /src/integrations/_template/index.ts: -------------------------------------------------------------------------------- 1 | import { Integration } from '../integration' 2 | 3 | export default class _TemplateIntegration extends Integration { 4 | id = '' 5 | } 6 | -------------------------------------------------------------------------------- /src/integrations/_template/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "", 3 | "name": "", 4 | "credentials": ["INTEGRATION_API_KEY", "INTEGRATION_SECRET"] 5 | } 6 | -------------------------------------------------------------------------------- /src/integrations/_template/router.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginCallback } from 'fastify' 2 | 3 | const webhookRouter: FastifyPluginCallback = async (fastify, opts) => { 4 | fastify.post('/data', (request, reply) => { 5 | return reply.ok() 6 | }) 7 | } 8 | 9 | module.exports = { 10 | webhookRouter 11 | } 12 | -------------------------------------------------------------------------------- /src/integrations/_template/types.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/polkadot-hub-app/a84ea4b708bdff2f8aa65e5aaa1d95369e60d9c0/src/integrations/_template/types.ts -------------------------------------------------------------------------------- /src/integrations/bamboohr/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "bamboohr", 3 | "name": "BambooHR", 4 | "credentials": ["BAMBOOHR_API_TOKEN", "BAMBOOHR_SUBDOMAIN"] 5 | } 6 | -------------------------------------------------------------------------------- /src/integrations/bamboohr/types.ts: -------------------------------------------------------------------------------- 1 | export type Employee = { 2 | id: string 3 | displayName: string 4 | firstName: string 5 | lastName: string 6 | preferredName: string | null 7 | jobTitle: string 8 | workPhone: string | null 9 | workEmail: string 10 | department: string 11 | location: string 12 | division: string 13 | pronouns: string | null 14 | supervisor: string 15 | photoUploaded: boolean 16 | photoUrl: string 17 | canUploadPhoto: number 18 | customRiotID: string 19 | } 20 | 21 | export type EmployeeWithExtraFields = Pick< 22 | Employee, 23 | 'id' | 'workEmail' | 'firstName' | 'lastName' 24 | > & { [key: string]: any } 25 | 26 | export type EmployeeTimeOffRequest = { 27 | id: string 28 | employeeId: string 29 | status: { 30 | status: 'approved' | 'denied' | 'superseded' | 'requested' | 'canceled' 31 | } 32 | name: string 33 | start: string // YYYY-MM-DD 34 | end: string 35 | created: string 36 | type: { id: string; name: string } 37 | amount: { unit: 'days' | 'hours'; amount: '6' } 38 | dates: Record // { 'YYYY-MM-DD': '0' | '1' | '4' | ... } 39 | } 40 | -------------------------------------------------------------------------------- /src/integrations/email-smtp/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "email-smtp", 3 | "name": "EmailSMTP", 4 | "credentials": [ 5 | "SMTP_ENDPOINT", 6 | "SMTP_PORT", 7 | "SMTP_USERNAME", 8 | "SMTP_PASSWORD", 9 | "SMTP_FROM_NAME", 10 | "SMTP_FROM_EMAIL" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/integrations/email-smtp/types.ts: -------------------------------------------------------------------------------- 1 | export type Email = { 2 | to: string 3 | html: string 4 | subject: string 5 | } 6 | -------------------------------------------------------------------------------- /src/integrations/humaans/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "humaans", 3 | "name": "Humaans", 4 | "credentials": ["HUMAANS_API_TOKEN"] 5 | } 6 | -------------------------------------------------------------------------------- /src/integrations/integration.ts: -------------------------------------------------------------------------------- 1 | import { SafeResponse } from '#server/types' 2 | import { log } from '#server/utils/log' 3 | 4 | export type Credentials = Record 5 | 6 | export class Integration { 7 | id!: string 8 | 9 | async init(): Promise { 10 | log.info(`Integration "${this.id}" is initialised`) 11 | } 12 | 13 | async destroy(): Promise { 14 | log.info(`Integration "${this.id}" is destroyed.`) 15 | } 16 | 17 | error(error: unknown, message?: string): SafeResponse { 18 | log.error(`Error in "${this.id}" integration: ` + message) 19 | log.error(JSON.stringify(error)) 20 | return { success: false, error: error as Error } 21 | } 22 | 23 | success(data?: T): SafeResponse { 24 | if (data !== undefined) { 25 | return { success: true, data: data } as SafeResponse 26 | } 27 | return { success: true } as SafeResponse 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/integrations/mapbox/index.ts: -------------------------------------------------------------------------------- 1 | import { Integration } from '../integration' 2 | 3 | export default class Mapbox extends Integration { 4 | id = 'mapbox' 5 | private credentials = { 6 | apiKey: process.env.MAPBOX_API_KEY || '', 7 | } 8 | 9 | get apiKey(): string { 10 | return this.credentials.apiKey 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/integrations/mapbox/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "mapbox", 3 | "name": "Mapbox", 4 | "credentials": ["MAPBOX_API_KEY"] 5 | } 6 | -------------------------------------------------------------------------------- /src/integrations/matrix/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "matrix", 3 | "name": "Matrix", 4 | "credentials": [ 5 | "MATRIX_SERVER", 6 | "MATRIX_DOMAIN", 7 | "MATRIX_ADMIN_ROOM_ID", 8 | "MATRIX_API_TOKEN", 9 | "MATRIX_BOT_NAME", 10 | "MATRIX_BOT_USERNAME" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/integrations/matrix/types.ts: -------------------------------------------------------------------------------- 1 | export type MatrixUsername = string 2 | 3 | export type MatrixRoomId = string 4 | 5 | // export type MatrixRoomSettings = { 6 | // name: string 7 | // preset?: string 8 | // creation_content?: Record 9 | // power_level_content_override?: Record 10 | // is_direct?: boolean 11 | // } 12 | -------------------------------------------------------------------------------- /src/integrations/notion/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { Integration } from '../integration' 3 | import { NotionQueryDatabaseResponse } from './types' 4 | 5 | class Notion extends Integration { 6 | id = 'notion' 7 | private credentials = { 8 | apiToken: process.env.NOTION_API_TOKEN || '', 9 | } 10 | private baseUrl = `https://api.notion.com/v1` 11 | private headers = { 12 | Accept: 'application/json', 13 | 'Notion-Version': '2022-06-28', 14 | Authorization: `Bearer ${this.credentials.apiToken}`, 15 | } 16 | 17 | async queryDatabase( 18 | databaseId: string, 19 | query: any 20 | ): Promise { 21 | // https://developers.notion.com/reference/post-database-query 22 | return axios 23 | .post(`${this.baseUrl}/databases/${databaseId}/query`, query, { 24 | headers: this.headers, 25 | }) 26 | .then((res) => res.data) 27 | } 28 | } 29 | 30 | export default Notion 31 | -------------------------------------------------------------------------------- /src/integrations/notion/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "notion", 3 | "name": "Notion", 4 | "credentials": ["NOTION_API_TOKEN"] 5 | } 6 | -------------------------------------------------------------------------------- /src/integrations/notion/types.ts: -------------------------------------------------------------------------------- 1 | type MultiSelect = { 2 | id: string 3 | type: 'multi_select' 4 | multi_select: Array 5 | } 6 | 7 | type DateValue = { 8 | id: string 9 | type: 'date' 10 | date: { start: Date; end: Date; time_zone: string | null } 11 | } 12 | 13 | type MultiSelectValue = { 14 | id: string 15 | name: string 16 | color: string 17 | } 18 | 19 | type NotionPage = { 20 | object: string 21 | id: string 22 | created_time: string 23 | last_edited_time: string 24 | created_by: [Object] 25 | last_edited_by: [Object] 26 | cover: string | null 27 | icon: string | null 28 | parent: [Object] 29 | archived: boolean 30 | properties: { 31 | 'Event Name': { 32 | id: string 33 | type: string 34 | title: [ 35 | { 36 | type: string 37 | text: { 38 | content: string 39 | } 40 | annotations: [Object] 41 | plain_text: string 42 | href: string 43 | } 44 | ] 45 | } 46 | Status: MultiSelect 47 | Type: MultiSelect 48 | City: MultiSelect 49 | URL: { 50 | id: string 51 | type: string 52 | url: string 53 | } 54 | Dates: DateValue 55 | } 56 | url: string 57 | public_url: string | null 58 | } 59 | 60 | export type NotionQueryDatabaseResponse = { 61 | object: string 62 | results: NotionPage[] 63 | has_more: boolean 64 | next_cursor: string | null 65 | } 66 | -------------------------------------------------------------------------------- /src/modules/_template/client/components/index.ts: -------------------------------------------------------------------------------- 1 | // export { SomeComponent } from './SomeComponent' 2 | -------------------------------------------------------------------------------- /src/modules/_template/client/queries.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, useMutation } from 'react-query' 2 | import { AxiosError, AxiosResponse } from 'axios' 3 | import { api } from '#client/utils/api' 4 | // import { Entity } from '#shared/types' 5 | 6 | // export const useCreateEntity = (cb: () => void) => 7 | // useMutation( 8 | // (data: EntityCreationRequest) => 9 | // api.post('/admin-api//', data), 10 | // { onSuccess: cb } 11 | // ) 12 | 13 | // export const useEntities = () => { 14 | // const path = '/admin-api//' 15 | // return useQuery( 16 | // path, 17 | // async () => (await api.get(path)).data 18 | // ) 19 | // } 20 | -------------------------------------------------------------------------------- /src/modules/_template/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "", 3 | "name": "", 4 | "dependencies": [""], 5 | "requiredIntegrations": [], 6 | "recommendedIntegrations": [], 7 | "availableCronJobs": [], 8 | "models": [], 9 | "clientRouter": { 10 | "public": {}, 11 | "user": {}, 12 | "admin": {} 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/_template/metadata-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const schema = z.object({}).strict() 4 | 5 | export type Metadata = z.infer 6 | -------------------------------------------------------------------------------- /src/modules/_template/permissions.ts: -------------------------------------------------------------------------------- 1 | export const Permissions = { 2 | // __Admin: '{MODULE_ID}.__admin', 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/_template/server/jobs/index.ts: -------------------------------------------------------------------------------- 1 | import { CronJob, CronJobContext } from '#server/types' 2 | // import * as someCronJob from './someCronJob' 3 | 4 | // exported as a function for dynamic cron/name generation based on passed argument 5 | module.exports.moduleCronJobsFactory = (ctx: CronJobContext): CronJob[] => { 6 | return [ 7 | // { 8 | // cron: someCronJob.cron, 9 | // name: someCronJob.name, 10 | // fn: someCronJob.fn, 11 | // }, 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/_template/server/models/index.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | -------------------------------------------------------------------------------- /src/modules/_template/server/router.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginCallback, FastifyRequest } from 'fastify' 2 | 3 | const publicRouter: FastifyPluginCallback = async function (fastify, opts) {} 4 | 5 | const userRouter: FastifyPluginCallback = async function (fastify, opts) {} 6 | 7 | const adminRouter: FastifyPluginCallback = async function (fastify, opts) {} 8 | 9 | module.exports = { 10 | publicRouter, 11 | userRouter, 12 | adminRouter, 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/_template/types.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | -------------------------------------------------------------------------------- /src/modules/about/client/components/index.ts: -------------------------------------------------------------------------------- 1 | // export { SomeComponent } from './SomeComponent' 2 | export { AboutPage } from './AboutPage' 3 | export { AboutWidget } from './AboutWidget' 4 | -------------------------------------------------------------------------------- /src/modules/about/client/queries.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, useMutation } from 'react-query' 2 | import { AxiosError, AxiosResponse } from 'axios' 3 | import { api } from '#client/utils/api' 4 | // import { Entity } from '#shared/types' 5 | 6 | // export const useCreateEntity = (cb: () => void) => 7 | // useMutation( 8 | // (data: EntityCreationRequest) => 9 | // api.post('/admin-api//', data), 10 | // { onSuccess: cb } 11 | // ) 12 | 13 | // export const useEntities = () => { 14 | // const path = '/admin-api//' 15 | // return useQuery( 16 | // path, 17 | // async () => (await api.get(path)).data 18 | // ) 19 | // } 20 | -------------------------------------------------------------------------------- /src/modules/about/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "about", 3 | "name": "About", 4 | "dependencies": ["users"], 5 | "requiredIntegrations": [], 6 | "recommendedIntegrations": [], 7 | "models": [], 8 | "clientRouter": { 9 | "public": {}, 10 | "user": { 11 | "aboutPage": { 12 | "path": "/about/:hubId", 13 | "componentId": "AboutPage", 14 | "fullScreen": true 15 | } 16 | }, 17 | "admin": {} 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/about/metadata-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const schema = z.object({}).strict() 4 | 5 | export type Metadata = z.infer 6 | -------------------------------------------------------------------------------- /src/modules/about/permissions.ts: -------------------------------------------------------------------------------- 1 | export const Permissions = { 2 | // __Admin: '{MODULE_ID}.__admin', 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/about/server/jobs/index.ts: -------------------------------------------------------------------------------- 1 | import { CronJob, CronJobContext } from '#server/types' 2 | // import * as someCronJob from './someCronJob' 3 | 4 | // exported as a function for dynamic cron/name generation based on passed argument 5 | module.exports.moduleCronJobsFactory = (ctx: CronJobContext): CronJob[] => { 6 | return [ 7 | // { 8 | // cron: someCronJob.cron, 9 | // name: someCronJob.name, 10 | // fn: someCronJob.fn, 11 | // }, 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/about/server/models/index.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | -------------------------------------------------------------------------------- /src/modules/about/server/router.ts: -------------------------------------------------------------------------------- 1 | import { appConfig } from '#server/app-config' 2 | import { FastifyPluginCallback, FastifyRequest } from 'fastify' 3 | 4 | const mId = 'about' 5 | 6 | const publicRouter: FastifyPluginCallback = async function (fastify, opts) {} 7 | 8 | const userRouter: FastifyPluginCallback = async function (fastify, opts) { 9 | fastify.get( 10 | '/offices', 11 | async ( 12 | req: FastifyRequest<{ 13 | Params: { officeId: string } 14 | }>, 15 | reply 16 | ) => { 17 | if (!req.office) { 18 | return reply.throw.badParams('Missing office ID') 19 | } 20 | const addressDetail = appConfig.templates.text(mId, 'aboutOffice', { 21 | officeId: req.office, 22 | }) 23 | const facilities = appConfig.templates.text(mId, 'facilities', { 24 | officeId: req.office, 25 | }) 26 | return { 27 | addressDetail, 28 | facilities, 29 | } 30 | } 31 | ) 32 | } 33 | 34 | const adminRouter: FastifyPluginCallback = async function (fastify, opts) {} 35 | 36 | module.exports = { 37 | publicRouter, 38 | userRouter, 39 | adminRouter, 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/about/types.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | -------------------------------------------------------------------------------- /src/modules/admin-dashboard/client/components/index.ts: -------------------------------------------------------------------------------- 1 | export { AdminDashboard } from './AdminDashboard' 2 | -------------------------------------------------------------------------------- /src/modules/admin-dashboard/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "admin-dashboard", 3 | "name": "Dashboard", 4 | "dependencies": [], 5 | "requiredIntegrations": [], 6 | "recommendedIntegrations": [], 7 | "models": [], 8 | "clientRouter": { 9 | "public": {}, 10 | "user": {}, 11 | "admin": { 12 | "adminDashboard": { 13 | "path": "/admin/admin-dashboard", 14 | "componentId": "AdminDashboard", 15 | "availablePortals": [ 16 | "users-data", 17 | "visits-data", 18 | "guest-invites-data", 19 | "room-reservation-data" 20 | ] 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/admin-dashboard/metadata-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const schema = z.object({}).strict() 4 | 5 | export type Metadata = z.infer 6 | -------------------------------------------------------------------------------- /src/modules/admin-dashboard/permissions.ts: -------------------------------------------------------------------------------- 1 | export const Permissions = { 2 | __Admin: 'admin-dashboard.__admin', 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/admin-dashboard/types.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | -------------------------------------------------------------------------------- /src/modules/announcements/client/components/Announcement.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ComponentWrapper, 3 | H1, 4 | H2, 5 | HR, 6 | WidgetWrapper, 7 | } from '#client/components/ui' 8 | import { renderMarkdown } from '#client/utils/markdown' 9 | import * as React from 'react' 10 | import { useActiveAnnouncements } from '../queries' 11 | import { useStore } from '@nanostores/react' 12 | import * as stores from '#client/stores' 13 | 14 | export const Announcement: React.FC<{ 15 | title: string 16 | content: string 17 | }> = () => { 18 | const officeId = useStore(stores.officeId) 19 | const { data: announcements } = useActiveAnnouncements(officeId) 20 | if (!announcements?.length) { 21 | return <> 22 | } 23 | return ( 24 | 25 |

26 | {announcements && 27 | announcements?.map((ann, i) => { 28 | return ( 29 |
30 | {!!i &&
} 31 |
32 |

{ann.title}

33 |
39 |
40 |
41 | ) 42 | })} 43 |
44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/modules/announcements/client/components/index.ts: -------------------------------------------------------------------------------- 1 | export { AdminAnnouncements } from './AdminAnnouncements' 2 | export { AdminAnnouncementsEditor } from './AdminAnnouncementsEditor' 3 | export { Announcement } from './Announcement' 4 | -------------------------------------------------------------------------------- /src/modules/announcements/client/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { DATE_FORMAT_DAY_NAME } from '#client/constants' 2 | import dayjs from 'dayjs' 3 | 4 | export const getDayDifference = (date: string) => { 5 | const now = dayjs().startOf('day') 6 | const givenDate = dayjs(date).startOf('day') 7 | const numberOfDays = givenDate.diff(now, 'day') 8 | if (numberOfDays === 0) { 9 | return 'today' 10 | } 11 | if (numberOfDays < -21) { 12 | return 'a while ago' 13 | } 14 | return numberOfDays < 0 15 | ? `${Math.abs(numberOfDays)} days ago` 16 | : `in ${numberOfDays} days` 17 | } 18 | 19 | export const formatDate = (date: string) => { 20 | const d = dayjs(date).format(DATE_FORMAT_DAY_NAME) 21 | const diff = getDayDifference(date) 22 | return `${d} (${diff})` 23 | } 24 | 25 | export const isDateInPast = (date: string) => 26 | dayjs(date).isBefore(dayjs(), 'day') 27 | 28 | export const isCurrentlyHappening = (start: string, end: string) => 29 | dayjs().isAfter(dayjs(start)) && dayjs().isBefore(dayjs(end)) 30 | 31 | export enum AnnouncementNotifications { 32 | CreatedSuccess = 'The announcement has been created successfully', 33 | UpdatedSuccess = 'The announcement has been updated successfully', 34 | } 35 | -------------------------------------------------------------------------------- /src/modules/announcements/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "announcements", 3 | "name": "Announcements", 4 | "dependencies": ["users"], 5 | "requiredIntegrations": [], 6 | "recommendedIntegrations": [], 7 | "models": ["Announcement"], 8 | "clientRouter": { 9 | "admin": { 10 | "adminAnnouncementPage": { 11 | "path": "/admin/announcements", 12 | "componentId": "AdminAnnouncements" 13 | }, 14 | "adminAnnouncementEditorPage": { 15 | "path": "/admin/announcements/:announcementId", 16 | "componentId": "AdminAnnouncementsEditor" 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/announcements/permissions.ts: -------------------------------------------------------------------------------- 1 | export const Permissions = { 2 | __Admin: 'announcements.__admin', 3 | Use: 'announcements.use', 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/announcements/server/migrations/20230727121511_announcements_init.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { Sequelize, DataTypes } = require('sequelize') 3 | 4 | module.exports = { 5 | async up({ context: queryInterface, appConfig }) { 6 | await queryInterface.createTable('announcements', { 7 | id: { 8 | type: DataTypes.UUID, 9 | defaultValue: DataTypes.UUIDV4, 10 | allowNull: false, 11 | primaryKey: true, 12 | }, 13 | title: { 14 | type: DataTypes.STRING, 15 | allowNull: false, 16 | unique: true, 17 | }, 18 | content: DataTypes.TEXT, 19 | offices: { 20 | type: DataTypes.ARRAY(DataTypes.STRING), 21 | defaultValue: [], 22 | }, 23 | creatorUserId: { 24 | type: DataTypes.UUID, 25 | references: { 26 | model: 'users', 27 | key: 'id', 28 | }, 29 | onDelete: 'CASCADE', 30 | }, 31 | visibility: { 32 | type: DataTypes.STRING, 33 | allowNull: true, 34 | }, 35 | allowedRoles: { 36 | type: DataTypes.ARRAY(DataTypes.STRING), 37 | allowNull: false, 38 | defaultValue: [], 39 | }, 40 | scheduledAt: DataTypes.DATE, 41 | expiresAt: DataTypes.DATE, 42 | createdAt: DataTypes.DATE, 43 | updatedAt: DataTypes.DATE, 44 | }) 45 | }, 46 | async down({ context: queryInterface, appConfig }) { 47 | await queryInterface.dropTable('announcements') 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/modules/announcements/server/models/index.ts: -------------------------------------------------------------------------------- 1 | import { Announcement } from './announcement' 2 | 3 | export { Announcement } 4 | -------------------------------------------------------------------------------- /src/modules/announcements/types.ts: -------------------------------------------------------------------------------- 1 | import { EntityVisibility } from '#shared/types' 2 | 3 | export interface AnnouncementItem { 4 | id: string 5 | title: string 6 | content: string 7 | creatorUserId: string 8 | offices: string[] 9 | allowedRoles: string[] 10 | visibility: EntityVisibility 11 | scheduledAt: Date | null 12 | expiresAt: Date | null 13 | createdAt: Date 14 | updatedAt: Date 15 | } 16 | export type AnnouncementItemRequest = Pick< 17 | AnnouncementItem, 18 | | 'title' 19 | | 'content' 20 | | 'offices' 21 | | 'visibility' 22 | | 'allowedRoles' 23 | | 'expiresAt' 24 | | 'scheduledAt' 25 | > 26 | 27 | export type AnnouncementItemResponse = Pick< 28 | AnnouncementItem, 29 | 'title' | 'content' | 'expiresAt' | 'scheduledAt' 30 | > 31 | -------------------------------------------------------------------------------- /src/modules/checklists/client/components/index.ts: -------------------------------------------------------------------------------- 1 | export { Checklist } from './Checklist' 2 | export { AdminChecklist } from './AdminChecklist' 3 | export { AdminChecklistEditor } from './AdminChecklistEditor' 4 | -------------------------------------------------------------------------------- /src/modules/checklists/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "checklists", 3 | "name": "Checklist", 4 | "dependencies": ["users"], 5 | "requiredIntegrations": [], 6 | "recommendedIntegrations": [], 7 | "availableCronJobs": ["checklist-answer-delete-data"], 8 | "models": ["Checklist", "ChecklistAnswer"], 9 | "clientRouter": { 10 | "admin": { 11 | "adminChecklist": { 12 | "path": "/admin/checklists", 13 | "componentId": "AdminChecklist" 14 | }, 15 | "adminChecklistEditor": { 16 | "path": "/admin/checklists/:checklistId", 17 | "componentId": "AdminChecklistEditor" 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/checklists/permissions.ts: -------------------------------------------------------------------------------- 1 | export const Permissions = { 2 | __Admin: 'checklists.__admin', 3 | AdminManage: 'checklists.admin.manage', 4 | Use: 'checklists.use', 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/checklists/server/jobs/checklist-answer-delete-data.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import { Op } from 'sequelize' 3 | import { CronJob, CronJobContext } from '#server/types' 4 | 5 | const JobName = 'checklist-answer-delete-data' 6 | export const jobFactory = (): CronJob => { 7 | return { 8 | name: JobName, 9 | cron: `0 0 * * *`, 10 | fn: async (ctx: CronJobContext) => { 11 | try { 12 | const users = await ctx.models.User.findAllActive({ 13 | where: { 14 | scheduledToDelete: { [Op.lte]: dayjs().format('YYYY-MM-DD') }, 15 | }, 16 | }) 17 | if (!users.length) { 18 | ctx.log.info(`${JobName}: No one is scheduled to be deleted today.`) 19 | return 20 | } 21 | ctx.log.info( 22 | `${JobName}: Found ${users.length} users scheduled to be deleted today.` 23 | ) 24 | for (const user of users) { 25 | await ctx.models.ChecklistAnswer.destroy({ 26 | where: { userId: user.id }, 27 | }) 28 | 29 | ctx.log.info( 30 | `${JobName}: Removed ChecklistAnswer for user ${user.fullName} (id: ${user.id}).` 31 | ) 32 | } 33 | } catch (e) { 34 | ctx.log.error(JSON.stringify(e)) 35 | } 36 | }, 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/modules/checklists/server/jobs/index.ts: -------------------------------------------------------------------------------- 1 | import { CronJob, CronJobContext } from '#server/types' 2 | import * as checklistAnswerDeleteData from './checklist-answer-delete-data' 3 | 4 | module.exports.moduleCronJobsFactory = (ctx: CronJobContext): CronJob[] => { 5 | return [checklistAnswerDeleteData.jobFactory()] 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/checklists/server/models/index.ts: -------------------------------------------------------------------------------- 1 | import { Checklist } from './checklist' 2 | import { ChecklistAnswer } from './checklist-answer' 3 | 4 | Checklist.hasMany(ChecklistAnswer, { 5 | foreignKey: 'checklistId', 6 | as: 'answers', 7 | }) 8 | 9 | ChecklistAnswer.belongsTo(Checklist, { 10 | foreignKey: 'checklistId', 11 | as: 'checklist', 12 | }) 13 | 14 | export { Checklist, ChecklistAnswer } 15 | -------------------------------------------------------------------------------- /src/modules/events/client/components/AdminEventFormSubmissionsBadge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Link, Icons } from '#client/components/ui' 3 | import { Form } from '#shared/types' 4 | import { useEventWithForm } from '../queries' 5 | 6 | type Props = { 7 | form: Form 8 | } 9 | 10 | export const AdminEventFormSubmissionsBadge: React.FC = ({ form }) => { 11 | const { data: event = null } = useEventWithForm(form.id) 12 | return event ? ( 13 |
14 |
15 | 16 |
17 | This form is attached to the {event.title} event. 18 |
19 | In order to manage applications, please go to the{' '} 20 | 21 | event applications page 22 | 23 | . 24 |
25 |
26 |
27 | ) : null 28 | } 29 | -------------------------------------------------------------------------------- /src/modules/events/client/components/EventPublicFormDetector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Form } from '#shared/types' 3 | import { useEventWithForm } from '../queries' 4 | 5 | type Props = { 6 | form?: Form 7 | } 8 | 9 | export const EventPublicFormDetector: React.FC = ({ form }) => { 10 | const { data: event = null } = useEventWithForm(form?.id || null) 11 | React.useEffect(() => { 12 | if (event) { 13 | window.location.href = `/event/${event.id}/application` 14 | } 15 | }, [event]) 16 | return null 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/events/client/components/UncompletedActions.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useStore } from '@nanostores/react' 3 | import * as stores from '#client/stores' 4 | import { WidgetWrapper } from '#client/components/ui' 5 | import { useUncompletedActions } from '../queries' 6 | import { cn } from '#client/utils' 7 | import { EventBadge } from './EventBadge' 8 | 9 | export const UncompletedActions: React.FC = () => { 10 | const officeId = useStore(stores.officeId) 11 | const { data: events = [], isFetched } = useUncompletedActions(officeId) 12 | 13 | return (events.length && isFetched) ? ( 14 | 15 | {!events?.length ? ( 16 |
No upcoming events yet
17 | ) : ( 18 |
19 | {events?.map((x, i) => ( 20 |
21 | 25 |
26 | ))} 27 |
28 | )} 29 |
30 | ) : null 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/events/client/components/index.ts: -------------------------------------------------------------------------------- 1 | export { AdminEventApplicationEditor } from './AdminEventApplicationEditor' 2 | export { AdminEventApplications } from './AdminEventApplications' 3 | export { AdminEventEditor } from './AdminEventEditor' 4 | export { AdminEventFormSubmissionsBadge } from './AdminEventFormSubmissionsBadge' 5 | export { AdminEvents } from './AdminEvents' 6 | export { EventBadge } from './EventBadge' 7 | export { EventForm } from './EventForm' 8 | export { EventPage } from './EventPage' 9 | export { EventPublicFormDetector } from './EventPublicFormDetector' 10 | export { GlobalEvents } from './GlobalEvents' 11 | export { MyEvents } from './MyEvents' 12 | export { UncompletedActions } from './UncompletedActions' 13 | export { UpcomingEvents } from './UpcomingEvents' 14 | export { EventsPage } from './EventsPage' 15 | -------------------------------------------------------------------------------- /src/modules/events/metadata-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const schema = z 4 | .object({ 5 | links: z 6 | .array( 7 | z.object({ 8 | name: z.string().nonempty(), 9 | url: z.string().nonempty(), 10 | }) 11 | ) 12 | .default([]), 13 | typeColorMap: z.record( 14 | z.enum(['green', 'blue', 'red', 'purple', 'gray', 'yellow']) 15 | ), 16 | officesWithGlobalEvents: z.array(z.string().nonempty()), 17 | notionGlobalEventsDatabaseId: z.string().optional(), 18 | }) 19 | .strict() 20 | .optional() 21 | 22 | export type Metadata = z.infer 23 | -------------------------------------------------------------------------------- /src/modules/events/permissions.ts: -------------------------------------------------------------------------------- 1 | export const Permissions = { 2 | __Admin: 'events.__admin', 3 | AdminList: 'events.admin.list', 4 | AdminManage: 'events.admin.manage', 5 | ListGlobalEvents: 'events.list_global_events', 6 | ListParticipants: 'events.list_participants', 7 | AdminReceiveNotifications: 'events.admin.receive_notifications', 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/events/server/jobs/index.ts: -------------------------------------------------------------------------------- 1 | import { CronJob, CronJobContext } from '#server/types' 2 | import * as eventChecklistReminder from './event-checklist-reminder' 3 | import * as pullGlobalEvents from './pull-global-events' 4 | import * as deleteUserData from './event-delete-data' 5 | 6 | module.exports.moduleCronJobsFactory = (ctx: CronJobContext): CronJob[] => { 7 | return [ 8 | eventChecklistReminder.cronJob, 9 | pullGlobalEvents.cronJob, 10 | deleteUserData.cronJob, 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/events/server/models/index.ts: -------------------------------------------------------------------------------- 1 | import { Event } from './event' 2 | import { EventApplication } from './event-application' 3 | 4 | export { Event } from './event' 5 | export { EventApplication } from './event-application' 6 | export { EventCheckmark } from './event-checkmark' 7 | export { EventChecklistReminderJob } from './event-checklist-reminder-job' 8 | 9 | Event.hasMany(EventApplication, { 10 | foreignKey: 'eventId', 11 | as: 'applications', 12 | }) 13 | 14 | EventApplication.belongsTo(Event, { 15 | foreignKey: 'eventId', 16 | as: 'event', 17 | }) 18 | -------------------------------------------------------------------------------- /src/modules/events/templates/email.yaml: -------------------------------------------------------------------------------- 1 | eventApplicationCancelledUserSubject: | 2 | {{ user.fullName }} opted out from {{ event.title }} 3 | eventApplicationCancelledUserHtml: | 4 | Hello,

5 | {{ user.fullName }} ({{ user.email }}) opted out of the event.

6 | Event: {{ event.title }}
7 | {{ user.fullName }}'s application: View/edit
8 | All applications: " {{ event.title }}"

9 | Have a nice day, 10 | Your {{ appName }} 11 | eventApplicationDeletedSubject: | 12 | Event application deleted for {{ user.fullName }} 13 | eventApplicationDeletedHtml: | 14 | Hello,

15 | Application for event was deleted because the user was removed.

16 | Event: {{ event.title }}
17 | User: {{ user.fullName }} ({{ user.email }})
18 | All applications: "{{ event.title }}"

19 | Have a nice day, 20 | Your {{ appName }} 21 | -------------------------------------------------------------------------------- /src/modules/events/templates/notification.yaml: -------------------------------------------------------------------------------- 1 | eventApplicationOpened: | 2 | Hi 👋
3 | Thank you for your interest in the "{{ event.title }}" event!
{{ event.dates }}

4 | Your application is waiting for confirmation. I'll notify you when it's confirmed. 5 | eventApplicationConfirmed: | 6 | Hi 👋
7 | Thank you for your interest in the "{{ event.title }}" event!
{{ event.dates }}

8 | Your application has been CONFIRMED! Check the event details here. 9 | eventApplicationCancelledUser: | 10 | Hi 👋
11 | Thank you for your interest in the "{{ event.title }}" event!
{{ event.dates }}

12 | You have successfully opted out of the event. 13 | eventApplicationCancelledAdmin: | 14 | Hi 👋
15 | Thank you for your interest in the "{{ event.title }}" event!
{{ event.dates }}

16 | Your application has been REJECTED. Feel free to contact admin for details. 17 | eventApplicationUpdateForAdmin: | 18 | RSVP Change: {{ event.title }}

19 | {{ user.fullName }} changed their RSVP to {{ status }} 20 | eventChecklistReminder: | 21 | Hey {{ user.fullName }},
22 | Please, don't forget to complete the "{{ event.title }}" checklist here.
23 | Remaining tasks: {{ remainingTasks }} 24 | -------------------------------------------------------------------------------- /src/modules/forms/client/components/index.ts: -------------------------------------------------------------------------------- 1 | export { AdminFormEditor } from './AdminFormEditor' 2 | export { AdminForms } from './AdminForms' 3 | export { AdminFormSubmissionEditor } from './AdminFormSubmissionEditor' 4 | export { AdminFormSubmissions } from './AdminFormSubmissions' 5 | export { PublicForm } from './PublicForm' 6 | -------------------------------------------------------------------------------- /src/modules/forms/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "forms", 3 | "name": "Forms", 4 | "dependencies": ["users"], 5 | "requiredIntegrations": [], 6 | "recommendedIntegrations": ["email-smtp", "matrix"], 7 | "availableCronJobs": ["forms-delete-data", "purge-form-submissions"], 8 | "models": ["Form", "FormSubmission"], 9 | "clientRouter": { 10 | "public": { 11 | "form": { 12 | "path": "/form/:formId", 13 | "componentId": "PublicForm", 14 | "availablePortals": ["public_form_header"] 15 | } 16 | }, 17 | "user": {}, 18 | "admin": { 19 | "adminForms": { 20 | "path": "/admin/forms", 21 | "componentId": "AdminForms" 22 | }, 23 | "adminForm": { 24 | "path": "/admin/forms/:formId", 25 | "componentId": "AdminFormEditor" 26 | }, 27 | "adminFormSubmissions": { 28 | "path": "/admin/forms/:formId/submissions", 29 | "componentId": "AdminFormSubmissions", 30 | "availablePortals": ["admin_form_submissions_header"] 31 | }, 32 | "adminFormSubmission": { 33 | "path": "/admin/forms/:formId/submissions/:formSubmissionId", 34 | "componentId": "AdminFormSubmissionEditor" 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/modules/forms/permissions.ts: -------------------------------------------------------------------------------- 1 | export const Permissions = { 2 | __Admin: 'forms.__admin', 3 | AdminList: 'forms.admin.list', 4 | AdminManage: 'forms.admin.manage', 5 | AdminReceiveNotifications: 'forms.admin.receive_notifications', 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/forms/server/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import config from '#server/config' 2 | import { User } from '#modules/users/server/models' 3 | import { Form, FormSubmission } from '#shared/types' 4 | 5 | export const getAllFormSubmissionsUrl = (formId: string) => 6 | `${config.appHost}/admin/forms/${formId}/submissions` 7 | 8 | export const getFormUrl = (formId: string) => 9 | `${config.appHost}/admin/forms/${formId}` 10 | 11 | export const getUserFormSubmissionUrl = ( 12 | formId: string, 13 | submissionId: string 14 | ) => `${config.appHost}/admin/forms/${formId}/submissions/${submissionId}` 15 | 16 | export const getTemplateData = ( 17 | user: User | null, 18 | form: Form, 19 | formSubmission: FormSubmission, 20 | changes?: string | string[] 21 | ) => ({ 22 | user: user?.usePublicProfileView(), 23 | form: { title: form.title, url: getFormUrl(form.id) }, 24 | changes, 25 | formSubmissionsUrl: getAllFormSubmissionsUrl(form.id), 26 | userFormSubmissionUrl: getUserFormSubmissionUrl(form.id, formSubmission.id), 27 | }) 28 | -------------------------------------------------------------------------------- /src/modules/forms/server/jobs/forms-delete-data.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import { Op } from 'sequelize' 3 | import { CronJob, CronJobContext } from '#server/types' 4 | 5 | const JobName = 'forms-delete-data' 6 | export const jobFactory = (): CronJob => { 7 | return { 8 | name: JobName, 9 | cron: `0 0 * * *`, 10 | fn: async (ctx: CronJobContext) => { 11 | try { 12 | const users = await ctx.models.User.findAllActive({ 13 | where: { 14 | scheduledToDelete: { [Op.lte]: dayjs().format('YYYY-MM-DD') }, 15 | }, 16 | }) 17 | if (!users.length) { 18 | ctx.log.info(`${JobName}: No one is scheduled to be deleted today.`) 19 | return 20 | } 21 | ctx.log.info( 22 | `${JobName}: Found ${users.length} users scheduled to be deleted today.` 23 | ) 24 | for (const user of users) { 25 | await ctx.models.FormSubmission.destroy({ 26 | where: { userId: user.id }, 27 | }) 28 | ctx.log.info( 29 | `${JobName}: Removed FormSubmissions for user ${user.fullName} (id: ${user.id}).` 30 | ) 31 | } 32 | } catch (e) { 33 | ctx.log.error(JSON.stringify(e)) 34 | } 35 | }, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/forms/server/jobs/index.ts: -------------------------------------------------------------------------------- 1 | import { CronJob, CronJobContext } from '#server/types' 2 | import * as formsDeleteDate from './forms-delete-data' 3 | import * as purgeFormSubmissions from './purge-form-submissions' 4 | 5 | module.exports.moduleCronJobsFactory = (ctx: CronJobContext): CronJob[] => { 6 | return [formsDeleteDate.jobFactory(), purgeFormSubmissions.jobFactory()] 7 | } 8 | -------------------------------------------------------------------------------- /src/modules/forms/server/jobs/purge-form-submissions.ts: -------------------------------------------------------------------------------- 1 | import { Op } from 'sequelize' 2 | import dayjs from 'dayjs' 3 | import { CronJob, CronJobContext } from '#server/types' 4 | 5 | const JOB_NAME = 'purge-form-submissions' 6 | 7 | export const jobFactory = (): CronJob => { 8 | return { 9 | name: JOB_NAME, 10 | cron: `0 0 * * *`, 11 | fn: async (ctx: CronJobContext) => { 12 | const forms = await ctx.models.Form.findAll({ 13 | where: { 14 | purgeSubmissionsAfterDays: { [Op.ne]: null }, 15 | }, 16 | attributes: ['id', 'title', 'purgeSubmissionsAfterDays'], 17 | }) 18 | for (const form of forms) { 19 | if (!form.purgeSubmissionsAfterDays) continue 20 | const deleted = await ctx.models.FormSubmission.destroy({ 21 | where: { 22 | formId: form.id, 23 | createdAt: { 24 | [Op.lt]: dayjs() 25 | .subtract(form.purgeSubmissionsAfterDays, 'day') 26 | .toDate(), 27 | }, 28 | }, 29 | }) 30 | if (deleted) { 31 | ctx.log.info( 32 | `${JOB_NAME}: Deleted ${deleted} submissions. Form "${form.title}"` 33 | ) 34 | } 35 | } 36 | }, 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/modules/forms/server/migrations/20240228150156_forms_purge-submissions-after-days.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { Sequelize, DataTypes } = require('sequelize') 3 | 4 | module.exports = { 5 | async up({ context: queryInterface, appConfig }) { 6 | await queryInterface.addColumn('forms', 'purgeSubmissionsAfterDays', { 7 | type: DataTypes.INTEGER, 8 | }) 9 | }, 10 | async down({ context: queryInterface, appConfig }) { 11 | await queryInterface.removeColumn('forms', 'purgeSubmissionsAfterDays') 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/forms/server/models/index.ts: -------------------------------------------------------------------------------- 1 | export { Form } from './form' 2 | export { FormSubmission } from './form-submission' 3 | -------------------------------------------------------------------------------- /src/modules/forms/templates/email.yaml: -------------------------------------------------------------------------------- 1 | formSubmissionChangeSubject: | 2 | {{ form.title }} submission change from {{ user.fullName }} 3 | formSubmissionChangeHtml: | 4 | Hello,

5 | 6 | {{ user.fullName }} ({{ user.email }}) changed their submission of {{ form.title }}

7 | 8 | Submission: View/edit
9 | Changes: {{ changes }}
10 | All Form Submissions: {{ form.title }} 11 | -------------------------------------------------------------------------------- /src/modules/forms/templates/notification.yaml: -------------------------------------------------------------------------------- 1 | formSubmission: The user {{ user.fullName }} {{ user.email }} has submitted the {{ form.title }} form.
View/edit submission
All submissions 2 | formSubmissionAnonymous: 📝 Anonymous user has submitted the {{ form.title }} form.
View/edit submission
All submissions 3 | formSubmissionChange: | 4 | ⚠️ Data Change
5 | Form: {{ form.title }}
6 | User: {{ user.fullName }} ({{ user.email }})
7 | Changes: {{ changes }}
8 | Submission: View/edit 9 | -------------------------------------------------------------------------------- /src/modules/guest-invites/client/components/GuestInviteStatusTag.tsx: -------------------------------------------------------------------------------- 1 | import { Size } from '#client/components/ui/Tag' 2 | import { GuestInvite } from '#shared/types' 3 | import { Tag } from '#client/components/ui' 4 | 5 | export const GuestInviteStatusTag: React.FC<{ 6 | status: GuestInvite['status'], size?: Size 7 | }> = ({ status, size = 'small' }) => { 8 | type Color = 'gray' | 'yellow' | 'green' | 'red' | 'blue' 9 | const label: Record = { 10 | pending: 'Pending', 11 | opened: 'Open', 12 | confirmed: 'Confirmed', 13 | rejected: 'Rejected', 14 | cancelled: 'Cancelled' 15 | } 16 | const color: Record = { 17 | pending: 'gray', 18 | opened: 'yellow', 19 | confirmed: 'green', 20 | rejected: 'red', 21 | cancelled: 'red' 22 | } 23 | return {label[status]} 24 | } -------------------------------------------------------------------------------- /src/modules/guest-invites/client/components/index.ts: -------------------------------------------------------------------------------- 1 | export { AdminGuestInvite } from './AdminGuestInvite' 2 | export { AdminGuestInvites } from './AdminGuestInvites' 3 | export { GuestInviteDetail } from './GuestInviteDetail' 4 | export { GuestInviteForm } from './GuestInviteForm' 5 | export { GuestInviteRequestForm } from './GuestInviteRequestForm' 6 | export { GuestInviteStatusTag } from './GuestInviteStatusTag' 7 | export { AdminGuestInviteEditor } from './AdminGuestInviteEditor' 8 | export { AdminDashboardStats } from './AdminDashboardStats' 9 | -------------------------------------------------------------------------------- /src/modules/guest-invites/client/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export enum Notifications { 2 | CreatedSuccess = 'Guest invitation has been created successfully', 3 | UpdatedSuccess = 'Guest invitation has been updated successfully', 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/guest-invites/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "guest-invites", 3 | "name": "Guest Invites", 4 | "dependencies": ["users", "visits"], 5 | "requiredIntegrations": ["email-smtp"], 6 | "recommendedIntegrations": ["matrix"], 7 | "models": ["GuestInvite"], 8 | "clientRouter": { 9 | "public": { 10 | "guestInviteForm": { 11 | "path": "/guest-invite/:inviteCode", 12 | "componentId": "GuestInviteForm" 13 | } 14 | }, 15 | "user": { 16 | "guestInviteRequestForm": { 17 | "path": "/guest-invites/request", 18 | "componentId": "GuestInviteRequestForm" 19 | }, 20 | "guestInviteDetail": { 21 | "path": "/guest-invites/:inviteId", 22 | "componentId": "GuestInviteDetail" 23 | } 24 | }, 25 | "admin": { 26 | "adminGuestInvites": { 27 | "path": "/admin/guest-invites", 28 | "componentId": "AdminGuestInvites" 29 | }, 30 | "adminGuestInvite": { 31 | "path": "/admin/guest-invites/:inviteId", 32 | "componentId": "AdminGuestInvite" 33 | }, 34 | "adminGuestInviteEditor": { 35 | "path": "/admin/guest-invites/editor/:inviteId", 36 | "componentId": "AdminGuestInviteEditor" 37 | } 38 | } 39 | }, 40 | "adminLinkCounter": true 41 | } 42 | -------------------------------------------------------------------------------- /src/modules/guest-invites/metadata-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const rule = z.object({ 4 | id: z.string().nonempty(), 5 | label: z.string().nonempty(), 6 | }) 7 | 8 | export const schema = z 9 | .object({ 10 | rulesByOffice: z 11 | .record(z.array(rule).min(1)) 12 | .and(z.object({ __default: z.array(rule).min(1) })), 13 | }) 14 | .strict() 15 | 16 | export type Metadata = z.infer 17 | -------------------------------------------------------------------------------- /src/modules/guest-invites/permissions.ts: -------------------------------------------------------------------------------- 1 | export const Permissions = { 2 | __Admin: 'guest-invites.__admin', 3 | AdminList: 'guest-invites.admin.list', 4 | AdminManage: 'guest-invites.admin.manage', 5 | Create: 'guest-invites.create', 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/guest-invites/server/models/index.ts: -------------------------------------------------------------------------------- 1 | export { GuestInvite } from './guest-invite' 2 | -------------------------------------------------------------------------------- /src/modules/guest-invites/templates/email.yaml: -------------------------------------------------------------------------------- 1 | newInvitationSubject: | 2 | Office Invitation @ {{ companyName }}, {{ office.name }} office 3 | newInvitationHtml: | 4 | Dear {{ guest.fullName }},

5 | You have received an invitation to visit {{ companyName }} at their {{ office.name }} office.

6 | To confirm your reservation, kindly fill out the Guest form. 7 |

8 | Thank you,
9 | {{ companyName }} 10 | invitationConfirmedByAdminSubject: | 11 | Confirmed: Office Invitation @ {{ companyName }}, {{ office.name }} office 12 | invitationConfirmedByAdminHtml: | 13 | Dear {{ guest.fullName }},

14 | Thank you for submitting your request to come to the {{ companyName }} {{ office.name }} office at {{ office.address }}.

15 | Your visit dates:
16 | {{#visitDates}} 17 | {{ date }} 18 | {{/visitDates}} 19 |

20 | Thank you,
21 | {{ companyName }} 22 | invitationCancelledByUserSubject: | 23 | Cancelled: Office Invitation @ {{ companyName }}, {{ office.name }} office 24 | invitationCancelledByUserHtml: | 25 | Dear {{ guest.fullName }},

26 | You invitation to the {{ companyName }} {{ office.name }} office for the following dates was cancelled:
27 |
    28 | {{#visitDates}} 29 |
  • {{ date }}
  • 30 | {{/visitDates}} 31 |
32 |

33 | Thank you,
34 | {{ companyName }} 35 | -------------------------------------------------------------------------------- /src/modules/guest-invites/templates/notification.yaml: -------------------------------------------------------------------------------- 1 | openedGuestInviteAdmin: | 2 | {{ guest.fullName }} ({{ guest.email }}) confirmed their guest invitation from {{ user.fullName }} to visit {{ office.name }} office.
3 | Visit dates: 4 | {{#visitDates}} 5 | {{ date }} 6 | {{/visitDates}} 7 | newGuestInviteAdmin: | 8 | {{ user.fullName }} invited {{ guest.fullName }} ({{ guest.email }}) to visit {{ office.name }} office. 9 | invitationConfirmedByAdmin: | 10 | {{ admin.fullName }} has confirmed a guest invite for {{ guest.fullName }} ({{ guest.email }}) to visit {{ office.name }} office.
11 | Dates: 12 | {{#visitDates}} 13 | {{ date }} 14 | {{/visitDates}}
15 | Area: {{ areaName }}
16 | Desk: {{ deskName }} 17 | invitationCancelledByUser: | 18 | You have {{ status }} guest invitation for {{ guest.fullName }} on 19 | {{#visitDates}} 20 | {{ date }} 21 | {{/visitDates}}
22 | invitationCancelledbyUserForAdmin: | 23 | Guest invitation for {{ guest.fullName }} on 24 | {{#visitDates}} 25 | {{ date }} 26 | {{/visitDates}} has been {{ status }} by {{ user.fullName }} ({{ user.email }}) 27 | -------------------------------------------------------------------------------- /src/modules/guest-invites/types.ts: -------------------------------------------------------------------------------- 1 | export type GuestInviteRequest = { 2 | fullName: string 3 | email: string 4 | } 5 | 6 | export type GuestInviteStatus = 7 | | 'pending' 8 | | 'opened' 9 | | 'confirmed' 10 | | 'rejected' 11 | | 'cancelled' 12 | 13 | export type GuestInvite = { 14 | id: string 15 | creatorUserId: string 16 | code: string 17 | fullName: string 18 | email: string 19 | dates: string[] 20 | office: string 21 | areaId: string | null 22 | deskId: string | null 23 | status: GuestInviteStatus 24 | createdAt: Date 25 | updatedAt: Date 26 | } 27 | 28 | export type PublicGuestInvite = Pick< 29 | GuestInvite, 30 | 'code' | 'fullName' | 'office' 31 | > 32 | 33 | export type GuestInviteGuestRequest = Pick< 34 | GuestInvite, 35 | 'fullName' | 'dates' 36 | > & { rules: string[] } 37 | 38 | export type GuestInviteOfficeRule = { id: string; label: string } 39 | 40 | export type GuestInviteUpdateRequest = Partial< 41 | Pick 42 | > 43 | 44 | export type GuestInvitesAdminDashboardStats = { 45 | guestsTotal: number 46 | guestsToday: number 47 | topGuests: { 48 | userId: string 49 | fullName: string 50 | avatar: string | null 51 | visits: number 52 | }[] 53 | topInviters: { 54 | userId: string 55 | fullName: string 56 | avatar: string | null 57 | guests: number 58 | }[] 59 | guestsByDate: { 60 | date: string 61 | total: number 62 | }[] 63 | } 64 | -------------------------------------------------------------------------------- /src/modules/hub-map/client/components/index.ts: -------------------------------------------------------------------------------- 1 | export { HubMap } from './HubMap' 2 | -------------------------------------------------------------------------------- /src/modules/hub-map/client/queries.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from 'react-query' 2 | import { AxiosError } from 'axios' 3 | import { api } from '#client/utils/api' 4 | import dayjs from 'dayjs' 5 | import { DATE_FORMAT } from '#server/constants' 6 | 7 | export const useUpcoming = ( 8 | officeId: string, 9 | date: string, 10 | userId?: string 11 | ) => { 12 | const path = '/user-api/hub-map/upcoming' 13 | return useQuery( 14 | [path, { officeId, date: dayjs(date).format(DATE_FORMAT), userId }], 15 | async ({ queryKey }) => 16 | (await api.get(path, { params: queryKey[1] })).data, 17 | { enabled: !!officeId } 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/hub-map/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "hub-map", 3 | "name": "HubMap", 4 | "dependencies": [ 5 | "visits", 6 | "guest-invites", 7 | "room-reservation", 8 | "users", 9 | "events" 10 | ], 11 | "requiredIntegrations": [], 12 | "recommendedIntegrations": [], 13 | "models": [], 14 | "clientRouter": { 15 | "public": {}, 16 | "user": {}, 17 | "admin": {} 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/hub-map/metadata-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const schema = z.object({}).strict().optional() 4 | 5 | export type Metadata = z.infer 6 | -------------------------------------------------------------------------------- /src/modules/hub-map/permissions.ts: -------------------------------------------------------------------------------- 1 | export const Permissions = { 2 | // __Admin: '{MODULE_ID}.__admin', 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/hub-map/server/models/index.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | -------------------------------------------------------------------------------- /src/modules/hub-map/types.ts: -------------------------------------------------------------------------------- 1 | import { User } from '#shared/types' 2 | 3 | export type ScheduledItemType = { 4 | id: string 5 | value: string 6 | type: string 7 | date: string 8 | extraInformation?: string 9 | description: string 10 | areaId?: string 11 | objectId?: string 12 | user?: User 13 | status: string 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/news/client/components/index.ts: -------------------------------------------------------------------------------- 1 | export { AdminNews } from './AdminNews' 2 | export { AdminNewsEditor } from './AdminNewsEditor' 3 | export { LatestNews } from './LatestNews' 4 | export { NewsPage } from './NewsPage' 5 | export { NewsListPage } from './NewsListPage' 6 | -------------------------------------------------------------------------------- /src/modules/news/client/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export const DATE_FORMAT = 'YYYY-MM-DD HH:mm' 2 | 3 | export enum Notifications { 4 | CreatedSuccess = 'The news article has been created successfully', 5 | UpdatedSuccess = 'The news article has been updated successfully', 6 | } 7 | 8 | -------------------------------------------------------------------------------- /src/modules/news/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "news", 3 | "name": "News", 4 | "dependencies": ["users"], 5 | "requiredIntegrations": [], 6 | "recommendedIntegrations": [], 7 | "models": ["News"], 8 | "clientRouter": { 9 | "public": { 10 | "newsPage": { 11 | "path": "/news/:newsId", 12 | "componentId": "NewsPage", 13 | "fullScreen": true 14 | } 15 | }, 16 | "user": { 17 | "newsList": { 18 | "path": "/news", 19 | "componentId": "NewsListPage", 20 | "fullScreen": false 21 | } 22 | }, 23 | "admin": { 24 | "adminNews": { 25 | "path": "/admin/news", 26 | "componentId": "AdminNews" 27 | }, 28 | "adminNewsPage": { 29 | "path": "/admin/news/:newsId", 30 | "componentId": "AdminNewsEditor" 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/modules/news/permissions.ts: -------------------------------------------------------------------------------- 1 | export const Permissions = { 2 | __Admin: 'news.__admin', 3 | AdminList: 'news.admin.list', 4 | AdminManage: 'news.admin.manage', 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/news/server/migrations/20230219200202_news_create-news-table.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { Sequelize, DataTypes } = require('sequelize') 3 | 4 | module.exports = { 5 | async up({ context: queryInterface }) { 6 | await queryInterface.createTable('news', { 7 | id: { 8 | type: DataTypes.UUID, 9 | defaultValue: DataTypes.UUIDV4, 10 | allowNull: false, 11 | primaryKey: true, 12 | }, 13 | title: { 14 | type: DataTypes.STRING, 15 | allowNull: false, 16 | unique: true, 17 | }, 18 | content: DataTypes.TEXT, 19 | offices: { 20 | type: DataTypes.ARRAY(DataTypes.STRING), 21 | defaultValue: [], 22 | }, 23 | visibility: { 24 | type: DataTypes.STRING, 25 | allowNull: false, 26 | }, 27 | allowedRoles: { 28 | type: DataTypes.ARRAY(DataTypes.STRING), 29 | allowNull: false, 30 | defaultValue: [], 31 | }, 32 | creatorUserId: { 33 | type: DataTypes.UUID, 34 | references: { 35 | model: 'users', 36 | key: 'id', 37 | }, 38 | onDelete: 'CASCADE', 39 | }, 40 | published: { 41 | type: DataTypes.BOOLEAN, 42 | defaultValue: false, 43 | }, 44 | publishedAt: DataTypes.DATE, 45 | createdAt: DataTypes.DATE, 46 | updatedAt: DataTypes.DATE, 47 | }) 48 | }, 49 | async down({ context: queryInterface }) { 50 | await queryInterface.dropTable('news') 51 | }, 52 | } 53 | -------------------------------------------------------------------------------- /src/modules/news/server/models/index.ts: -------------------------------------------------------------------------------- 1 | import { News } from './news' 2 | 3 | export { News } 4 | -------------------------------------------------------------------------------- /src/modules/news/types.ts: -------------------------------------------------------------------------------- 1 | import { EntityVisibility } from '#shared/types' 2 | 3 | export interface NewsItem { 4 | id: string 5 | title: string 6 | content: string 7 | creatorUserId: string 8 | offices: string[] 9 | allowedRoles: string[] 10 | visibility: EntityVisibility 11 | published: boolean 12 | publishedAt: Date | null 13 | createdAt: Date 14 | updatedAt: Date 15 | } 16 | 17 | export type NewsResponse = Pick< 18 | NewsItem, 19 | 'id' | 'title' | 'content' | 'offices' | 'publishedAt' | 'published' 20 | > 21 | 22 | export type NewsRequest = Pick< 23 | NewsItem, 24 | 'title' | 'content' | 'offices' | 'publishedAt' | 'published' | 'allowedRoles' | 'visibility' 25 | > 26 | -------------------------------------------------------------------------------- /src/modules/office-visits/client/components/index.ts: -------------------------------------------------------------------------------- 1 | export { Actions } from './Actions' 2 | export { MyOfficeVisits } from './MyOfficeVisits' 3 | export { Visit } from './Visit' 4 | export { VisitsStats } from './VisitsStats' 5 | -------------------------------------------------------------------------------- /src/modules/office-visits/client/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { VisitType } from '#shared/types' 2 | 3 | export const PAGE_SIZE = 5 4 | 5 | export const RESERVATION_TYPES: Record = { 6 | [VisitType.Visit]: 'office visit', 7 | [VisitType.RoomReservation]: 'room reservation', 8 | [VisitType.Guest]: 'guest invite', 9 | } 10 | 11 | export const paginateObject = ( 12 | entries: Object, 13 | page: number, 14 | pageSize: number 15 | ) => { 16 | const startIndex = (page - 1) * pageSize 17 | const endIndex = startIndex + pageSize 18 | const entriesArr = Object.entries(entries) // convert object to array of key-value pairs 19 | const slicedArr = entriesArr.slice(startIndex, endIndex) // slice the array 20 | return Object.fromEntries(slicedArr) 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/office-visits/client/queries.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios' 2 | import dayjs from 'dayjs' 3 | import { useQuery } from 'react-query' 4 | import { api } from '#client/utils/api' 5 | import { DATE_FORMAT } from '#client/constants' 6 | import { VisitsDailyStats } from '#shared/types' 7 | 8 | type OfficeVisitParms = { 9 | officeId: string 10 | date: string 11 | } 12 | 13 | export const useOfficeVisits = (officeId: string, date: string) => { 14 | const path = '/user-api/office-visits/entries' 15 | return useQuery( 16 | [path, { officeId, date: dayjs(date).format(DATE_FORMAT) }], 17 | async ({ queryKey }) => 18 | (await api.get(path, { params: queryKey[1] })).data, 19 | { enabled: !!officeId } 20 | ) 21 | } 22 | 23 | export const useVisitsStatsAdmin = ( 24 | officeId: string, 25 | dateRange: [string, string], 26 | { enabled } = { enabled: true } 27 | ) => { 28 | const path = '/admin-api/office-visits/stats' 29 | return useQuery( 30 | [path, { office: officeId, from: dateRange[0], to: dateRange[1] }], 31 | async ({ queryKey }) => 32 | (await api.get(path, { params: queryKey[1] })).data, 33 | { enabled: !!officeId && enabled } 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/office-visits/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "office-visits", 3 | "name": "Office visits", 4 | "dependencies": ["visits", "guest-invites", "room-reservation", "users"], 5 | "requiredIntegrations": [], 6 | "recommendedIntegrations": [], 7 | "models": [] 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/office-visits/metadata-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const schema = z 4 | .object({ 5 | statistics: z.object({ 6 | splitByRoleGroup: z.string(), 7 | }), 8 | }) 9 | .strict() 10 | 11 | export type Metadata = z.infer 12 | -------------------------------------------------------------------------------- /src/modules/office-visits/permissions.ts: -------------------------------------------------------------------------------- 1 | export const Permissions = { 2 | __Admin: 'office-visits.__admin', 3 | AdminList: 'office-visits.admin.list', 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/office-visits/server/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import { DATE_FORMAT } from '#server/constants' 3 | 4 | export const BUSINESS_DAYS_LIMIT: number = 40 5 | 6 | // FIXME: temporary fix 7 | export const getDate = (d: string, timezone?: string) => 8 | dayjs(d).format(DATE_FORMAT) 9 | 10 | // export const getDate = (d: string, timezone: string) => 11 | // dayjs(d).tz(timezone).format(DATE_FORMAT).toString() 12 | 13 | export const getTime = (date: string | Date) => dayjs(date).format('LT') 14 | 15 | export const getBusinessDaysFromDate = ( 16 | date: string, 17 | businessDaysLimit = BUSINESS_DAYS_LIMIT 18 | ) => { 19 | const dates = [] 20 | const theDate = date ? dayjs(date) : dayjs() 21 | 22 | if (theDate.day() !== 6 && theDate.day() !== 0) { 23 | dates.push(theDate.format(DATE_FORMAT)) 24 | } 25 | 26 | let nextDate = theDate.add(1, 'day') 27 | 28 | while (dates.length < businessDaysLimit) { 29 | if (nextDate.day() !== 6 && nextDate.day() !== 0) { 30 | dates.push(nextDate.format(DATE_FORMAT)) 31 | } 32 | nextDate = nextDate.add(nextDate.day() === 5 ? 3 : 1, 'day') 33 | } 34 | 35 | return dates 36 | } 37 | -------------------------------------------------------------------------------- /src/modules/office-visits/types.ts: -------------------------------------------------------------------------------- 1 | export enum VisitType { 2 | Visit = 'visit', 3 | RoomReservation = 'room-reservation', 4 | Guest = 'guest-invite', 5 | } 6 | 7 | export const OfficeVisitsHeaders = { 8 | [VisitType.Visit]: '', 9 | [VisitType.Guest]: 'Guest Visit', 10 | [VisitType.RoomReservation]: 'Meeting Room Bookings', 11 | } as const 12 | 13 | export type GenericVisit = { 14 | id: string 15 | value: string 16 | type: VisitType 17 | dateTime?: string 18 | deskName?: string 19 | areaName?: string 20 | description?: string 21 | mainType?: boolean 22 | } 23 | 24 | export type VisitsDailyStats = { 25 | date: string 26 | maxCapacity: number 27 | existingVisitsNumber: number 28 | occupancyPercent: number 29 | occupancyPercentByRole: Array<{ 30 | role: string 31 | occupancyPercent: number 32 | }> 33 | guests: Array<{ fullName: string; email: string }> 34 | } 35 | -------------------------------------------------------------------------------- /src/modules/profile-questions/client/components/index.ts: -------------------------------------------------------------------------------- 1 | export { ProfileQuestionForm } from './ProfileQuestionForm' 2 | export { ProfileQuestionsProgress } from './ProfileQuestionsProgress' 3 | export { ProfileQuestionsWidget } from './ProfileQuestionsWidget' 4 | -------------------------------------------------------------------------------- /src/modules/profile-questions/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "profile-questions", 3 | "name": "ProfileQuestions", 4 | "dependencies": ["users"], 5 | "requiredIntegrations": [], 6 | "recommendedIntegrations": [], 7 | "availableCronJobs": ["checklist-answer-delete-user-data"], 8 | "models": ["ProfileQuestionAnswer"], 9 | "clientRouter": { 10 | "user": { 11 | "profileQuestions": { 12 | "path": "/me/profile-questions", 13 | "componentId": "ProfileQuestionForm" 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/profile-questions/metadata-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const schema = z 4 | .object({ 5 | questions: z 6 | .array( 7 | z.object({ 8 | category: z.string().nonempty(), 9 | questions: z.array(z.string().nonempty()), 10 | }) 11 | ) 12 | .min(1), 13 | }) 14 | .strict() 15 | 16 | export type Metadata = z.infer 17 | -------------------------------------------------------------------------------- /src/modules/profile-questions/permissions.ts: -------------------------------------------------------------------------------- 1 | export const Permissions = { 2 | Use: 'profile-questions.use', 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/profile-questions/server/jobs/index.ts: -------------------------------------------------------------------------------- 1 | import { CronJob, CronJobContext } from '#server/types' 2 | import * as profileQuestionsDeleteData from './profile-questions-delete-data' 3 | 4 | module.exports.moduleCronJobsFactory = (ctx: CronJobContext): CronJob[] => { 5 | return [profileQuestionsDeleteData.jobFactory()] 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/profile-questions/server/jobs/profile-questions-delete-data.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import { Op } from 'sequelize' 3 | import { CronJob, CronJobContext } from '#server/types' 4 | 5 | const JobName = 'checklist-answer-delete-user-data' 6 | export const jobFactory = (): CronJob => { 7 | return { 8 | name: JobName, 9 | cron: `0 0 * * *`, 10 | fn: async (ctx: CronJobContext) => { 11 | try { 12 | const users = await ctx.models.User.findAllActive({ 13 | where: { 14 | scheduledToDelete: { [Op.lte]: dayjs().format('YYYY-MM-DD') }, 15 | }, 16 | }) 17 | ctx.log.info( 18 | `${JobName}: Found ${users.length} users scheduled to be deleted today.` 19 | ) 20 | for (const user of users) { 21 | await ctx.models.ProfileQuestionAnswer.destroy({ 22 | where: { userId: user.id }, 23 | }) 24 | ctx.log.info( 25 | `${JobName}: Removed ProfileQuestionAnswer for user ${user.fullName} (id: ${user.id}).` 26 | ) 27 | } 28 | } catch (e) { 29 | ctx.log.error(JSON.stringify(e)) 30 | } 31 | }, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/modules/profile-questions/server/migrations/20230514165149_profile-questions_initial.js.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { Sequelize, DataTypes } = require('sequelize') 3 | 4 | module.exports = { 5 | async up({ context: queryInterface }) { 6 | await queryInterface.createTable('profile_questions_answers', { 7 | id: { 8 | type: DataTypes.UUID, 9 | defaultValue: DataTypes.UUIDV4, 10 | allowNull: false, 11 | primaryKey: true, 12 | }, 13 | userId: { 14 | type: DataTypes.UUID, 15 | allowNull: false, 16 | references: { 17 | model: 'users', 18 | key: 'id', 19 | }, 20 | onDelete: 'CASCADE', 21 | }, 22 | answers: { 23 | type: DataTypes.JSONB, 24 | defaultValue: [], 25 | }, 26 | createdAt: DataTypes.DATE, 27 | updatedAt: DataTypes.DATE, 28 | }) 29 | }, 30 | async down({ context: queryInterface }) { 31 | await queryInterface.dropTable('profile_questions_answers') 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /src/modules/profile-questions/server/models/index.ts: -------------------------------------------------------------------------------- 1 | import { ProfileQuestionAnswer } from './profile-question-answer' 2 | 3 | export { ProfileQuestionAnswer } 4 | -------------------------------------------------------------------------------- /src/modules/profile-questions/server/models/profile-question-answer.ts: -------------------------------------------------------------------------------- 1 | import { CreationOptional, DataTypes, Model } from 'sequelize' 2 | import { sequelize } from '#server/db' 3 | import { 4 | ProfileQuestionAnswer as ProfileQuestionAnswerModel, 5 | } from '../../types' 6 | 7 | type WidgetUserCreateFields = Partial 8 | 9 | export class ProfileQuestionAnswer 10 | extends Model 11 | implements ProfileQuestionAnswerModel 12 | { 13 | declare id: CreationOptional 14 | declare userId: ProfileQuestionAnswerModel['userId'] 15 | declare answers: ProfileQuestionAnswerModel['answers'] 16 | declare createdAt: CreationOptional 17 | declare updatedAt: CreationOptional 18 | } 19 | 20 | ProfileQuestionAnswer.init( 21 | { 22 | id: { 23 | type: DataTypes.UUID, 24 | defaultValue: DataTypes.UUIDV4, 25 | allowNull: false, 26 | primaryKey: true, 27 | }, 28 | userId: { 29 | type: DataTypes.UUID, 30 | allowNull: false, 31 | references: { 32 | model: 'users', 33 | key: 'id', 34 | }, 35 | onDelete: 'CASCADE', 36 | }, 37 | answers: { 38 | type: DataTypes.JSONB, 39 | defaultValue: [], 40 | }, 41 | createdAt: DataTypes.DATE, 42 | updatedAt: DataTypes.DATE, 43 | }, 44 | { 45 | sequelize, 46 | modelName: 'ProfileQuestionAnswer', 47 | tableName: 'profile_questions_answers', 48 | timestamps: true, 49 | } 50 | ) 51 | -------------------------------------------------------------------------------- /src/modules/profile-questions/types.ts: -------------------------------------------------------------------------------- 1 | export type ProfileQuestionAnswer = { 2 | id: string 3 | userId: string 4 | answers: Array 5 | createdAt: Date 6 | updatedAt: Date 7 | } 8 | 9 | export type ProfileQuestionCategory = { 10 | category: string 11 | questions: Array 12 | } 13 | 14 | export type ProfileQuestionCategoryMetadata = { 15 | category: string 16 | questions: Array 17 | } 18 | 19 | export type ProfileQuestion = { 20 | text: string 21 | answer: string 22 | } 23 | 24 | export type CountResponse = { 25 | answersCount: number 26 | totalQuestions: number 27 | progressValue: number 28 | } 29 | -------------------------------------------------------------------------------- /src/modules/quick-navigation/client/components/index.ts: -------------------------------------------------------------------------------- 1 | export { QuickNav } from './QuickNav' 2 | -------------------------------------------------------------------------------- /src/modules/quick-navigation/client/queries.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios' 2 | import { useQuery } from 'react-query' 3 | import { api } from '#client/utils/api' 4 | import { NavigationSection } from '#shared/types' 5 | 6 | export const useQuickNavigationMetadata = () => { 7 | const path = `/user-api/quick-navigation/links` 8 | return useQuery( 9 | path, 10 | async () => (await api.get(path)).data, 11 | { 12 | retry: false, 13 | refetchOnWindowFocus: false, 14 | refetchOnMount: false, 15 | refetchOnReconnect: false, 16 | } 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/modules/quick-navigation/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "quick-navigation", 3 | "name": "Quick Navigation", 4 | "dependencies": ["users"], 5 | "requiredIntegrations": [], 6 | "recommendedIntegrations": [], 7 | "models": [] 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/quick-navigation/metadata-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const schema = z 4 | .object({ 5 | navigation: z 6 | .array( 7 | z.object({ 8 | section: z.string(), 9 | main: z.boolean().optional(), 10 | links: z.array( 11 | z.object({ 12 | url: z.string(), 13 | name: z.string(), 14 | icon: z.string().optional(), 15 | sameTab: z.boolean().optional(), 16 | external: z.boolean(), 17 | }) 18 | ), 19 | }) 20 | ) 21 | .min(1), 22 | }) 23 | .strict() 24 | 25 | export type Metadata = z.infer 26 | -------------------------------------------------------------------------------- /src/modules/quick-navigation/permissions.ts: -------------------------------------------------------------------------------- 1 | export const Permissions = { 2 | // __Admin: 'quick-navigation.__admin', 3 | Use: 'quick-navigation.use', 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/quick-navigation/server/router.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginCallback, FastifyRequest } from 'fastify' 2 | import { appConfig } from '#server/app-config' 3 | import { Permissions } from '../permissions' 4 | import { Metadata } from '../metadata-schema' 5 | 6 | const publicRouter: FastifyPluginCallback = async function (fastify, opts) {} 7 | 8 | const userRouter: FastifyPluginCallback = async function (fastify, opts) { 9 | fastify.get('/links', async (req: FastifyRequest, reply) => { 10 | req.check(Permissions.Use) 11 | const metadata = appConfig.getModuleMetadata('quick-navigation') as Metadata 12 | return metadata.navigation 13 | }) 14 | } 15 | 16 | const adminRouter: FastifyPluginCallback = async function (fastify, opts) {} 17 | 18 | module.exports = { 19 | publicRouter, 20 | userRouter, 21 | adminRouter, 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/quick-navigation/types.ts: -------------------------------------------------------------------------------- 1 | export type QuickNavigationItem = { 2 | url: string 3 | name: string 4 | icon: string 5 | sameTab: boolean 6 | external: boolean 7 | } 8 | 9 | export type NavigationSection = { 10 | section: string 11 | main: string 12 | links: Array 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/room-reservation/client/components/RoomReservationStatusTag.tsx: -------------------------------------------------------------------------------- 1 | import { RoomReservationStatus } from '#shared/types' 2 | import { Tag } from '#client/components/ui' 3 | import { Size } from '#client/components/ui/Tag' 4 | 5 | export const RoomReservationStatusTag: React.FC<{ 6 | status: RoomReservationStatus 7 | size: Size 8 | }> = ({ status, size = 'small' }) => { 9 | type Color = 'gray' | 'yellow' | 'green' | 'red' | 'blue' 10 | const label: Record = { 11 | pending: 'Pending', 12 | confirmed: 'Confirmed', 13 | cancelled: 'Cancelled', 14 | } 15 | const color: Record = { 16 | pending: 'yellow', 17 | confirmed: 'green', 18 | cancelled: 'red', 19 | } 20 | return ( 21 | 22 | {label[status]} 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/modules/room-reservation/client/components/index.ts: -------------------------------------------------------------------------------- 1 | export { AdminRoomReservations } from './AdminRoomReservations' 2 | export { DeviceRoomReservation } from './DeviceRoomReservation' 3 | export { MeetingRoomBookingModal } from './MeetingRoomBookingModal' 4 | export { NextMeetings } from './NextMeetings' 5 | export { RoomDisplay } from './RoomDisplay' 6 | export { RoomDisplayDevice } from './RoomDisplayDevice' 7 | export { RoomListing } from './RoomListing' 8 | export { RoomReservationDetail } from './RoomReservationDetail' 9 | export { RoomReservationRequest } from './RoomReservationRequest' 10 | export { RoomReservationStatusTag } from './RoomReservationStatusTag' 11 | export { TimeSlotsListing } from './TimeSlotsListing' 12 | export { AdminDashboardStats } from './AdminDashboardStats' 13 | -------------------------------------------------------------------------------- /src/modules/room-reservation/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "room-reservation", 3 | "name": "Meeting Rooms", 4 | "dependencies": ["users"], 5 | "requiredIntegrations": [], 6 | "recommendedIntegrations": ["email-smtp", "matrix"], 7 | "models": ["RoomReservation", "RoomDisplayDevice"], 8 | "clientRouter": { 9 | "public": { 10 | "roomDisplay": { 11 | "path": "/room-display", 12 | "componentId": "RoomDisplay", 13 | "fullScreen": true 14 | } 15 | }, 16 | "user": { 17 | "roomReservationRequest": { 18 | "path": "/room-reservation/request", 19 | "componentId": "RoomReservationRequest" 20 | }, 21 | "roomReservationDetail": { 22 | "path": "/room-reservations/:roomReservationId", 23 | "componentId": "RoomReservationDetail" 24 | } 25 | }, 26 | "admin": { 27 | "roomDisplayDevice": { 28 | "path": "/admin/room-reservation/device/:deviceId", 29 | "componentId": "RoomDisplayDevice" 30 | }, 31 | "adminRoomReservations": { 32 | "path": "/admin/room-reservation", 33 | "componentId": "AdminRoomReservations" 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/room-reservation/permissions.ts: -------------------------------------------------------------------------------- 1 | export const Permissions = { 2 | __Admin: 'room-reservation.__admin', 3 | AdminList: 'room-reservation.admin.list', 4 | AdminManage: 'room-reservation.admin.manage', 5 | Create: 'room-reservation.create', 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/room-reservation/server/models/index.ts: -------------------------------------------------------------------------------- 1 | export { RoomReservation } from './room-reservation' 2 | export { RoomDisplayDevice } from './room-display-device' 3 | -------------------------------------------------------------------------------- /src/modules/room-reservation/server/models/room-display-device.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Model, CreationOptional } from 'sequelize' 2 | import { sequelize } from '#server/db' 3 | import { RoomDisplayDevice as RoomDisplayDeviceModel } from '#shared/types' 4 | 5 | export class RoomDisplayDevice 6 | extends Model 7 | implements RoomDisplayDeviceModel 8 | { 9 | declare id: CreationOptional 10 | declare confirmedByUserId: string 11 | declare confirmedAt: Date 12 | declare office: string 13 | declare roomId: string 14 | declare createdAt: Date 15 | declare updatedAt: Date 16 | } 17 | 18 | RoomDisplayDevice.init( 19 | { 20 | id: { 21 | type: DataTypes.UUID, 22 | defaultValue: DataTypes.UUIDV4, 23 | allowNull: false, 24 | primaryKey: true, 25 | }, 26 | confirmedByUserId: { 27 | type: DataTypes.UUID, 28 | allowNull: true, 29 | references: { 30 | model: 'users', 31 | key: 'id', 32 | }, 33 | onDelete: 'CASCADE', 34 | }, 35 | confirmedAt: DataTypes.DATE, 36 | office: DataTypes.STRING, 37 | roomId: DataTypes.STRING, 38 | createdAt: DataTypes.DATE, 39 | updatedAt: DataTypes.DATE, 40 | }, 41 | { 42 | sequelize, 43 | modelName: 'RoomDisplayDevice', 44 | tableName: 'room_display_devices', 45 | timestamps: true, 46 | } 47 | ) 48 | -------------------------------------------------------------------------------- /src/modules/room-reservation/shared-helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { Office, OfficeArea, OfficeRoom } from '#shared/types' 2 | import dayjs from 'dayjs' 3 | import { boolean } from 'zod' 4 | 5 | export function isWithinWorkingHours( 6 | timeSlot: string, 7 | hours: Array 8 | ): boolean { 9 | const startTime = dayjs(hours[0], 'HH:mm') 10 | const endTime = dayjs(hours[1], 'HH:mm') 11 | const checkTime = dayjs(timeSlot, 'HH:mm') 12 | return checkTime.isSameOrAfter(startTime) && checkTime.isBefore(endTime) 13 | } 14 | 15 | export const getRooms = (office: Office) => 16 | office?.areas?.flatMap((area) => area.meetingRooms).filter((a) => !!a) || [] 17 | 18 | export const getRoom = ( 19 | office: Office, 20 | roomId: string 21 | ): OfficeRoom | undefined => 22 | getRooms(office).find((room) => room?.id === roomId) 23 | -------------------------------------------------------------------------------- /src/modules/room-reservation/templates/notification.yaml: -------------------------------------------------------------------------------- 1 | newReservation: | 2 | You requested a room reservation ({{ status }}) for {{ room }}, {{ date }}, Location: {{ office.name }} 3 | newReservationAdmin: | 4 | {{ user.fullName }} ({{ user.email }}) requested a room reservation ({{ status }}) for {{ room }}, {{ date }}, Location: {{ office.name }} 5 | reservationStatusChange: | 6 | You {{ status }} room reservation for {{ room }}, {{ date }}, Location: {{ office.name }} 7 | reservationStatusChangeAdmin: | 8 | {{ user.fullName }} ({{ user.email }}) {{ status }} room reservation for {{ room }}, {{ date }}, Location: {{ office.name }} 9 | reservationCancelledForUser: | 10 | Your reservation was cancelled: {{ room }}, {{ date }}, Location: {{ office.name }}. 11 | -------------------------------------------------------------------------------- /src/modules/search/client/components/index.ts: -------------------------------------------------------------------------------- 1 | export { SearchBar } from './SearchBar' 2 | export { SearchResults } from './SearchResults' 3 | -------------------------------------------------------------------------------- /src/modules/search/client/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export enum SearchCustomEvents { 2 | UpdateSearchResults = 'search:update-search-results', 3 | SearchBarKeyUp = 'search:search-bar-key-up', 4 | } 5 | 6 | export const goToSearchPage = (query: string) => { 7 | const url = new URL(window.location.href) 8 | url.pathname = '/search' 9 | url.searchParams.set('q', query) 10 | window.location.href = url.toString() 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/search/client/queries.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, useMutation } from 'react-query' 2 | import { AxiosError, AxiosResponse } from 'axios' 3 | import { api } from '#client/utils/api' 4 | import { SearchSuggestion } from '#shared/types' 5 | 6 | 7 | export const useSearchSuggestions = (query: string, opts: { enabled: boolean } = { enabled: true }) => { 8 | const path = '/user-api/search/suggestion' 9 | return useQuery( 10 | [path, { query }], 11 | async ({ queryKey }) => (await api.get(path, { params: queryKey[1] })).data, 12 | { enabled: opts.enabled && !!query.trim() } 13 | ) 14 | } 15 | 16 | export const useSearchResults = (query: string, opts: { enabled: boolean } = { enabled: true }) => { 17 | const path = '/user-api/search/results' 18 | return useQuery( 19 | [path, { query }], 20 | async ({ queryKey }) => (await api.get(path, { params: queryKey[1] })).data, 21 | { enabled: opts.enabled && !!query.trim() } 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/modules/search/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "search", 3 | "name": "Search", 4 | "dependencies": ["users"], 5 | "requiredIntegrations": [], 6 | "recommendedIntegrations": ["matrix"], 7 | "models": [], 8 | "clientRouter": { 9 | "user": { 10 | "searchPage": { 11 | "path": "/search", 12 | "componentId": "SearchResults" 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/search/permissions.ts: -------------------------------------------------------------------------------- 1 | export const Permissions = { 2 | // __Admin: 'search.__admin', 3 | Use: 'search.use', 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/search/shared-helpers/index.ts: -------------------------------------------------------------------------------- 1 | // [#Ran-dom Tag_123] 2 | export const TAG_ELEMENTS_REGEXP = /\[\#([\w\d\s\-_\/\.]+)\]/g 3 | 4 | export const useTagSyntax = (tagName: string): string => `[#${tagName}]` 5 | 6 | export const findTags = ( 7 | tags: T[], 8 | query: string, 9 | ignoreNames: string[] = [] 10 | ): T[] => { 11 | const q = query.toLowerCase().trim() 12 | // run `filter` if only query is more than 2 characters length 13 | return q.length > 2 14 | ? tags 15 | .filter( 16 | (x) => x.name.toLowerCase().includes(q) || x.altNames.includes(q) 17 | ) 18 | .filter((x) => !ignoreNames.includes(x.name)) 19 | : [] 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/search/types.ts: -------------------------------------------------------------------------------- 1 | export enum SearchSuggestionEntity { 2 | User = 'user', 3 | Event = 'event', 4 | Tag = 'tag' 5 | } 6 | 7 | export type SearchSuggestion = { 8 | id: string 9 | entity: SearchSuggestionEntity 10 | title: string 11 | subtitle: string | null 12 | image: string | null 13 | url: string 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/time-off/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "time-off", 3 | "name": "Time Off", 4 | "dependencies": ["users"], 5 | "requiredIntegrations": [], 6 | "recommendedIntegrations": ["bamboohr", "humaans"], 7 | "availableCronJobs": ["fetch-time-off-requests", "fetch-public-holidays"], 8 | "models": ["TimeOffRequest", "PublicHoliday"] 9 | } 10 | -------------------------------------------------------------------------------- /src/modules/time-off/metadata-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const schema = z 4 | .object({ 5 | publicHolidayCalendarExternalIds: z.array(z.string()).optional(), 6 | excludeTimeOffTypes: z.array(z.string()).default([]), 7 | }) 8 | .strict() 9 | 10 | export type Metadata = z.infer 11 | -------------------------------------------------------------------------------- /src/modules/time-off/server/jobs/index.ts: -------------------------------------------------------------------------------- 1 | import { CronJob, CronJobContext } from '#server/types' 2 | import { cronJob as fetchTimeOffRequests } from './fetch-time-off-requests' 3 | import { cronJob as fetchPublicHolidays } from './fetch-public-holidays' 4 | 5 | module.exports.moduleCronJobsFactory = (ctx: CronJobContext): CronJob[] => { 6 | return [fetchTimeOffRequests, fetchPublicHolidays] 7 | } 8 | -------------------------------------------------------------------------------- /src/modules/time-off/server/migrations/20240510172800_time-off_add-public-holidays.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { Sequelize, DataTypes } = require('sequelize') 3 | 4 | module.exports = { 5 | async up({ context: queryInterface, appConfig }) { 6 | await queryInterface.createTable('public_holidays', { 7 | id: { 8 | type: DataTypes.UUID, 9 | defaultValue: DataTypes.UUIDV4, 10 | allowNull: false, 11 | primaryKey: true, 12 | }, 13 | date: { 14 | type: DataTypes.DATEONLY, 15 | allowNull: false, 16 | }, 17 | name: { 18 | type: DataTypes.STRING, 19 | allowNull: false, 20 | }, 21 | calendarId: { 22 | type: DataTypes.STRING, 23 | allowNull: false, 24 | }, 25 | externalIds: { 26 | type: DataTypes.JSONB, 27 | defaultValue: {}, 28 | }, 29 | createdAt: DataTypes.DATE, 30 | updatedAt: DataTypes.DATE, 31 | }) 32 | await queryInterface.addIndex('public_holidays', ['date', 'calendarId'], { 33 | unique: true, 34 | name: 'date_calendarId_index', 35 | }) 36 | }, 37 | async down({ context: queryInterface, appConfig }) { 38 | await queryInterface.dropTable('public_holidays') 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/time-off/server/models/index.ts: -------------------------------------------------------------------------------- 1 | export { TimeOffRequest } from './time-off-request' 2 | export { PublicHoliday } from './public-holiday' 3 | -------------------------------------------------------------------------------- /src/modules/time-off/types.ts: -------------------------------------------------------------------------------- 1 | export enum TimeOffRequestStatus { 2 | Open = 'open', 3 | Approved = 'approved', 4 | Rejected = 'rejected', 5 | Cancelled = 'cancelled', 6 | } 7 | 8 | export enum TimeOffRequestUnit { 9 | Hour = 'hour', 10 | Day = 'day', 11 | } 12 | 13 | export interface TimeOffRequest { 14 | id: string 15 | createdAt: Date 16 | updatedAt: Date 17 | status: TimeOffRequestStatus 18 | unit: TimeOffRequestUnit 19 | dates: string[] 20 | unitsPerDay: Record 21 | startDate: string // YYYY-MM-DD 22 | endDate: string 23 | userId: string 24 | externalIds: Record 25 | } 26 | 27 | export interface PublicHoliday { 28 | id: string 29 | name: string 30 | date: string // YYYY-MM-DD 31 | calendarId: string 32 | externalIds: Record 33 | createdAt: Date 34 | updatedAt: Date 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/users/client/components/NewjoinerDetector.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useStore } from '@nanostores/react' 3 | import * as stores from '#client/stores' 4 | import { PermissionsValidator } from '#client/components/PermissionsValidator' 5 | import Permissions from '#shared/permissions' 6 | 7 | export const NewjoinerDetector: React.FC = () => ( 8 | 9 | <_NewjoinerDetector /> 10 | 11 | ) 12 | 13 | export const _NewjoinerDetector: React.FC = () => { 14 | const me = useStore(stores.me) 15 | React.useEffect(() => { 16 | if (me && !me.isInitialised) { 17 | stores.goTo('welcome') 18 | } 19 | }, [me]) 20 | return null 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/users/client/components/Profile.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { BackButton, ComponentWrapper, H1 } from '#client/components/ui' 3 | import { PermissionsValidator } from '#client/components/PermissionsValidator' 4 | import { useDocumentTitle } from '#client/utils/hooks' 5 | import Permissions from '#shared/permissions' 6 | import { ProfileForm } from './ProfileForm' 7 | import { RootComponentProps } from '#shared/types' 8 | 9 | export const Profile: React.FC = (props) => { 10 | useDocumentTitle('Your profile') 11 | return ( 12 | 13 | 14 | 15 |
16 |

Your profile

17 |
18 | 19 |
20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/users/client/components/UserMap.tsx: -------------------------------------------------------------------------------- 1 | import { Map } from '#client/components/ui/Map' 2 | import { dropMarker, highlightCountry } from '#client/components/ui/Map/mapbox' 3 | import { 4 | bigCountries, 5 | Coordinates, 6 | MapTypes, 7 | } from '#client/components/ui/Map/mapbox/config' 8 | import React from 'react' 9 | 10 | export const UserMap: React.FC<{ 11 | coords: Coordinates 12 | countryCode: string 13 | type: MapTypes 14 | }> = ({ coords, countryCode = '', type = MapTypes.Country }) => { 15 | const isCountry = type === MapTypes.Country 16 | const zoom = isCountry ? (bigCountries.includes(countryCode) ? 2 : 4) : 9 17 | const onLoad = (mapboxgl: any, map: mapboxgl.Map) => 18 | isCountry 19 | ? highlightCountry(map, countryCode) 20 | : dropMarker(mapboxgl, map, coords) 21 | const events = [ 22 | { 23 | name: 'load', 24 | action: onLoad, 25 | }, 26 | ] 27 | return 28 | } 29 | -------------------------------------------------------------------------------- /src/modules/users/client/components/UserStatus.tsx: -------------------------------------------------------------------------------- 1 | import { Link, TimeLabel, WidgetWrapper } from '#client/components/ui' 2 | import { useStore } from '@nanostores/react' 3 | import React from 'react' 4 | import * as stores from '#client/stores' 5 | 6 | export const UserStatus: React.FC = () => { 7 | const me = useStore(stores.me) 8 | const isAdmin = useStore(stores.isAdmin) 9 | return ( 10 | 11 | Welcome back 12 | {me?.fullName ? ( 13 | 14 | , {me?.fullName} 15 | 16 | ) : ''} 17 | ! Today is .{' '} 18 | {isAdmin && Admin} 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/users/client/components/UsersMapPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import config from '#client/config' 3 | import { ComponentWrapper, H1 } from '#client/components/ui' 4 | import { PermissionsValidator } from '#client/components/PermissionsValidator' 5 | import { UsersMap } from './UsersMap' 6 | import Permissions from '#shared/permissions' 7 | 8 | export const UsersMapPage: React.FC = () => { 9 | return ( 10 | 11 | 12 |

{config.companyName} people

13 | 14 |
15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/users/client/components/UsersMapWidget.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import config from '#client/config' 3 | import { FButton, WidgetWrapper } from '#client/components/ui' 4 | import { PermissionsValidator } from '#client/components/PermissionsValidator' 5 | import Permissions from '#shared/permissions' 6 | import { useMapStats } from '../queries' 7 | import { UsersMap } from './UsersMap' 8 | 9 | export const UsersMapWidget = () => ( 10 | 11 | <_UsersMapWidget /> 12 | 13 | ) 14 | 15 | const _UsersMapWidget: React.FC = () => { 16 | const { data: stats } = useMapStats() 17 | if (!config.mapBoxApiKey || !stats) { 18 | return null 19 | } 20 | return ( 21 | 22 | { 23 |
24 | {stats && ( 25 |
26 |
27 | {stats.userCount} people in {stats.countryCount} countries 28 |
29 |
30 | 31 | Open map page 32 | 33 |
34 |
35 | )} 36 | 37 |
38 | } 39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/modules/users/client/components/index.ts: -------------------------------------------------------------------------------- 1 | export { AdminUsers } from './AdminUsers' 2 | export { AuthAccount } from './AuthAccount' 3 | export { AuthAccountsLinkModal } from './AuthAccountsLinkModal' 4 | export { DeleteUserModal } from './DeleteUserModal' 5 | export { MySettings } from './MySettings' 6 | export { NewjoinerDetector } from './NewjoinerDetector' 7 | export { Profile } from './Profile' 8 | export { ProfileCard } from './ProfileCard' 9 | export { ProfileForm } from './ProfileForm' 10 | export { PublicProfile } from './PublicProfile' 11 | export { TagSelection } from './TagSelection' 12 | export { UserMap } from './UserMap' 13 | export { UserStatus } from './UserStatus' 14 | export { UsersMap } from './UsersMap' 15 | export { UsersMapPage } from './UsersMapPage' 16 | export { UsersMapWidget } from './UsersMapWidget' 17 | export { Welcome } from './Welcome' 18 | export { UserRolesEditorModal } from './UserRolesEditorModal' 19 | export { AdminDashboardStats } from './AdminDashboardStats' 20 | -------------------------------------------------------------------------------- /src/modules/users/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "users", 3 | "name": "Users", 4 | "dependencies": [], 5 | "requiredIntegrations": [], 6 | "recommendedIntegrations": ["matrix", "mapbox"], 7 | "availableCronJobs": ["users-delete-users-data"], 8 | "models": ["User", "Session", "City", "Tag", "UserTag"], 9 | "clientRouter": { 10 | "public": {}, 11 | "user": { 12 | "profile": { 13 | "path": "/me", 14 | "componentId": "Profile", 15 | "availablePortals": ["profile_form_extra_fields"] 16 | }, 17 | "profileTags": { 18 | "path": "/me/tags", 19 | "componentId": "TagSelection" 20 | }, 21 | "publicProfile": { 22 | "path": "/profile/:userId", 23 | "componentId": "PublicProfile", 24 | "availablePortals": ["profile_extra_fields"] 25 | }, 26 | "welcome": { 27 | "path": "/welcome", 28 | "componentId": "Welcome", 29 | "fullScreen": true 30 | }, 31 | "map": { 32 | "path": "/map", 33 | "componentId": "UsersMapPage" 34 | }, 35 | "settings": { 36 | "path": "/settings", 37 | "componentId": "MySettings" 38 | } 39 | }, 40 | "admin": { 41 | "adminUsers": { 42 | "path": "/admin/users", 43 | "componentId": "AdminUsers" 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/modules/users/metadata-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const contactFieldSchema = z.object({ 4 | label: z.string().optional(), 5 | placeholder: z.string().optional(), 6 | required: z.boolean().optional(), 7 | requiredForRoles: z.array(z.string()).default([]), 8 | prefix: z.string().optional(), // Optional because not all fields have it 9 | }) 10 | 11 | const profileFieldSchema = z.object({ 12 | label: z.string().optional(), // Optional because some fields use 'fieldName' instead 13 | placeholder: z.string().optional(), 14 | required: z.boolean().optional(), 15 | }) 16 | 17 | const contactsSchema = z.record(contactFieldSchema) 18 | 19 | export const schema = z 20 | .object({ 21 | profileFields: z.object({ 22 | birthday: profileFieldSchema.optional(), 23 | team: profileFieldSchema.optional(), 24 | jobTitle: profileFieldSchema.optional(), 25 | bio: profileFieldSchema.optional(), 26 | contacts: contactsSchema.optional(), 27 | }), 28 | adminDashboardStats: z.object({ 29 | registeredUsersByRoleGroup: z.string(), 30 | }), 31 | }) 32 | .strict() 33 | 34 | export type Metadata = z.infer 35 | -------------------------------------------------------------------------------- /src/modules/users/permissions.ts: -------------------------------------------------------------------------------- 1 | export const Permissions = { 2 | __Admin: 'users.__admin', 3 | AdminList: 'users.admin.list', 4 | AdminManage: 'users.admin.manage', 5 | AdminAssignRoles: 'users.admin.assign_roles', 6 | ListProfiles: 'users.list_profiles', 7 | ManageProfile: 'users.manage_profile', 8 | ManageProfileLimited: 'users.manage_profile_limited', 9 | UseMap: 'users.use_map', 10 | UseOnboarding: 'users.use_onboarding', 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/users/server/jobs/index.ts: -------------------------------------------------------------------------------- 1 | import { CronJob, CronJobContext } from '#server/types' 2 | import * as deleteUsersData from './users-delete-users-data' 3 | 4 | module.exports.moduleCronJobsFactory = (ctx: CronJobContext): CronJob[] => { 5 | return [deleteUsersData.jobFactory()] 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/users/server/jobs/users-delete-users-data.ts: -------------------------------------------------------------------------------- 1 | import { Op } from 'sequelize' 2 | import dayjs from 'dayjs' 3 | import { CronJob, CronJobContext } from '#server/types' 4 | import { DATE_FORMAT } from '#server/constants' 5 | 6 | const JobName = 'users-delete-users-data' 7 | export const jobFactory = (): CronJob => { 8 | return { 9 | cron: '0 1 * * *', 10 | name: JobName, 11 | fn: async (ctx: CronJobContext) => { 12 | try { 13 | const users = await ctx.models.User.findAllActive({ 14 | where: { 15 | scheduledToDelete: { [Op.lte]: dayjs().format(DATE_FORMAT) }, 16 | }, 17 | }) 18 | if (!users.length) { 19 | ctx.log.info(`${JobName}: No one is scheduled to be deleted today.`) 20 | return 21 | } 22 | ctx.log.info( 23 | `${JobName}: Found ${users.length} users scheduled to be deleted today.` 24 | ) 25 | for (const user of users) { 26 | ctx.log.info( 27 | `${JobName}: Anonymizing ${user.fullName} (email: ${user.email}, id: ${user.id}).` 28 | ) 29 | await user.anonymize() 30 | await ctx.models.UserTag.destroy({ where: { userId: user.id } }) 31 | } 32 | } catch (e) { 33 | ctx.log.error(JSON.stringify(e)) 34 | } 35 | }, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/users/server/migrations/20230419141425_users_ghost_user.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { Sequelize, DataTypes } = require('sequelize') 3 | 4 | module.exports = { 5 | async up({ context: queryInterface, appConfig }) { 6 | const defaultRole = appConfig.lowPriorityRole 7 | if (!defaultRole) throw new Error('Missing default role') 8 | await queryInterface.sequelize.query(` 9 | INSERT INTO users ( 10 | id, 11 | "fullName", 12 | email, 13 | role 14 | ) 15 | VALUES ( 16 | '00000000-0000-0000-0000-000000000000', 17 | 'Ghost User', 18 | 'ghost@user', 19 | '${defaultRole}' 20 | ) 21 | `) 22 | }, 23 | async down({ context: queryInterface }) {}, 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/users/server/migrations/20231206175252_users_add-roles-list.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { Sequelize, DataTypes } = require('sequelize') 3 | 4 | module.exports = { 5 | async up({ context: queryInterface, appConfig }) { 6 | await queryInterface.sequelize.transaction(async (transaction) => { 7 | await queryInterface.addColumn( 8 | 'users', 9 | 'roles', 10 | { 11 | type: DataTypes.ARRAY(DataTypes.STRING), 12 | allowNull: false, 13 | defaultValue: [], 14 | }, 15 | { transaction } 16 | ) 17 | await queryInterface.sequelize.query( 18 | ` 19 | UPDATE users 20 | SET roles = ARRAY[role]::varchar[] 21 | WHERE role IS NOT NULL; 22 | `, 23 | { transaction } 24 | ) 25 | await queryInterface.changeColumn( 26 | 'users', 27 | 'role', 28 | { 29 | type: DataTypes.STRING, 30 | allowNull: true, 31 | }, 32 | { transaction } 33 | ) 34 | }) 35 | }, 36 | async down({ context: queryInterface, appConfig }) { 37 | await queryInterface.removeColumn('users', 'roles') 38 | }, 39 | } 40 | -------------------------------------------------------------------------------- /src/modules/users/server/migrations/20240827193202_users_update-avatar-url-length.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { Sequelize, DataTypes } = require('sequelize') 3 | 4 | module.exports = { 5 | async up({ context: queryInterface, appConfig }) { 6 | await queryInterface.changeColumn('users', 'avatar', { 7 | type: DataTypes.STRING(2000), 8 | }) 9 | }, 10 | async down({ context: queryInterface, appConfig }) {}, 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/users/server/migrations/20241029121408_users_users-email-lowercase.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { Sequelize, DataTypes } = require('sequelize') 3 | 4 | module.exports = { 5 | async up({ context: queryInterface, appConfig }) { 6 | await queryInterface.sequelize.query(` 7 | UPDATE users 8 | SET email = LOWER(email) 9 | WHERE email <> LOWER(email) 10 | `) 11 | }, 12 | 13 | async down({ context: queryInterface, appConfig }) {}, 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/users/server/models/index.ts: -------------------------------------------------------------------------------- 1 | import { User } from './user' 2 | import { Session } from './session' 3 | import { City } from './city' 4 | import { Tag } from './tag' 5 | import { UserTag } from './user-tag' 6 | 7 | // FIXME:TODO: make the declaration of model relations more universal 8 | User.belongsToMany(Tag, { 9 | through: 'user_tags', 10 | as: 'tags', 11 | foreignKey: 'userId' 12 | }) 13 | Tag.belongsToMany(User, { 14 | through: 'user_tags', 15 | as: 'users', 16 | foreignKey: 'tagId' 17 | }) 18 | UserTag.belongsTo(User) 19 | UserTag.belongsTo(Tag) 20 | 21 | export { 22 | User, 23 | Session, 24 | City, 25 | Tag, 26 | UserTag 27 | } -------------------------------------------------------------------------------- /src/modules/users/server/models/session.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Model, ForeignKey, CreationOptional } from 'sequelize' 2 | import { sequelize } from '#server/db' 3 | import { Session as SessionModel } from '../../types' 4 | import { User } from './user' 5 | 6 | type SessionCreateFields = Pick 7 | 8 | export class Session 9 | extends Model 10 | implements SessionModel 11 | { 12 | declare token: string 13 | declare userId: ForeignKey 14 | declare createdAt: CreationOptional 15 | } 16 | 17 | Session.init( 18 | { 19 | token: { 20 | type: DataTypes.STRING, 21 | allowNull: false, 22 | primaryKey: true, 23 | }, 24 | userId: { 25 | type: DataTypes.UUID, 26 | allowNull: false, 27 | references: { 28 | model: 'users', 29 | key: 'id', 30 | }, 31 | onDelete: 'CASCADE', 32 | }, 33 | createdAt: DataTypes.DATE, 34 | }, 35 | { 36 | sequelize, 37 | modelName: 'Session', 38 | tableName: 'sessions', 39 | timestamps: true, 40 | updatedAt: false, 41 | } 42 | ) 43 | 44 | Session.belongsTo(User, { 45 | targetKey: 'id', 46 | foreignKey: 'userId', 47 | }) 48 | -------------------------------------------------------------------------------- /src/modules/users/server/models/tag.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Model, CreationOptional, Op, ModelStatic } from 'sequelize' 2 | import { sequelize } from '#server/db' 3 | import { Tag as TagModel } from '../../types' 4 | 5 | type UserCreateFields = Partial 6 | 7 | export class Tag extends Model implements TagModel { 8 | declare id: CreationOptional 9 | declare name: string 10 | declare altNames: string[] 11 | declare category: string 12 | declare order: number 13 | declare createdAt: CreationOptional 14 | declare updatedAt: CreationOptional 15 | } 16 | 17 | Tag.init( 18 | { 19 | id: { 20 | type: DataTypes.UUID, 21 | defaultValue: DataTypes.UUIDV4, 22 | allowNull: false, 23 | primaryKey: true, 24 | }, 25 | name: { 26 | type: DataTypes.STRING, 27 | allowNull: false, 28 | unique: true, 29 | }, 30 | altNames: { 31 | type: DataTypes.ARRAY(DataTypes.STRING), 32 | defaultValue: [], 33 | }, 34 | category: { 35 | type: DataTypes.STRING, 36 | allowNull: true, 37 | }, 38 | order: { 39 | type: DataTypes.SMALLINT, 40 | allowNull: true, 41 | defaultValue: 0, 42 | }, 43 | createdAt: DataTypes.DATE, 44 | updatedAt: DataTypes.DATE, 45 | }, 46 | { 47 | sequelize, 48 | modelName: 'Tag', 49 | tableName: 'tags', 50 | timestamps: true 51 | } 52 | ) 53 | -------------------------------------------------------------------------------- /src/modules/users/server/models/user-tag.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Model, CreationOptional, literal } from 'sequelize' 2 | import { sequelize } from '#server/db' 3 | import { UserTag as UserTagModel } from '../../types' 4 | 5 | type UserCreateFields = Partial 6 | 7 | export class UserTag extends Model implements UserTagModel { 8 | declare id: CreationOptional 9 | declare userId: string 10 | declare tagId: string 11 | declare createdAt: CreationOptional 12 | declare updatedAt: CreationOptional 13 | } 14 | 15 | UserTag.init( 16 | { 17 | id: { 18 | type: DataTypes.UUID, 19 | defaultValue: literal('gen_random_uuid()'), 20 | allowNull: false, 21 | primaryKey: true, 22 | }, 23 | userId: { 24 | type: DataTypes.UUID, 25 | references: { 26 | model: 'users', 27 | key: 'id', 28 | }, 29 | onDelete: 'CASCADE', 30 | }, 31 | tagId: { 32 | type: DataTypes.UUID, 33 | references: { 34 | model: 'tags', 35 | key: 'id', 36 | }, 37 | onDelete: 'CASCADE', 38 | }, 39 | createdAt: DataTypes.DATE, 40 | updatedAt: DataTypes.DATE, 41 | }, 42 | { 43 | sequelize, 44 | modelName: 'UserTag', 45 | tableName: 'user_tags', 46 | timestamps: true, 47 | } 48 | ) 49 | -------------------------------------------------------------------------------- /src/modules/users/shared-helpers/index.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import dayjsTimezone from 'dayjs/plugin/timezone' 3 | import dayjsUtc from 'dayjs/plugin/utc' 4 | 5 | dayjs.extend(dayjsTimezone) 6 | dayjs.extend(dayjsUtc) 7 | 8 | export const parseGmtOffset = (timezone: string): string => { 9 | if (!timezone) { 10 | return '' 11 | } 12 | const gmtOffset: string = dayjs().tz(timezone).format('Z') 13 | 14 | // @todo Do we care about countries with 30 minute difference in timezones? 15 | // https://www.worldtimeserver.com/learn/unusual-time-zones/ 16 | // we only want to use the hour part of the offset 17 | const offsetNumber: number = parseInt(gmtOffset.substring(0, 3)) 18 | const sign = offsetNumber > 0 ? '+' : '' 19 | 20 | if (offsetNumber === 0) { 21 | return 'GMT' 22 | } 23 | 24 | return 'GMT'.concat(`${sign}`).concat(offsetNumber.toString()) 25 | } 26 | 27 | export const formatName = (source: string) => 28 | source.replace(/ /g, '').toLowerCase() 29 | -------------------------------------------------------------------------------- /src/modules/users/templates/notification.yaml: -------------------------------------------------------------------------------- 1 | roleChanged: | 2 | {{ admin.fullName }} ({{ admin.email }}) updated {{ user.fullName }}'s ({{ user.email }}) roles set: 3 |
4 | Previous: 5 | {{#previousRoleNames}} 6 | {{.}} 7 | {{/previousRoleNames}} 8 |
9 | New: 10 | {{#targetRoleNames}} 11 | {{.}} 12 | {{/targetRoleNames}} 13 | -------------------------------------------------------------------------------- /src/modules/visits/client/components/VisitStatusTag.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Tag } from '#client/components/ui' 3 | import { VisitStatus } from '#shared/types' 4 | import { Size } from '#client/components/ui/Tag' 5 | 6 | type Props = { 7 | status: VisitStatus 8 | size?: Size 9 | } 10 | export const VisitStatusTag: React.FC = ({ status, size = 'small' }) => { 11 | type Color = 'gray' | 'yellow' | 'green' | 'red' | 'blue' 12 | const label: Record = { 13 | confirmed: 'Confirmed', 14 | pending: 'Pending', 15 | cancelled: 'Cancelled', 16 | } 17 | const color: Record = { 18 | confirmed: 'green', 19 | pending: 'yellow', 20 | cancelled: 'red', 21 | } 22 | return ( 23 | 24 | {label[status]} 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/visits/client/components/index.ts: -------------------------------------------------------------------------------- 1 | export { AdminVisits } from './AdminVisits' 2 | export { DateDeskPicker } from './DateDeskPicker' 3 | export { VisitNotice } from './VisitNotice' 4 | export { VisitDetail } from './VisitDetail' 5 | export { VisitRequestForm } from './VisitRequestForm' 6 | export { VisitStatusTag } from './VisitStatusTag' 7 | export { WhoIsInOffice } from './WhoIsInOffice' 8 | export { DeskPicker } from './DeskPicker' 9 | export { AdminDashboardStats } from './AdminDashboardStats' 10 | -------------------------------------------------------------------------------- /src/modules/visits/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "visits", 3 | "name": "Office Visits", 4 | "dependencies": ["users"], 5 | "requiredIntegrations": [], 6 | "recommendedIntegrations": ["matrix"], 7 | "availableCronJobs": ["visit-delete-data", "visit-reminder:*"], 8 | "models": ["Visit", "VisitReminderJob"], 9 | "clientRouter": { 10 | "public": {}, 11 | "user": { 12 | "visitRequest": { 13 | "path": "/visits/request", 14 | "componentId": "VisitRequestForm" 15 | }, 16 | "visit": { 17 | "path": "/visits/:visitId", 18 | "componentId": "VisitDetail" 19 | } 20 | }, 21 | "admin": { 22 | "adminVisits": { 23 | "path": "/admin/visits", 24 | "componentId": "AdminVisits", 25 | "availablePortals": ["admin_visits_header"] 26 | } 27 | } 28 | }, 29 | "adminLinkCounter": true 30 | } 31 | -------------------------------------------------------------------------------- /src/modules/visits/permissions.ts: -------------------------------------------------------------------------------- 1 | export const Permissions = { 2 | __Admin: 'visits.__admin', 3 | AdminList: 'visits.admin.list', 4 | AdminManage: 'visits.admin.manage', 5 | ListVisitors: 'visits.list_visitors', 6 | Create: 'visits.create', 7 | } 8 | -------------------------------------------------------------------------------- /src/modules/visits/server/jobs/index.ts: -------------------------------------------------------------------------------- 1 | import { CronJob, CronJobContext } from '#server/types' 2 | import * as visitReminder from './visit-reminder' 3 | import * as visitDeleteUserData from './visit-delete-data' 4 | 5 | module.exports.moduleCronJobsFactory = (ctx: CronJobContext): CronJob[] => { 6 | const offices = ctx.appConfig.offices.filter((x) => x.timezone) 7 | return [ 8 | ...offices.map((office) => visitReminder.jobFactory(office)), 9 | visitDeleteUserData.jobFactory(), 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/visits/server/jobs/visit-delete-data.ts: -------------------------------------------------------------------------------- 1 | import { CronJob, CronJobContext } from '#server/types' 2 | import dayjs from 'dayjs' 3 | import { Op } from 'sequelize' 4 | 5 | const JobName = 'visit-delete-data' 6 | export const jobFactory = (): CronJob => { 7 | return { 8 | name: JobName, 9 | cron: `0 0 * * *`, 10 | fn: async (ctx: CronJobContext) => { 11 | try { 12 | const today = dayjs().format('YYYY-MM-DD') 13 | const users = await ctx.models.User.findAllActive({ 14 | where: { 15 | scheduledToDelete: today, 16 | }, 17 | }) 18 | if (!users.length) { 19 | ctx.log.info(`${JobName}: No one is scheduled to be deleted today.`) 20 | return 21 | } 22 | ctx.log.info( 23 | `${JobName}: Found ${users.length} users scheduled to be deleted today.` 24 | ) 25 | for (const user of users) { 26 | await ctx.models.Visit.destroy({ 27 | where: { 28 | [Op.and]: [ 29 | { userId: user.id }, 30 | { 31 | date: { 32 | [Op.gte]: today, 33 | }, 34 | }, 35 | ], 36 | }, 37 | }) 38 | ctx.log.info( 39 | `${JobName}: Removed future Visit for user ${user.fullName} (id: ${user.id}).` 40 | ) 41 | } 42 | } catch (e) { 43 | ctx.log.error(JSON.stringify(e)) 44 | } 45 | }, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/modules/visits/server/models/index.ts: -------------------------------------------------------------------------------- 1 | export { Visit } from './visit' 2 | export { VisitReminderJob } from './visit-reminder-job' 3 | -------------------------------------------------------------------------------- /src/modules/visits/templates/error.yaml: -------------------------------------------------------------------------------- 1 | unavailableDesks: | 2 | Failed to create a reservation. The following desks are not available: {{ conflictedVisitLabels }} 3 | unavailableArea: | 4 | Failed to create a reservation. The following area(s) is fully unavailable: {{ reservedAreaNames }} 5 | failedBookingArea: | 6 | Failed to book an entire area(s): {{ areaNames }} 7 | -------------------------------------------------------------------------------- /src/modules/visits/templates/notification.yaml: -------------------------------------------------------------------------------- 1 | visitStatusChangeByAdminForUser: | 2 | Your office visit on {{ date }} to {{ office.name }} office was {{ status }} by admin. 3 | visitStatusChangeByAdminMessage: | 4 | {{ adminUser.fullName }} ({{ adminUser.email}}) {{ status }} an office visit for 5 | {{ visitUser.fullName }} ({{ visitUser.email }}) on {{ date }} to {{ office.name }} office. 6 | visitStatusChange: | 7 | {{ user }} {{ status }} an office visit on {{ date }} to {{ office.name }} office. 8 | officeVisitBooking: | 9 | Hi!

10 | You booked the following {{ location.name }} office visits:

11 |
    12 | {{#visits}} 13 |
  • {{ date }} cancel
  • 14 | {{/visits}} 15 |
16 | officeVisitBookingAdmin: | 17 | {{ user.fullName }} ({{ user.email }}) requested an office visit/s. Location: {{ location.name }}.

18 |
    19 | {{#visits}} 20 |
  • {{ date }} cancel
  • 21 | {{/visits}} 22 |
23 | visitReminderCron: | 24 | Hi!

25 | You have an office visit booked for today. If you change your mind, please cancel your visit so that someone else 26 | can use your spot: {{ visitUrl }} 27 | -------------------------------------------------------------------------------- /src/modules/working-hours/client/components/MaxConsecutiveHoursWarning.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | type Props = { 4 | maxHours: number | undefined 5 | time: [string, string] 6 | } 7 | 8 | export function MaxConsecutiveHoursWarning(props: Props) { 9 | const durationMinutes = React.useMemo(() => { 10 | if (!props.maxHours || !props.time[0] || !props.time[1]) return null 11 | const [startHours = 0, startMinutes = 0] = props.time[0] 12 | .split(':') 13 | .map(Number) 14 | const [endHours = 0, endMinutes = 0] = props.time[1].split(':').map(Number) 15 | const diff = 60 * endHours + endMinutes - (60 * startHours + startMinutes) 16 | if (diff <= 0) return null 17 | return diff 18 | }, [props.time]) 19 | 20 | if (!props.maxHours || !durationMinutes) { 21 | return null 22 | } 23 | 24 | if (durationMinutes <= props.maxHours * 60) { 25 | return null 26 | } 27 | 28 | return ( 29 |
30 | 👆 Hmm, are you sure you’ve been working non-stop for{' '} 31 | {Math.floor(durationMinutes / 60)} hours? 32 |
33 | You deserve a break – don’t forget to log it! 34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/modules/working-hours/client/components/index.ts: -------------------------------------------------------------------------------- 1 | export { AdminWorkingHours } from './AdminWorkingHours' 2 | export { WorkingHoursEditor } from './WorkingHoursEditor' 3 | export { WorkingHoursExportModal } from './WorkingHoursExportModal' 4 | export { WorkingHoursUserModal } from './WorkingHoursUserModal' 5 | export { WorkingHoursWidget } from './WorkingHoursWidget' 6 | -------------------------------------------------------------------------------- /src/modules/working-hours/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "working-hours", 3 | "name": "Working hours", 4 | "dependencies": ["users", "time-off"], 5 | "requiredIntegrations": [], 6 | "recommendedIntegrations": ["matrix", "bamboohr", "humaans"], 7 | "availableCronJobs": [ 8 | "working-hours-reminder", 9 | "fetch-default-working-hours" 10 | ], 11 | "models": [ 12 | "WorkingHoursEntry", 13 | "DefaultWorkingHoursEntry", 14 | "WorkingHoursUserConfig" 15 | ], 16 | "clientRouter": { 17 | "user": { 18 | "workingHoursEditor": { 19 | "path": "/working-hours", 20 | "componentId": "WorkingHoursEditor" 21 | } 22 | }, 23 | "admin": { 24 | "adminWorkingHours": { 25 | "path": "/admin/working-hours", 26 | "componentId": "AdminWorkingHours" 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/modules/working-hours/metadata-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const timeSchema = z.string().regex(/^([0-1][0-9]|2[0-3]):[0-5][0-9]$/) 4 | 5 | export const roleConfigSchema = z 6 | .object({ 7 | workingDays: z.array(z.number().min(0).max(6)).min(1), 8 | defaultEntries: z.array(z.tuple([timeSchema, timeSchema])).min(1), 9 | canPrefillDay: z.boolean().default(false), 10 | canPrefillWeek: z.boolean().default(false), 11 | weeklyWorkingHours: z.number().min(1).default(40), 12 | weeklyOvertimeHoursNotice: z.number().min(1).default(2), 13 | weeklyOvertimeHoursWarning: z.number().min(1).default(6), 14 | editablePeriod: z.object({ 15 | /* 16 | Editable period: 17 | + current month 18 | + prev month (if now.date() > {prevMonthBefore}) 19 | + {nextWeeks} whole weeks from now 20 | */ 21 | prevMonthBefore: z.number().min(1).max(28), 22 | nextWeeks: z.number().min(0).max(18), 23 | }), 24 | publicHolidayCalendarId: z.string().optional(), 25 | policyText: z.string().optional(), 26 | maxConsecutiveWorkingHours: z.number().min(2).max(24).optional(), 27 | }) 28 | .strict() 29 | 30 | export type WorkingHoursRoleConfig = z.infer 31 | 32 | export const schema = z 33 | .object({ 34 | configByRole: z.record(roleConfigSchema), 35 | }) 36 | .strict() 37 | 38 | export type Metadata = z.infer 39 | -------------------------------------------------------------------------------- /src/modules/working-hours/permissions.ts: -------------------------------------------------------------------------------- 1 | export const Permissions = { 2 | __Admin: 'working-hours.__admin', 3 | AdminList: 'working-hours.admin.list', 4 | AdminManage: 'working-hours.admin.manage', 5 | Create: 'working-hours.create', 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/working-hours/server/jobs/index.ts: -------------------------------------------------------------------------------- 1 | import { CronJob, CronJobContext } from '#server/types' 2 | import { cronJob as workingHoursReminderJob } from './working-hours-reminder' 3 | import { cronJob as fetchDefaultWorkingHoursJob } from './fetch-default-working-hours' 4 | 5 | // exported as a function for dynamic cron/name generation based on passed argument 6 | module.exports.moduleCronJobsFactory = (ctx: CronJobContext): CronJob[] => { 7 | return [workingHoursReminderJob, fetchDefaultWorkingHoursJob] 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/working-hours/server/migrations/20240808181550_working-hours_add-working-hours-entry-metadata.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { Sequelize, DataTypes } = require('sequelize') 3 | 4 | module.exports = { 5 | async up({ context: queryInterface, appConfig }) { 6 | await queryInterface.addColumn('working_hours_entries', 'metadata', { 7 | type: DataTypes.JSONB, 8 | defaultValue: {}, 9 | }) 10 | }, 11 | async down({ context: queryInterface, appConfig }) { 12 | await queryInterface.removeColumn('working_hours_entries', 'metadata') 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/working-hours/server/migrations/20241028153105_working-hours_add-working-hours-entry-auto.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { Sequelize, DataTypes } = require('sequelize') 3 | 4 | module.exports = { 5 | async up({ context: queryInterface, appConfig }) { 6 | await queryInterface.sequelize.transaction(async (transaction) => { 7 | await queryInterface.addColumn( 8 | 'working_hours_entries', 9 | 'autoGenerated', 10 | { 11 | type: DataTypes.BOOLEAN, 12 | allowNull: false, 13 | defaultValue: false, 14 | }, 15 | { transaction } 16 | ) 17 | 18 | await queryInterface.sequelize.query( 19 | ` 20 | UPDATE working_hours_entries 21 | SET "autoGenerated" = (metadata->>'auto')::boolean 22 | WHERE metadata->>'auto' IS NOT NULL 23 | `, 24 | { transaction } 25 | ) 26 | }) 27 | }, 28 | 29 | async down({ context: queryInterface, appConfig }) { 30 | await queryInterface.sequelize.transaction(async (transaction) => { 31 | await queryInterface.removeColumn( 32 | 'working_hours_entries', 33 | 'autoGenerated', 34 | { 35 | transaction, 36 | } 37 | ) 38 | }) 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/working-hours/server/models/index.ts: -------------------------------------------------------------------------------- 1 | export { WorkingHoursEntry } from './working-hours-entry' 2 | export { DefaultWorkingHoursEntry } from './default-working-hours-entry' 3 | export { WorkingHoursUserConfig } from './working-hours-user-config' 4 | -------------------------------------------------------------------------------- /src/modules/working-hours/templates/notification.yaml: -------------------------------------------------------------------------------- 1 | unfilledWorkingHours: | 2 | Hi! You have unfilled working hours for the previous working day, {{lastWorkingDate}}. Please fill them out here. 3 | -------------------------------------------------------------------------------- /src/server/auth/auth-plugin.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { FastifyPluginCallback } from 'fastify' 3 | import { SESSION_TOKEN_COOKIE_NAME } from '#server/constants' 4 | import { appConfig } from '#server/app-config' 5 | import { safeRequire, getFilePath } from '#server/utils' 6 | 7 | export const authPlugin = (): FastifyPluginCallback => { 8 | return async (fastify, opts) => { 9 | // decorate "db" 10 | const coreModels = 11 | safeRequire( 12 | getFilePath('dist_server/src/modules/users/server/models') 13 | ) || {} 14 | fastify.decorate('db', coreModels) 15 | 16 | // register each auth provider 17 | const providers = appConfig.config.application.auth.providers 18 | for (const provider of providers) { 19 | const { plugin } = 20 | safeRequire( 21 | getFilePath(`dist_server/src/server/auth/providers/${provider}`) 22 | ) || {} 23 | if (plugin) { 24 | fastify.register(plugin, { prefix: `/${provider}` }) 25 | } 26 | } 27 | 28 | fastify.get('/logout', async (req, reply) => { 29 | return reply.clearCookie(SESSION_TOKEN_COOKIE_NAME).redirect('/') 30 | }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/server/auth/providers/google/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "google", 3 | "requiredCredentials": ["OAUTH2_GOOGLE_CLIENT_ID", "OAUTH2_GOOGLE_CLIENT_SECRET"] 4 | } -------------------------------------------------------------------------------- /src/server/auth/providers/polkadot/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "polkadot", 3 | "requiredCredentials": ["ALLOWED_EXTENSIONS", "AUTH_MESSAGE_TO_SIGN"] 4 | } 5 | -------------------------------------------------------------------------------- /src/server/db.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize } from 'sequelize' 2 | import config from './config' 3 | 4 | export const sequelize = new Sequelize(config.databaseUri, { 5 | logging: config.logDbQueries ? console.log : false, 6 | }) 7 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | import customPaths from './utils/custom-paths' 2 | customPaths.register({ prefixes: ['#server', '#shared', '#modules', '#custom-modules'] }) 3 | 4 | require('./server') 5 | -------------------------------------------------------------------------------- /src/server/utils/app-events.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | 3 | class AppEvents { 4 | eventEmitters: Record = {} 5 | 6 | useModule(moduleId: string): EventEmitter { 7 | if (!this.eventEmitters[moduleId]) { 8 | this.eventEmitters[moduleId] = new EventEmitter() 9 | } 10 | return this.eventEmitters[moduleId] 11 | } 12 | } 13 | 14 | export const appEvents = new AppEvents() 15 | -------------------------------------------------------------------------------- /src/server/utils/log.ts: -------------------------------------------------------------------------------- 1 | import pino from 'pino' 2 | 3 | const loggerConfig = { 4 | transport: { 5 | target: 'pino-pretty', 6 | options: { 7 | translateTime: 'HH:MM:ss Z', 8 | ignore: 'pid,hostname', 9 | singleLine: true, 10 | }, 11 | }, 12 | timestamp: () => `,"time":"${new Date(Date.now()).toISOString()}"`, 13 | } 14 | 15 | export const log = pino(loggerConfig) 16 | -------------------------------------------------------------------------------- /src/server/utils/rate-limit.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance, FastifyPluginCallback } from 'fastify' 2 | import fastifyRateLimit, { RateLimitPluginOptions } from '@fastify/rate-limit' 3 | 4 | const DEFAULT_OPTIONS: RateLimitPluginOptions = { 5 | max: 1, 6 | timeWindow: 30e3, 7 | hook: 'preHandler', 8 | cache: 10e3, 9 | addHeadersOnExceeding: { 10 | 'x-ratelimit-limit': false, 11 | 'x-ratelimit-remaining': false, 12 | 'x-ratelimit-reset': false, 13 | }, 14 | addHeaders: { 15 | 'x-ratelimit-limit': false, 16 | 'x-ratelimit-remaining': false, 17 | 'x-ratelimit-reset': false, 18 | 'retry-after': false, 19 | }, 20 | errorResponseBuilder: (_, context) => ({ 21 | statusCode: 429, 22 | message: `Too many requests. Try after ${context.after}.`, 23 | }), 24 | } 25 | 26 | export async function useRateLimit( 27 | fastify: FastifyInstance, 28 | cb: FastifyPluginCallback, 29 | opts: Partial = {} 30 | ) { 31 | await fastify.register(fastifyRateLimit, { 32 | ...DEFAULT_OPTIONS, 33 | ...opts, 34 | }) 35 | return cb(fastify, {}, () => {}) 36 | } 37 | -------------------------------------------------------------------------------- /src/shared/permissions/index.ts: -------------------------------------------------------------------------------- 1 | import { Permissions } from './__import-permissions' 2 | 3 | export default Permissions 4 | -------------------------------------------------------------------------------- /src/shared/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './__import-types' 2 | export * from '#server/app-config/types' 3 | 4 | export enum EntityVisibility { 5 | None = 'none', 6 | Url = 'url', 7 | Visible = 'visible', 8 | UrlPublic = 'url_public', 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './fp' 2 | export * from './permissions-set' 3 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo " 4 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 5 | ░██╗░░██╗██╗░░░██╗██████╗░░░░░░█████╗░██████╗░██████╗░░ 6 | ░██║░░██║██║░░░██║██╔══██╗░░░░██╔══██╗██╔══██╗██╔══██╗░ 7 | ░███████║██║░░░██║██████╦╝░░░░███████║██████╔╝██████╔╝░ 8 | ░██╔══██║██║░░░██║██╔══██╗░░░░██╔══██║██╔═══╝░██╔═══╝░░ 9 | ░██║░░██║╚██████╔╝██████╦╝░░░░██║░░██║██║░░░░░██║░░░░░░ 10 | ░╚═╝░░╚═╝░╚═════╝░╚═════╝░░░░░╚═╝░░╚═╝╚═╝░░░░░╚═╝░░░░░░ 11 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░" 12 | 13 | demo_config=false 14 | dev_flag=false 15 | 16 | if [ "$1" == "--demo" ]; then 17 | demo_config=true 18 | 19 | elif [ "$1" == "--dev" ]; then 20 | dev_flag=true 21 | fi 22 | 23 | # Run the appropriate command based on the presence of the --demo flag 24 | if [ "$demo_config" = true ]; then 25 | echo "Starting Polkadot Hub using demo docker-compose-demo.yml and ./config-demo" 26 | docker compose -f docker-compose-demo.yml up 27 | elif [ "$dev_flag" = true ]; then 28 | echo "Starting Polkadot Hub using docker-compose-dev.yml and ./config" 29 | docker compose -f docker-compose-dev.yml up 30 | else 31 | echo "Starting Polkadot Hub using docker-compose.yml and ./config" 32 | docker compose -f docker-compose.yml up 33 | fi 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "lib": ["es2020", "DOM"], 5 | "module": "commonjs", 6 | "allowJs": true, 7 | "removeComments": true, 8 | "resolveJsonModule": true, 9 | "typeRoots": [ 10 | "./node_modules/@types" 11 | ], 12 | "sourceMap": true, 13 | "outDir": "dist_server", 14 | "strict": true, 15 | "baseUrl": "src", 16 | "forceConsistentCasingInFileNames": true, 17 | "esModuleInterop": true, 18 | "allowSyntheticDefaultImports": true, 19 | "experimentalDecorators": true, 20 | "emitDecoratorMetadata": true, 21 | "moduleResolution": "Node", 22 | "skipLibCheck": true, 23 | "jsx": "react-jsx", 24 | "paths": { 25 | "#client/*": ["client/*"], 26 | "#server/*": ["server/*"], 27 | "#shared/*": ["shared/*"], 28 | "#modules/*": ["modules/*"], 29 | "#custom-modules/*": ["../config/modules/*"], 30 | } 31 | }, 32 | "files": [ 33 | "src/server/index.ts", 34 | "src/server/types/index.ts", 35 | ], 36 | "include": [ 37 | "src/server", 38 | "src/integrations", 39 | "src/client", 40 | "src/modules/**/*.ts", 41 | "src/modules/**/*.json", 42 | "src/utils", 43 | "src/shared", 44 | "config/modules", 45 | ], 46 | "exclude": [ 47 | "node_modules", 48 | ] 49 | } --------------------------------------------------------------------------------