├── .devcontainer └── devcontainer.json ├── .dockerignore ├── .eslintignore ├── .eslintrc.yml ├── .github ├── FUNDING.yml └── workflows │ ├── codeql-analysis.yml │ └── push.yml ├── .gitignore ├── .meteor ├── .finished-upgraders ├── .gitignore ├── .id ├── packages ├── platforms ├── release └── versions ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── client ├── head.html └── main.js ├── docker-compose-auto-update.yml ├── docker-compose.yml ├── entrypoint.sh ├── imports ├── api │ ├── customers │ │ ├── customers.js │ │ └── server │ │ │ ├── methods.js │ │ │ └── publications.js │ ├── customfields │ │ ├── customfields.js │ │ └── server │ │ │ ├── methods.js │ │ │ └── publications.js │ ├── dashboards │ │ ├── dashboards.js │ │ └── server │ │ │ ├── methods.js │ │ │ └── publications.js │ ├── extensions │ │ ├── extensions.js │ │ └── server │ │ │ ├── methods.js │ │ │ └── publications.js │ ├── globalsettings │ │ ├── globalsettings.js │ │ └── server │ │ │ ├── methods.js │ │ │ └── publications.js │ ├── holidays │ │ └── server │ │ │ └── methods.js │ ├── inboundinterfaces │ │ ├── inboundinterfaces.js │ │ └── server │ │ │ ├── methods.js │ │ │ └── publications.js │ ├── notifications │ │ ├── notifications.js │ │ └── server │ │ │ ├── methods.js │ │ │ └── publications.js │ ├── outboundinterfaces │ │ ├── outboundinterfaces.js │ │ └── server │ │ │ ├── methods.js │ │ │ └── publications.js │ ├── projects │ │ ├── projects.js │ │ ├── server │ │ │ ├── methods.js │ │ │ └── publications.js │ │ └── setup.js │ ├── statistics │ │ └── methods.js │ ├── tasks │ │ ├── server │ │ │ ├── methods.js │ │ │ └── publications.js │ │ └── tasks.js │ ├── timecards │ │ ├── server │ │ │ ├── methods.js │ │ │ └── publications.js │ │ └── timecards.js │ ├── transactions │ │ ├── server │ │ │ └── publications.js │ │ └── transactions.js │ └── users │ │ ├── server │ │ ├── methods.js │ │ └── publications.js │ │ └── users.js ├── startup │ ├── client │ │ ├── index.js │ │ ├── routes.js │ │ ├── startup.js │ │ └── useraccounts-configuration.js │ └── server │ │ ├── index.js │ │ ├── startup.js │ │ └── useraccounts-configuration.js ├── ui │ ├── layouts │ │ ├── appLayout.html │ │ └── appLayout.js │ ├── pages │ │ ├── 404.html │ │ ├── about.html │ │ ├── about.js │ │ ├── administration │ │ │ ├── administration.html │ │ │ ├── administration.js │ │ │ └── components │ │ │ │ ├── customfieldscomponent.html │ │ │ │ ├── customfieldscomponent.js │ │ │ │ ├── extensionscomponent.html │ │ │ │ ├── extensionscomponent.js │ │ │ │ ├── globalsettingscomponent.html │ │ │ │ ├── globalsettingscomponent.js │ │ │ │ ├── inboundinterfacescomponent.html │ │ │ │ ├── inboundinterfacescomponent.js │ │ │ │ ├── oidccomponent.html │ │ │ │ ├── oidccomponent.js │ │ │ │ ├── outboundinterfacescomponent.html │ │ │ │ ├── outboundinterfacescomponent.js │ │ │ │ ├── transactionscomponent.html │ │ │ │ ├── transactionscomponent.js │ │ │ │ ├── userscomponent.html │ │ │ │ └── userscomponent.js │ │ ├── changePassword.html │ │ ├── changePassword.js │ │ ├── details │ │ │ ├── components │ │ │ │ ├── dailytimetable.html │ │ │ │ ├── dailytimetable.js │ │ │ │ ├── detailtimetable.html │ │ │ │ ├── detailtimetable.js │ │ │ │ ├── filterbar.html │ │ │ │ ├── filterbar.js │ │ │ │ ├── limitpicker.html │ │ │ │ ├── limitpicker.js │ │ │ │ ├── multiselectfilter.html │ │ │ │ ├── multiselectfilter.js │ │ │ │ ├── pagination.html │ │ │ │ ├── pagination.js │ │ │ │ ├── periodpicker.html │ │ │ │ ├── periodpicker.js │ │ │ │ ├── periodtimetable.html │ │ │ │ ├── periodtimetable.js │ │ │ │ ├── workingtimetable.html │ │ │ │ └── workingtimetable.js │ │ │ ├── dashboard.html │ │ │ ├── dashboard.js │ │ │ ├── details.html │ │ │ └── details.js │ │ ├── overview │ │ │ ├── components │ │ │ │ ├── allprojectschart.html │ │ │ │ ├── allprojectschart.js │ │ │ │ ├── dashboardModal.html │ │ │ │ ├── dashboardModal.js │ │ │ │ ├── projectProgress.html │ │ │ │ ├── projectProgress.js │ │ │ │ ├── projectchart.html │ │ │ │ └── projectchart.js │ │ │ ├── editproject │ │ │ │ ├── components │ │ │ │ │ ├── importcsv.html │ │ │ │ │ ├── importcsv.js │ │ │ │ │ ├── projectAccessRights.html │ │ │ │ │ ├── projectAccessRights.js │ │ │ │ │ ├── taskModal.html │ │ │ │ │ ├── taskModal.js │ │ │ │ │ ├── wekanInterfaceSettings.html │ │ │ │ │ └── wekanInterfaceSettings.js │ │ │ │ ├── editproject.html │ │ │ │ └── editproject.js │ │ │ ├── projectlist.html │ │ │ └── projectlist.js │ │ ├── profile.html │ │ ├── profile.js │ │ ├── register.html │ │ ├── register.js │ │ ├── settings.html │ │ ├── settings.js │ │ ├── signIn.html │ │ ├── signIn.js │ │ └── track │ │ │ ├── components │ │ │ ├── calendar.html │ │ │ ├── calendar.js │ │ │ ├── editTimeEntryModal.html │ │ │ ├── editTimeEntryModal.js │ │ │ ├── magicPopup.html │ │ │ ├── magicPopup.js │ │ │ ├── projectInfoPopup.html │ │ │ ├── projectInfoPopup.js │ │ │ ├── projectTasks.html │ │ │ ├── projectTasks.js │ │ │ ├── projectsearch.html │ │ │ ├── projectsearch.js │ │ │ ├── taskSelectPopup.html │ │ │ ├── taskSelectPopup.js │ │ │ ├── tasksearch.html │ │ │ ├── tasksearch.js │ │ │ ├── timeline.html │ │ │ ├── timeline.js │ │ │ ├── timetracker.html │ │ │ ├── timetracker.js │ │ │ ├── usersearch.html │ │ │ ├── usersearch.js │ │ │ ├── weektable.html │ │ │ └── weektable.js │ │ │ ├── tracktime.html │ │ │ └── tracktime.js │ ├── shared components │ │ ├── backbutton.html │ │ ├── backbutton.js │ │ ├── bootstrapDialogs.js │ │ ├── connectioncheck.html │ │ ├── connectioncheck.js │ │ ├── datatable.html │ │ ├── datatable.js │ │ ├── navbar.html │ │ ├── navbar.js │ │ ├── tablecell.html │ │ └── toast.html │ ├── styles │ │ ├── dark.scss │ │ ├── general.scss │ │ └── light.scss │ └── translations │ │ ├── de.json │ │ ├── en.json │ │ ├── es.json │ │ ├── fr.json │ │ ├── ru.json │ │ ├── ukr.json │ │ └── zh.json └── utils │ ├── autocomplete.js │ ├── debugLog.js │ ├── frontend_helpers.js │ ├── google │ ├── google_client.js │ └── google_server.js │ ├── hex2rgba.js │ ├── holiday.js │ ├── i18n.js │ ├── ldap.js │ ├── ldap_client.js │ ├── oidc │ ├── oidc_client.js │ ├── oidc_helper.js │ └── oidc_server.js │ ├── openai │ └── openai_server.js │ ├── periodHelpers.js │ └── server_method_helpers.js ├── package-lock.json ├── package.json ├── public ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── favicons │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-record-16x16.png │ ├── favicon-record-32x32.png │ ├── favicon.ico │ ├── favicon_record.ico │ ├── manifest.json │ └── mstile-150x150.png ├── img │ ├── ai_working.gif │ ├── appgrid-128x128.svg │ ├── gitlab.svg │ ├── grain-24x24.svg │ ├── market-150x150.svg │ ├── market-big-300x300.svg │ ├── maskable_icon.png │ ├── navbar_logo.svg │ ├── wekan.png │ └── zammad.svg ├── offline.html └── sw.js ├── sandstorm-pkgdef.capnp └── server ├── APIroutes.js ├── bodyparser.js └── main.js /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "geoffreybooth/meteor-base", 3 | "features": { 4 | }, 5 | "forwardedPorts": [3000], 6 | "postCreateCommand": "meteor npm install", 7 | "postAttachCommand": "ROOT_URL=`echo https://$CODESPACE_NAME-3000.app.github.dev/`" 8 | } 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .meteor/local/ 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | # enable ES6 2 | parser: "@babel/eslint-parser" 3 | parserOptions: 4 | ecmaVersion: 8 5 | requireConfigFile: false 6 | sourceType: "module" 7 | allowImportExportEverywhere: true 8 | 9 | # register plugins 10 | plugins: 11 | - meteor 12 | - i18next 13 | 14 | # use the rules of eslint-config-airbnb 15 | # and the recommended rules of eslint-plugin-meteor 16 | extends: 17 | - airbnb-base 18 | - plugin:meteor/recommended 19 | 20 | # registerenvironments 21 | env: 22 | meteor: true 23 | browser: true 24 | node: true 25 | 26 | settings: 27 | import/resolver: "meteor" 28 | 29 | rules: 30 | # overwrite some rules (avoid semicolons) 31 | semi: [2, 'never'] 32 | no-unexpected-multiline: 2 33 | no-underscore-dangle: ['error', { allow: ['_id', '_loginStyle','_redirectUri','_stateParam']}] 34 | no-throw-literal: 0 35 | new-cap: 1 36 | object-shorthand: 1 37 | import/no-extraneous-dependencies: 0 38 | import/no-unresolved: [2, { ignore: ['^meteor'] }] 39 | no-console: [2, { allow: ['warn', 'error'] }] 40 | no-restricted-syntax: [2, 'DebuggerStatement', 'LabeledStatement', 'WithStatement'] 41 | i18next/no-literal-string: [1, { "ignoreCallee": ["*.$","$","get","set","add","added","changed","children","html","prop","call","on","is","tab","addClass","removeClass","toggle","querySelector","querySelectorAll","go","remove","getParam","getQueryParam","moment","format","subscribe","publish","setParams","setQueryParams","startOf","endOf","Collection","subtract","find","data","getGlobalSetting","getUserSetting"] }] 42 | import/extensions: 0 43 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: kromitgmbh # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master, ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '0 1 * * 5' 11 | 12 | jobs: 13 | analyse: 14 | name: Analyse 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v2 20 | with: 21 | # We must fetch at least the immediate parents so that if this is 22 | # a pull request then we can checkout the head. 23 | fetch-depth: 2 24 | 25 | # If this run was triggered by a pull request event, then checkout 26 | # the head of the pull request instead of the merge commit. 27 | - run: git checkout HEAD^2 28 | if: ${{ github.event_name == 'pull_request' }} 29 | 30 | # Initializes the CodeQL tools for scanning. 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@v1 33 | # Override language selection by uncommenting this and choosing your languages 34 | # with: 35 | # languages: go, javascript, csharp, python, cpp, java 36 | 37 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 38 | # If this step fails, then you should remove it and run the build manually (see below) 39 | - name: Autobuild 40 | uses: github/codeql-action/autobuild@v1 41 | 42 | # ℹ️ Command-line programs to run using the OS shell. 43 | # 📚 https://git.io/JvXDl 44 | 45 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 46 | # and modify them (or add more) to build your code if your project 47 | # uses a compiled language 48 | 49 | #- run: | 50 | # make bootstrap 51 | # make release 52 | 53 | - name: Perform CodeQL Analysis 54 | uses: github/codeql-action/analyze@v1 55 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | name: Docker build 6 | jobs: 7 | release: 8 | name: Autotag 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | steps: 13 | - name: checkout 14 | uses: actions/checkout@v3 15 | - name: autotag 16 | id: autotag 17 | uses: butlerlogic/action-autotag@1.1.2 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | with: 21 | strategy: package 22 | tag_message: ${{ github.event.head_commit.message }} 23 | - name: create_release 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | uses: actions/create-release@latest 27 | if: success() 28 | with: 29 | tag_name: ${{steps.autotag.outputs.tagname}} 30 | release_name: ${{steps.autotag.outputs.tagname}} 31 | body: ${{steps.autotag.outputs.tagmessage}} 32 | draft: false 33 | prerelease: false 34 | - name: Set up QEMU 35 | uses: docker/setup-qemu-action@v3 36 | - name: Set up Docker Buildx 37 | uses: docker/setup-buildx-action@v3 38 | - name: Login to DockerHub 39 | uses: docker/login-action@v3 40 | with: 41 | username: ${{ secrets.DOCKERHUB_USERNAME }} 42 | password: ${{ secrets.DOCKERHUB_TOKEN }} 43 | - name: Build and push 44 | uses: docker/build-push-action@v5 45 | with: 46 | context: . 47 | platforms: linux/amd64 48 | push: true 49 | tags: kromit/titra:latest, kromit/titra:${{steps.autotag.outputs.tagname}} 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .log 3 | .vscode/ 4 | jsconfig.json 5 | -------------------------------------------------------------------------------- /.meteor/.finished-upgraders: -------------------------------------------------------------------------------- 1 | # This file contains information which helps Meteor properly upgrade your 2 | # app when you run 'meteor update'. You should check it into version control 3 | # with your project. 4 | 5 | notices-for-0.9.0 6 | notices-for-0.9.1 7 | 0.9.4-platform-file 8 | notices-for-facebook-graph-api-2 9 | 1.2.0-standard-minifiers-package 10 | 1.2.0-meteor-platform-split 11 | 1.2.0-cordova-changes 12 | 1.2.0-breaking-changes 13 | 1.3.0-split-minifiers-package 14 | 1.4.0-remove-old-dev-bundle-link 15 | 1.4.1-add-shell-server-package 16 | 1.4.3-split-account-service-packages 17 | 1.5-add-dynamic-import-package 18 | 1.7-split-underscore-from-meteor-base 19 | 1.8.3-split-jquery-from-blaze 20 | -------------------------------------------------------------------------------- /.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | -------------------------------------------------------------------------------- /.meteor/.id: -------------------------------------------------------------------------------- 1 | # This file contains a token that is unique to your project. 2 | # Check it into your repository along with the rest of this directory. 3 | # It can be used for purposes such as: 4 | # - ensuring you don't accidentally deploy one app on top of another 5 | # - providing package authors with aggregated statistics 6 | 7 | 1ear3zd154k8qhxe9yn1 8 | -------------------------------------------------------------------------------- /.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # Check this file (and the other files in this directory) into your repository. 3 | # 4 | # 'meteor add' and 'meteor remove' will edit this file for you, 5 | # but you can also edit it by hand. 6 | 7 | meteor-base@1.5.2 # Packages every Meteor app needs to have 8 | mobile-experience@1.1.2 # Packages for a great mobile UX 9 | mongo@2.1.1 # The database Meteor supports right now 10 | reactive-var@1.0.13 # Reactive variable for tracker 11 | tracker@1.3.4 # Meteor's client-side reactive programming library 12 | 13 | es5-shim@4.8.1 # ECMAScript 5 compatibility for older browsers. 14 | ecmascript@0.16.10 # Enable ECMAScript2015+ syntax in app code 15 | shell-server@0.6.1 # Server-side component of the `meteor shell` command 16 | 17 | accounts-password@3.1.0 18 | check@1.4.4 19 | random@1.2.2 20 | dynamic-import@0.7.4 21 | ostrio:loggerconsole 22 | faburem:client-server-logger 23 | email@3.1.2 24 | ostrio:flow-router-extra@3.11.0-rc300.1 25 | jquery@3.0.0 26 | faburem:accounts-anonymous 27 | mdg:validated-method 28 | service-configuration@1.3.5 29 | oauth@3.0.2 30 | accounts-base@3.1.0 31 | accounts-oauth@1.4.6 32 | oauth2@1.3.3 33 | browser-policy-framing@1.1.3 34 | blaze-html-templates 35 | ddp-rate-limiter@1.2.2 36 | standard-minifier-css@1.9.3 37 | standard-minifier-js@3.0.0 38 | leonardoventurini:scss 39 | -------------------------------------------------------------------------------- /.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@3.2.2 2 | -------------------------------------------------------------------------------- /.meteor/versions: -------------------------------------------------------------------------------- 1 | accounts-base@3.1.0 2 | accounts-oauth@1.4.6 3 | accounts-password@3.1.0 4 | allow-deny@2.1.0 5 | autoupdate@2.0.0 6 | babel-compiler@7.11.3 7 | babel-runtime@1.5.2 8 | base64@1.0.13 9 | binary-heap@1.0.12 10 | blaze@3.0.2 11 | blaze-html-templates@3.0.0 12 | blaze-tools@2.0.0 13 | boilerplate-generator@2.0.0 14 | browser-policy-common@1.0.13 15 | browser-policy-framing@1.1.3 16 | caching-compiler@2.0.1 17 | caching-html-compiler@2.0.0 18 | callback-hook@1.6.0 19 | check@1.4.4 20 | core-runtime@1.0.0 21 | ddp@1.4.2 22 | ddp-client@3.1.0 23 | ddp-common@1.4.4 24 | ddp-rate-limiter@1.2.2 25 | ddp-server@3.1.2 26 | diff-sequence@1.1.3 27 | dynamic-import@0.7.4 28 | ecmascript@0.16.10 29 | ecmascript-runtime@0.8.3 30 | ecmascript-runtime-client@0.12.3 31 | ecmascript-runtime-server@0.11.1 32 | ejson@1.1.4 33 | email@3.1.2 34 | es5-shim@4.8.1 35 | faburem:accounts-anonymous@0.5.2 36 | faburem:client-server-logger@0.0.5 37 | facts-base@1.0.2 38 | fetch@0.1.6 39 | geojson-utils@1.0.12 40 | hot-code-push@1.0.5 41 | html-tools@2.0.0 42 | htmljs@2.0.1 43 | id-map@1.2.0 44 | inter-process-messaging@0.1.2 45 | jquery@3.0.2 46 | launch-screen@2.0.1 47 | leonardoventurini:scss@2.0.2 48 | localstorage@1.2.1 49 | logging@1.3.6 50 | mdg:validated-method@1.3.0 51 | meteor@2.1.0 52 | meteor-base@1.5.2 53 | minifier-css@2.0.1 54 | minifier-js@3.0.1 55 | minimongo@2.0.2 56 | mobile-experience@1.1.2 57 | mobile-status-bar@1.1.1 58 | modern-browsers@0.2.1 59 | modules@0.20.3 60 | modules-runtime@0.13.2 61 | mongo@2.1.1 62 | mongo-decimal@0.2.0 63 | mongo-dev-server@1.1.1 64 | mongo-id@1.0.9 65 | npm-mongo@6.10.2 66 | oauth@3.0.2 67 | oauth2@1.3.3 68 | observe-sequence@2.0.0 69 | ordered-dict@1.2.0 70 | ostrio:flow-router-extra@3.12.0 71 | ostrio:logger@2.1.1 72 | ostrio:loggerconsole@2.1.0 73 | promise@1.0.0 74 | random@1.2.2 75 | rate-limit@1.1.2 76 | react-fast-refresh@0.2.9 77 | reactive-dict@1.3.2 78 | reactive-var@1.0.13 79 | reload@1.3.2 80 | retry@1.1.1 81 | routepolicy@1.1.2 82 | service-configuration@1.3.5 83 | sha@1.0.10 84 | shell-server@0.6.1 85 | socket-stream-client@0.6.0 86 | spacebars@2.0.0 87 | spacebars-compiler@2.0.0 88 | standard-minifier-css@1.9.3 89 | standard-minifier-js@3.0.0 90 | templating@1.4.4 91 | templating-compiler@2.0.0 92 | templating-runtime@2.0.1 93 | templating-tools@2.0.0 94 | tracker@1.3.4 95 | typescript@5.6.3 96 | url@1.3.5 97 | webapp@2.0.6 98 | webapp-hashing@1.1.2 99 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22.14 2 | ENV METEOR_ALLOW_SUPERUSER true 3 | RUN curl https://install.meteor.com/\?release\=3.0 | sh 4 | RUN meteor --version 5 | WORKDIR /app/ 6 | COPY package.json . 7 | COPY package-lock.json . 8 | RUN meteor npm ci 9 | COPY public/ ./public/ 10 | COPY server/ ./server/ 11 | COPY client/ ./client/ 12 | COPY imports/ ./imports/ 13 | COPY .meteor/ ./.meteor/ 14 | ENV DISABLE_CLIENT_STATS true 15 | ENV METEOR_DISABLE_OPTIMISTIC_CACHING 1 16 | RUN meteor build /build/ --server-only --architecture os.linux.x86_64 17 | 18 | FROM node:22.14-alpine 19 | RUN apk --no-cache add \ 20 | bash \ 21 | curl \ 22 | g++ \ 23 | make \ 24 | python3 25 | COPY --from=0 /build/*.tar.gz /app/bundle.tar.gz 26 | WORKDIR /app/ 27 | RUN tar xvzf bundle.tar.gz 28 | RUN cd /app/bundle/programs/server; npm ci --silent --prefer-offline --no-audit; npm prune --production; 29 | RUN curl -sfL https://gobinaries.com/tj/node-prune -o /tmp/node-prune.sh 30 | RUN chmod +x /tmp/node-prune.sh 31 | RUN /tmp/node-prune.sh 32 | RUN rm -rf /app/bundle/programs/server/npm/node_modules/meteor/babel-compiler/node_modules/typescript 33 | RUN rm -rf /app/bundle/programs/server/npm/node_modules/meteor/babel-compiler/node_modules/@babel 34 | RUN rm -rf /app/bundle/programs/server/npm/node_modules/@neovici/nullxlsx/cc-test-reporter 35 | RUN rm -rf /app/bundle/programs/server/npm/node_modules/moment/locale 36 | RUN rm -rf /app/bundle/programs/server/npm/node_modules/moment/dist/locale 37 | RUN rm -rf /app/bundle/programs/server/npm/node_modules/moment/src/locale 38 | RUN find /app/bundle/programs/server/npm/node_modules/astronomia/data/ -type f -not -name "deltat.js" -or -name "vsop87Bearth.js" -delete 39 | RUN find /app/bundle/programs/server/npm/node_modules/astronomia/lib/data/ -type f -not -name "deltat.js" -or -name "vsop87Bearth.js" -delete 40 | 41 | FROM node:22.14-alpine 42 | RUN apk --no-cache add \ 43 | bash \ 44 | ca-certificates 45 | ENV PORT 3000 46 | EXPOSE 3000 47 | WORKDIR /app/ 48 | COPY --from=1 app/bundle bundle 49 | COPY entrypoint.sh /docker/entrypoint.sh 50 | ENTRYPOINT ["/docker/entrypoint.sh"] 51 | CMD ["node", "bundle/main.js"] 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Docker build](https://github.com/kromitgmbh/titra/actions/workflows/push.yml/badge.svg)](https://github.com/kromitgmbh/titra/actions/workflows/push.yml) ![Docker Pulls](https://badgen.net/docker/pulls/kromit/titra) ![Latest Release](https://img.shields.io/github/v/release/kromitgmbh/titra.svg) 2 | 3 | 4 | # ![titra logo](public/favicons/favicon-32x32.png) titra 5 | Modern open source project time tracking for freelancers and small teams 6 | 7 | We believe in the philosophy ["Do One Thing And Do It Well."](https://en.wikipedia.org/wiki/Unix_philosophy#Do_One_Thing_and_Do_It_Well) and try to follow it in the design and implementation of titra. A great companion for titra is [Wekan](https://wekan.github.io/), where you can plan your tasks and track your time against later on. 8 | 9 | ## ⏱️ No risk, no fun, just time tracking 10 | According to the philosophy described above, titra has been built to be the easiest, most convenient and modern way to track your time spent on projects. We want you to get started tracking your time as fast and with the least distractions as possible. After tracking your time, the second most important aspect is the ability to report and export your tracked time efficiently. 11 | 12 | ## 🚀 Blazing fast 13 | Track your important project tasks in less than 10 seconds from login to done so you an focus on more important things. 14 | 15 | ![titra_track_time](https://github.com/kromitgmbh/titra/assets/11456790/c22d850e-d9de-4452-b9e0-a029d35acd89) 16 | 17 | This is possible because we care a lot about performance and data sent over the wire, but you don't have to trust us on this one - just run a lighthouse audit to confirm the [performance score](https://github.com/kromitgmbh/titra/assets/11456790/84f26959-0000-40d4-a85c-4e968b1237f2) of 💯 18 | 19 | ## 👀 Try it! 20 | We are providing a hosted version of titra free of charge at [app.titra.io](https://app.titra.io). Create an account in seconds and start tracking your time! 21 | 22 | There is no better time to get started, titra just got a dark mode 🌑 and it is 🔥! 23 | 24 | ## 🐳 Running with Docker Compose 25 | Here is a one-line example on how to get started with titra locally if you have [docker-compose](https://docs.docker.com/compose/) installed: 26 | ``` 27 | curl -L https://github.com/kromitgmbh/titra/blob/master/docker-compose.yml | ROOT_URL=http://localhost:3000 docker-compose -f - up 28 | ``` 29 | 30 | This will pull in the latest titra release and spin up a local Mongodb instance in the latest version supported and link them together. 31 | Congratulations! titra should now be up and running at http://localhost:3000 32 | 33 | ## 🚚 Deploy on DigitalOcean 34 | titra is available in the [DigitalOcean Marketplace](https://marketplace.digitalocean.com/apps/titra?refcode=bc1d2516c8d2) for easy 1-click deployment of droplets. Get started below: 35 | 36 | [![do-btn-blue](https://user-images.githubusercontent.com/11456790/74553033-c9399f80-4f56-11ea-9f9f-6f1ac4af50ce.png)](https://marketplace.digitalocean.com/apps/titra?refcode=bc1d2516c8d2&action=deploy) 37 | 38 | 39 | ## 📚 Documentation and more 40 | Checkout our [wiki](https://wiki.titra.io) for best practices and to learn how to setup interfaces with external tools like Wekan. 41 | 42 | 43 | 44 | Built with ❤️ in 🇦🇹 45 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # titra Open Source Security Policies and Procedures 2 | 3 | This document outlines security procedures and general policies for the 4 | titra Open Source project as found on https://github.com/kromitgmbh/titra. 5 | 6 | * [Reporting a Vulnerability](#reporting-a-vulnerability) 7 | * [Disclosure Policy](#disclosure-policy) 8 | 9 | ## Reporting a Vulnerability 10 | 11 | The titra OSS team and community take all security vulnerabilities 12 | seriously. Thank you for improving the security of our open source 13 | software. We appreciate your efforts and responsible disclosure and will 14 | make every effort to acknowledge your contributions. 15 | 16 | Report security vulnerabilities by emailing the Atomist security team at: 17 | 18 | security@titra.io 19 | 20 | The lead maintainer will acknowledge your email within 24 hours, and will 21 | send a more detailed response within 48 hours indicating the next steps in 22 | handling your report. After the initial reply to your report, the security 23 | team will endeavor to keep you informed of the progress towards a fix and 24 | full announcement, and may ask for additional information or guidance. 25 | 26 | Report security vulnerabilities in third-party modules to the person or 27 | team maintaining the module. 28 | 29 | ## Disclosure Policy 30 | 31 | When the security team receives a security bug report, they will assign it 32 | to a primary handler. This person will coordinate the fix and release 33 | process, involving the following steps: 34 | 35 | * Confirm the problem and determine the affected versions. 36 | * Audit code to find any potential similar problems. 37 | * Prepare fixes for all releases still under maintenance. These fixes 38 | will be released as fast as possible to docker hub. 39 | -------------------------------------------------------------------------------- /client/head.html: -------------------------------------------------------------------------------- 1 | 2 | titra - modern open source time tracking 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 21 | 22 | -------------------------------------------------------------------------------- /client/main.js: -------------------------------------------------------------------------------- 1 | import '../imports/startup/client' 2 | -------------------------------------------------------------------------------- /docker-compose-auto-update.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | titra: 4 | image: kromit/titra 5 | container_name: titra_app 6 | depends_on: 7 | - mongodb 8 | environment: 9 | - ROOT_URL=${ROOT_URL} 10 | - MONGO_URL=mongodb://mongodb/titra?directConnection=true 11 | - PORT=3000 12 | ports: 13 | - "3000:3000" 14 | labels: 15 | - "com.centurylinklabs.watchtower.enable=true" 16 | restart: always 17 | mongodb: 18 | image: mongo:7.0 19 | container_name: titra_db 20 | restart: always 21 | environment: 22 | - MONGO_DB:titra 23 | volumes: 24 | - titra_db_volume:/data/db 25 | watchtower: 26 | image: containrrr/watchtower 27 | container_name: watchtower 28 | restart: always 29 | command: --cleanup --schedule "0 0 0 * * *" --label-enable 30 | volumes: 31 | - /var/run/docker.sock:/var/run/docker.sock 32 | volumes: 33 | titra_db_volume: 34 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | titra: 4 | image: kromit/titra 5 | container_name: titra_app 6 | depends_on: 7 | - mongodb 8 | environment: 9 | - ROOT_URL=${ROOT_URL} 10 | - MONGO_URL=mongodb://mongodb/titra?directConnection=true 11 | - PORT=3000 12 | ports: 13 | - "3000:3000" 14 | restart: always 15 | mongodb: 16 | image: mongo:7.0 17 | container_name: titra_db 18 | restart: always 19 | environment: 20 | - MONGO_DB:titra 21 | volumes: 22 | - titra_db_volume:/data/db 23 | volumes: 24 | titra_db_volume: 25 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [ -n "${MONGO_URL:-}" ]; then # Check for MongoDB connection if MONGO_URL is set 3 | # Poll until we can successfully connect to MongoDB 4 | echo 'Connecting to MongoDB...' 5 | cd bundle/programs/server/npm/node_modules/meteor/npm-mongo/node_modules 6 | node <<- 'EOJS' 7 | const mongoClient = require('mongodb').MongoClient; 8 | setInterval(async function() { 9 | let client; 10 | try { 11 | client = await mongoClient.connect(process.env.MONGO_URL); 12 | } catch (err) { 13 | console.error(err); 14 | } 15 | if (client && client.topology.isConnected()) { 16 | console.log('Successfully connected to MongoDB'); 17 | client.close(); 18 | process.exit(0); 19 | } 20 | }, 1000); 21 | EOJS 22 | fi 23 | cd /app 24 | echo 'Starting titra...' 25 | exec "$@" -------------------------------------------------------------------------------- /imports/api/customers/customers.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo' 2 | 3 | const Customers = new Mongo.Collection('customers') 4 | 5 | export default Customers 6 | -------------------------------------------------------------------------------- /imports/api/customers/server/methods.js: -------------------------------------------------------------------------------- 1 | import { ValidatedMethod } from 'meteor/mdg:validated-method' 2 | import Projects from '../../projects/projects.js' 3 | import { authenticationMixin } from '../../../utils/server_method_helpers.js' 4 | 5 | /** 6 | * A ValidatedMethod that retrieves all customers from the Projects collection. 7 | * 8 | * @typedef {Object} ValidatedMethod 9 | * @property {string} name - The name of the method. 10 | * @property {function} validate - The validation function for the method. 11 | * @property {Array} mixins - An array of mixins to be applied to the method. 12 | * @property {function} run - The function to be executed when the method is called. 13 | * 14 | * @function getAllCustomers 15 | * @returns {Promise} - A promise that resolves to an array of customer objects. 16 | */ 17 | const getAllCustomers = new ValidatedMethod({ 18 | name: 'getAllCustomers', 19 | validate: null, 20 | mixins: [authenticationMixin], 21 | async run() { 22 | return Projects.rawCollection().aggregate([{ $match: { $or: [{ userId: this.userId }, { public: true }, { team: this.userId }] } }, { $group: { _id: '$customer' } }]).toArray() 23 | }, 24 | }) 25 | 26 | export { getAllCustomers } 27 | -------------------------------------------------------------------------------- /imports/api/customers/server/publications.js: -------------------------------------------------------------------------------- 1 | import { Match } from 'meteor/check' 2 | import Projects from '../../projects/projects.js' 3 | import { checkAuthentication } from '../../../utils/server_method_helpers.js' 4 | 5 | /** 6 | @function projectCustomers 7 | @param {Object} options - An object containing the projectId or an array of projectIds. 8 | @param {string|Array} options.projectId - The id of the project(s) to retrieve customers for. 9 | @throws {Error} If projectId is not a string or array, or if the user is not authenticated. 10 | @returns {void} - This publication returns 'customers' to the client for the provided project id. 11 | */ 12 | Meteor.publish('projectCustomers', async function projectCustomers({ projectId }) { 13 | check(projectId, Match.OneOf(String, Array)) 14 | await checkAuthentication(this) 15 | const customers = [] 16 | if (projectId.includes('all')) { 17 | await Projects.find( 18 | { $or: [{ userId: this.userId }, { public: true }, { team: this.userId }] }, 19 | { _id: 1 }, 20 | ).forEachAsync((item) => { 21 | if (item.customer && !customers.includes(item.customer)) { 22 | this.added('customers', item.customer, { name: item.customer }) 23 | customers.push(item.customer) 24 | } 25 | }) 26 | } else if (projectId instanceof Array) { 27 | await Projects.find( 28 | { 29 | _id: { _in: projectId }, 30 | $or: [{ userId: this.userId }, { public: true }, { team: this.userId }], 31 | }, 32 | { _id: 1 }, 33 | ).forEachAsync((item) => { 34 | if (item.customer && !customers.includes(item.customer)) { 35 | this.added('customers', item.customer, { name: item.customer }) 36 | customers.push(item.customer) 37 | } 38 | }) 39 | } else { 40 | const project = await Projects.findOneAsync({ _id: projectId }) 41 | if (project.customer && !customers.includes(project.customer)) { 42 | this.added('customers', project.customer, { name: project.customer }) 43 | customers.push(project.customer) 44 | } 45 | } 46 | this.ready() 47 | }) 48 | -------------------------------------------------------------------------------- /imports/api/customfields/customfields.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo' 2 | 3 | const CustomFields = new Mongo.Collection('customfields') 4 | 5 | export default CustomFields 6 | -------------------------------------------------------------------------------- /imports/api/customfields/server/publications.js: -------------------------------------------------------------------------------- 1 | import { check } from 'meteor/check' 2 | import CustomFields from '../customfields.js' 3 | /** 4 | * Publishes the custom fields. 5 | * @returns {Mongo.Cursor} The custom fields. 6 | */ 7 | Meteor.publish('customfields', () => CustomFields.find({})) 8 | /** 9 | * Publishes the custom fields for a class. 10 | * @param {String} classname The class name. 11 | * @returns {Mongo.Cursor} The custom fields for the class. 12 | */ 13 | Meteor.publish('customfieldsForClass', ({ classname }) => { 14 | check(classname, String) 15 | return CustomFields.find({ classname }) 16 | }) 17 | -------------------------------------------------------------------------------- /imports/api/dashboards/dashboards.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo' 2 | 3 | const Dashboards = new Mongo.Collection('dashboards') 4 | const dashboardAggregation = new Mongo.Collection('dashboardAggregation') 5 | 6 | export { dashboardAggregation, Dashboards } 7 | -------------------------------------------------------------------------------- /imports/api/dashboards/server/methods.js: -------------------------------------------------------------------------------- 1 | import { ValidatedMethod } from 'meteor/mdg:validated-method' 2 | import { Dashboards } from '../dashboards' 3 | import { periodToDates } from '../../../utils/periodHelpers.js' 4 | import { authenticationMixin, transactionLogMixin, getGlobalSettingAsync } from '../../../utils/server_method_helpers.js' 5 | 6 | /** 7 | * Adds a dashboard. 8 | * 9 | * @param {Object} args - The arguments to use when adding the dashboard. 10 | * @param {string} args.projectId - The ID of the project to associate with the dashboard. 11 | * @param {string} args.resourceId - The ID of the resource to associate with the dashboard. 12 | * @param {string} args.customer - The customer to associate with the dashboard. 13 | * @param {string} args.timePeriod - The time period to associate with the dashboard. 14 | * 15 | * @return {Promise} - A promise that resolves to the ID of the added dashboard. 16 | */ 17 | const addDashboard = new ValidatedMethod({ 18 | name: 'addDashboard', 19 | validate(args) { 20 | check(args, { 21 | projectId: String, 22 | resourceId: String, 23 | customer: String, 24 | timePeriod: String, 25 | }) 26 | }, 27 | mixins: [authenticationMixin, transactionLogMixin], 28 | async run({ 29 | projectId, resourceId, customer, timePeriod, 30 | }) { 31 | const { startDate, endDate } = await periodToDates(timePeriod) 32 | const meteorUser = await Meteor.users.findOneAsync({ _id: this.userId }) 33 | let timeunit = await getGlobalSettingAsync('timeunit') 34 | let hoursToDays = await getGlobalSettingAsync('hoursToDays') 35 | if (meteorUser.profile.timeunit) { 36 | timeunit = meteorUser.profile.timeunit 37 | } 38 | if (meteorUser.profile.hoursToDays) { 39 | hoursToDays = meteorUser.profile.hoursToDays 40 | } 41 | const _id = Random.id() 42 | await Dashboards.insertAsync({ 43 | _id, projectId, customer, startDate, endDate, resourceId, timeunit, hoursToDays, 44 | }) 45 | return _id 46 | }, 47 | }) 48 | 49 | export { addDashboard } 50 | -------------------------------------------------------------------------------- /imports/api/dashboards/server/publications.js: -------------------------------------------------------------------------------- 1 | import { Dashboards } from '../dashboards.js' 2 | import Timecards from '../../timecards/timecards' 3 | import Projects from '../../projects/projects' 4 | /** 5 | * Publishes the dashboard matching the provided ID. 6 | * @param {string} _id - The ID of the dashboard to publish. 7 | * @returns {Mongo.Cursor} The dashboard. 8 | */ 9 | Meteor.publish('dashboardById', async function dashboardById(_id) { 10 | check(_id, String) 11 | if (!await Dashboards.findOneAsync({ _id })) { 12 | return this.ready() 13 | } 14 | const dashboard = await Dashboards.findOneAsync({ _id }) 15 | if (dashboard.customer !== 'all') { 16 | let projectList = await Projects.find( 17 | { 18 | customer: dashboard.customer, 19 | }, 20 | { fields: { _id: 1 } }, 21 | ).fetchAsync() 22 | projectList = projectList.map((value) => value._id) 23 | if (dashboard.resourceId.includes('all')) { 24 | return Timecards.find({ 25 | projectId: { $in: projectList }, 26 | date: { $gte: dashboard.startDate, $lte: dashboard.endDate }, 27 | }, { sort: { date: 1 } }) 28 | } 29 | return Timecards.find({ 30 | projectId: { $in: projectList }, 31 | userId: dashboard.resourceId, 32 | date: { $gte: dashboard.startDate, $lte: dashboard.endDate }, 33 | }, { sort: { date: 1 } }) 34 | } 35 | if (dashboard.resourceId.includes('all')) { 36 | return Timecards.find({ 37 | projectId: dashboard.projectId, 38 | date: { $gte: dashboard.startDate, $lte: dashboard.endDate }, 39 | }, { sort: { date: 1 } }) 40 | } 41 | return Timecards.find({ 42 | projectId: dashboard.projectId, 43 | userId: dashboard.resourceId, 44 | date: { $gte: dashboard.startDate, $lte: dashboard.endDate }, 45 | }, { sort: { date: 1 } }) 46 | }) 47 | /** 48 | * Publishes the dashboard details matching the provided ID. 49 | * @param {string} _id - The ID of the dashboard to publish. 50 | * @returns {Mongo.Cursor} The dashboard details. 51 | */ 52 | Meteor.publish('dashboardByIdDetails', (_id) => { 53 | check(_id, String) 54 | return Dashboards.find({ _id }) 55 | }) 56 | -------------------------------------------------------------------------------- /imports/api/extensions/extensions.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo' 2 | 3 | const Extensions = new Mongo.Collection('extensions') 4 | 5 | export default Extensions 6 | -------------------------------------------------------------------------------- /imports/api/extensions/server/publications.js: -------------------------------------------------------------------------------- 1 | import Extensions from '../extensions' 2 | /** 3 | * Publishes all extensions. 4 | * @returns {Mongo.Cursor} All extensions. 5 | */ 6 | Meteor.publish('extensions', () => Extensions.find({}, { 7 | name: 1, description: 1, version: 1, state: 1, client: 1, 8 | })) 9 | -------------------------------------------------------------------------------- /imports/api/globalsettings/server/publications.js: -------------------------------------------------------------------------------- 1 | import { Globalsettings } from '../globalsettings.js' 2 | 3 | /** 4 | * Publishes the global settings. 5 | * @returns {Mongo.Cursor} The global settings. 6 | */ 7 | Meteor.publish('globalsettings', () => Globalsettings.find()) 8 | -------------------------------------------------------------------------------- /imports/api/holidays/server/methods.js: -------------------------------------------------------------------------------- 1 | import { check } from 'meteor/check' 2 | import { ValidatedMethod } from 'meteor/mdg:validated-method' 3 | import Holidays from 'date-holidays' 4 | import { authenticationMixin, getUserSettingAsync } from '../../../utils/server_method_helpers.js' 5 | 6 | const hd = new Holidays() 7 | 8 | /** 9 | * Retrieves the current holiday based on user settings. 10 | * @returns {Holidays} The current holiday object. 11 | */ 12 | async function getCurrentHoliday() { 13 | const country = await getUserSettingAsync('holidayCountry') 14 | const state = await getUserSettingAsync('holidayState') 15 | const region = await getUserSettingAsync('holidayRegion') 16 | return new Holidays(country, state, region) 17 | } 18 | 19 | /** 20 | @summary Get a list of all holidays. 21 | @return {Array} 22 | Returns a list of holidays. 23 | */ 24 | const getHolidays = new ValidatedMethod({ 25 | name: 'getHolidays', 26 | validate: null, 27 | mixins: [authenticationMixin], 28 | async run() { 29 | const h = await getCurrentHoliday() 30 | if (h) { 31 | return h.getHolidays() 32 | } 33 | return [] 34 | }, 35 | }) 36 | /** 37 | @summary Get a list of all holiday countries. 38 | @return {Array} 39 | Returns a list of holiday countries. 40 | */ 41 | const getHolidayCountries = new ValidatedMethod({ 42 | name: 'getHolidayCountries', 43 | validate: null, 44 | mixins: [authenticationMixin], 45 | async run() { 46 | return hd.getCountries() 47 | }, 48 | }) 49 | /** 50 | @summary Get a list of all holiday states. 51 | @param {Object} options 52 | @param {string} options.country - ID of the country to get list of holiday states for 53 | @return {Array} Returns a list of holiday countries. 54 | */ 55 | const getHolidayStates = new ValidatedMethod({ 56 | name: 'getHolidayStates', 57 | validate(args) { 58 | check(args, { 59 | country: String, 60 | }) 61 | }, 62 | mixins: [authenticationMixin], 63 | async run({ country }) { 64 | if (country) { 65 | return hd.getStates(country) 66 | } 67 | return false 68 | }, 69 | }) 70 | /** 71 | @summary Get a list of all holiday regions. 72 | @param {Object} options 73 | @param {string} options.country - ID of the country to get list of holiday states for 74 | @param {string} options.state - ID of the state to get list of holiday states for 75 | @return {Array} Returns a list of holiday countries. 76 | */ 77 | const getHolidayRegions = new ValidatedMethod({ 78 | name: 'getHolidayRegions', 79 | validate(args) { 80 | check(args, { 81 | country: String, 82 | state: String, 83 | }) 84 | }, 85 | mixins: [authenticationMixin], 86 | async run({ country, state }) { 87 | if (country && state) { 88 | return hd.getRegions(country, state) 89 | } 90 | return false 91 | }, 92 | }) 93 | export { 94 | getHolidayCountries, getHolidayRegions, getHolidayStates, getHolidays, 95 | } 96 | -------------------------------------------------------------------------------- /imports/api/inboundinterfaces/inboundinterfaces.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo' 2 | 3 | const InboundInterfaces = new Mongo.Collection('inboundinterfaces') 4 | 5 | export default InboundInterfaces 6 | -------------------------------------------------------------------------------- /imports/api/inboundinterfaces/server/publications.js: -------------------------------------------------------------------------------- 1 | import InboundInterfaces from '../inboundinterfaces.js' 2 | import { checkAdminAuthentication } from '../../../utils/server_method_helpers.js' 3 | 4 | /** 5 | * Publishes the inbound interfaces to the client. 6 | * Requires admin authentication. 7 | * @function getInboundInterfaces 8 | * @memberof Meteor.publish 9 | * @name 'inboundinterfaces' 10 | * @returns {Mongo.Cursor} The cursor containing the inbound interfaces. 11 | */ 12 | Meteor.publish('inboundinterfaces', async function getInboundInterfaces() { 13 | checkAdminAuthentication(this) 14 | return InboundInterfaces.find({}) 15 | }) 16 | -------------------------------------------------------------------------------- /imports/api/notifications/notifications.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo' 2 | import { Email } from 'meteor/email' 3 | import dayjs from 'dayjs' 4 | import utc from 'dayjs/plugin/utc' 5 | import { getGlobalSettingAsync } from '../../utils/server_method_helpers' 6 | 7 | const Notifications = new Mongo.Collection('notifications') 8 | const DailyMailLimit = new Mongo.Collection('dailymaillimit') 9 | async function addNotification(message, userId) { 10 | dayjs.extend(utc) 11 | const id = Random.id() 12 | const meteorUser = await Meteor.users.findOneAsync({ _id: userId, inactive: { $ne: true } }) 13 | const start = dayjs.utc().startOf('day').toDate() 14 | const end = dayjs.utc().endOf('day').toDate() 15 | const mailFrom = await getGlobalSettingAsync('fromAddress') 16 | const mailName = await getGlobalSettingAsync('fromName') 17 | let recipient = '' 18 | if (meteorUser) { 19 | recipient = meteorUser.emails[0].address 20 | await Notifications.removeAsync({ userId }) 21 | await Notifications.insertAsync({ _id: id, userId, message }) 22 | Meteor.setTimeout(async () => { 23 | await Notifications.removeAsync({ _id: id }) 24 | }, 60000) 25 | } else { 26 | recipient = userId 27 | } 28 | if (!await DailyMailLimit 29 | .findOneAsync({ email: recipient, timestamp: { $gte: start, $lte: end } })) { 30 | await Email.sendAsync({ 31 | to: recipient, 32 | from: `${mailName} <${mailFrom}>`, 33 | subject: `New notification from ${mailName}`, 34 | text: `Hey there ${meteorUser.profile.name}, 35 | 36 | I just wanted to let you know that something happened on ${mailName}: 37 | 38 | ${message} 39 | 40 | Go to ${process.env.ROOT_URL} and login to learn more! 41 | 42 | Have a nice day, 43 | ${mailName} Bot`, 44 | }).then(async () => { 45 | await DailyMailLimit.insertAsync({ email: recipient, timestamp: new Date() }) 46 | }) 47 | } 48 | } 49 | 50 | export { addNotification, Notifications as default } 51 | -------------------------------------------------------------------------------- /imports/api/notifications/server/methods.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kromitgmbh/titra/005732aa9311bfea893a8349de651271a4740d41/imports/api/notifications/server/methods.js -------------------------------------------------------------------------------- /imports/api/notifications/server/publications.js: -------------------------------------------------------------------------------- 1 | import Notifications from '../notifications.js' 2 | 3 | /** 4 | * Publishes all notifications for the current user. 5 | * @name 'mynotifications' 6 | * @returns {Mongo.Cursor} The cursor containing the notifications. 7 | 8 | */ 9 | Meteor.publish('mynotifications', function myProjects() { 10 | if (!this.userId) { 11 | return this.ready() 12 | } 13 | return Notifications.find({ 14 | userId: this.userId, 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /imports/api/outboundinterfaces/outboundinterfaces.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo' 2 | 3 | const OutboundInterfaces = new Mongo.Collection('outboundinterfaces') 4 | 5 | export default OutboundInterfaces -------------------------------------------------------------------------------- /imports/api/outboundinterfaces/server/publications.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Publishes the outbound interfaces to the client. 3 | * Requires admin authentication. 4 | * @function getOutboundInterfaces 5 | * @memberof Meteor.publish 6 | * @name 'outboundinterfaces' 7 | * @returns {Mongo.Cursor} The cursor containing the outbound interfaces. 8 | */ 9 | import OutboundInterfaces from '../outboundinterfaces.js' 10 | import { checkAdminAuthentication } from '../../../utils/server_method_helpers.js' 11 | 12 | Meteor.publish('outboundinterfaces', async function getOutboundInterfaces() { 13 | checkAdminAuthentication(this) 14 | return OutboundInterfaces.find({}) 15 | }) 16 | -------------------------------------------------------------------------------- /imports/api/projects/projects.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo' 2 | 3 | const Projects = new Mongo.Collection('projects') 4 | const ProjectStats = new Mongo.Collection('projectStats') 5 | export { ProjectStats, Projects as default } 6 | -------------------------------------------------------------------------------- /imports/api/projects/setup.js: -------------------------------------------------------------------------------- 1 | import Projects from './projects.js' 2 | 3 | export default async function initNewUser(userId, info) { 4 | if (info.profile) { 5 | if (Meteor.settings.public.sandstorm) { 6 | if (!await Projects.findOneAsync({ public: true })) { 7 | await Projects.insertAsync({ 8 | _id: 'sandstorm', 9 | userId, 10 | name: `👋 ${info.profile?.name}'s ${info.profile?.currentLanguageProject}`, 11 | desc: { ops: [{ insert: info.profile.currentLanguageProjectDesc }] }, 12 | public: true, 13 | }) 14 | } 15 | } else { 16 | await Projects.insertAsync({ 17 | userId, 18 | name: `👋 ${info.profile?.name}'s ${info.profile?.currentLanguageProject}`, 19 | desc: { ops: [{ insert: info.profile.currentLanguageProjectDesc }] }, 20 | }) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /imports/api/statistics/methods.js: -------------------------------------------------------------------------------- 1 | import { ValidatedMethod } from 'meteor/mdg:validated-method' 2 | import { MongoInternals } from 'meteor/mongo' 3 | import os from 'os' 4 | import { authenticationMixin } from '/imports/utils/server_method_helpers' 5 | 6 | /** 7 | * Retrieves statistics related to the host running the titra application. 8 | * @returns {Object} The statistics object containing information about the host running the titra application. 9 | */ 10 | const getStatistics = new ValidatedMethod({ 11 | name: 'getStatistics', 12 | validate: null, 13 | mixins: [authenticationMixin], 14 | async run() { 15 | // this is completely based on WeKans implementation 16 | // https://github.com/wekan/wekan/blob/master/server/statistics.js 17 | const pjson = require('/package.json') 18 | const statistics = {} 19 | const { isAdmin } = await Meteor.userAsync() 20 | statistics.version = pjson.version 21 | if (isAdmin) { 22 | statistics.os = { 23 | type: os.type(), 24 | platform: os.platform(), 25 | arch: os.arch(), 26 | release: os.release(), 27 | uptime: os.uptime(), 28 | loadavg: os.loadavg(), 29 | totalmem: os.totalmem(), 30 | freemem: os.freemem(), 31 | cpus: os.cpus(), 32 | } 33 | } else { 34 | statistics.os = { 35 | type: os.type(), 36 | arch: os.arch(), 37 | } 38 | } 39 | if (isAdmin) { 40 | let nodeVersion = process.version 41 | nodeVersion = nodeVersion.replace('v', '') 42 | statistics.process = { 43 | nodeVersion, 44 | pid: process.pid, 45 | uptime: process.uptime(), 46 | } 47 | // Remove beginning of Meteor release text METEOR@ 48 | let meteorVersion = Meteor.release 49 | meteorVersion = meteorVersion.replace('METEOR@', '') 50 | statistics.meteor = { 51 | meteorVersion, 52 | } 53 | // Thanks to RocketChat for MongoDB version detection ! 54 | // https://github.com/RocketChat/Rocket.Chat/blob/develop/app/utils/server/functions/getMongoInfo.js 55 | let mongoVersion 56 | let mongoStorageEngine 57 | let mongoOplogEnabled 58 | try { 59 | const { mongo } = MongoInternals.defaultRemoteCollectionDriver() 60 | const oplogEnabled = Boolean( 61 | mongo._oplogHandle && mongo._oplogHandle.onOplogEntry, 62 | ) 63 | const { version, storageEngine } = await mongo.db.command({ serverStatus: 1 }) 64 | mongoVersion = version 65 | mongoStorageEngine = storageEngine.name 66 | mongoOplogEnabled = oplogEnabled 67 | } catch (e) { 68 | try { 69 | const { mongo } = MongoInternals.defaultRemoteCollectionDriver() 70 | const { version } = await mongo.db.command({ buildinfo: 1 }) 71 | mongoVersion = version 72 | mongoStorageEngine = 'unknown' 73 | } catch (error) { 74 | mongoVersion = 'unknown' 75 | mongoStorageEngine = 'unknown' 76 | } 77 | } 78 | statistics.mongo = { 79 | mongoVersion, 80 | mongoStorageEngine, 81 | mongoOplogEnabled, 82 | } 83 | } 84 | return statistics 85 | }, 86 | }) 87 | 88 | export { getStatistics } 89 | -------------------------------------------------------------------------------- /imports/api/tasks/server/methods.js: -------------------------------------------------------------------------------- 1 | import { ValidatedMethod } from 'meteor/mdg:validated-method' 2 | import { check, Match } from 'meteor/check' 3 | import Tasks from '../tasks.js' 4 | import { authenticationMixin, transactionLogMixin } from '../../../utils/server_method_helpers.js' 5 | 6 | /** 7 | Inserts a new project task into the Tasks collection. 8 | @param {Object} args - The arguments object containing the task information. 9 | @param {string} args.projectId - The ID of the project for the task. 10 | @param {string} args.name - The name of the task. 11 | @param {Date} args.start - The start date of the task. 12 | @param {Date} args.end - The end date of the task. 13 | @param {string[]} [args.dependencies] - An array of task IDs that this task depends on. 14 | */ 15 | const insertProjectTask = new ValidatedMethod({ 16 | name: 'insertProjectTask', 17 | validate(args) { 18 | check(args, { 19 | projectId: String, 20 | name: String, 21 | start: Date, 22 | end: Date, 23 | dependencies: Match.Optional([String]), 24 | customfields: Match.Optional(Object), 25 | }) 26 | }, 27 | mixins: [authenticationMixin, transactionLogMixin], 28 | async run({ 29 | projectId, name, start, end, dependencies, customfields, 30 | }) { 31 | await Tasks.insertAsync({ 32 | projectId, 33 | name, 34 | start, 35 | end, 36 | dependencies, 37 | ...customfields, 38 | }) 39 | }, 40 | }) 41 | /** 42 | * Updates a task in the Tasks collection. 43 | * @param {Object} args - The arguments object containing the task information. 44 | * @param {string} args.taskId - The ID of the task to update. 45 | * @param {string} [args.projectId] - The ID of the project for the task. 46 | * @param {string} [args.name] - The name of the task. 47 | * @param {Date} [args.start] - The start date of the task. 48 | * @param {Date} [args.end] - The end date of the task. 49 | * @param {string[]} [args.dependencies] - An array of task IDs that this task depends on. 50 | * @throws {Meteor.Error} If user is not authenticated. 51 | * @returns {String} 'notifications.success' if successful 52 | */ 53 | const updateTask = new ValidatedMethod({ 54 | name: 'updateTask', 55 | validate(args) { 56 | check(args, { 57 | taskId: String, 58 | projectId: Match.Optional(String), 59 | name: Match.Optional(String), 60 | start: Match.Optional(Date), 61 | end: Match.Optional(Date), 62 | dependencies: Match.Optional([String]), 63 | customfields: Match.Optional(Object), 64 | }) 65 | }, 66 | mixins: [authenticationMixin, transactionLogMixin], 67 | async run({ 68 | taskId, name, start, end, dependencies, customfields, 69 | }) { 70 | await Tasks.updateAsync(taskId, { 71 | $set: { 72 | name, 73 | start, 74 | end, 75 | dependencies, 76 | ...customfields, 77 | }, 78 | }) 79 | }, 80 | }) 81 | /** 82 | * Removes a task from the Tasks collection. 83 | * @param {Object} args - The arguments object containing the task information. 84 | * @param {string} args.taskId - The ID of the task to remove. 85 | * @throws {Meteor.Error} If user is not authenticated. 86 | * @returns {String} 'notifications.success' if successful 87 | */ 88 | const removeProjectTask = new ValidatedMethod({ 89 | name: 'removeProjectTask', 90 | validate(args) { 91 | check(args, { 92 | taskId: String, 93 | }) 94 | }, 95 | mixins: [authenticationMixin, transactionLogMixin], 96 | async run({ taskId }) { 97 | await Tasks.removeAsync({ _id: taskId }) 98 | }, 99 | }) 100 | 101 | export { 102 | insertProjectTask, updateTask, removeProjectTask, 103 | } 104 | -------------------------------------------------------------------------------- /imports/api/tasks/server/publications.js: -------------------------------------------------------------------------------- 1 | import { check } from 'meteor/check' 2 | import { checkAuthentication, getGlobalSettingAsync } from '../../../utils/server_method_helpers.js' 3 | import Tasks from '../tasks.js' 4 | 5 | /** 6 | * Publishes all tasks for the current user. 7 | * @param {String} filter - The string to filter tasks by. 8 | * @param {String} projectId - The project ID to filter tasks by. 9 | * @returns {Array} - The list of tasks that match the filter and projectId. 10 | */ 11 | Meteor.publish('mytasks', async function mytasks({ filter, projectId }) { 12 | await checkAuthentication(this) 13 | const taskFilter = { 14 | $or: [{ userId: this.userId }], 15 | } 16 | if (projectId && projectId !== undefined && projectId !== '') { 17 | check(projectId, String) 18 | taskFilter.$or.push({ projectId }) 19 | } 20 | if (filter && filter !== undefined && filter !== '') { 21 | check(filter, String) 22 | taskFilter.name = { $regex: `.*${filter.replace(/[-[\]{}()*+?.,\\/^$|#\s]/g, '\\$&')}.*`, $options: 'i' } 23 | } 24 | return Tasks.find(taskFilter, { sort: { projectId: -1, lastUsed: -1 }, limit: await getGlobalSettingAsync('taskSearchNumResults') }) 25 | }) 26 | /** 27 | * Publishes all tasks for the current user. 28 | * @param {String} filter - The string to filter tasks by. 29 | * @param {Number} limit - The number of tasks to return. 30 | * @returns {Array} - The list of tasks that match the filter. 31 | */ 32 | Meteor.publish('allmytasks', async function mytasks({ filter, limit }) { 33 | check(filter, Match.Maybe(String)) 34 | check(limit, Number) 35 | await checkAuthentication(this) 36 | 37 | if (filter && filter !== undefined) { 38 | check(filter, String) 39 | return Tasks.find({ userId: this.userId, name: { $regex: `.*${filter.replace(/[-[\]{}()*+?.,\\/^$|#\s]/g, '\\$&')}.*`, $options: 'i' } }, { limit, sort: { name: 1 } }) 40 | } 41 | return Tasks.find({ userId: this.userId }, { limit, sort: { name: 1 } }) 42 | }) 43 | /** 44 | * Publishes all tasks for the provided projectId. 45 | * @param {String} projectId - The project ID to filter tasks by. 46 | * @returns {Array} - The list of tasks that match the projectId. 47 | */ 48 | Meteor.publish('projectTasks', async function projectTasks({ projectId }) { 49 | check(projectId, String) 50 | await checkAuthentication(this) 51 | return Tasks.find({ projectId }) 52 | }) 53 | -------------------------------------------------------------------------------- /imports/api/tasks/tasks.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo' 2 | 3 | const Tasks = new Mongo.Collection('tasks') 4 | const TopTasks = new Mongo.Collection('toptasks') 5 | export { TopTasks, Tasks as default } 6 | -------------------------------------------------------------------------------- /imports/api/timecards/timecards.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo' 2 | 3 | const Timecards = new Mongo.Collection('timecards') 4 | export { Timecards as default } 5 | -------------------------------------------------------------------------------- /imports/api/transactions/server/publications.js: -------------------------------------------------------------------------------- 1 | import { check, Match } from 'meteor/check' 2 | import Transactions from '../transactions.js' 3 | import { checkAdminAuthentication } from '../../../utils/server_method_helpers' 4 | 5 | /** 6 | * Publishes all transactions. 7 | * @param {Number} limit - The number of transactions to return. 8 | * @param {String} filter - The string to filter transactions by. 9 | * @returns {Array} - The list of transactions that match the filter. 10 | */ 11 | Meteor.publish('allTransactions', async function allTransactions({ limit, filter }) { 12 | check(limit, Match.Maybe(Number)) 13 | check(filter, Match.Maybe(String)) 14 | await checkAdminAuthentication(this) 15 | const selector = {} 16 | if (filter) { 17 | selector.$or = [ 18 | { user: { $regex: filter, $options: 'i' } }, 19 | { method: { $regex: filter, $options: 'i' } }, 20 | { args: { $regex: filter, $options: 'i' } }, 21 | ] 22 | } 23 | return Transactions.find(selector, { limit: limit || 25, sort: { timestamp: -1 } }) 24 | }) 25 | -------------------------------------------------------------------------------- /imports/api/transactions/transactions.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo' 2 | 3 | const Transactions = new Mongo.Collection('transactions') 4 | 5 | export default Transactions 6 | -------------------------------------------------------------------------------- /imports/api/users/users.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo' 2 | 3 | const projectUsers = new Mongo.Collection('projectUsers') 4 | const projectResources = new Mongo.Collection('projectResources') 5 | export { projectUsers, projectResources } 6 | -------------------------------------------------------------------------------- /imports/startup/client/index.js: -------------------------------------------------------------------------------- 1 | // Import to load these templates 2 | // import Popper from 'popper.js' 3 | import './useraccounts-configuration.js' 4 | import './routes.js' 5 | import '../../utils/ldap_client.js' 6 | import './startup.js' 7 | -------------------------------------------------------------------------------- /imports/startup/client/useraccounts-configuration.js: -------------------------------------------------------------------------------- 1 | import { Accounts } from 'meteor/accounts-base' 2 | 3 | Accounts.onResetPasswordLink((token, done) => { 4 | document.location.href = `/changePwd/${token}` 5 | done() 6 | }) 7 | Accounts.onLogout(() => { 8 | window.location.href = '/' 9 | }) 10 | -------------------------------------------------------------------------------- /imports/startup/server/index.js: -------------------------------------------------------------------------------- 1 | import '../../api/projects/projects.js' 2 | import '../../api/projects/server/methods.js' 3 | import '../../api/projects/server/publications.js' 4 | import '../../api/timecards/timecards.js' 5 | import '../../api/timecards/server/publications.js' 6 | import '../../api/timecards/server/methods.js' 7 | import '../../api/tasks/tasks.js' 8 | import '../../api/tasks/server/publications.js' 9 | import '../../api/tasks/server/methods.js' 10 | import '../../api/users/server/publications.js' 11 | import '../../api/users/server/methods.js' 12 | import '../../api/notifications/notifications.js' 13 | import '../../api/notifications/server/publications.js' 14 | import '../../api/notifications/server/methods.js' 15 | import './useraccounts-configuration.js' 16 | import '../../api/dashboards/dashboards.js' 17 | import '../../api/dashboards/server/publications.js' 18 | import '../../api/dashboards/server/methods.js' 19 | import '../../api/customers/server/publications.js' 20 | import '../../api/customers/server/methods.js' 21 | import '../../api/statistics/methods.js' 22 | import '../../api/globalsettings/server/publications.js' 23 | import '../../api/globalsettings/server/methods.js' 24 | import '../../api/extensions/server/methods.js' 25 | import '../../api/extensions/server/publications' 26 | import '../../api/customfields/server/methods.js' 27 | import '../../api/customfields/server/publications' 28 | import '../../api/holidays/server/methods.js' 29 | import '../../api/transactions/server/publications.js' 30 | import '../../api/inboundinterfaces/server/publications.js' 31 | import '../../api/inboundinterfaces/server/methods.js' 32 | import '../../api/outboundinterfaces/server/publications.js' 33 | import '../../api/outboundinterfaces/server/methods.js' 34 | import '../../utils/ldap.js' 35 | import './startup.js' 36 | -------------------------------------------------------------------------------- /imports/startup/server/startup.js: -------------------------------------------------------------------------------- 1 | import { AccountsAnonymous } from 'meteor/faburem:accounts-anonymous' 2 | import { BrowserPolicy } from 'meteor/browser-policy-framing' 3 | import { DDPRateLimiter } from 'meteor/ddp-rate-limiter' 4 | import { ServiceConfiguration } from 'meteor/service-configuration' 5 | import Extensions from '../../api/extensions/extensions.js' 6 | import { defaultSettings, Globalsettings } from '../../api/globalsettings/globalsettings.js' 7 | import { getGlobalSettingAsync } from '../../utils/server_method_helpers.js' 8 | 9 | Meteor.startup(async () => { 10 | AccountsAnonymous.init() 11 | for await (const setting of defaultSettings) { 12 | if (!await Globalsettings.findOneAsync({ name: setting.name })) { 13 | await Globalsettings.insertAsync(setting) 14 | } 15 | } 16 | if (Meteor.settings.disablePublic) { 17 | // eslint-disable-next-line i18next/no-literal-string 18 | await Globalsettings.updateAsync({ name: 'disablePublicProjects' }, { $set: { value: Meteor.settings.disablePublic === 'true' } }) 19 | } 20 | if (Meteor.settings.enableAnonymousLogins) { 21 | // eslint-disable-next-line i18next/no-literal-string 22 | await Globalsettings.updateAsync({ name: 'enableAnonymousLogins' }, { $set: { value: Meteor.settings.disablePublic === 'true' } }) 23 | } 24 | if (await getGlobalSettingAsync('enableOpenIDConnect')) { 25 | import('../../utils/oidc/oidc_server').then((Oidc) => { 26 | if(Accounts.oauth.serviceNames().indexOf('oidc') === -1) { 27 | Oidc.registerOidc() 28 | } 29 | }) 30 | } 31 | if (await getGlobalSettingAsync('google_clientid') && await getGlobalSettingAsync('google_secret')) { 32 | await ServiceConfiguration.configurations.upsertAsync({ 33 | service: 'googleapi', 34 | }, { 35 | $set: { 36 | clientId: await getGlobalSettingAsync('google_clientid'), 37 | secret: await getGlobalSettingAsync('google_secret'), 38 | }, 39 | }) 40 | import('../../utils/google/google_server.js').then((registerGoogleAPI) => { 41 | registerGoogleAPI.default() 42 | }) 43 | } 44 | for (const extension of await Extensions.find({}).fetchAsync()) { 45 | if (extension.isActive) { 46 | // eslint-disable-next-line no-eval 47 | eval(extension.server) 48 | } 49 | } 50 | if (await getGlobalSettingAsync('XFrameOptionsOrigin')) { 51 | BrowserPolicy.framing.restrictToOrigin(await getGlobalSettingAsync('XFrameOptionsOrigin')) 52 | } 53 | if (process.env.NODE_ENV !== 'development') { 54 | // eslint-disable-next-line no-console 55 | console.log(`titra started on port ${process.env.PORT}`) 56 | } 57 | 58 | // Rate limiting all methods and subscriptions, defaulting to 100 calls per second 59 | for (const subscription in Meteor.server.publish_handlers) { 60 | if ({}.hasOwnProperty.call(Meteor.server.publish_handlers, subscription)) { 61 | DDPRateLimiter.addRule({ 62 | type: 'subscription', 63 | name: subscription, 64 | }, 100, 1000) 65 | } 66 | } 67 | for (const method in Meteor.server.method_handlers) { 68 | if ({}.hasOwnProperty.call(Meteor.server.method_handlers, method)) { 69 | DDPRateLimiter.addRule({ 70 | type: 'method', 71 | name: method, 72 | }, 100, 1000) 73 | } 74 | } 75 | }) 76 | -------------------------------------------------------------------------------- /imports/startup/server/useraccounts-configuration.js: -------------------------------------------------------------------------------- 1 | import { Accounts } from 'meteor/accounts-base' 2 | import dockerNames from 'docker-names' 3 | import { getGlobalSettingAsync } from '../../utils/server_method_helpers' 4 | import initNewUser from '../../api/projects/setup.js' 5 | 6 | Accounts.setAdditionalFindUserOnExternalLogin(({ serviceName, serviceData }) => { 7 | if (serviceName === 'oidc') { 8 | return Accounts.findUserByEmail(serviceData.email) 9 | } 10 | return undefined 11 | }) 12 | Accounts.validateLoginAttempt((attempt) => !attempt.user?.inactive) 13 | Accounts.onCreateUser(async (options, user) => { 14 | if (options.anonymous) { 15 | options.profile = { 16 | name: dockerNames.getRandomName(), 17 | avatarColor: `#${(`000000${Math.floor(0x1000000 * Math.random()).toString(16)}`).slice(-6)}`, 18 | } 19 | } 20 | if (!options.profile?.currentLanguageProject) { 21 | if (options.profile) { 22 | options.profile.currentLanguageProject = 'Projekt' 23 | options.profile.currentLanguageProjectDesc = 'Dieses Projekt wurde automatisch erstellt, Sie können es nach Belieben bearbeiten. Wussten Sie, dass Sie Emojis wie 💰 ⏱ 👍 überall verwenden können?' 24 | } 25 | } 26 | 27 | await initNewUser(user._id, options) 28 | 29 | const localUser = user 30 | if (options.profile) { 31 | localUser.profile = options.profile 32 | delete localUser.profile.currentLanguageProject 33 | delete localUser.profile.currentLanguageProjectDesc 34 | } 35 | 36 | if (!localUser.emails && options.emails) { 37 | localUser.emails = options.emails 38 | } 39 | 40 | // the first user registered on a server will automatically receive the isAdmin flag 41 | if (localUser && await Meteor.users.find().countAsync() === 0) { 42 | localUser.isAdmin = true 43 | } 44 | return localUser 45 | }) 46 | const fromName = await getGlobalSettingAsync('fromName') 47 | const fromAddress = await getGlobalSettingAsync('fromAddress') 48 | Accounts.emailTemplates.from = `${fromName} <${fromAddress}>` 49 | Accounts.emailTemplates.enrollAccount.subject = (user) => `Welcome to Awesome Town, ${user.profile.name}` 50 | Accounts.emailTemplates.resetPassword.from = () => `${fromName} Password Reset <${fromAddress}>` 51 | -------------------------------------------------------------------------------- /imports/ui/layouts/appLayout.html: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /imports/ui/layouts/appLayout.js: -------------------------------------------------------------------------------- 1 | import { FlowRouter } from 'meteor/ostrio:flow-router-extra' 2 | import { Template } from 'meteor/templating' 3 | import Notifications from '../../api/notifications/notifications.js' 4 | import { showToast } from '../../utils/frontend_helpers.js' 5 | import './appLayout.html' 6 | import '../shared components/navbar.js' 7 | import '../shared components/connectioncheck.js' 8 | import '../shared components/toast.html' 9 | 10 | Template.appLayout.events({ 11 | 'click #logout': (event) => { 12 | event.preventDefault() 13 | Meteor.logout() 14 | FlowRouter.go('signin') 15 | }, 16 | }) 17 | 18 | Template.appLayout.onRendered(function appLayoutRendered() { 19 | this.subscribe('mynotifications') 20 | this.autorun(() => { 21 | if (this.subscriptionsReady() 22 | && Meteor.userId() && Notifications.findOne({ userId: Meteor.userId() })) { 23 | showToast(Notifications.findOne().message) 24 | } 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /imports/ui/pages/404.html: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /imports/ui/pages/about.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /imports/ui/pages/about.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import { t } from '../../utils/i18n.js' 3 | import { emojify, getGlobalSetting } from '../../utils/frontend_helpers' 4 | import './about.html' 5 | 6 | Template.about.onCreated(function aboutCreated() { 7 | this.statistics = new ReactiveVar() 8 | Meteor.call('getStatistics', (error, result) => { 9 | if (!error) { 10 | this.statistics.set(result) 11 | } else { 12 | console.error(error) 13 | } 14 | }) 15 | }) 16 | Template.about.events({ 17 | 'click #retrieveChangeLog': (event, templateInstance) => { 18 | event.preventDefault() 19 | if (!templateInstance.$('#changelog').hasClass('show')) { 20 | $.getJSON('https://api.github.com/repos/kromitgmbh/titra/tags', (data) => { 21 | const tag = data[2] 22 | $.getJSON(tag.commit.url, async (commitData) => { 23 | templateInstance.$('#titra-changelog').html(`Version ${tag.name} (${dayjs(commitData.commit.committer.date).format(getGlobalSetting('dateformat'))}) :
${await emojify(commitData.commit.message)}`) 24 | }) 25 | }).fail(() => { 26 | templateInstance.$('#titra-changelog').html(t('settings.titra_changelog_error')) 27 | }) 28 | } 29 | }, 30 | }) 31 | Template.about.helpers({ 32 | statistics() { 33 | return Template.instance().statistics.get() 34 | }, 35 | bytesToSize(bytes) { 36 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] 37 | if (bytes === 0) { 38 | return '0 Byte' 39 | } 40 | const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)), 10) 41 | return `${Math.round(bytes / Math.pow(1024, i), 2)} ${sizes[i]}` 42 | }, 43 | humanReadableTime(time) { 44 | const days = Math.floor(time / 86400) 45 | const hours = Math.floor((time % 86400) / 3600) 46 | const minutes = Math.floor(((time % 86400) % 3600) / 60) 47 | // const seconds = Math.floor(((time % 86400) % 3600) % 60) 48 | let out = '' 49 | if (days > 0) { 50 | out += `${days} ${t('globals.day_plural')}, ` 51 | } 52 | if (hours > 0) { 53 | out += `${hours} ${t('globals.hour_plural')}, ` 54 | } 55 | if (minutes > 0) { 56 | out += `${minutes} ${t('globals.minute_plural')} ` 57 | } 58 | return out 59 | }, 60 | }) 61 | -------------------------------------------------------------------------------- /imports/ui/pages/administration/administration.js: -------------------------------------------------------------------------------- 1 | import bootstrap from 'bootstrap' 2 | import { FlowRouter } from 'meteor/ostrio:flow-router-extra' 3 | import './administration.html' 4 | import '../details/components/limitpicker.js' 5 | import './components/globalsettingscomponent.js' 6 | import './components/customfieldscomponent.js' 7 | import './components/userscomponent.js' 8 | import './components/extensionscomponent.js' 9 | import './components/oidccomponent.js' 10 | import './components/transactionscomponent.js' 11 | import './components/inboundinterfacescomponent.js' 12 | import './components/outboundinterfacescomponent.js' 13 | 14 | Template.administration.onCreated(function administrationCreated() { 15 | this.activeTab = new ReactiveVar() 16 | this.subscribe('userRoles') 17 | this.autorun(() => { 18 | $(`#${this.activeTab.get()}`).tab('show') 19 | }) 20 | }) 21 | Template.administration.onRendered(() => { 22 | const templateInstance = Template.instance() 23 | templateInstance.autorun(() => { 24 | if (templateInstance.subscriptionsReady()) { 25 | if (!Meteor.loggingIn() && !Meteor.user()?.isAdmin) { 26 | FlowRouter.go('/') 27 | } 28 | if (FlowRouter.getQueryParam('activeTab')) { 29 | templateInstance.activeTab.set(FlowRouter.getQueryParam('activeTab')) 30 | } else { 31 | templateInstance.activeTab.set('globalsettings-tab') 32 | } 33 | } 34 | }) 35 | }) 36 | Template.administration.helpers({ 37 | isActive(tab) { 38 | return Template.instance().activeTab.get() === tab 39 | }, 40 | }) 41 | Template.administration.events({ 42 | 'click .accordion-button': (event) => { 43 | event.preventDefault() 44 | bootstrap.Collapse 45 | .getOrCreateInstance(event.currentTarget.parentNode.nextElementSibling).toggle() 46 | }, 47 | 'click .nav-link[data-bs-toggle]': (event, templateInstance) => { 48 | FlowRouter.setQueryParams({ activeTab: templateInstance.$(event.currentTarget)[0].id }) 49 | }, 50 | }) 51 | -------------------------------------------------------------------------------- /imports/ui/pages/administration/components/extensionscomponent.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /imports/ui/pages/administration/components/extensionscomponent.js: -------------------------------------------------------------------------------- 1 | import './extensionscomponent.html' 2 | import Extensions from '../../../../api/extensions/extensions' 3 | import { showToast } from '../../../../utils/frontend_helpers' 4 | import { t } from '../../../../utils/i18n' 5 | 6 | Template.extensionscomponent.onCreated(function extensionscomponentCreated() { 7 | this.subscribe('extensions') 8 | }) 9 | Template.extensionscomponent.helpers({ 10 | extensions: () => (Extensions.find({}).fetch().length > 0 ? Extensions.find({}) : false), 11 | }) 12 | Template.extensionscomponent.events({ 13 | 'change #extensionFile': (event) => { 14 | event.preventDefault() 15 | const file = event.currentTarget.files[0] 16 | const reader = new FileReader() 17 | if (file && reader) { 18 | reader.readAsDataURL(file) 19 | reader.onload = () => { 20 | const zipFile = reader.result 21 | Meteor.call('addExtension', { zipFile }, (error, result) => { 22 | if (error) { 23 | console.error(error) 24 | } else { 25 | showToast(t(result)) 26 | } 27 | }) 28 | } 29 | } 30 | }, 31 | 'click .js-remove-extension': (event, templateInstance) => { 32 | event.preventDefault() 33 | Meteor.call('removeExtension', { extensionId: templateInstance.$(event.currentTarget).data('extension-id') }, (error) => { 34 | if (error) { 35 | console.error(error) 36 | } else { 37 | showToast(t('administration.extension_removed')) 38 | } 39 | }) 40 | }, 41 | 'click .js-launch-extension': (event, templateInstance) => { 42 | event.preventDefault() 43 | Meteor.call('launchExtension', { extensionId: templateInstance.$(event.currentTarget).data('extension-id') }, (error, result) => { 44 | if (error) { 45 | console.error(error) 46 | } else { 47 | showToast(t(result)) 48 | } 49 | }) 50 | }, 51 | 'change .js-extension-state': (event, templateInstance) => { 52 | event.preventDefault() 53 | Meteor.call('toggleExtensionState', { extensionId: templateInstance.$(event.currentTarget).data('extension-id'), state: templateInstance.$(event.currentTarget).is(':checked') }, (error) => { 54 | if (error) { 55 | console.error(error) 56 | } else { 57 | showToast(t('notifications.success')) 58 | } 59 | }) 60 | }, 61 | }) 62 | -------------------------------------------------------------------------------- /imports/ui/pages/administration/components/inboundinterfacescomponent.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /imports/ui/pages/administration/components/inboundinterfacescomponent.js: -------------------------------------------------------------------------------- 1 | import './inboundinterfacescomponent.html' 2 | import InboundInterfaces from '../../../../api/inboundinterfaces/inboundinterfaces' 3 | import { showToast } from '../../../../utils/frontend_helpers' 4 | 5 | Template.inboundinterfacescomponent.onCreated(function inboundinterfacesCreated() { 6 | this.subscribe('inboundinterfaces') 7 | }) 8 | Template.inboundinterfacescomponent.helpers({ 9 | inboundInterfaces() { 10 | return InboundInterfaces.find() 11 | }, 12 | }) 13 | Template.inboundinterfacescomponent.events({ 14 | 'click .js-add-inbound-interface': (event, templateInstance) => { 15 | event.preventDefault() 16 | Meteor.call('inboundinterfaces.insert', { 17 | name: templateInstance.$('#name').val(), description: templateInstance.$('#description').val(), processData: templateInstance.$('#processData').val(), active: templateInstance.$('#isActive').is(':checked'), 18 | }, (error, result) => { 19 | if (error) { 20 | console.error(error) 21 | } else { 22 | showToast(result) 23 | templateInstance.$('#_id').val('') 24 | templateInstance.$('#name').val('') 25 | templateInstance.$('#description').val('') 26 | templateInstance.$('#processData').val('') 27 | templateInstance.$('#isActive').val(false) 28 | } 29 | }) 30 | }, 31 | 'click .js-update-inbound-interface': (event, templateInstance) => { 32 | event.preventDefault() 33 | Meteor.call('inboundinterfaces.update', { 34 | _id: templateInstance.$('#_id').val(), name: templateInstance.$('#name').val(), description: templateInstance.$('#description').val(), processData: templateInstance.$('#processData').val(), active: templateInstance.$('#isActive').is(':checked'), 35 | }, (error, result) => { 36 | if (error) { 37 | console.error(error) 38 | } else { 39 | showToast(result) 40 | templateInstance.$('#_id').val('') 41 | templateInstance.$('#name').val('') 42 | templateInstance.$('#description').val('') 43 | templateInstance.$('#processData').val('') 44 | templateInstance.$('#isActive').prop('checked', false) 45 | } 46 | }) 47 | }, 48 | 'click .js-edit-inbound-interface': (event, templateInstance) => { 49 | event.preventDefault() 50 | const inboundInterface = InboundInterfaces.findOne({ _id: templateInstance.$(event.currentTarget).data('interface-id') }) 51 | templateInstance.$('#_id').val(inboundInterface._id) 52 | templateInstance.$('#name').val(inboundInterface.name) 53 | templateInstance.$('#description').val(inboundInterface.description) 54 | templateInstance.$('#processData').val(inboundInterface.processData) 55 | templateInstance.$('#isActive').prop('checked', inboundInterface.active) 56 | templateInstance.$('.js-update-inbound-interface').removeClass('d-none') 57 | templateInstance.$('.js-add-inbound-interface').addClass('d-none') 58 | }, 59 | 'click .js-remove-inbound-interface': (event, templateInstance) => { 60 | event.preventDefault() 61 | Meteor.call('inboundinterfaces.remove', { _id: templateInstance.$(event.currentTarget).data('interface-id') }, (error, result) => { 62 | if (error) { 63 | console.error(error) 64 | } else { 65 | showToast(result) 66 | } 67 | }) 68 | }, 69 | 'click .js-reset': (event, templateInstance) => { 70 | templateInstance.$('.js-update-inbound-interface').addClass('d-none') 71 | templateInstance.$('.js-add-inbound-interface').removeClass('d-none') 72 | }, 73 | }) 74 | -------------------------------------------------------------------------------- /imports/ui/pages/administration/components/oidccomponent.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /imports/ui/pages/administration/components/oidccomponent.js: -------------------------------------------------------------------------------- 1 | import './oidccomponent.html' 2 | import { oidcFields, getOidcConfiguration } from '../../../../utils/oidc/oidc_helper' 3 | import { showToast } from '../../../../utils/frontend_helpers' 4 | import { t } from '../../../../utils/i18n.js' 5 | 6 | Template.oidccomponent.helpers({ 7 | oidcSettings: () => oidcFields, 8 | oidcValue: (name) => getOidcConfiguration(name), 9 | siteUrl: () => Meteor.absoluteUrl({ replaceLocalhost: true }), 10 | isCheckbox: (setting) => setting.type === 'checkbox', 11 | isChecked: (name) => (getOidcConfiguration(name) ? 'checked' : ''), 12 | }) 13 | Template.oidccomponent.events({ 14 | 'click .js-update-oidc': (event, templateInstance) => { 15 | event.preventDefault() 16 | const configuration = { 17 | service: 'oidc', 18 | } 19 | for (const element of templateInstance.$('.js-setting-input')) { 20 | const { name } = element 21 | let value = templateInstance.$(element).val() 22 | if (element.type === 'checkbox') { 23 | value = templateInstance.$(element).is(':checked') 24 | } 25 | configuration[name] = value 26 | } 27 | configuration.idTokenWhitelistFields = configuration.idTokenWhitelistFields.split(' ') 28 | // Configure this login service 29 | Meteor.call('updateOidcSettings', { configuration }, (error) => { 30 | if (error) { 31 | // eslint-disable-next-line no-underscore-dangle 32 | Meteor._debug('Error configuring login service oidc', error) 33 | } else { 34 | showToast(t('notifications.success')) 35 | } 36 | }) 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /imports/ui/pages/administration/components/transactionscomponent.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /imports/ui/pages/administration/components/transactionscomponent.js: -------------------------------------------------------------------------------- 1 | import { FlowRouter } from 'meteor/ostrio:flow-router-extra' 2 | import dayjs from 'dayjs' 3 | import './transactionscomponent.html' 4 | import '../../details/components/limitpicker.js' 5 | import '../../../shared components/datatable.js' 6 | import { addToolTipToTableCell } from '../../../../utils/frontend_helpers.js' 7 | import { t } from '../../../../utils/i18n.js' 8 | import Transactions from '../../../../api/transactions/transactions' 9 | 10 | function addToolTipToTableCellWithLabel(value, label) { 11 | if (value) { 12 | const toolTipElement = $('').text(label) 13 | toolTipElement.addClass('js-tooltip') 14 | toolTipElement.attr('data-bs-toggle', 'tooltip') 15 | toolTipElement.attr('data-bs-placement', 'left') 16 | toolTipElement.attr('title', value) 17 | return toolTipElement.get(0).outerHTML 18 | } 19 | return '' 20 | } 21 | 22 | Template.transactionscomponent.onCreated(function transactionscomponentCreated() { 23 | this.limit = new ReactiveVar(25) 24 | this.transactions = new ReactiveVar([]) 25 | this.filter = new ReactiveVar() 26 | this.columns = new ReactiveVar([{ 27 | name: t('transactions.user'), 28 | editable: false, 29 | format: (value) => addToolTipToTableCellWithLabel(value, value ? JSON.parse(value)?.name : ''), 30 | }, { 31 | name: t('transactions.method'), 32 | editable: false, 33 | format: addToolTipToTableCell, 34 | }, { 35 | name: t('transactions.payload'), 36 | editable: false, 37 | format: addToolTipToTableCell, 38 | }, { 39 | name: t('transactions.timestamp'), 40 | editable: false, 41 | format: addToolTipToTableCell, 42 | }]) 43 | this.autorun(() => { 44 | this.subscribe('allTransactions', { limit: this.limit?.get(), filter: this.filter?.get() }) 45 | }) 46 | this.autorun(() => { 47 | if (this.subscriptionsReady()) { 48 | this.transactions 49 | .set(Transactions.find().fetch() 50 | .map((transaction) => [ 51 | transaction.user, 52 | transaction.method, 53 | transaction.args, 54 | dayjs(transaction.timestamp).format(), 55 | ])) 56 | } 57 | }) 58 | }) 59 | Template.transactionscomponent.onRendered(() => { 60 | const templateInstance = Template.instance() 61 | templateInstance.autorun(() => { 62 | if (FlowRouter.getQueryParam('limit')) { 63 | templateInstance.limit.set(Number(FlowRouter.getQueryParam('limit'))) 64 | templateInstance.$('#limitpicker').val(FlowRouter.getQueryParam('limit')) 65 | } 66 | }) 67 | }) 68 | 69 | Template.transactionscomponent.helpers({ 70 | columns: () => Template.instance()?.columns, 71 | transactions: () => Template.instance()?.transactions, 72 | }) 73 | 74 | Template.transactionscomponent.events({ 75 | 'change .js-transaction-search': (event, templateInstance) => { 76 | templateInstance.filter.set(event.target.value) 77 | }, 78 | }) 79 | -------------------------------------------------------------------------------- /imports/ui/pages/changePassword.html: -------------------------------------------------------------------------------- 1 | 54 | -------------------------------------------------------------------------------- /imports/ui/pages/details/components/dailytimetable.html: -------------------------------------------------------------------------------- 1 | 30 | -------------------------------------------------------------------------------- /imports/ui/pages/details/components/filterbar.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /imports/ui/pages/details/components/filterbar.js: -------------------------------------------------------------------------------- 1 | import { FlowRouter } from 'meteor/ostrio:flow-router-extra' 2 | import './filterbar.html' 3 | import './periodpicker.js' 4 | import './multiselectfilter.js' 5 | import Projects from '../../../../api/projects/projects.js' 6 | import { projectResources } from '../../../../api/users/users.js' 7 | import Customers from '../../../../api/customers/customers.js' 8 | 9 | Template.filterbar.onCreated(function filterbarCreated() { 10 | this.subscribe('myprojects', {}) 11 | this.autorun(() => { 12 | const projectId = FlowRouter.getParam('projectId').split(',').length > 1 ? FlowRouter.getParam('projectId').split(',') : FlowRouter.getParam('projectId') 13 | this.subscribe('projectResources', { projectId }) 14 | this.subscribe('projectCustomers', { projectId }) 15 | }) 16 | }) 17 | Template.filterbar.onRendered(() => { 18 | const templateInstance = Template.instance() 19 | templateInstance.autorun(() => { 20 | if (templateInstance.subscriptionsReady()) { 21 | if (FlowRouter.getParam('projectId')) { 22 | templateInstance.$('.js-projectselect').val(FlowRouter.getParam('projectId').split(',')) 23 | templateInstance.$('.js-projectselect').trigger('change') 24 | } 25 | if (FlowRouter.getQueryParam('resource')) { 26 | templateInstance.$('.js-resourceselect').val(FlowRouter.getQueryParam('resource')?.split(',')) 27 | } 28 | if (FlowRouter.getQueryParam('customer')) { 29 | templateInstance.$('.js-customerselect').val(FlowRouter.getQueryParam('customer')?.split(',')) 30 | } 31 | } 32 | }) 33 | }) 34 | Template.filterbar.helpers({ 35 | projects() { 36 | if (FlowRouter.getQueryParam('customer') && FlowRouter.getQueryParam('customer') !== 'all') { 37 | return Projects.find( 38 | { 39 | customer: FlowRouter.getQueryParam('customer'), 40 | $or: [{ archived: { $exists: false } }, { archived: false }], 41 | }, 42 | { sort: { priority: 1, name: 1 } }, 43 | ) 44 | } 45 | return Projects.find( 46 | { $or: [{ archived: { $exists: false } }, { archived: false }] }, 47 | { sort: { priority: 1, name: 1 } }, 48 | ) 49 | }, 50 | resources() { 51 | return projectResources.find({}, { sort: { name: 1 } }) 52 | }, 53 | customers() { 54 | return Customers.find({}, { sort: { name: 1 } }) 55 | }, 56 | }) 57 | 58 | Template.filterbar.events({ 59 | 'change .js-projectselect': (event, templateInstance) => { 60 | event.preventDefault() 61 | if ($(event.currentTarget).val().length > 0 && !templateInstance.data?.isComponent) { 62 | FlowRouter.setParams({ projectId: templateInstance.$(event.currentTarget).val().join(',') }) 63 | } 64 | }, 65 | 'change .js-resourceselect': (event, templateInstance) => { 66 | event.preventDefault() 67 | if (templateInstance.$(event.currentTarget).val().length > 0 68 | && !templateInstance.data?.isComponent) { 69 | FlowRouter.setQueryParams({ resource: templateInstance.$(event.currentTarget).val().join(',') }) 70 | } 71 | }, 72 | 'change .js-customerselect': (event, templateInstance) => { 73 | event.preventDefault() 74 | if (templateInstance.$(event.currentTarget).val().length > 0 75 | && !templateInstance.data?.isComponent) { 76 | FlowRouter.setQueryParams({ customer: templateInstance.$(event.currentTarget).val().join(',') }) 77 | } 78 | }, 79 | }) 80 | -------------------------------------------------------------------------------- /imports/ui/pages/details/components/limitpicker.html: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /imports/ui/pages/details/components/limitpicker.js: -------------------------------------------------------------------------------- 1 | import { FlowRouter } from 'meteor/ostrio:flow-router-extra' 2 | import './limitpicker.html' 3 | 4 | Template.limitpicker.onCreated(function createLimitPicker() { 5 | this.limit = new ReactiveVar(25) 6 | }) 7 | Template.limitpicker.events({ 8 | 'change #limitpicker': (event, templateInstance) => { 9 | templateInstance.limit.set(Number($(event.currentTarget).val())) 10 | FlowRouter.setQueryParams({ limit: $(event.currentTarget).val() }) 11 | }, 12 | }) 13 | Template.limitpicker.onRendered(() => { 14 | Template.instance().autorun(() => { 15 | if (FlowRouter.getQueryParam('limit')) { 16 | Template.instance().limit.set(Number(FlowRouter.getQueryParam('limit'))) 17 | Template.instance().$('#limitpicker').val(FlowRouter.getQueryParam('limit')) 18 | } else { 19 | Template.instance().limit.set(25) 20 | } 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /imports/ui/pages/details/components/multiselectfilter.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /imports/ui/pages/details/components/multiselectfilter.js: -------------------------------------------------------------------------------- 1 | import { createPopper } from '@popperjs/core' 2 | import { ModuleFactory as dashboardCodeFactory } from '@dashboardcode/bsmultiselect' 3 | import './multiselectfilter.html' 4 | 5 | Template.multiselectfilter.onRendered(() => { 6 | const templateInstance = Template.instance() 7 | const environment = { window, createPopper } 8 | templateInstance.dashboardCode = dashboardCodeFactory(environment) 9 | const setSelected = (option, value) => { 10 | templateInstance.$(option).prop('selected', value) 11 | if (templateInstance.$('.js-multiselect').val().length === 0) { 12 | templateInstance.$('option[value="all"]').prop('selected', true) 13 | } 14 | if (option.value === 'all' && value) { 15 | templateInstance.$('option:not([value="all"])').prop('selected', false) 16 | templateInstance.$('option[value="all"]').prop('selected', true) 17 | } 18 | if (option.value !== 'all' && value) { 19 | templateInstance.$('option[value="all"]').prop('selected', false) 20 | } 21 | templateInstance.$('.js-multiselect').trigger('change') 22 | return false 23 | } 24 | templateInstance.options = { 25 | cssPatch: { 26 | pick: { 27 | color: 'var(--bs-text-body)', 28 | }, 29 | }, 30 | setSelected, 31 | } 32 | templateInstance.autorun(() => { 33 | templateInstance.data.items.fetch() 34 | templateInstance.bsmultiselect?.dispose() 35 | templateInstance.bsmultiselect = templateInstance.dashboardCode.BsMultiSelect(templateInstance.$('.js-multiselect').get(0), templateInstance.options) 36 | }) 37 | }) 38 | Template.multiselectfilter.events({ 39 | 'change .js-multiselect': (event, templateInstance) => { 40 | event.preventDefault() 41 | templateInstance.bsmultiselect?.dispose() 42 | templateInstance.bsmultiselect = templateInstance.dashboardCode.BsMultiSelect(templateInstance.$('.js-multiselect').get(0), templateInstance.options) 43 | }, 44 | }) 45 | -------------------------------------------------------------------------------- /imports/ui/pages/details/components/pagination.html: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /imports/ui/pages/details/components/pagination.js: -------------------------------------------------------------------------------- 1 | import { FlowRouter } from 'meteor/ostrio:flow-router-extra' 2 | import './pagination.html' 3 | 4 | Template.pagination.onCreated(function paginationCreated() { 5 | this.currentPage = new ReactiveVar(1) 6 | this.numPages = new ReactiveVar(1) 7 | this.autorun(() => { 8 | this.numPages.set(Math.ceil(Number((this.data?.totalEntries.get() / this.data?.limit.get()))) 9 | .toFixed(0)) 10 | if (FlowRouter.getQueryParam('page')) { 11 | this.currentPage.set(FlowRouter.getQueryParam('page')) 12 | if (this.currentPage.get() > this.numPages.get()) { 13 | FlowRouter.setQueryParams({ page: null }) 14 | } 15 | } 16 | }) 17 | }) 18 | Template.pagination.helpers({ 19 | showPagination() { 20 | if (Template.instance().data.limit && Template.instance().data.totalEntries) { 21 | return Template.instance().data.limit.get() < Template.instance().data.totalEntries.get() 22 | } 23 | return false 24 | }, 25 | getPages() { 26 | const pages = [] 27 | if (Template.instance().data.limit && Template.instance().data.totalEntries) { 28 | for (let i = 0; i < Template.instance().numPages.get(); i++) { 29 | pages.push(Number(i + 1)) 30 | } 31 | } 32 | return pages 33 | }, 34 | activeClass(page) { 35 | return Number(page).toFixed(0) === Number(Template.instance().currentPage.get()).toFixed(0) ? 'active' : '' 36 | }, 37 | disabledClass(type) { 38 | if (type === 'previous' && Number(Template.instance().currentPage.get()).toFixed(0) === Number(1).toFixed(0)) { 39 | return 'disabled' 40 | } 41 | if (type === 'next' && Number(Template.instance().currentPage.get()).toFixed(0) === Template.instance().numPages.get()) { 42 | return 'disabled' 43 | } 44 | if ((Template.instance().data.limit && Template.instance().data.totalEntries)) { 45 | return Template.instance().data.limit.get() < 0 || Template.instance().data.limit.get() > Template.instance().data.totalEntries.get() ? 'disabled' : '' 46 | } 47 | return 'disabled' 48 | }, 49 | }) 50 | Template.pagination.events({ 51 | 'click .js-previous': (event) => { 52 | event.preventDefault() 53 | Template.instance().currentPage.set(Number(Template.instance().currentPage.get()) - 1) 54 | FlowRouter.setQueryParams({ page: Template.instance().currentPage.get() }) 55 | }, 56 | 'click .js-next': (event) => { 57 | event.preventDefault() 58 | Template.instance().currentPage.set(Number(Template.instance().currentPage.get()) + 1) 59 | FlowRouter.setQueryParams({ page: Template.instance().currentPage.get() }) 60 | }, 61 | 'click .js-page-number': (event) => { 62 | event.preventDefault() 63 | Template.instance().currentPage.set($(event.currentTarget).text()) 64 | FlowRouter.setQueryParams({ page: $(event.currentTarget).text() }) 65 | }, 66 | }) 67 | -------------------------------------------------------------------------------- /imports/ui/pages/details/components/periodpicker.html: -------------------------------------------------------------------------------- 1 | 44 | -------------------------------------------------------------------------------- /imports/ui/pages/details/components/periodpicker.js: -------------------------------------------------------------------------------- 1 | import { FlowRouter } from 'meteor/ostrio:flow-router-extra' 2 | import dayjs from 'dayjs' 3 | import utc from 'dayjs/plugin/utc' 4 | import bootstrap from 'bootstrap' 5 | import './periodpicker.html' 6 | import { getUserSetting } from '../../../../utils/frontend_helpers' 7 | 8 | Template.periodpicker.onCreated(function createPeriodPicker() { 9 | dayjs.extend(utc) 10 | this.period = new ReactiveVar('') 11 | }) 12 | 13 | Template.periodpicker.events({ 14 | 'change #period': (event, templateInstance) => { 15 | templateInstance.period.set($(event.currentTarget).val()) 16 | FlowRouter.setQueryParams({ period: $(event.currentTarget).val() }) 17 | if ($(event.currentTarget).val() === 'custom') { 18 | templateInstance.modal = new bootstrap.Modal(templateInstance.$('.js-custom-period-modal')[0]) 19 | templateInstance.modal.show() 20 | } 21 | $(event.currentTarget).blur() 22 | }, 23 | 'focus #period': (event) => { 24 | if ($(event.currentTarget).val() === 'custom') { 25 | event.currentTarget.selectedIndex = 0 26 | $(event.currentTarget).trigger('blur') 27 | } 28 | }, 29 | 'click .js-save': (event, templateInstance) => { 30 | event.preventDefault() 31 | const customStartDate = dayjs.utc(templateInstance.$('#customStartDate').val()).isValid() 32 | ? dayjs.utc(templateInstance.$('#customStartDate').val()).toDate() : false 33 | const customEndDate = (dayjs.utc(templateInstance.$('#customEndDate').val()).isValid() && dayjs.utc(templateInstance.$('#customEndDate').val()).isAfter(dayjs.utc(templateInstance.$('#customStartDate').val()))) 34 | ? dayjs.utc(templateInstance.$('#customEndDate').val()).toDate() : false 35 | if (customStartDate && customEndDate) { 36 | Meteor.call('setCustomPeriodDates', { 37 | customStartDate, 38 | customEndDate, 39 | }, (error) => { 40 | if (error) { 41 | console.error(error) 42 | } else { 43 | templateInstance.$('#customStartDate').removeClass('is-invalid') 44 | templateInstance.$('#customEndDate').removeClass('is-invalid') 45 | templateInstance.modal.toggle() 46 | } 47 | }) 48 | } else { 49 | templateInstance.$('#customStartDate').addClass('is-invalid') 50 | templateInstance.$('#customEndDate').addClass('is-invalid') 51 | } 52 | }, 53 | }) 54 | 55 | Template.periodpicker.onRendered(() => { 56 | Template.instance().autorun(() => { 57 | if (FlowRouter.getQueryParam('period')) { 58 | Template.instance().period.set(FlowRouter.getQueryParam('period')) 59 | Template.instance().$('#period').val(FlowRouter.getQueryParam('period')) 60 | } else { 61 | if (FlowRouter.getRouteName() === 'timecards') { 62 | Template.instance().period.set('currentMonth') 63 | } 64 | if (FlowRouter.getRouteName() === 'projectlist') { 65 | Template.instance().period.set('all') 66 | Template.instance().$('#period').val(Template.instance().period.get()) 67 | 68 | } 69 | } 70 | }) 71 | }) 72 | 73 | Template.periodpicker.helpers({ 74 | startDate: () => (getUserSetting('customStartDate') ? dayjs.utc(getUserSetting('customStartDate')).format('YYYY-MM-DD') : dayjs.utc().startOf('month').format('YYYY-MM-DD')), 75 | endDate: () => (getUserSetting('customEndDate') ? dayjs.utc(getUserSetting('customEndDate')).format('YYYY-MM-DD') : dayjs.utc().format('YYYY-MM-DD')), 76 | }) 77 | -------------------------------------------------------------------------------- /imports/ui/pages/details/components/periodtimetable.html: -------------------------------------------------------------------------------- 1 | 30 | -------------------------------------------------------------------------------- /imports/ui/pages/details/components/workingtimetable.html: -------------------------------------------------------------------------------- 1 | 26 | 27 | -------------------------------------------------------------------------------- /imports/ui/pages/details/dashboard.html: -------------------------------------------------------------------------------- 1 | 75 | -------------------------------------------------------------------------------- /imports/ui/pages/details/details.js: -------------------------------------------------------------------------------- 1 | import { FlowRouter } from 'meteor/ostrio:flow-router-extra' 2 | import './details.html' 3 | import './components/dailytimetable.js' 4 | import './components/periodtimetable.js' 5 | import './components/workingtimetable.js' 6 | import './components/detailtimetable' 7 | import './components/filterbar.js' 8 | import './dashboard.js' 9 | 10 | Template.timecardlist.onCreated(function createTimeCardList() { 11 | this.project = new ReactiveVar() 12 | this.resource = new ReactiveVar() 13 | this.period = new ReactiveVar() 14 | this.limit = new ReactiveVar(25) 15 | this.customer = new ReactiveVar() 16 | this.activeTab = new ReactiveVar() 17 | this.autorun(() => { 18 | if (window && window.BootstrapLoaded && window.BootstrapLoaded.get()) { 19 | $(`#${this.activeTab.get()}`).tab('show') 20 | } 21 | }) 22 | }) 23 | Template.timecardlist.onRendered(() => { 24 | const templateInstance = Template.instance() 25 | templateInstance.autorun(() => { 26 | if (FlowRouter.getParam('projectId')) { 27 | const projectIdParam = FlowRouter.getParam('projectId') 28 | const projectIdArray = projectIdParam.split(',') 29 | templateInstance.project.set(projectIdArray.length > 1 ? projectIdArray : projectIdParam) 30 | } else { 31 | templateInstance.project.set('all') 32 | } 33 | if (FlowRouter.getQueryParam('resource')) { 34 | const resourceIdParam = FlowRouter.getQueryParam('resource') 35 | const resourceIdArray = resourceIdParam.split(',') 36 | templateInstance.resource.set(resourceIdArray.length > 1 ? resourceIdArray : resourceIdParam) 37 | } else { 38 | templateInstance.resource.set('all') 39 | } 40 | if (FlowRouter.getQueryParam('period')) { 41 | templateInstance.period.set(FlowRouter.getQueryParam('period')) 42 | } else { 43 | templateInstance.period.set('currentMonth') 44 | } 45 | if (FlowRouter.getQueryParam('customer')) { 46 | const customerIdParam = FlowRouter.getQueryParam('customer') 47 | const customerIdArray = customerIdParam.split(',') 48 | templateInstance.customer.set(customerIdArray.length > 1 ? customerIdArray : customerIdParam) 49 | } else { 50 | templateInstance.customer.set('all') 51 | } 52 | if (FlowRouter.getQueryParam('activeTab')) { 53 | templateInstance.activeTab.set(FlowRouter.getQueryParam('activeTab')) 54 | } else { 55 | templateInstance.activeTab.set('detailed-tab') 56 | } 57 | if (FlowRouter.getQueryParam('limit')) { 58 | templateInstance.limit.set(Number(FlowRouter.getQueryParam('limit'))) 59 | } else { 60 | templateInstance.limit.set(25) 61 | } 62 | }) 63 | }) 64 | Template.timecardlist.helpers({ 65 | project() { 66 | return Template.instance().project 67 | }, 68 | resource() { 69 | return Template.instance().resource 70 | }, 71 | period() { 72 | return Template.instance().period 73 | }, 74 | limit() { 75 | return Template.instance().limit 76 | }, 77 | customer() { 78 | return Template.instance().customer 79 | }, 80 | isActive(tab) { 81 | return Template.instance().activeTab.get() === tab 82 | }, 83 | }) 84 | 85 | Template.timecardlist.events({ 86 | 'click .nav-link[data-bs-toggle]': (event, templateInstance) => { 87 | FlowRouter.setQueryParams({ activeTab: templateInstance.$(event.currentTarget)[0].id }) 88 | }, 89 | }) 90 | -------------------------------------------------------------------------------- /imports/ui/pages/overview/components/allprojectschart.html: -------------------------------------------------------------------------------- 1 | 49 | -------------------------------------------------------------------------------- /imports/ui/pages/overview/components/dashboardModal.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /imports/ui/pages/overview/components/dashboardModal.js: -------------------------------------------------------------------------------- 1 | import bootstrap from 'bootstrap' 2 | import { FlowRouter } from 'meteor/ostrio:flow-router-extra' 3 | import { t } from '../../../../utils/i18n' 4 | import { showToast } from '../../../../utils/frontend_helpers' 5 | import Projects from '../../../../api/projects/projects' 6 | import '../../details/components/periodpicker.js' 7 | import './dashboardModal.html' 8 | 9 | Template.dashboardModal.onCreated(function dashboardModalCreated() { 10 | this.subscribe('myprojects', {}) 11 | this.dashboardId = new ReactiveVar(null) 12 | }) 13 | 14 | Template.dashboardModal.onRendered(() => { 15 | }) 16 | 17 | Template.dashboardModal.helpers({ 18 | projects() { 19 | return Projects.find( 20 | { $or: [{ archived: { $exists: false } }, { archived: false }] }, 21 | { sort: { priority: 1, name: 1 } }, 22 | ) 23 | }, 24 | dashboardId() { 25 | return Template.instance().dashboardId.get() 26 | }, 27 | selectedProjectName() { 28 | const project = Projects.findOne({ _id: Template.instance().data.projectId.get() }) 29 | if (project) { 30 | return project.name 31 | } 32 | return '' 33 | }, 34 | dashboardURL() { 35 | const dashboardId = Template.instance().dashboardId.get() 36 | if (dashboardId) { 37 | return FlowRouter.url('dashboard', { _id: dashboardId }) 38 | } 39 | return '' 40 | }, 41 | }) 42 | 43 | Template.dashboardModal.events({ 44 | 'click .js-create-dashboard': (event, templateInstance) => { 45 | event.preventDefault() 46 | Meteor.call('addDashboard', { 47 | projectId: templateInstance.data.projectId.get(), resourceId: 'all', customer: 'all', timePeriod: templateInstance.$('#period').val(), 48 | }, (error, _id) => { 49 | if (error) { 50 | showToast(t('notifications.dashboard_creation_failed')) 51 | } else { 52 | templateInstance.dashboardId.set(_id) 53 | } 54 | }) 55 | }, 56 | }) 57 | -------------------------------------------------------------------------------- /imports/ui/pages/overview/components/projectProgress.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /imports/ui/pages/overview/components/projectProgress.js: -------------------------------------------------------------------------------- 1 | import './projectProgress.html' 2 | import hex2rgba from '../../../../utils/hex2rgba.js' 3 | import { ProjectStats } from '../../../../api/projects/projects.js' 4 | import { getUserSetting } from '../../../../utils/frontend_helpers' 5 | 6 | Template.projectProgress.onCreated(function projectProgressCreated() { 7 | this.autorun(() => { 8 | if (Template.currentData()._id) { 9 | this.subscribe('singleProject', Template.currentData()._id) 10 | this.subscribe('projectStats', Template.currentData()._id) 11 | } 12 | }) 13 | }) 14 | Template.projectProgress.helpers({ 15 | totalHours() { 16 | const precision = getUserSetting('precision') 17 | const projectStats = ProjectStats.findOne({ _id: Template.currentData()._id }) 18 | return projectStats 19 | ? Number(projectStats.totalHours).toFixed(precision) 20 | : false 21 | }, 22 | percentage() { 23 | const projectStats = ProjectStats.findOne({ _id: Template.currentData()._id }) 24 | return projectStats 25 | && projectStats.totalHours 26 | && (Template.currentData().target && Template.currentData().target > 0) 27 | && projectStats && projectStats.totalHours 28 | ? Number((projectStats.totalHours * 100) / Template.currentData().target) 29 | .toFixed(0) : false 30 | }, 31 | target() { 32 | return Number(Template.currentData()?.target) > 0 33 | ? Template.currentData()?.target : false 34 | }, 35 | isPercentageAbove15() { 36 | const projectStats = ProjectStats.findOne({ _id: Template.currentData()._id }) 37 | const percentage = projectStats && projectStats.totalHours 38 | && Template.currentData().target && Template.currentData().target > 0 39 | ? Number((projectStats.totalHours * 100) / Template.currentData().target).toFixed(0) 40 | : false 41 | return percentage && Number(percentage) >= 15 42 | }, 43 | colorOpacity(hex, op) { 44 | return hex2rgba(hex || '#009688', !isNaN(op) ? op : 50) 45 | }, 46 | }) 47 | -------------------------------------------------------------------------------- /imports/ui/pages/overview/components/projectchart.html: -------------------------------------------------------------------------------- 1 | 53 | -------------------------------------------------------------------------------- /imports/ui/pages/overview/editproject/components/projectAccessRights.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /imports/ui/pages/overview/editproject/components/wekanInterfaceSettings.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /imports/ui/pages/register.html: -------------------------------------------------------------------------------- 1 | 66 | -------------------------------------------------------------------------------- /imports/ui/pages/register.js: -------------------------------------------------------------------------------- 1 | import { FlowRouter } from 'meteor/ostrio:flow-router-extra' 2 | import { t } from '../../utils/i18n.js' 3 | import { validateEmail, validatePassword } from '../../utils/frontend_helpers.js' 4 | import './register.html' 5 | 6 | Template.register.events({ 7 | 'click #register': (event, templateInstance) => { 8 | event.preventDefault() 9 | if (templateInstance.$('#at-field-password').val() !== templateInstance.$('#at-field-password-again').val()) { 10 | templateInstance.$('#at-field-password').addClass('is-invalid') 11 | templateInstance.$('#at-field-password-again').addClass('is-invalid') 12 | templateInstance.$('.notification').text(t('login.password_mismatch')) 13 | document.querySelector('.notification').classList.toggle('d-none') 14 | return 15 | } 16 | if (templateInstance.$('#at-field-email').val() && templateInstance.$('#at-field-password').val() && templateInstance.$('#at-field-password-again').val()) { 17 | if (!validateEmail(templateInstance.$('#at-field-email').val())) { 18 | templateInstance.$('#at-field-email').addClass('is-invalid') 19 | templateInstance.$('.notification').text(t('login.invalid_email')) 20 | document.querySelector('.notification').classList.toggle('d-none') 21 | return 22 | } 23 | const passwordValidation = validatePassword(templateInstance.$('#at-field-password').val()) 24 | if (!passwordValidation.valid) { 25 | templateInstance.$('#at-field-password').addClass('is-invalid') 26 | templateInstance.$('#at-field-password-again').addClass('is-invalid') 27 | templateInstance.$('.notification').text(passwordValidation.message) 28 | document.querySelector('.notification').classList.toggle('d-none') 29 | return 30 | } 31 | Accounts.createUser({ 32 | email: templateInstance.$('#at-field-email').val(), 33 | password: templateInstance.$('#at-field-password').val(), 34 | profile: { 35 | name: templateInstance.$('#at-field-name').val(), 36 | currentLanguageProject: t('globals.project'), 37 | currentLanguageProjectDesc: t('project.first_project_desc'), 38 | }, 39 | }, (error) => { 40 | if (error && error.error !== 145546287) { 41 | console.error(error) 42 | templateInstance.$('.notification').text(`${t(`login.${error.error}`)} (${error.reason})`) 43 | document.querySelector('.notification').classList.toggle('d-none') 44 | } else { 45 | FlowRouter.go('projectlist') 46 | } 47 | }) 48 | } 49 | }, 50 | 'keyup #at-field-password': (event, templateInstance) => { 51 | event.preventDefault() 52 | const validetedPW = validatePassword(templateInstance.$('#at-field-password').val()) 53 | templateInstance.$('.js-password-feedback').text(validetedPW.message) 54 | if (validetedPW.valid) { 55 | templateInstance.$('#at-field-password').removeClass('is-invalid') 56 | templateInstance.$('#at-field-password-again').removeClass('is-invalid') 57 | templateInstance.$('.js-password-feedback').removeClass('invalid-feedback') 58 | templateInstance.$('.js-password-feedback').addClass('valid-feedback') 59 | templateInstance.$('.js-password-feedback').removeClass('hide') 60 | } else { 61 | templateInstance.$('#at-field-password').addClass('is-invalid') 62 | templateInstance.$('.js-password-feedback').removeClass('valid-feedback') 63 | templateInstance.$('.js-password-feedback').addClass('invalid-feedback') 64 | templateInstance.$('.js-password-feedback').removeClass('hide') 65 | templateInstance.$('.js-password-feedback').addClass('d-block') 66 | } 67 | }, 68 | }) 69 | Template.register.helpers({ 70 | email: () => FlowRouter.getQueryParam('email'), 71 | name: () => FlowRouter.getQueryParam('name'), 72 | }) 73 | -------------------------------------------------------------------------------- /imports/ui/pages/signIn.html: -------------------------------------------------------------------------------- 1 | 63 | -------------------------------------------------------------------------------- /imports/ui/pages/track/components/calendar.html: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /imports/ui/pages/track/components/editTimeEntryModal.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /imports/ui/pages/track/components/editTimeEntryModal.js: -------------------------------------------------------------------------------- 1 | import { Blaze } from 'meteor/blaze' 2 | import './editTimeEntryModal.html' 3 | 4 | Template.editTimeEntryModal.onRendered(() => { 5 | const templateInstance = Template.instance() 6 | let bodyInstance 7 | templateInstance.$('#edit-tc-entry-modal').on('hidden.bs.modal', () => { 8 | Blaze.remove(bodyInstance) 9 | }) 10 | templateInstance.$('#edit-tc-entry-modal').on('show.bs.modal', () => { 11 | if (templateInstance.data?.tcid?.get()) { 12 | bodyInstance = Blaze.renderWithData( 13 | Template.tracktime, 14 | { tcid: templateInstance.data?.tcid }, 15 | templateInstance.$('#editTimeEntryModalBody')[0], 16 | ) 17 | } else if (((!!templateInstance.data?.selectedDate?.get()) 18 | || (!!templateInstance.data?.selectedProjectId?.get()))) { 19 | bodyInstance = Blaze.renderWithData( 20 | Template.tracktime, 21 | { 22 | dateArg: templateInstance.data?.selectedDate, 23 | projectIdArg: templateInstance.data?.selectedProjectId, 24 | }, 25 | templateInstance.$('#editTimeEntryModalBody')[0], 26 | ) 27 | } 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /imports/ui/pages/track/components/projectInfoPopup.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /imports/ui/pages/track/components/projectInfoPopup.js: -------------------------------------------------------------------------------- 1 | import './projectInfoPopup.html' 2 | import { Meteor } from 'meteor/meteor' 3 | import Projects from '../../../../api/projects/projects.js' 4 | import '../../overview/components/projectProgress.js' 5 | 6 | Template.projectInfoPopup.onCreated(function projectInfoPopupCreated() { 7 | this.project = new ReactiveVar() 8 | this.projectDescAsHtml = new ReactiveVar() 9 | 10 | this.autorun(() => { 11 | // this will work only in the project select component context 12 | // - we should make this more flexible in the future! 13 | if (Template.parentData(1) && Template.parentData(1).projectId 14 | && Template.parentData(1).projectId?.get()) { 15 | this.subscribe('singleProject', Template.parentData(1).projectId.get()) 16 | this.subscribe('projectStats', Template.parentData(1).projectId.get()) 17 | } 18 | if (this.subscriptionsReady()) { 19 | if (Template.parentData(1)?.projectId?.get()) { 20 | const project = Projects.findOne({ _id: Template.parentData(1).projectId.get() }) 21 | this.project.set(project) 22 | if (project?.desc instanceof Object) { 23 | import('quill-delta-to-html').then((deltaToHtml) => { 24 | const converter = new deltaToHtml.QuillDeltaToHtmlConverter(project.desc.ops, {}) 25 | this.projectDescAsHtml.set(converter.convert()) 26 | }) 27 | } else { 28 | this.projectDescAsHtml.set(project?.desc) 29 | } 30 | } 31 | } 32 | }) 33 | }) 34 | Template.projectInfoPopup.helpers({ 35 | name: () => (Template.instance().project.get() ? Template.instance().project.get().name : false), 36 | projectDescAsHtml: () => Template.instance().projectDescAsHtml.get(), 37 | desc: () => (Template.instance().project.get() ? Template.instance().project.get().desc : false), 38 | color: () => (Template.instance().project.get() 39 | ? Template.instance().project.get().color : Template.instance().color), 40 | customer: () => (Template.instance().project.get() 41 | ? Template.instance().project.get().customer : false), 42 | rate: () => (Template.instance().project.get() ? Template.instance().project.get().rate : false), 43 | team: () => { 44 | if (Template.instance().project.get() && Template.instance().project.get().team) { 45 | return Meteor.users.find({ _id: { $in: Template.instance().project.get().team } }) 46 | } 47 | return false 48 | }, 49 | target: () => (Template.instance().project.get() 50 | ? Template.instance().project.get().target : false), 51 | notbillable: () => Template.instance().project.get()?.notbillable, 52 | project: () => Template.instance().project.get(), 53 | }) 54 | Template.projectInfoPopup.events({ 55 | 'click .js-close-projectinfo-popup': (event, templateInstance) => { 56 | event.preventDefault() 57 | $('#projectInfoPopup').modal('hide') 58 | }, 59 | }) 60 | -------------------------------------------------------------------------------- /imports/ui/pages/track/components/projectTasks.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /imports/ui/pages/track/components/projectsearch.html: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /imports/ui/pages/track/components/tasksearch.html: -------------------------------------------------------------------------------- 1 | 26 | -------------------------------------------------------------------------------- /imports/ui/pages/track/components/timeline.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /imports/ui/pages/track/components/timetracker.html: -------------------------------------------------------------------------------- 1 | 33 | -------------------------------------------------------------------------------- /imports/ui/pages/track/components/usersearch.html: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /imports/ui/pages/track/components/usersearch.js: -------------------------------------------------------------------------------- 1 | import { FlowRouter } from 'meteor/ostrio:flow-router-extra' 2 | import './usersearch.html' 3 | import Timecards from '../../../../api/timecards/timecards.js' 4 | import Autocomplete from '../../../../utils/autocomplete' 5 | import { getGlobalSetting } from '../../../../utils/frontend_helpers' 6 | 7 | Template.usersearch.events({ 8 | 'click .js-remove-value': (event, templateInstance) => { 9 | event.preventDefault() 10 | event.stopPropagation() 11 | templateInstance.$('.js-usersearch-input').val('') 12 | templateInstance.targetUser.renderIfNeeded() 13 | }, 14 | }) 15 | 16 | Template.usersearch.onCreated(function usersearchcreated() { 17 | this.users = new ReactiveVar() 18 | this.autorun(() => { 19 | let tcid 20 | if (this.data?.tcid && this.data?.tcid.get()) { 21 | tcid = this.data?.tcid.get() 22 | } else if (FlowRouter.getParam('tcid')) { 23 | tcid = FlowRouter.getParam('tcid') 24 | } 25 | if (tcid) { 26 | const handle = this.subscribe('singleTimecard', tcid) 27 | if (handle.ready()) { 28 | const card = Timecards.findOne({ _id: tcid }) 29 | const user = this.users?.get()?.find((u) => u._id === card.userId) 30 | if (user?.profile) { 31 | this.$('.js-usersearch-input').val(user.profile.name) 32 | } 33 | Meteor.call('getProjectUsers', { projectId: card.projectId }, (error, result) => { 34 | const found = result.find((u) => u._id === card.userId) 35 | if (found?.profile) { 36 | this.$('.js-usersearch-input').val(found.profile.name) 37 | } 38 | }) 39 | } 40 | } 41 | }) 42 | this.autorun(() => { 43 | const loadUsers = (projectId) => { 44 | Meteor.call('getProjectUsers', { projectId }, (error, result) => { 45 | this.users.set(result) 46 | }) 47 | } 48 | if (FlowRouter.getParam('projectId')) { 49 | loadUsers(FlowRouter.getParam('projectId')) 50 | } 51 | if (this.data?.projectId.get()) { 52 | loadUsers(this.data?.projectId.get()) 53 | } 54 | }) 55 | }) 56 | Template.usersearch.onRendered(() => { 57 | const templateInstance = Template.instance() 58 | templateInstance.autorun(() => { 59 | if (templateInstance.subscriptionsReady()) { 60 | const users = templateInstance.users.get() || [] 61 | const userlist = users.map((entry) => ({ label: entry.profile.name, value: entry._id })) 62 | if (!templateInstance.targetUser) { 63 | templateInstance.targetUser = new Autocomplete(templateInstance.$('.js-usersearch-input').get(0), { 64 | data: userlist, 65 | maximumItems: getGlobalSetting('userSearchNumResults'), 66 | threshold: 0, 67 | onSelectItem: () => { 68 | templateInstance.$('.js-usersearch-input').removeClass('is-invalid') 69 | $('#tasks').first().trigger('focus') 70 | }, 71 | }) 72 | } else if (userlist.length > 0) { 73 | templateInstance.targetUser?.setData(userlist) 74 | } 75 | } 76 | }) 77 | }) 78 | Template.usersearch.helpers({ 79 | user: () => Template.instance()?.data?.user, 80 | }) 81 | -------------------------------------------------------------------------------- /imports/ui/shared components/backbutton.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /imports/ui/shared components/backbutton.js: -------------------------------------------------------------------------------- 1 | import './backbutton.html' 2 | 3 | Template.backbutton.events({ 4 | 'click .js-backbutton': (event) => { 5 | event.preventDefault() 6 | window.history.back() 7 | }, 8 | }) 9 | -------------------------------------------------------------------------------- /imports/ui/shared components/connectioncheck.html: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /imports/ui/shared components/connectioncheck.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import relativeTime from 'dayjs/plugin/relativeTime' 3 | 4 | import './connectioncheck.html' 5 | 6 | Template.connectioncheck.events({ 7 | 'click #connectButton': (event) => { 8 | event.preventDefault() 9 | Meteor.reconnect() 10 | }, 11 | }) 12 | Template.connectioncheck.onCreated(() => { 13 | dayjs.extend(relativeTime) 14 | }) 15 | Template.connectioncheck.helpers({ 16 | offline: () => (Meteor.status().status === 'failed' 17 | || Meteor.status().status === 'waiting') && Meteor.status().retryCount > 1, 18 | nextRetry: () => dayjs(new Date(Meteor.status().retryTime)).fromNow(), 19 | }) 20 | -------------------------------------------------------------------------------- /imports/ui/shared components/datatable.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /imports/ui/shared components/datatable.js: -------------------------------------------------------------------------------- 1 | import './datatable.html' 2 | import { t } from '../../utils/i18n.js' 3 | 4 | Template.datatable.onRendered(() => { 5 | const templateInstance = Template.instance() 6 | templateInstance.autorun(() => { 7 | const columns = templateInstance.data.columns?.get() 8 | const data = templateInstance.data.data?.get() 9 | if (columns && data) { 10 | if (!templateInstance.datatableInstance) { 11 | import('frappe-datatable/dist/frappe-datatable.css').then(() => { 12 | import('frappe-datatable').then((datatable) => { 13 | const DataTable = datatable.default 14 | const datatableConfig = { 15 | columns, 16 | data, 17 | serialNoColumn: false, 18 | clusterize: false, 19 | layout: 'ratio', 20 | noDataMessage: t('tabular.sZeroRecords'), 21 | } 22 | try { 23 | window.requestAnimationFrame(() => { 24 | templateInstance.datatableInstance = new DataTable(templateInstance.$('.js-datatable-container').get(0), datatableConfig) 25 | templateInstance.$('.dt-scrollable').height('+=4') 26 | }) 27 | } catch (datatableCreationError) { 28 | console.error(`Caught error: ${datatableCreationError}`) 29 | } 30 | }) 31 | }) 32 | } else { 33 | try { 34 | templateInstance.datatableInstance.refresh(data, columns) 35 | } catch (datatableRefreshError) { 36 | console.error(`Caught error: ${datatableRefreshError}`) 37 | } 38 | } 39 | } 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /imports/ui/shared components/navbar.js: -------------------------------------------------------------------------------- 1 | import { Template } from 'meteor/templating' 2 | import { FlowRouter } from 'meteor/ostrio:flow-router-extra' 3 | import { displayUserAvatar, getUserSetting, getGlobalSetting } from '../../utils/frontend_helpers' 4 | 5 | import './navbar.html' 6 | import '../pages/track/components/timetracker.js' 7 | 8 | Template.navbar.onCreated(function navbarCreated() { 9 | this.displayHoursToDays = new ReactiveVar() 10 | this.autorun(() => { 11 | if (Meteor.user()) { 12 | this.subscribe('userRoles') 13 | } 14 | this.displayHoursToDays.set(getUserSetting('timeunit')) 15 | }) 16 | }) 17 | Template.navbar.onRendered(function settingsRendered() { 18 | const templateInstance = Template.instance() 19 | templateInstance.autorun(() => { 20 | if (!Meteor.loggingIn() && Meteor.user() 21 | && Meteor.user().profile && this.subscriptionsReady()) { 22 | templateInstance.$('#timeunit').val(getUserSetting('timeunit')) 23 | } 24 | }) 25 | }) 26 | Template.navbar.helpers({ 27 | isRouteActive: (routename) => (FlowRouter.getRouteName() === routename ? 'active' : ''), 28 | displayLinkText: (routename) => (FlowRouter.getRouteName() === routename), 29 | avatar: () => displayUserAvatar(Meteor.user()), 30 | getUserSetting: (setting) => getUserSetting(setting), 31 | getGlobalSetting: (setting) => getGlobalSetting(setting), 32 | }) 33 | Template.navbar.events({ 34 | 'click .js-logout': (event) => { 35 | event.preventDefault() 36 | Meteor.logout() 37 | }, 38 | 'change #timeunitnavbar': (event, templateInstance) => { 39 | event.preventDefault() 40 | Meteor.call('updateTimeUnit', { timeunit: templateInstance.$('#timeunitnavbar').val() }, (error, result) => { 41 | if (error) { 42 | console.error(error) 43 | } 44 | }) 45 | }, 46 | }) 47 | -------------------------------------------------------------------------------- /imports/ui/shared components/tablecell.html: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /imports/ui/shared components/toast.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /imports/utils/debugLog.js: -------------------------------------------------------------------------------- 1 | import { getGlobalSetting } from './frontend_helpers.js' 2 | import { getGlobalSettingAsync } from './server_method_helpers.js' 3 | 4 | export async function debugLog(...args) { 5 | let setting 6 | if (Meteor.isClient) { 7 | setting = getGlobalSetting('debugMode') 8 | } else if (Meteor.isServer) { 9 | setting = await getGlobalSettingAsync('debugMode') 10 | } 11 | if (setting && (setting.value === true || setting.value === 'true') ) { 12 | // eslint-disable-next-line no-console 13 | console.log('[DEBUG]', ...args) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /imports/utils/google/google_server.js: -------------------------------------------------------------------------------- 1 | import { fetch } from 'meteor/fetch' 2 | import { OAuth } from 'meteor/oauth' 3 | import { ServiceConfiguration } from 'meteor/service-configuration' 4 | 5 | const registerGoogleAPI = () => { 6 | OAuth.registerService('googleapi', 2, null, async (query) => { 7 | const { userId } = JSON.parse(Buffer.from(query.state, 'base64').toString('binary')) 8 | let tokens 9 | const config = await ServiceConfiguration.configurations.findOneAsync({ 10 | service: 'googleapi', 11 | }) 12 | if (!config) throw new ServiceConfiguration.ConfigError() 13 | const content = new URLSearchParams({ 14 | code: query.code, 15 | client_id: config.clientId, 16 | client_secret: OAuth.openSecret(config.secret), 17 | redirect_uri: OAuth._redirectUri('googleapi', config), 18 | grant_type: 'authorization_code', 19 | }) 20 | const request = await fetch('https://accounts.google.com/o/oauth2/token', { 21 | method: 'POST', 22 | headers: { 23 | Accept: 'application/json', 24 | 'Content-Type': 'application/x-www-form-urlencoded', 25 | }, 26 | body: content, 27 | }) 28 | const response = await request.json() 29 | if (response.error) { 30 | // if the http response was a json object with an error attribute 31 | throw new Meteor.Error( 32 | `Failed to complete OAuth handshake with Google. ${response.error}`, 33 | ) 34 | } else { 35 | const data = { 36 | accessToken: response.access_token, 37 | refreshToken: response.refresh_token, 38 | expiresIn: response.expires_in, 39 | idToken: response.id_token, 40 | } 41 | tokens = data 42 | } 43 | const { accessToken, idToken } = tokens 44 | let scopes 45 | try { 46 | const scopeContent = new URLSearchParams({ access_token: accessToken }) 47 | let scopeResponse 48 | try { 49 | const scopeRequest = await fetch( 50 | `https://www.googleapis.com/oauth2/v1/tokeninfo?${scopeContent.toString()}`, 51 | { 52 | method: 'GET', 53 | headers: { Accept: 'application/json' }, 54 | }, 55 | ) 56 | scopeResponse = await scopeRequest.json() 57 | } catch (e) { 58 | throw new Meteor.Error(e.reason) 59 | } 60 | scopes = scopeResponse.scope.split(' ') 61 | } catch (err) { 62 | const error = Object.assign( 63 | new Error(`Failed to fetch tokeninfo from Google. ${err.message}`), 64 | { response: err.response }, 65 | ) 66 | throw error 67 | } 68 | const serviceData = { 69 | id: userId, 70 | accessToken, 71 | idToken, 72 | scope: scopes, 73 | } 74 | if (Object.prototype.hasOwnProperty.call(tokens, 'expiresIn')) { 75 | serviceData.expiresAt = Date.now() + 1000 * parseInt(tokens.expiresIn, 10) 76 | } 77 | // only set the token in serviceData if it's there. this ensures 78 | // that we don't lose old ones (since we only get this on the first 79 | // log in attempt) 80 | if (tokens.refreshToken) { 81 | serviceData.refreshToken = tokens.refreshToken 82 | } 83 | const returnValue = { 84 | serviceData, 85 | } 86 | await Meteor.users.updateAsync({ _id: userId }, { $set: { 'services.googleapi': returnValue, 'profile.googleAPIexpiresAt': serviceData.expiresAt } }) 87 | return { serviceData: {} } 88 | }) 89 | } 90 | export default registerGoogleAPI 91 | -------------------------------------------------------------------------------- /imports/utils/hex2rgba.js: -------------------------------------------------------------------------------- 1 | // http://stackoverflow.com/questions/21646738/convert-hex-to-rgba 2 | export default function hex2rgba(hex,opacity = 50){ 3 | hex = hex.replace('#',''); 4 | r = parseInt(hex.substring(0, hex.length/3), 16); 5 | g = parseInt(hex.substring(hex.length/3, 2*hex.length/3), 16); 6 | b = parseInt(hex.substring(2*hex.length/3, 3*hex.length/3), 16); 7 | 8 | result = 'rgba('+r+','+g+','+b+','+opacity/100+')'; 9 | return result; 10 | } -------------------------------------------------------------------------------- /imports/utils/holiday.js: -------------------------------------------------------------------------------- 1 | async function getHolidayCountries() { 2 | return new Promise((resolve, reject) => { 3 | Meteor.call('getHolidayCountries', undefined, (error, result) => { 4 | if (error) { 5 | reject(error) 6 | } 7 | resolve(result) 8 | }) 9 | }) 10 | } 11 | 12 | async function getHolidayStates(country) { 13 | const c = country || '' 14 | return new Promise((resolve, reject) => { 15 | Meteor.call('getHolidayStates', { country: c }, (error, result) => { 16 | if (error) { 17 | reject(error) 18 | } 19 | resolve(result) 20 | }) 21 | }) 22 | } 23 | 24 | async function getHolidayRegions(country, state) { 25 | const c = country || '' 26 | const s = state || '' 27 | return new Promise((resolve, reject) => { 28 | Meteor.call('getHolidayRegions', { country: c, state: s }, (error, result) => { 29 | if (error) { 30 | reject(error) 31 | } 32 | resolve(result) 33 | }) 34 | }) 35 | } 36 | 37 | async function getHolidays() { 38 | return new Promise((resolve, reject) => { 39 | Meteor.call('getHolidays', undefined, (error, result) => { 40 | if (error) { 41 | reject(error) 42 | } 43 | resolve(result) 44 | }) 45 | }) 46 | } 47 | 48 | function checkHoliday(holidays, date) { 49 | const currentHolidays = [] 50 | holidays.forEach((holiday) => { 51 | if (date >= holiday.start && date < holiday.end) { 52 | currentHolidays.push(holiday) 53 | } 54 | }) 55 | return currentHolidays.length ? currentHolidays : false 56 | } 57 | 58 | export { 59 | getHolidayCountries, getHolidayStates, getHolidayRegions, getHolidays, checkHoliday, 60 | } 61 | -------------------------------------------------------------------------------- /imports/utils/i18n.js: -------------------------------------------------------------------------------- 1 | const i18nReady = new ReactiveVar(false) 2 | let _i18n = {} 3 | let _debug = true 4 | let _lang = '' 5 | const weekDaysMin = new ReactiveVar([]) 6 | const months = new ReactiveVar([]) 7 | const setup = (language, lang, debugMode) => { 8 | _i18n = language 9 | _debug = debugMode 10 | _lang = lang 11 | if (_debug) { 12 | console.log(`Loading i18n for language ${lang}`) 13 | } 14 | } 15 | const getLanguage = () => _lang 16 | const t = (key) => { 17 | if (!key) { 18 | return false 19 | } 20 | const keys = key.split('.') 21 | let translatedValue = _i18n 22 | keys.forEach((innerKey) => { 23 | translatedValue = translatedValue[innerKey] !== undefined ? translatedValue[innerKey] : key 24 | }) 25 | 26 | if (translatedValue === undefined || translatedValue === key) { 27 | if (_debug) { 28 | console.warn(`Translation for key ${key} not found.`) 29 | } 30 | translatedValue = key 31 | } 32 | return translatedValue 33 | } 34 | let fallbackLocale = {} 35 | import(`dayjs/locale/en-gb`).then((locale) => { 36 | fallbackLocale = locale 37 | }) 38 | 39 | const loadLanguage = (language, i18nextDebugMode) => { 40 | // Meteor.js is not very smart 41 | // eslint-disable-next no-constant-condition 42 | if (false) { 43 | import('../ui/translations/en.json') 44 | import('dayjs/locale/en') 45 | import('../ui/translations/de.json') 46 | import('dayjs/locale/de') 47 | import('../ui/translations/fr.json') 48 | import('dayjs/locale/fr') 49 | import('../ui/translations/zh.json') 50 | import('dayjs/locale/zh') 51 | import('../ui/translations/ru.json') 52 | import('dayjs/locale/ru') 53 | import('../ui/translations/uk.json') 54 | import('dayjs/locale/uk') 55 | import('../ui/translations/es.json') 56 | import('dayjs/locale/es') 57 | } 58 | import(`/imports/ui/translations/${language}.json`).then((lang) => { 59 | i18nReady.set(false) 60 | setup(lang.default, language, i18nextDebugMode) 61 | import(`dayjs/locale/${language}`).then((locale) => { 62 | if (!locale.weekdaysMin) { 63 | weekDaysMin.set(fallbackLocale.weekdaysMin) 64 | } else { 65 | weekDaysMin.set(locale.weekdaysMin) 66 | } 67 | months.set(locale.months) 68 | import('dayjs').then((dayjs) => { 69 | dayjs.locale(language) 70 | }) 71 | }) 72 | i18nReady.set(true) 73 | $('html').attr('lang', language) 74 | }).catch(() => { 75 | import('../ui/translations/en.json').then((lang) => { 76 | if (i18nextDebugMode) { 77 | console.warn('Language not found, using default language en') 78 | } 79 | i18nReady.set(false) 80 | setup(lang.default, 'en', i18nextDebugMode) 81 | import('dayjs/locale/en-gb').then((locale) => { 82 | weekDaysMin.set(locale.weekdaysMin) 83 | months.set(locale.months) 84 | import('dayjs').then((dayjs) => { 85 | dayjs.locale('en') 86 | }) 87 | }) 88 | i18nReady.set(true) 89 | $('html').attr('lang', 'en') 90 | }) 91 | }) 92 | } 93 | export { 94 | t, setup, getLanguage, i18nReady, loadLanguage, weekDaysMin, months, 95 | } 96 | -------------------------------------------------------------------------------- /imports/utils/ldap_client.js: -------------------------------------------------------------------------------- 1 | import { Tracker } from 'meteor/tracker' 2 | import { getGlobalSetting } from "./frontend_helpers" 3 | import { debugLog } from './debugLog' 4 | // Pass in username, password as normal 5 | // customLdapOptions should be passed in if you want to override LDAP_DEFAULTS 6 | // on any particular call (if you have multiple ldap servers you'd like to connect to) 7 | // You'll likely want to set the dn value here {dn: "..."} 8 | const handle = Meteor.subscribe('globalsettings') 9 | Tracker.autorun(() => { 10 | if(handle.ready()) { 11 | if(getGlobalSetting('enableLDAP')){ 12 | debugLog('LDAP ENABLED') 13 | Meteor.loginWithLDAP = function (username, password, customLdapOptions, callback) { 14 | // Retrieve arguments as array 15 | const args = [] 16 | for (let i = 0; i < arguments.length; i++) { 17 | args.push(arguments[i]) 18 | } 19 | // Pull username and password 20 | username = args.shift() 21 | password = args.shift() 22 | 23 | // Check if last argument is a function 24 | // if it is, pop it off and set callback to it 25 | if (typeof args[args.length - 1] === 'function') { 26 | callback = args.pop() 27 | } else { 28 | callback = null 29 | } 30 | 31 | // if args still holds options item, grab it 32 | if (args.length > 0) { 33 | customLdapOptions = args.shift() 34 | } else { 35 | customLdapOptions = {} 36 | } 37 | 38 | // Set up loginRequest object 39 | const loginRequest = { 40 | ldap: true, 41 | username, 42 | ldapPass: password, 43 | ldapOptions: customLdapOptions, 44 | } 45 | Accounts.callLoginMethod({ 46 | // Call login method with ldap = true 47 | // This will hook into our login handler for ldap 48 | methodArguments: [loginRequest], 49 | userCallback(error/* , result */) { 50 | if (error) { 51 | if (callback) { 52 | callback(error) 53 | } 54 | } else if (callback) { 55 | callback() 56 | } 57 | }, 58 | }) 59 | } 60 | } 61 | } 62 | }) -------------------------------------------------------------------------------- /imports/utils/oidc/oidc_client.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable i18next/no-literal-string */ 2 | import { OAuth } from 'meteor/oauth' 3 | import { Random } from 'meteor/random' 4 | import { ServiceConfiguration } from 'meteor/service-configuration' 5 | import { debugLog } from '../debugLog' 6 | 7 | const SERVICE_NAME = 'oidc' 8 | const oidcReady = new ReactiveVar(false) 9 | function registerOidc() { 10 | Accounts.oauth.registerService(SERVICE_NAME) 11 | 12 | Meteor.loginWithOidc = (callback) => { 13 | debugLog('[OIDC] Meteor.loginWithOidc called') 14 | const options = {} 15 | const completeCallback = Accounts.oauth.credentialRequestCompleteHandler((...args) => { 16 | debugLog('[OIDC] OIDC completeCallback invoked', ...args) 17 | if (callback) callback(...args) 18 | }) 19 | 20 | const config = ServiceConfiguration.configurations.findOne({ service: SERVICE_NAME }) 21 | debugLog('[OIDC] Loaded OIDC config', config) 22 | if (!config) { 23 | if (completeCallback) { 24 | debugLog('[OIDC] No OIDC config found, calling callback with error') 25 | completeCallback( 26 | new ServiceConfiguration.ConfigError('Service oidc not configured.'), 27 | ) 28 | } 29 | return 30 | } 31 | 32 | const credentialToken = Random.secret() 33 | const mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone/i.test(navigator.userAgent) 34 | const display = mobile ? 'touch' : 'popup' 35 | const loginStyle = OAuth._loginStyle(SERVICE_NAME, config, options) 36 | const scope = config.requestPermissions?.split(',') || ['openid', 'profile', 'email'] 37 | 38 | // options 39 | options.client_id = config.clientId 40 | options.response_type = 'code' 41 | options.redirect_uri = OAuth._redirectUri(SERVICE_NAME, config) 42 | options.state = OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl) 43 | options.scope = scope.join(' ') 44 | 45 | if (config.loginStyle) { 46 | options.display = display 47 | } 48 | 49 | let loginUrl = config.authorizationEndpoint.startsWith('http') 50 | ? config.authorizationEndpoint 51 | : `${config.serverUrl}${config.authorizationEndpoint}` 52 | // check if the loginUrl already contains a '?' 53 | const hasExistingParams = loginUrl.indexOf('?') !== -1 54 | 55 | if (!hasExistingParams) { 56 | loginUrl += '?' 57 | } else { 58 | loginUrl += '&' 59 | } 60 | 61 | loginUrl += Object.keys(options).map((key) => [key, options[key]].map(encodeURIComponent).join('=')).join('&') 62 | 63 | options.popupOptions = options.popupOptions || {} 64 | const popupOptions = { 65 | width: options.popupOptions.width || 320, 66 | height: options.popupOptions.height || 450, 67 | } 68 | 69 | debugLog('[OIDC] Launching OAuth login', { loginService: SERVICE_NAME, loginStyle, loginUrl, credentialToken, popupOptions }) 70 | OAuth.launchLogin({ 71 | loginService: SERVICE_NAME, 72 | loginStyle, 73 | loginUrl, 74 | credentialRequestCompleteCallback: completeCallback, 75 | credentialToken, 76 | popupOptions, 77 | }) 78 | } 79 | oidcReady.set(true) 80 | } 81 | export { registerOidc, oidcReady } 82 | -------------------------------------------------------------------------------- /imports/utils/oidc/oidc_helper.js: -------------------------------------------------------------------------------- 1 | import { ServiceConfiguration } from 'meteor/service-configuration' 2 | import { getGlobalSetting } from '../frontend_helpers.js' 3 | 4 | const SERVICE_NAME = 'oidc' 5 | 6 | const oidcFields = [ 7 | { 8 | property: 'disableDefaultLoginForm', label: 'Disable Default Login Form', type: 'checkbox', value: false, 9 | }, 10 | { 11 | property: 'autoInitiateLogin', label: 'Automatically initiate login', type: 'checkbox', value: false, 12 | }, 13 | { 14 | property: 'clientId', label: 'Client ID', type: 'text', value: '', 15 | }, 16 | { 17 | property: 'secret', label: 'Client Secret', type: 'text', value: '', 18 | }, 19 | { 20 | property: 'serverUrl', label: 'OIDC Server URL', type: 'text', value: '', 21 | }, 22 | { 23 | property: 'authorizationEndpoint', label: 'Authorization Endpoint', type: 'text', value: '', 24 | }, 25 | { 26 | property: 'tokenEndpoint', label: 'Token Endpoint', type: 'text', value: '', 27 | }, 28 | { 29 | property: 'userinfoEndpoint', label: 'Userinfo Endpoint', type: 'text', value: '', 30 | }, 31 | { 32 | property: 'idTokenWhitelistFields', label: 'Id Token Fields', type: 'text', value: '', 33 | }, 34 | { 35 | property: 'requestPermissions', label: 'Request Permissions', type: 'text', value: '"openid", "profile", "email"', 36 | }, 37 | { 38 | property: 'loginStyle', label: 'Login style (popup or redirect)', type: 'text', value: 'popup', 39 | }, 40 | ] 41 | 42 | function isOidcConfigured() { 43 | if (getGlobalSetting('enableOpenIDConnect')) { 44 | return ServiceConfiguration.configurations.findOne({ service: SERVICE_NAME }) !== undefined 45 | } 46 | return false 47 | } 48 | function isAutoLoginEnabled() { 49 | if (!getGlobalSetting('enableOpenIDConnect')) { 50 | return false 51 | } 52 | const configuration = ServiceConfiguration.configurations.findOne({ service: SERVICE_NAME }) 53 | if (configuration === undefined) { 54 | return false 55 | } 56 | return configuration.autoInitiateLogin 57 | } 58 | function disableDefaultLoginForm() { 59 | if (!getGlobalSetting('enableOpenIDConnect')) { 60 | return false 61 | } 62 | 63 | const configuration = ServiceConfiguration.configurations.findOne({ service: SERVICE_NAME }) 64 | if (configuration === undefined) { 65 | return false 66 | } 67 | 68 | return configuration.disableDefaultLoginForm 69 | } 70 | 71 | function getOidcConfiguration(name) { 72 | if (getGlobalSetting('enableOpenIDConnect')) { 73 | return ServiceConfiguration.configurations.findOne({ service: SERVICE_NAME }) 74 | ? ServiceConfiguration.configurations.findOne({ service: SERVICE_NAME })[name] : false 75 | } 76 | return '' 77 | } 78 | export { 79 | oidcFields, isOidcConfigured, disableDefaultLoginForm, getOidcConfiguration, isAutoLoginEnabled 80 | } 81 | -------------------------------------------------------------------------------- /imports/utils/openai/openai_server.js: -------------------------------------------------------------------------------- 1 | import { fetch } from 'meteor/fetch' 2 | import { getGlobalSettingAsync } from '../server_method_helpers' 3 | 4 | export const getOpenAIResponse = async (prompt) => { 5 | if (!await getGlobalSettingAsync('openapi_apikey')) { 6 | try { 7 | const aiResponse = await fetch('https://api.openai.com/v1/chat/completions', { 8 | method: 'POST', 9 | headers: { 10 | 'Content-Type': 'application/json', 11 | Authorization: `Bearer ${await getGlobalSettingAsync('openai_apikey')}`, 12 | Accept: 'application/json', 13 | }, 14 | body: JSON.stringify({ 15 | model: 'gpt-3.5-turbo', 16 | messages: [ 17 | { 18 | role: 'user', 19 | content: prompt, 20 | }, 21 | ], 22 | temperature: 0, 23 | }), 24 | }) 25 | const aiResponseContent = await aiResponse.json() 26 | return JSON.parse(aiResponseContent?.choices[0]?.message?.content) 27 | } catch (e) { 28 | throw new Meteor.Error('notifications.OpenAI_error', e.message) 29 | } 30 | } 31 | throw new Meteor.Error('notifications.OpenAI_API_key_not_set') 32 | } 33 | export default getOpenAIResponse 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "titra", 3 | "version": "0.99.34", 4 | "private": true, 5 | "scripts": { 6 | "start": "meteor run" 7 | }, 8 | "dependencies": { 9 | "@babel/runtime": "^7.27.1", 10 | "@dashboardcode/bsmultiselect": "^1.1.18", 11 | "@fortawesome/fontawesome-free": "^6.7.2", 12 | "@fullcalendar/core": "6.1.17", 13 | "@fullcalendar/daygrid": "6.1.17", 14 | "@fullcalendar/interaction": "6.1.17", 15 | "@neovici/nullxlsx": "^3.1.0", 16 | "@popperjs/core": "^2.11.8", 17 | "adm-zip": "^0.5.16", 18 | "bcrypt": "^6.0.0", 19 | "bootstrap": "^5.3.6", 20 | "cl-editor": "^2.3.0", 21 | "content-type": "^1.0.5", 22 | "date-holidays": "^3.24.3", 23 | "dayjs": "^1.11.13", 24 | "dayjs-precise-range": "^1.0.1", 25 | "docker-names": "^1.2.1", 26 | "file-saver": "^2.0.5", 27 | "frappe-charts": "1.6.2", 28 | "frappe-datatable": "^1.18.4", 29 | "frappe-gantt": "^0.6.1", 30 | "hotkeys-js": "^3.13.10", 31 | "is-dark": "^1.0.4", 32 | "jquery": "3.7.1", 33 | "jquery-serializejson": "^3.2.1", 34 | "ldapjs": "3.0.7", 35 | "math-expression-evaluator": "^2.0.6", 36 | "meteor-node-stubs": "^1.2.19", 37 | "namedavatar": "^1.2.0", 38 | "node-emoji": "^2.2.0", 39 | "quill-delta-to-html": "^0.12.1", 40 | "randomcolor": "^0.6.2", 41 | "raw-body": "^3.0.0", 42 | "sortablejs": "^1.15.6", 43 | "tiny-date-picker": "^3.2.8", 44 | "vm2": "^3.9.19" 45 | }, 46 | "devDependencies": { 47 | "eslint": "^8.5.7", 48 | "eslint-config-airbnb": "^19.0.4", 49 | "eslint-import-resolver-meteor": "^0.4.0", 50 | "eslint-plugin-i18next": "^6.1.1", 51 | "eslint-plugin-import": "^2.31.0", 52 | "eslint-plugin-meteor": "^7.3.0" 53 | }, 54 | "apidoc": { 55 | "name": "titra API", 56 | "version": "0.85.0", 57 | "description": "
This is the official titra API documentation. For more information about the open source timetracking application developed with lots of ❤️ and ☕️ by kromit checkout titra.io.
", 58 | "sampleUrl": "https://app.titra.io" 59 | }, 60 | "meteor": { 61 | "mainModule": { 62 | "client": "client/main.js", 63 | "server": "server/main.js" 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kromitgmbh/titra/005732aa9311bfea893a8349de651271a4740d41/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kromitgmbh/titra/005732aa9311bfea893a8349de651271a4740d41/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kromitgmbh/titra/005732aa9311bfea893a8349de651271a4740d41/public/favicon.ico -------------------------------------------------------------------------------- /public/favicons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kromitgmbh/titra/005732aa9311bfea893a8349de651271a4740d41/public/favicons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/favicons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kromitgmbh/titra/005732aa9311bfea893a8349de651271a4740d41/public/favicons/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kromitgmbh/titra/005732aa9311bfea893a8349de651271a4740d41/public/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kromitgmbh/titra/005732aa9311bfea893a8349de651271a4740d41/public/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kromitgmbh/titra/005732aa9311bfea893a8349de651271a4740d41/public/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicons/favicon-record-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kromitgmbh/titra/005732aa9311bfea893a8349de651271a4740d41/public/favicons/favicon-record-16x16.png -------------------------------------------------------------------------------- /public/favicons/favicon-record-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kromitgmbh/titra/005732aa9311bfea893a8349de651271a4740d41/public/favicons/favicon-record-32x32.png -------------------------------------------------------------------------------- /public/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kromitgmbh/titra/005732aa9311bfea893a8349de651271a4740d41/public/favicons/favicon.ico -------------------------------------------------------------------------------- /public/favicons/favicon_record.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kromitgmbh/titra/005732aa9311bfea893a8349de651271a4740d41/public/favicons/favicon_record.ico -------------------------------------------------------------------------------- /public/favicons/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "titra", 3 | "short_name": "titra", 4 | "start_url": "/", 5 | "scope": "/", 6 | "background_color": "#455A64", 7 | "orientation": "portrait", 8 | "description": "titra", 9 | "icons": [ 10 | { 11 | "src": "android-chrome-192x192.png", 12 | "sizes": "192x192", 13 | "type": "image\/png" 14 | }, 15 | { 16 | "src": "android-chrome-512x512.png", 17 | "sizes": "512x512", 18 | "type": "image\/png" 19 | }, 20 | { 21 | "src": "maskable_icon.png", 22 | "sizes": "1024x1024", 23 | "type": "image\/png", 24 | "purpose": "any maskable" 25 | } 26 | ], 27 | "theme_color": "#455A64", 28 | "display": "standalone" 29 | } 30 | -------------------------------------------------------------------------------- /public/favicons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kromitgmbh/titra/005732aa9311bfea893a8349de651271a4740d41/public/favicons/mstile-150x150.png -------------------------------------------------------------------------------- /public/img/ai_working.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kromitgmbh/titra/005732aa9311bfea893a8349de651271a4740d41/public/img/ai_working.gif -------------------------------------------------------------------------------- /public/img/appgrid-128x128.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/img/gitlab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/img/grain-24x24.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/img/market-150x150.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/img/market-big-300x300.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/img/maskable_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kromitgmbh/titra/005732aa9311bfea893a8349de651271a4740d41/public/img/maskable_icon.png -------------------------------------------------------------------------------- /public/img/navbar_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Layer 1 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/img/wekan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kromitgmbh/titra/005732aa9311bfea893a8349de651271a4740d41/public/img/wekan.png -------------------------------------------------------------------------------- /public/img/zammad.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | logo 5 | Created with Sketch. 6 | 7 | 8 | 29 | 30 | -------------------------------------------------------------------------------- /public/offline.html: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 17 | 18 | 19 | titra | disconnected 20 | 36 | 37 | 38 | 39 |

