├── .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 | [](https://github.com/kromitgmbh/titra/actions/workflows/push.yml)  
2 |
3 |
4 | #  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 | 
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 | [](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 |
17 |
20 |
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 |
2 | {{>toast}}
3 | {{>navbar}}
4 |
5 |
6 |
7 | {{>connectioncheck}}
8 | {{> yield}}
9 |
10 |
11 |
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 |
2 | 404
3 |
4 | {{t "globals.not_found"}}
5 |
6 |
7 |
--------------------------------------------------------------------------------
/imports/ui/pages/about.html:
--------------------------------------------------------------------------------
1 |
2 |
52 |
--------------------------------------------------------------------------------
/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 |
2 |
3 |
6 |
7 |
{{t "administration.uploadExtension"}}
8 |
9 |
{{t "administration.extensionStore"}} Extension Store !
10 |
11 |
12 | {{#if extensions}}
13 |
42 |
43 | {{t "administration.extensionHint"}}
44 |
45 | {{else}}
46 | {{t "administration.no_extensions"}}
47 | {{/if}}
48 |
--------------------------------------------------------------------------------
/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 |
2 |
3 |
33 |
{{t "administration.inboundinterfaces"}}
34 |
35 |
36 |
37 | {{t "globals.name"}}
38 | {{t "globals.description"}}
39 | {{t "tracktime.actions"}}
40 |
41 |
42 | {{#each inboundInterface in inboundInterfaces}}
43 |
44 | {{inboundInterface.name}}
45 | {{inboundInterface.description}}
46 |
47 |
48 |
49 |
50 |
51 | {{/each}}
52 |
53 |
54 |
--------------------------------------------------------------------------------
/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 |
2 | {{#if getGlobalSetting "enableOpenIDConnect"}}
3 |
4 | {{t "administration.oidc_configure"}}: {{siteUrl}}_oauth/oidc
5 |
6 |
30 |
31 |
32 | {{t "navigation.save"}}
33 |
34 |
35 | {{/if}}
36 |
--------------------------------------------------------------------------------
/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 |
2 |
3 |
6 |
7 |
8 |
9 | {{>limitpicker}}
10 |
11 |
17 |
18 |
19 |
20 | {{> datatable columns=columns data=transactions}}
21 |
--------------------------------------------------------------------------------
/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 |
2 |
53 |
54 |
--------------------------------------------------------------------------------
/imports/ui/pages/details/components/dailytimetable.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{#if dailyTimecards}}
6 | CSV
7 | Excel
8 | {{else}}
9 | CSV
10 | Excel
11 | {{/if}}
12 | {{#each outboundInterface in outboundInterfaces}}
13 | {{outboundInterface.description}}
14 | {{/each}}
15 |
16 |
17 |
18 |
19 |
20 |
21 | {{#if dailyTimecards}}
22 | {{>pagination totalEntries=totalEntries limit=limit}}
23 | {{/if}}
24 |
25 |
26 | {{>limitpicker}}
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/imports/ui/pages/details/components/filterbar.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | {{>multiselectfilter items=projects name="projectselect" all="overview.all_projects" label="globals.project"}}
8 |
9 |
10 | {{>periodpicker}}
11 |
12 | {{#if getGlobalSetting 'showResourceInDetails'}}
13 |
14 | {{>multiselectfilter items=resources name="resourceselect" all="resource.all" label="globals.resource"}}
15 |
16 | {{/if}}
17 |
18 | {{>multiselectfilter items=customers name="customerselect" all="customer.all" label="globals.customer"}}
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/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 |
2 |
3 |
{{t "limit.show"}}
4 |
5 |
6 |
7 | 10
8 | 25
9 | 50
10 | 100
11 | 200
12 | {{t "limit.all"}}
13 |
14 |
15 |
16 |
17 |
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 |
2 |
3 |
4 | {{t all}}
5 | {{#each item in items}}
6 | {{item.name}}
7 | {{/each}}
8 |
9 |
10 | {{t label}}
11 |
12 |
--------------------------------------------------------------------------------
/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 |
2 |
3 |
12 |
13 |
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 |
2 |
3 |
4 | {{t "period.currentMonth"}}
5 | {{t "period.currentWeek"}}
6 | {{t "period.currentYear"}}
7 | {{t "period.lastMonth"}}
8 | {{t "period.last3months"}}
9 | {{t "period.lastWeek"}}
10 | {{t "period.lastYear"}}
11 | {{t "period.custom"}}
12 | {{t "period.all"}}
13 |
14 |
15 |
16 | {{t "period.time_period"}}
17 |
18 |
43 |
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 |
2 |
3 |
4 |
5 | {{#if periodTimecards}}
6 | CSV
7 | Excel
8 | {{else}}
9 | CSV
10 | Excel
11 | {{/if}}
12 | {{#each outboundInterface in outboundInterfaces}}
13 | {{outboundInterface.description}}
14 | {{/each}}
15 |
16 |
17 |
18 |
19 |
20 |
21 | {{#if periodTimecards}}
22 | {{>pagination totalEntries=totalPeriodTimeCards limit=limit}}
23 | {{/if}}
24 |
25 |
26 | {{>limitpicker}}
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/imports/ui/pages/details/components/workingtimetable.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{#if workingTimeEntries}}
6 | CSV
7 | Excel
8 | {{else}}
9 | CSV
10 | Excel
11 | {{/if}}
12 |
13 |
14 |
17 |
18 |
19 |
20 | {{>limitpicker}}
21 |
22 | {{#if workingTimeEntries}}
23 | {{>pagination totalEntries=totalWorkingTimeEntries limit=limit}}
24 | {{/if}}
25 |
26 |
27 |
--------------------------------------------------------------------------------
/imports/ui/pages/details/dashboard.html:
--------------------------------------------------------------------------------
1 |
2 | {{#if isCustomerDashboard}}
3 | {{customer}} dashboard {{#if dashBoardResource}} ({{dashBoardResource}}) {{/if}}
4 | {{else}}
5 | {{#if dashboardId}}
6 | {{projectName}}
7 | {{else}}
8 | {{#unless timecards}}
9 | {{t "tabular.sZeroRecords"}}
10 | {{/unless}}
11 | {{/if}}
12 |
13 |
14 | {{#if timecards}}
15 |
{{t "dashboard.time_per_date"}}
16 | {{/if}}
17 |
18 |
19 |
20 |
21 | {{#if timecards}}
22 |
{{t "dashboard.time_distribution_task"}}
23 | {{/if}}
24 |
25 |
26 |
27 |
28 | {{/if}}
29 | {{#if dashboardId}}
30 | {{#if timecards}}
31 | {{t "dashboard.detailed_working_time"}} ({{startDate}} - {{endDate}})
32 |
33 |
34 |
35 |
36 | {{t "globals.date"}}
37 |
38 |
39 | {{t "globals.task"}}
40 |
41 |
42 | {{timeunit}}
43 |
44 |
45 |
46 | {{#each timecard in timecards}}
47 |
48 |
49 |
50 | {{formatDate (timecard.date)}}
51 |
52 |
53 | {{emojify (timecard.task)}}
54 |
55 |
56 | {{timeInUnit (timecard.hours)}}
57 |
58 |
59 |
60 | {{/each}}
61 |
62 |
63 |
64 | {{t "details.total"}}:
65 |
66 |
67 | {{totalHours}}
68 |
69 |
70 |
71 |
72 | {{/if}}
73 | {{/if}}
74 |
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 |
2 |
3 |
4 |
{{t "overview.summary"}}
5 |
{{t 'overview.number_projects'}}: {{projectCount}}
6 |
{{t "overview.total_time"}} {{#if timeInUserUnit totalHours}} {{timeInUserUnit(totalHours)}} {{else}} 0 {{/if}}({{timeunitVerbose}})
7 |
8 |
{{t "details.filter"}}
9 |
10 |
11 |
12 |
13 | {{t "settings.show_not_billable_time"}}
14 |
15 |
16 |
17 | {{t "navigation.show_archived"}}
18 |
19 |
20 | {{>periodpicker}}
21 |
22 |
23 |
24 | 25
25 | 50
26 | 100
27 | 250
28 | 500
29 |
30 | {{t "limit.show"}}
31 |
32 |
33 |
34 |
35 |
36 |
{{t "overview.project_distribution"}}
37 |
40 |
41 |
42 |
{{t "overview.three_month_history"}}
43 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/imports/ui/pages/overview/components/dashboardModal.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
11 |
12 |
13 | {{t "globals.project"}}
14 |
15 |
16 | {{>periodpicker}}
17 |
18 |
{{t "administration.create"}}
19 | {{#if dashboardId}}
20 |
21 |
22 |
23 |
{{t "dashboard.copy_instructions"}}
24 | {{t "dashboard.security_notice"}}
25 | {{/if}}
26 |
27 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/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 |
2 | {{#if percentage}}
3 |
4 |
5 | {{#if isPercentageAbove15}}{{t "overview.percentage"}}:{{/if}} {{percentage}} %
6 |
7 |
8 | {{/if}}
9 |
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 |
2 |
3 |
4 |
{{t "overview.summary"}}
5 | {{#if projectDescAsHtml}}
6 |
7 | {{{truncatedProjectDescAsHtml}}}
8 |
9 | {{/if}}
10 | {{#if componentIsReady}}
11 | {{#if allTeamMembers}}
12 |
{{t "overview.team"}}: {{#each teamMember in allTeamMembers}}
13 | {{{avatarImg teamMember.profile.avatar teamMember.profile.name teamMember.profile.avatarColor}}}
14 | {{/each}}
15 | {{/if}}
16 | {{#if customer}}
17 |
{{t "globals.customer"}}:
{{customer}}
18 | {{/if}}
19 | {{#if totalHours}}
20 |
{{t "overview.total_time"}}: {{timeInUserUnit(totalHours)}} ({{timeunitVerbose}}) {{{hourIndicator}}}
21 | {{/if}}
22 | {{#if target}}
23 |
{{t "overview.target"}}: {{timeInUserUnit(target)}} ({{timeunitVerbose}})
24 | {{/if}}
25 | {{#if startDate}}
26 |
{{t "overview.startDate"}}: {{startDate}}
27 | {{/if}}
28 | {{#if endDate}}
29 |
{{t "overview.endDate"}}: {{endDate}}
30 | {{/if}}
31 | {{#if turnOver}}
32 |
{{t "overview.turnover"}}: {{turnOver}} {{unit}}
33 | {{/if}}
34 | {{else}}
35 | {{t "globals.loading"}}
36 | {{/if}}
37 |
38 |
39 |
{{t "overview.top_tasks"}}
40 |
43 |
44 |
45 |
{{t "overview.three_month_history"}}
46 |
49 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/imports/ui/pages/overview/editproject/components/projectAccessRights.html:
--------------------------------------------------------------------------------
1 |
2 | {{#unless public}}
3 |
10 |
14 | {{/unless}}
15 | {{#unless disablePublic}}
16 |
17 | {{#if public}}
18 |
19 | {{else}}
20 |
21 | {{/if}}
22 | {{t "project.public_project"}}
23 |
24 | {{/unless}}
25 |
--------------------------------------------------------------------------------
/imports/ui/pages/overview/editproject/components/wekanInterfaceSettings.html:
--------------------------------------------------------------------------------
1 |
2 |
9 | {{#unless displayHelp}}
10 | {{t "project.wekan_help"}} wiki .
11 | {{/unless}}
12 |
13 | {{#if wekanLists}}
14 |
15 | {{/if}}
16 |
17 | {{#if wekanSwimlanes}}
18 |
{{t "globals.or"}}
19 | {{/if}}
20 |
21 |
22 |
--------------------------------------------------------------------------------
/imports/ui/pages/register.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{#if getGlobalSetting "disableUserRegistration"}}
6 |
7 | {{t "login.registration_disabled_warning"}}
8 |
9 | {{else}}
10 |
61 | {{/if}}
62 |
63 |
64 |
65 |
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 |
2 |
62 |
63 |
--------------------------------------------------------------------------------
/imports/ui/pages/track/components/calendar.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{#each project in projects}}
5 |
7 | {{/each}}
8 |
9 |
10 | {{t "tracktime.month_drag_help_text"}}
11 |
12 |
13 |
18 |
19 | {{>editTimeEntryModal tcid=tcid selectedDate=selectedDate selectedProjectId=selectedProjectId}}
20 |
21 |
--------------------------------------------------------------------------------
/imports/ui/pages/track/components/editTimeEntryModal.html:
--------------------------------------------------------------------------------
1 |
2 |
22 |
--------------------------------------------------------------------------------
/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 |
2 |
43 |
--------------------------------------------------------------------------------
/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 |
2 | {{t "task.newTask"}}
3 |
4 |
5 | {{>taskModal editTaskID=editTaskID}}
6 |
--------------------------------------------------------------------------------
/imports/ui/pages/track/components/projectsearch.html:
--------------------------------------------------------------------------------
1 |
2 |
12 | {{#if displayProjectInfo}}
13 | {{>projectInfoPopup projectId=selectedId}}
14 | {{/if}}
15 |
16 |
--------------------------------------------------------------------------------
/imports/ui/pages/track/components/tasksearch.html:
--------------------------------------------------------------------------------
1 |
2 | {{#if isComponent}}
3 |
4 | {{else}}
5 |
24 | {{/if}}
25 |
26 |
--------------------------------------------------------------------------------
/imports/ui/pages/track/components/timeline.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{t "globals.date"}}
7 | {{#each project in projectList}}
8 | {{getProjectName project}}
9 | {{/each}}
10 |
11 |
12 |
13 | {{#each date in dateRange}}
14 |
15 |
16 | {{formatDate date}}
17 |
18 | {{t "project.add"}}
19 |
20 | {{#each project in projectList}}
21 |
22 | {{#each entry in getTimeEntriesForDateAndProject date project}}
23 | {{entry.task}} ({{entry.hours}})
24 |
25 | {{/each}}
26 | {{t "project.add"}}
27 |
28 | {{/each}}
29 |
30 | {{/each}}
31 |
32 |
33 |
34 | {{>editTimeEntryModal tcid=tcid selectedDate=selectedDate selectedProjectId=selectedProjectId}}
35 |
--------------------------------------------------------------------------------
/imports/ui/pages/track/components/timetracker.html:
--------------------------------------------------------------------------------
1 |
2 | {{#if timerIsRunning}}
3 | {{#if isWidget}}
4 |
5 |
6 |
7 |
8 |
9 |
10 | {{else}}
11 |
20 | {{/if}}
21 | {{else}}
22 |
31 | {{/if}}
32 |
33 |
--------------------------------------------------------------------------------
/imports/ui/pages/track/components/usersearch.html:
--------------------------------------------------------------------------------
1 |
2 | {{#if isComponent}}
3 |
4 | {{else}}
5 |
12 | {{/if}}
13 |
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 |
2 | {{t "navigation.back"}}
3 |
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 |
2 | {{#if offline}}
3 |
4 | {{t "notifications.connectioncheck"}} {{nextRetry}} ...
{{t "navigation.retry"}}
5 |
6 | {{/if}}
7 |
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 |
2 |
3 |
--------------------------------------------------------------------------------
/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 |
2 | {{#if canBeModified}}
3 |
6 | {{/if}}
7 |
8 |
--------------------------------------------------------------------------------
/imports/ui/shared components/toast.html:
--------------------------------------------------------------------------------
1 |
2 |
16 |
--------------------------------------------------------------------------------
/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 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
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 |
--------------------------------------------------------------------------------