Sorry!

40 |

We were unable to connect to the titra server.

41 |

Please check your network connection and try again.

42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /public/sw.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2015, 2019 Google Inc. All Rights Reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License") 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | */ 13 | 14 | // Incrementing OFFLINE_VERSION will kick off the install event and force 15 | // previously cached resources to be updated from the network. 16 | const OFFLINE_VERSION = 1 17 | const CACHE_NAME = 'offline' 18 | // Customize this with a different URL if needed. 19 | const OFFLINE_URL = 'offline.html' 20 | 21 | self.addEventListener('install', (event) => { 22 | event.waitUntil((async () => { 23 | const cache = await caches.open(CACHE_NAME) 24 | // Setting {cache: 'reload'} in the new request will ensure that the response 25 | // isn't fulfilled from the HTTP cache i.e., it will be from the network. 26 | await cache.add(new Request(OFFLINE_URL, { cache: 'reload' })) 27 | })()) 28 | }) 29 | 30 | self.addEventListener('activate', (event) => { 31 | event.waitUntil((async () => { 32 | // Enable navigation preload if it's supported. 33 | // See https://developers.google.com/web/updates/2017/02/navigation-preload 34 | if ('navigationPreload' in self.registration) { 35 | await self.registration.navigationPreload.enable() 36 | } 37 | })()) 38 | 39 | // Tell the active service worker to take control of the page immediately. 40 | self.clients.claim() 41 | }) 42 | 43 | self.addEventListener('fetch', (event) => { 44 | // We only want to call event.respondWith() if this is a navigation request 45 | // for an HTML page. 46 | if (event.request.mode === 'navigate') { 47 | event.respondWith((async () => { 48 | try { 49 | // First, try to use the navigation preload response if it's supported. 50 | const preloadResponse = await event.preloadResponse 51 | if (preloadResponse) { 52 | return preloadResponse 53 | } 54 | 55 | const networkResponse = await fetch(event.request) 56 | return networkResponse 57 | } catch (error) { 58 | // catch is only triggered if an exception is thrown, which is likely 59 | // due to a network error. 60 | // If fetch() returns a valid HTTP response with a response code in 61 | // the 4xx or 5xx range, the catch() will NOT be called. 62 | console.error('Fetch failed returning offline page instead.', error) 63 | 64 | const cache = await caches.open(CACHE_NAME) 65 | const cachedResponse = await cache.match(OFFLINE_URL) 66 | return cachedResponse 67 | } 68 | })()) 69 | } 70 | 71 | // If our if() condition is false, then this fetch handler won't intercept the 72 | // request. If there are any other fetch handlers registered, they will get a 73 | // chance to call event.respondWith(). If no fetch handlers call 74 | // event.respondWith(), the request will be handled by the browser as if there 75 | // were no service worker involvement. 76 | }) 77 | -------------------------------------------------------------------------------- /server/bodyparser.js: -------------------------------------------------------------------------------- 1 | import contentType from 'content-type' 2 | import getRawBody from 'raw-body' 3 | 4 | /** 5 | * @file Portions of code re-used from the zeit/micro repository 6 | * @see https://github.com/zeit/micro/blob/master/lib/index.js 7 | * @license 8 | * 9 | * The MIT License (MIT) 10 | * 11 | * Copyright (c) 2018 ZEIT, Inc. 12 | * 13 | * Permission is hereby granted, free of charge, to any person obtaining a copy 14 | * of this software and associated documentation files (the "Software"), to deal 15 | * in the Software without restriction, including without limitation the rights 16 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | * copies of the Software, and to permit persons to whom the Software is 18 | * furnished to do so, subject to the following conditions: 19 | * 20 | * The above copyright notice and this permission notice shall be included in all 21 | * copies or substantial portions of the Software. 22 | * 23 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | * SOFTWARE. 30 | */ 31 | 32 | 33 | /** 34 | * Maps requests to buffered raw bodies so that 35 | * multiple calls to `json` work as expected 36 | * @type {WeakMap} 37 | */ 38 | const rawBodyMap = new WeakMap() 39 | 40 | const parseJSON = (str) => { 41 | try { 42 | return JSON.parse(str) 43 | } catch (err) { 44 | throw new Error('Invalid JSON') 45 | } 46 | } 47 | 48 | export const getBuffer = (req, { limit = '1mb', encoding } = {}) => Promise.resolve().then(() => { 49 | const type = req.headers['content-type'] || 'text/plain' 50 | const length = req.headers['content-length'] 51 | // eslint-disable-next-line no-undefined 52 | if (encoding === undefined) { 53 | encoding = contentType.parse(type).parameters.charset 54 | } 55 | const body = rawBodyMap.get(req) 56 | if (body) { 57 | return body 58 | } 59 | return getRawBody(req, { limit, length, encoding }) 60 | .then((buf) => { 61 | rawBodyMap.set(req, buf) 62 | return buf 63 | }) 64 | .catch((e) => { 65 | throw new Error(e) 66 | }) 67 | }) 68 | 69 | export const getText = (req, { limit, encoding } = {}) => getBuffer(req, { limit, encoding }) 70 | .then((body) => body.toString(encoding)) 71 | 72 | export const getJson = (req, opts) => getText(req, opts).then((body) => parseJSON(body)) 73 | -------------------------------------------------------------------------------- /server/main.js: -------------------------------------------------------------------------------- 1 | import '../imports/startup/server' 2 | import './APIroutes' 3 | --------------------------------------------------------------------------------