Compare commits

..

3 Commits

Author SHA1 Message Date
Kevin Papst
b1cc5f2587 fix test 2023-10-31 16:58:47 +01:00
Kevin Papst
6b672a23b4 new "customer overview" listing report 2023-10-31 16:32:11 +01:00
Kevin Papst
900254b83a change default visibility 2023-10-31 16:30:01 +01:00
2359 changed files with 57467 additions and 95894 deletions

View File

@@ -1,20 +0,0 @@
<VirtualHost *:8001>
ServerAdmin webmaster@localhost
DocumentRoot /opt/kimai/public
PassEnv MAILER_FROM
PassEnv APP_ENV
PassEnv APP_SECRET
PassEnv DATABASE_URL
PassEnv MAILER_URL
PassEnv TRUSTED_PROXIES
<Directory "/opt/kimai/public">
Require all granted
DirectoryIndex index.php
AllowOverride All
</Directory>
</VirtualHost>
ServerName localhost

View File

@@ -1,51 +0,0 @@
<?php
$DATABASE_HOST = urldecode($argv[1]);
$DATABASE_BASE = urldecode($argv[2]);
$DATABASE_PORT = $argv[3];
$DATABASE_USER = urldecode($argv[4]);
$DATABASE_PASS = urldecode($argv[5]);
echo "Testing DB:";
try {
$pdo = new \PDO("mysql:host=$DATABASE_HOST;dbname=$DATABASE_BASE;port=$DATABASE_PORT", "$DATABASE_USER", "$DATABASE_PASS", [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION
]);
} catch(\Exception $ex) {
switch ($ex->getCode()) {
case 1045:
// we can immediately stop here and show the error message
echo 'Access denied (1045)';
die(1);
case 1049:
// error "Unknown database (1049)" can be ignored, the database will be created by Kimai
return;
// a lot of errors share the same meaningless error code zero
case 0:
// this error includes the database name, so we can only search for the static part of the error message
if (stripos($ex->getMessage(), 'SQLSTATE[HY000] [1049] Unknown database') !== false) {
// error "Unknown database (1049)" can be ignored, the database will be created by Kimai
return;
}
switch ($ex->getMessage()) {
// eg. no response (fw) - the startup script should retry it a couple of times
case 'SQLSTATE[HY000] [2002] Operation timed out':
echo 'Operation timed out (0-2002)';
die(4);
// special case "localhost" with a stopped db server (should not happen in docker compose setup)
case 'SQLSTATE[HY000] [2002] No such file or directory':
echo 'Connection could not be established (0-2002)';
die(5);
// using IP with stopped db server - the startup script should retry it a couple of times
case 'SQLSTATE[HY000] [2002] Connection refused':
echo 'Connection refused (0-2002)';
die(5);
}
echo $ex->getMessage() . " (0)";
die(7);
default:
// unknown error
echo $ex->getMessage() . " (?)";
die(10);
}
}

View File

@@ -1,102 +0,0 @@
#!/bin/bash -x
KIMAI=$(cat /opt/kimai/version.txt)
echo $KIMAI
function waitForDB() {
# Parse sql connection data
DATABASE_USER=$(awk -F '[/:@]' '{print $4}' <<< "$DATABASE_URL")
DATABASE_PASS=$(awk -F '[/:@]' '{print $5}' <<< "$DATABASE_URL")
DATABASE_HOST=$(awk -F '[/:@]' '{print $6}' <<< "$DATABASE_URL")
DATABASE_PORT=$(awk -F '[/:@]' '{print $7}' <<< "$DATABASE_URL")
DATABASE_BASE=$(awk -F '[/?]' '{print $4}' <<< "$DATABASE_URL")
re='^[0-9]+$'
if ! [[ $DATABASE_PORT =~ $re ]] ; then
DATABASE_PORT=3306
fi
echo "Wait for database connection ..."
until php /dbtest.php "$DATABASE_HOST" "$DATABASE_BASE" "$DATABASE_PORT" "$DATABASE_USER" "$DATABASE_PASS"; do
echo Checking DB: $?
sleep 3
done
echo "Connection established"
}
function handleStartup() {
# set mem limits and copy in custom logger config
if [ -z "$memory_limit" ]; then
memory_limit=512M
fi
sed -i "s/memory_limit.*/memory_limit=$memory_limit/g" /usr/local/etc/php/php.ini
cp /assets/monolog.yaml /opt/kimai/config/packages/monolog.yaml
if [ -z "$USER_ID" ]; then
USER_ID=$(id -u www-data)
fi
if [ -z "$GROUP_ID" ]; then
GROUP_ID=$(id -g www-data)
fi
# if group doesn't exist
if grep -w "$GROUP_ID" /etc/group &>/dev/null; then
echo Group already exists
else
echo www-kimai:x:"$GROUP_ID": >> /etc/group
grpconv
fi
# if user doesn't exist
if id "$USER_ID" &>/dev/null; then
echo User already exists
else
echo www-kimai:x:"$USER_ID":"$GROUP_ID":www-kimai:/var/www:/usr/sbin/nologin >> /etc/passwd
pwconv
fi
if [ -e /use_apache ]; then
export APACHE_RUN_USER=$(id -nu "$USER_ID")
# This doesn't _exactly_ run as the specified GID, it runs as the GID of the specified user but WTF
export APACHE_RUN_GROUP=$(id -ng "$USER_ID")
export APACHE_PID_FILE=/var/run/apache2/apache2.pid
export APACHE_RUN_DIR=/var/run/apache2
export APACHE_LOCK_DIR=/var/lock/apache2
export APACHE_LOG_DIR=/var/log/apache2
export LANG=C
elif [ -e /use_fpm ]; then
sed -i "s/user = .*/user = $USER_ID/g" /usr/local/etc/php-fpm.d/www.conf
sed -i "s/group = .*/group = $GROUP_ID/g" /usr/local/etc/php-fpm.d/www.conf
echo "Setting fpm to run as ${USER_ID}:${GROUP_ID}"
else
echo "Error, unknown server type"
fi
}
function prepareKimai() {
# These are idempotent, so we can run them on every start-up
/opt/kimai/bin/console -n kimai:install
if [ ! -z "$ADMINPASS" ] && [ ! -a "$ADMINMAIL" ]; then
/opt/kimai/bin/console kimai:user:create admin "$ADMINMAIL" ROLE_SUPER_ADMIN "$ADMINPASS"
fi
echo "$KIMAI" > /opt/kimai/var/installed
echo "Kimai is ready"
}
function runServer() {
# Just while I'm fixing things
/opt/kimai/bin/console kimai:reload --env="$APP_ENV"
chown -R $USER_ID:$GROUP_ID /opt/kimai/var
if [ -e /use_apache ]; then
exec /usr/sbin/apache2 -D FOREGROUND
elif [ -e /use_fpm ]; then
exec php-fpm
else
echo "Error, unknown server type"
fi
}
waitForDB
handleStartup
prepareKimai
runServer

View File

@@ -1,48 +0,0 @@
when@prod:
monolog:
channels: ["deprecation"]
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [403, 404]
nested:
type: stream
level: info
path: php://stderr
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: stream
channels: ["deprecation"]
path: php://stderr
when@dev:
monolog:
channels: ["deprecation"]
handlers:
main:
type: stream
path: php://stderr
level: info
channels: ["!event"]
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
deprecation:
type: stream
channels: ["deprecation"]
path: php://stderr
when@test:
monolog:
handlers:
main:
type: stream
path: php://stderr
level: info
channels: ["!event"]

View File

@@ -1,17 +1,23 @@
# editorconfig.org
; top-most EditorConfig file
root = true
; Unix-style newlines
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
charset = utf-8
[*.php]
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
indent_size = 4
[{compose.yaml,compose.*.yaml}]
indent_size = 2
[*.twig]
indent_style = space
indent_size = 4
[*.md]
trim_trailing_whitespace = false
[*.yaml]
indent_style = space
indent_size = 4
[*.yml]
indent_style = space
indent_size = 4

View File

@@ -1,18 +1,7 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
# You should NOT use this file in production, but instead move the environment variables to your webserver configuration.
# The .env file is only existing to simplify the initial setup!
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
#================================================================================
# Configure your database connection and set the correct server version.
@@ -27,34 +16,32 @@
# For MySQL that would be "serverVersion=5.7" as in:
# DATABASE_URL=mysql://user:password@127.0.0.1:3306/database?charset=utf8mb4&serverVersion=5.7
#
# For MariaDB it would be "serverVersion=10.11.15-MariaDB":
# DATABASE_URL=mysql://user:password@127.0.0.1:3306/database?charset=utf8mb4&serverVersion=10.11.15-MariaDB
# For MariaDB it would be "serverVersion=10.5.8-MariaDB":
# DATABASE_URL=mysql://user:password@127.0.0.1:3306/database?charset=utf8mb4&serverVersion=10.5.8-MariaDB
#
DATABASE_URL=mysql://user:password@127.0.0.1:3306/database?charset=utf8mb4&serverVersion=10.11.15-MariaDB
DATABASE_URL=mysql://user:password@127.0.0.1:3306/database?charset=utf8mb4&serverVersion=10.5.8-MariaDB
# Running behind reverse proxies? Try these:
# TRUSTED_PROXIES=127.0.0.1,127.0.0.2
# TRUSTED_HOSTS=localhost|example.com
###> symfony/framework-bundle ###
APP_ENV=prod
APP_SECRET=
APP_SHARE_DIR=var/share
###< symfony/framework-bundle ###
###> symfony/mailer ###
# Documentation at https://www.kimai.org/documentation/emails.html
#================================================================================
# The full documentation can be found at https://www.kimai.org/documentation/emails.html
#
# Email will be sent with this address as sender:
MAILER_FROM=kimai@example.com
# Email connection (disabled by default) - see documentation for the format
MAILER_URL=null://null
###< symfony/mailer ###
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###
#================================================================================
# do not change, unless you are developing for Kimai
APP_ENV=prod
###> symfony/routing ###
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
DEFAULT_URI=http://localhost
###< symfony/routing ###
#================================================================================
# should be changed to a unique character sequence, used for hashing cookies
APP_SECRET=change_this_to_something_unique
#================================================================================
# Running behind reverse proxies? Try these:
# TRUSTED_PROXIES=127.0.0.1,127.0.0.2
# TRUSTED_HOSTS=localhost,example.com
#================================================================================
# unlikely, that you need to change this one
CORS_ALLOW_ORIGIN=^https?://localhost(:[0-9]+)?$

11
.eslintrc.js Normal file
View File

@@ -0,0 +1,11 @@
module.exports = {
env: {
"browser": true,
"node": true,
"es6": true,
"amd": true,
},
parser: '@babel/eslint-parser',
extends: ['eslint:recommended'],
ignorePatterns: ["assets/*.js"],
}

20
.gitattributes vendored
View File

@@ -1,20 +0,0 @@
.docker export-ignore
.github export-ignore
assets export-ignore
tests export-ignore
.codecov.yml export-ignore
.editorconfig export-ignore
eslint.config.js export-ignore
eslint.config.mjs export-ignore
.gitattributes export-ignore
.gitignore export-ignore
.php-cs-fixer.dist.php export-ignore
php-cs-fixer.dist.php export-ignore
babel.config.js export-ignore
package.json export-ignore
php-cs-fixer.sh export-ignore
phpstan.neon export-ignore
phpstan.sh export-ignore
phpunit.xml.dist export-ignore
webpack.config.js export-ignore
yarn.lock export-ignore

3
.github/FUNDING.yml vendored
View File

@@ -1,3 +1,2 @@
github: [kevinpapst]
open_collective: kimai
custom: ["https://www.kimai.org/", "https://www.kimai.cloud/", "https://www.kevinpapst.de/"]
custom: ["https://www.kimai.org/donate/", "https://www.paypal.me/kevinpapst"]

View File

@@ -53,12 +53,12 @@ body:
attributes:
label: Which PHP version are you using?
options:
- "8.5"
- "8.4"
- "8.2"
- "8.3"
- "8.1"
- Unknown
- "8.2"
- "8.0"
- "7.4"
- "7.3"
- Other (please mention below)
validations:
required: true

View File

@@ -3,9 +3,12 @@ contact_links:
- name: Read our documentation
url: https://www.kimai.org/documentation/
about: Save your time! There is an instant solution for many issues in the documentation.
- name: Docker image issue tracker
url: https://github.com/tobybatch/kimai2/issues
about: For issues specific for Kimai Docker deployment
- name: Ask a Question
url: https://github.com/kimai/kimai/discussions
about: Want to discuss something with a community? Do it in discussions!
- name: Get Professional support
url: https://www.kimai.org/en/support.html
about: As a customer, you will always receive fast replies from the developer.
url: https://www.kimai.org/store/custom-plugins.html
about: As a customer, you will always receive fast and helpful replies from the developer.

View File

@@ -1,40 +0,0 @@
name: Docker issue
description: Experiencing an issue with your Docker Setup?
labels: [ "docker" ]
body:
- type: markdown
attributes:
value: |
Thank you for reporting an issue with your docker setup! This form will guide you to create a useful issue report.
- type: textarea
id: describe
attributes:
label: Describe the problem
description: >
Please provide a clear and concise description of what the issue is and the steps to reproduce the behaviour:
1. Start the container '...'
2. Click on '....'
3. See error xyz
validations:
required: true
- type: textarea
id: environment
attributes:
label: Describe your setup and add your Docker compose file (redact your credentials)
description: >
Your working environment:
- OS: [e.g. Linux, Windows, Mac]
- Docker version: [e.g. 19.03.2]
- Docker compose version: [e.g. 1.21.0]
Docker compose file:
version: '3.5'
services:
image: ...
validations:
required: true
- type: input
id: command
attributes:
label: Command used to run the container
description: e.g. docker run -v ....

View File

@@ -1,5 +1,17 @@
name-template: '$RESOLVED_VERSION'
tag-template: '$RESOLVED_VERSION'
categories:
- title: 'Enhancements'
labels:
- 'feature request'
- 'technical debt'
- 'translation'
- title: 'Fixed bugs'
labels:
- 'bug'
- title: 'Infrastructure'
labels:
- 'infrastructure'
exclude-labels:
- 'duplicate'
- 'invalid'
@@ -7,7 +19,6 @@ exclude-labels:
- 'release'
exclude-contributors:
- 'dependabot'
- 'weblate'
change-template: '- $TITLE (#$NUMBER)'
change-title-escapes: '\<*_&`#@'
version-resolver:
@@ -22,8 +33,10 @@ version-resolver:
- 'translation'
default: patch
template: |
**Compatible with PHP 8.1 to 8.5**
[Upgrade Kimai](https://www.kimai.org/documentation/updates.html) - [Install Kimai](https://www.kimai.org/documentation/installation.html) - [Docker](https://tobybatch.github.io/kimai2/)
**Compatible with PHP 8.1 and 8.2**
$CHANGES
Involved in this release: $CONTRIBUTORS
Involved in this release: $CONTRIBUTORS - thank you!

View File

@@ -1,92 +1,16 @@
name: 'Docker Build'
on:
workflow_dispatch:
inputs:
kimai_tag:
description: 'Kimai tag to build'
required: true
release:
types: [released]
release:
types: [released]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{secrets.DOCKERHUB_USERNAME}}
password: ${{secrets.DOCKERHUB_PASSWORD}}
- name: Determine version
run: |
input="${{ github.event.inputs.kimai_tag }}"
# Determine between manual trigger and release event
if [ -z "$input" ]; then
echo "Using release tag: ${{ github.event.release.tag_name }}"
version="${{ github.event.release.tag_name }}"
else
echo "Using tag provided: $input"
version="$input"
fi
if [[ ! $version =~ ^2\.(0|[1-9]*)(0?)\.(0|[0-9]*)(0?)$ ]]; then
echo "Invalid version number: $version"
exit 1
fi
echo "kimai_version=$version" >> $GITHUB_ENV
- name: FPM image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
build-args: |
KIMAI=${{ env.kimai_version }}
BASE=fpm
target: prod
platforms: linux/amd64,linux/arm64
tags: |
kimai/kimai2:latest
kimai/kimai2:fpm
push: true
- name: Apache image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
build-args: |
KIMAI=${{ env.kimai_version }}
BASE=apache
target: prod
platforms: linux/amd64,linux/arm64
tags: |
kimai/kimai2:apache
kimai/kimai2:apache-${{ env.kimai_version }}
push: true
- name: Development image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
build-args: |
KIMAI=${{ env.kimai_version }}
BASE=apache
target: dev
platforms: linux/amd64,linux/arm64
tags: |
kimai/kimai2:dev
push: true
build:
name: Trigger docker image build
runs-on: ubuntu-latest
steps:
- name: Emit repository_dispatch
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.DOCKER_ACCESS_TOKEN }}
repository: tobybatch/kimai2
event-type: kimai_release

View File

@@ -1,29 +0,0 @@
name: 'Lock Threads'
on:
schedule:
- cron: '17 1 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
concurrency:
group: lock-threads
jobs:
action:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
with:
process-only: 'issues, prs'
github-token: ${{ secrets.GITHUB_TOKEN }}
issue-inactive-days: '90'
issue-comment: >
This thread has been automatically locked since there has not been any recent activity after it was closed.
Please share your experience with the community and [leave a testimonial](https://love.kimai.org/) to support Kimai.
issue-lock-reason: 'resolved'
pr-inactive-days: '180'
log-output: true

56
.github/workflows/lock.yaml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: 'Lock Threads'
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
concurrency:
group: lock
jobs:
action:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v4
with:
github-token: ${{ github.token }}
issue-inactive-days: '90'
exclude-issue-created-before: ''
exclude-issue-created-after: ''
exclude-issue-created-between: ''
exclude-issue-closed-before: ''
exclude-issue-closed-after: ''
exclude-issue-closed-between: ''
include-any-issue-labels: ''
include-all-issue-labels: ''
exclude-any-issue-labels: ''
add-issue-labels: ''
remove-issue-labels: ''
issue-comment: >
This thread has been automatically locked since there has not been
any recent activity after it was closed. Please open a new issue for
related bugs.
If you use Kimai on a daily basis, please [consider donating](https://www.kimai.org/donate/) to
support further development of Kimai.
issue-lock-reason: 'resolved'
pr-inactive-days: '180'
exclude-pr-created-before: ''
exclude-pr-created-after: ''
exclude-pr-created-between: ''
exclude-pr-closed-before: ''
exclude-pr-closed-after: ''
exclude-pr-closed-between: ''
include-any-pr-labels: ''
include-all-pr-labels: ''
exclude-any-pr-labels: ''
add-pr-labels: ''
remove-pr-labels: ''
pr-comment: ''
pr-lock-reason: ''
process-only: ''
log-output: false

View File

@@ -1,21 +1,23 @@
name: Check .lock files
on:
pull_request_target: null
pull_request: null
push:
branches:
- main
permissions:
pull-requests: read
jobs:
lockfiles:
runs-on: ubuntu-latest
name: Verify lock file integrity
steps:
- name: Clone Kimai
uses: actions/checkout@v3
with:
persist-credentials: false
- name: Prevent file change
uses: xalvarez/prevent-file-change-action@v3
uses: xalvarez/prevent-file-change-action@v1
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
pattern: .*\.lock$|^\.github\/.*$
pattern: .*.lock
trustedAuthors: kevinpapst, dependabot

View File

@@ -25,6 +25,6 @@ jobs:
needs: correct_repository
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v6
- uses: release-drafter/release-drafter@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -19,13 +19,13 @@ jobs:
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
strategy:
matrix:
php: ['8.4', '8.5']
php: ['8.1', '8.2']
name: Integration (${{ matrix.php }})
steps:
- name: Clone Kimai
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
persist-credentials: false
@@ -39,15 +39,12 @@ jobs:
env:
fail-fast: true
- name: Determine software versions
run: mysql --version && php --version
- name: Determine composer cache directory
id: composer-cache
run: echo "composer_cache_directory=$(composer config cache-dir)" >> $GITHUB_ENV
- name: Cache Composer dependencies
uses: actions/cache@v4
uses: actions/cache@v3
with:
path: "${{ env.composer_cache_directory }}"
key: ${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
@@ -56,13 +53,13 @@ jobs:
run: composer install
- name: Validate Composer
run: composer validate --strict --no-check-all
run: composer validate --strict
- name: Warmup cache
run: APP_ENV=dev bin/console kimai:reload -n
- name: Check codestyles
run: vendor/bin/php-cs-fixer fix --dry-run --verbose --config=.php-cs-fixer.dist.php --using-cache=no --show-progress=none --format=checkstyle | cs2pr
run: PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run --verbose --config=.php-cs-fixer.dist.php --using-cache=no --show-progress=none --format=checkstyle | cs2pr
- name: Run PHPStan for application
run: vendor/bin/phpstan analyse -c phpstan.neon --no-progress --error-format=checkstyle | cs2pr
@@ -82,29 +79,21 @@ jobs:
- name: Run quick unit-tests
run: composer tests-unit
env:
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?charset=utf8mb4&serverVersion=8.0.35
APP_ENV: test
MAILER_URL: null://localhost
- name: Full test-suite
if: matrix.php != '8.5'
run: vendor/bin/phpunit tests/
env:
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?charset=utf8mb4&serverVersion=8.0.35
APP_ENV: test
MAILER_URL: null://localhost
- name: Full test-suite with coverage
if: matrix.php == '8.5'
run: vendor/bin/phpunit tests/ --coverage-clover=coverage.xml
env:
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?charset=utf8mb4&serverVersion=8.0.35
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?serverVersion=5.7
APP_ENV: dev
MAILER_URL: null://localhost
- name: Full test-suite with coverage
run: vendor/bin/phpunit tests/ --coverage-clover=coverage.xml
env:
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?serverVersion=5.7
APP_ENV: dev
MAILER_URL: null://localhost
TEST_WITH_BUNDLES: 1
- name: Upload code coverage
if: matrix.php == '8.5'
uses: codecov/codecov-action@v5
if: matrix.php == '8.1'
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
@@ -117,7 +106,7 @@ jobs:
bin/console doctrine:migrations:migrate -n
bin/console doctrine:migrations:migrate first -n
env:
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?charset=utf8mb4&serverVersion=8.0.35
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?serverVersion=5.7
APP_ENV: dev
MAILER_URL: null://localhost

View File

@@ -25,7 +25,7 @@ jobs:
echo "Using input provided: $input"
version="$input"
fi
echo "kimai_version=$version" >> $GITHUB_ENV
if [[ ! $version =~ ^2\.(0|[1-9]*)(0?)\.(0|[0-9]*)(0?)$ ]]; then
@@ -34,7 +34,7 @@ jobs:
fi
- name: Emit repository_dispatch
uses: peter-evans/repository-dispatch@v3
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.WEBSITE_ACCESS_TOKEN }}
repository: kimai/www.kimai.org

24
.gitignore vendored
View File

@@ -1,9 +1,9 @@
# some hosters require a htaccess to change the PHP version
/public/.htaccess
/.env-*
/.idea/
.DS_Store
var/templates/
/.DS_Store
/phpstan.sh
# custom apache rules e.g. to deactivate ioncube loader
/public/.user.ini
@@ -15,21 +15,19 @@ var/templates/
# YARN 2
/.yarnrc.yml
/.yarn
/.pnp.*
# for keeping empty directories
/config/packages/local.yaml
/config/bundles-local.php
/var/dev/*
/var/data/*
/var/cache/*
/var/share/*
/var/invoices*
/var/export*
/var/export/*
/var/log/*
/var/sessions/*
/var/packages/*
/var/plugins*
/var/plugins/*
/var/plugins_old/
/var/plugins/*/*.disabled
###> symfony/framework-bundle ###
@@ -37,6 +35,7 @@ var/templates/
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
.env
/public/bundles/
/vendor/
###< symfony/framework-bundle ###
@@ -47,8 +46,9 @@ var/templates/
###< phpunit/phpunit ###
###> friendsofphp/php-cs-fixer ###
/.php-cs-fixer.php
/.php-cs-fixer.cache
.php_cs
.php_cs.cache
.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ###
###> symfony/phpunit-bridge ###
@@ -60,6 +60,4 @@ var/templates/
npm-debug.log
yarn-error.log
###< symfony/webpack-encore-bundle ###
/nbproject/*
.dockerhub.secrets
/nbproject/*

View File

@@ -8,9 +8,7 @@ file that was distributed with this source code.
COMMENT;
$fixer = new PhpCsFixer\Config();
$fixer->setUnsupportedPhpVersionAllowed(true);
$fixer
->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect())
->setRiskyAllowed(true)
->setRules([
'encoding' => true,
@@ -44,7 +42,6 @@ $fixer
'single_line_after_imports' => true,
'switch_case_semicolon_to_colon' => true,
'switch_case_space' => true,
'php_unit_method_casing' => true,
'array_syntax' => [
'syntax' => 'short'
],
@@ -63,7 +60,7 @@ $fixer
'lowercase_static_reference' => true,
'magic_constant_casing' => true,
'native_function_casing' => true,
'new_with_parentheses' => true,
'new_with_braces' => true,
'no_blank_lines_after_class_opening' => true,
'no_blank_lines_after_phpdoc' => true,
'no_empty_comment' => true,
@@ -85,7 +82,7 @@ $fixer
'no_singleline_whitespace_before_semicolons' => true,
'no_spaces_around_offset' => true,
'no_trailing_comma_in_singleline' => true,
'no_unneeded_braces' => true,
'no_unneeded_curly_braces' => true,
'no_unneeded_final_method' => true,
'no_unused_imports' => true,
'no_whitespace_before_comma_in_array' => true,
@@ -143,7 +140,7 @@ $fixer
'whitespace_after_comma_in_array' => true,
'yoda_style' => false,
'ternary_to_null_coalescing' => true,
'modifier_keywords' => ['elements' => [
'visibility_required' => ['elements' => [
'const',
'method',
'property',
@@ -154,7 +151,7 @@ $fixer
],
'scope' => 'namespaced'
],
'native_type_declaration_casing' => true,
'native_function_type_declaration_casing' => true,
'no_alias_functions' => [
'sets' => [
'@internal'
@@ -173,7 +170,6 @@ $fixer
->setFinder(
PhpCsFixer\Finder::create()
->in([
__DIR__ . '/migrations/',
__DIR__ . '/src/',
__DIR__ . '/tests/',
])

View File

@@ -1,16 +1,15 @@
# Contributing
Kimai is an open source project, contributions made by the community are welcome.
But we can only accept contributions with a signed CLA (Contributor License Agreement) to prevent issues in the future (you will see a link when opening a PR).
Kimai is an open source project, contributions made by the community are welcome.
Send your ideas, code reviews, pull requests and feature requests to help to improve this project.
## Pull request rules
- Make your changes in a new git branch, based on the latest code in `main`
- Apply our code-style by running `composer codestyle-fix`
- Run the static code analysis with `composer phpstan`
- Verify everything still works with `composer tests`
- Verify everything still works with `composer tests-unit` and `composer tests-integration`
- Add tests for your changes
- When sending in a PR, you must accept that your contributions/code will be published under MIT license (see the [LICENSE](LICENSE) file as well), otherwise your PR will be closed
- If one of the PR checks/builds fails, fix it before asking for a review
Further documentation can be found in the [developer documentation](https://www.kimai.org/documentation/developers.html).

View File

@@ -1,330 +0,0 @@
# _ ___ _
# | |/ (_)_ __ ___ __ _(_)
# | ' /| | '_ ` _ \ / _` | |
# | . \| | | | | | | (_| | |
# |_|\_\_|_| |_| |_|\__,_|_|
#
# Kimai images for:
# - plain PHP FPM (kimai/kimai2:fpm)
# - Apache with PHP (kimai/kimai2:apache)
# - Development (kimai/kimai2:dev)
# ---------------------------------------------------------------------
# For local testing by maintainer:
#
# docker build --no-cache -t kimai-fpm --build-arg BASE=fpm .
# docker build --no-cache -t kimai-apache --build-arg BASE=apache .
# docker run -d --name kimai-apache-app kimai-apache
# docker exec -ti kimai-apache-app /bin/bash
# ---------------------------------------------------------------------
# Official PHP images: https://hub.docker.com/_/php/
# https://github.com/docker-library/docs/blob/master/php/README.md#supported-tags-and-respective-dockerfile-links
# Pass-through Arguments: https://benkyriakou.com/posts/docker-args-empty
# Best practices: https://docs.docker.com/build/building/best-practices/
# ---------------------------------------------------------------------
# Source base, one of: fpm, apache
ARG BASE="fpm"
# Kimai branch/tag to run
ARG KIMAI="main"
# Timezone for images
ARG TIMEZONE="Europe/Berlin"
###########################
# Shared tools
###########################
# composer base image
FROM composer:latest AS composer
###########################
# PHP extensions
###########################
# fpm alpine php extension base
FROM php:8.3-fpm-alpine AS fpm-php-ext-base
RUN apk add --no-cache \
# build-tools
autoconf \
dpkg \
dpkg-dev \
file \
g++ \
gcc \
icu-dev \
libatomic \
libc-dev \
libgomp \
libmagic \
m4 \
make \
mpc1 \
mpfr4 \
musl-dev \
perl \
re2c \
# gd
freetype-dev \
libpng-dev \
# icu
icu-dev \
icu-data-full \
# ldap
openldap-dev \
libldap \
# zip
libzip-dev \
# xsl
libxslt-dev
# apache debian php extension base
FROM php:8.3-apache-bookworm AS apache-php-ext-base
RUN apt-get update && \
apt-get install -y \
libldap2-dev \
libicu-dev \
libpng-dev \
libzip-dev \
libxslt1-dev \
libfreetype6-dev
# php extension gd - 13.86s
FROM ${BASE}-php-ext-base AS php-ext-gd
RUN docker-php-ext-configure gd \
--with-freetype && \
docker-php-ext-install -j$(nproc) gd
# php extension intl : 15.26s
FROM ${BASE}-php-ext-base AS php-ext-intl
RUN docker-php-ext-install -j$(nproc) intl
# php extension ldap : 8.45s
FROM ${BASE}-php-ext-base AS php-ext-ldap
RUN docker-php-ext-configure ldap && \
docker-php-ext-install -j$(nproc) ldap
# php extension pdo_mysql : 6.14s
FROM ${BASE}-php-ext-base AS php-ext-pdo_mysql
RUN docker-php-ext-install -j$(nproc) pdo_mysql
# php extension zip : 8.18s
FROM ${BASE}-php-ext-base AS php-ext-zip
RUN docker-php-ext-install -j$(nproc) zip
# php extension xsl : ?.?? s
FROM ${BASE}-php-ext-base AS php-ext-xsl
RUN docker-php-ext-install -j$(nproc) xsl
# php extension opcache
FROM ${BASE}-php-ext-base AS php-ext-opcache
RUN docker-php-ext-install -j$(nproc) opcache
###########################
# fpm base build
###########################
FROM php:8.3-fpm-alpine AS fpm-base
ARG TIMEZONE
RUN apk add --no-cache \
bash \
coreutils \
freetype \
haveged \
icu \
icu-data-full \
libldap \
libpng \
libzip \
libxslt-dev \
fcgi \
tzdata && \
touch /use_fpm && \
sed -i "s/;ping.path/ping.path/g" /usr/local/etc/php-fpm.d/www.conf && \
sed -i "s/;access.suppress_path\[\] = \/ping/access.suppress_path\[\] = \/ping/g" /usr/local/etc/php-fpm.d/www.conf
EXPOSE 9000
HEALTHCHECK --interval=20s --timeout=10s --retries=3 \
CMD \
SCRIPT_NAME=/ping \
SCRIPT_FILENAME=/ping \
REQUEST_METHOD=GET \
cgi-fcgi -bind -connect 127.0.0.1:9000 || exit 1
###########################
# apache base build
###########################
FROM php:8.3-apache-bookworm AS apache-base
ARG TIMEZONE
RUN apt-get update && \
apt-get install -y \
bash \
haveged \
libicu72 \
libldap-common \
libpng16-16 \
libzip4 \
libxslt1.1 \
libfreetype6 \
unzip && \
echo "Listen 8001" > /etc/apache2/ports.conf && \
a2enmod rewrite && \
touch /use_apache
COPY .docker/000-default.conf /etc/apache2/sites-available/000-default.conf
EXPOSE 8001
HEALTHCHECK --interval=20s --timeout=10s --retries=3 \
CMD curl -f http://127.0.0.1:8001 || exit 1
###########################
# global base build
###########################
FROM ${BASE}-base AS php-base
ARG TIMEZONE
ENV TIMEZONE=${TIMEZONE}
RUN ln -snf /usr/share/zoneinfo/${TIMEZONE} /etc/localtime && echo ${TIMEZONE} > /etc/timezone && \
# make composer home dir
mkdir /composer && \
chown -R www-data:www-data /composer
# copy composer
COPY --from=composer /usr/bin/composer /usr/bin/composer
# copy php extensions
# PHP extension xsl
COPY --from=php-ext-xsl /usr/local/etc/php/conf.d/docker-php-ext-xsl.ini /usr/local/etc/php/conf.d/docker-php-ext-xsl.ini
COPY --from=php-ext-xsl /usr/local/lib/php/extensions/no-debug-non-zts-20230831/xsl.so /usr/local/lib/php/extensions/no-debug-non-zts-20230831/xsl.so
# PHP extension pdo_mysql
COPY --from=php-ext-pdo_mysql /usr/local/etc/php/conf.d/docker-php-ext-pdo_mysql.ini /usr/local/etc/php/conf.d/docker-php-ext-pdo_mysql.ini
COPY --from=php-ext-pdo_mysql /usr/local/lib/php/extensions/no-debug-non-zts-20230831/pdo_mysql.so /usr/local/lib/php/extensions/no-debug-non-zts-20230831/pdo_mysql.so
# PHP extension zip
COPY --from=php-ext-zip /usr/local/etc/php/conf.d/docker-php-ext-zip.ini /usr/local/etc/php/conf.d/docker-php-ext-zip.ini
COPY --from=php-ext-zip /usr/local/lib/php/extensions/no-debug-non-zts-20230831/zip.so /usr/local/lib/php/extensions/no-debug-non-zts-20230831/zip.so
# PHP extension ldap
COPY --from=php-ext-ldap /usr/local/etc/php/conf.d/docker-php-ext-ldap.ini /usr/local/etc/php/conf.d/docker-php-ext-ldap.ini
COPY --from=php-ext-ldap /usr/local/lib/php/extensions/no-debug-non-zts-20230831/ldap.so /usr/local/lib/php/extensions/no-debug-non-zts-20230831/ldap.so
# PHP extension gd
COPY --from=php-ext-gd /usr/local/etc/php/conf.d/docker-php-ext-gd.ini /usr/local/etc/php/conf.d/docker-php-ext-gd.ini
COPY --from=php-ext-gd /usr/local/lib/php/extensions/no-debug-non-zts-20230831/gd.so /usr/local/lib/php/extensions/no-debug-non-zts-20230831/gd.so
# PHP extension intl
COPY --from=php-ext-intl /usr/local/etc/php/conf.d/docker-php-ext-intl.ini /usr/local/etc/php/conf.d/docker-php-ext-intl.ini
COPY --from=php-ext-intl /usr/local/lib/php/extensions/no-debug-non-zts-20230831/intl.so /usr/local/lib/php/extensions/no-debug-non-zts-20230831/intl.so
# PHP extension opcache
COPY --from=php-ext-opcache /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini
###########################
# fetch Kimai sources
###########################
FROM alpine:latest AS git-prod
ARG KIMAI
ARG TIMEZONE
# the convention in the Kimai repository is: tags are always version numbers, branch names always start with a letter
# if the KIMAI variable starts with a number (e.g. 2.24.0) we assume its a tag, otherwise its a branch
RUN [[ $KIMAI =~ ^[0-9] ]] && export REF='tags' || export REF='heads' && \
wget -O "/opt/kimai.tar.gz" "https://github.com/kimai/kimai/archive/refs/${REF}/${KIMAI}.tar.gz" && \
tar -xpzf /opt/kimai.tar.gz -C /opt/ && \
mv /opt/kimai-${KIMAI} /opt/kimai
###########################
# global base build
###########################
FROM php-base AS base
ARG KIMAI
ARG TIMEZONE
LABEL org.opencontainers.image.title="Kimai" \
org.opencontainers.image.description="Kimai is a time-tracking application." \
org.opencontainers.image.authors="Kimai Community" \
org.opencontainers.image.url="https://www.kimai.org/" \
org.opencontainers.image.documentation="https://www.kimai.org/documentation/" \
org.opencontainers.image.source="https://github.com/kimai/kimai" \
org.opencontainers.image.version="${KIMAI}" \
org.opencontainers.image.vendor="Kevin Papst" \
org.opencontainers.image.licenses="AGPL-3.0"
ENV KIMAI=${KIMAI}
ENV TIMEZONE=${TIMEZONE}
RUN ln -snf /usr/share/zoneinfo/${TIMEZONE} /etc/localtime && echo ${TIMEZONE} > /etc/timezone && \
mkdir -p /composer && \
chown -R www-data:www-data /composer
# copy startup script & DB checking script
COPY .docker/dbtest.php /dbtest.php
COPY .docker/entrypoint.sh /entrypoint.sh
ENV DATABASE_URL="mysql://kimai:kimai@127.0.0.1:3306/kimai?charset=utf8mb4&serverVersion=8.3"
ENV APP_SECRET=change_this_to_something_unique
# The default container name for nginx is nginx
ENV TRUSTED_PROXIES=nginx,localhost,127.0.0.1
ENV MAILER_FROM=kimai@example.com
ENV MAILER_URL=null://localhost
ENV ADMINPASS=
ENV ADMINMAIL=
ENV USER_ID=
ENV GROUP_ID=
# default values to configure composer behavior
ENV COMPOSER_MEMORY_LIMIT=-1
ENV COMPOSER_ALLOW_SUPERUSER=1
VOLUME [ "/opt/kimai/var" ]
CMD [ "/entrypoint.sh" ]
###########################
# final builds
###########################
# development build
FROM base AS dev
# copy kimai develop source
COPY --from=git-prod --chown=www-data:www-data /opt/kimai /opt/kimai
COPY .docker /assets
# do the composer deps installation
RUN \
export COMPOSER_HOME=/composer && \
composer --no-ansi install --working-dir=/opt/kimai --optimize-autoloader && \
composer --no-ansi clearcache && \
composer --no-ansi require --working-dir=/opt/kimai laminas/laminas-ldap && \
cp /usr/local/etc/php/php.ini-development /usr/local/etc/php/php.ini && \
chown -R www-data:www-data /opt/kimai /usr/local/etc/php/php.ini && \
mkdir -p /opt/kimai/var/logs && chmod 777 /opt/kimai/var/logs && \
sed "s/128M/-1/g" /usr/local/etc/php/php.ini-development > /opt/kimai/php-cli.ini && \
sed -i "s/env php/env -S php -c \/opt\/kimai\/php-cli.ini/g" /opt/kimai/bin/console && \
/opt/kimai/bin/console kimai:version | awk '{print $2}' > /opt/kimai/version.txt
ENV APP_ENV=dev
ENV DATABASE_URL=
ENV memory_limit=512M
# the "prod" stage (production build) is configured as last stage in the file, as this is the default target in BuildKit
FROM base AS prod
# copy kimai production source
COPY --from=git-prod --chown=www-data:www-data /opt/kimai /opt/kimai
COPY .docker /assets
# do the composer deps installation
RUN \
export COMPOSER_HOME=/composer && \
composer --no-ansi install --working-dir=/opt/kimai --no-dev --optimize-autoloader && \
composer --no-ansi clearcache && \
composer --no-ansi require --update-no-dev --working-dir=/opt/kimai laminas/laminas-ldap && \
cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini && \
sed -i "s/expose_php = On/expose_php = Off/g" /usr/local/etc/php/php.ini && \
sed -i "s/;opcache.enable=1/opcache.enable=1/g" /usr/local/etc/php/php.ini && \
sed -i "s/;opcache.memory_consumption=128/opcache.memory_consumption=256/g" /usr/local/etc/php/php.ini && \
sed -i "s/;opcache.interned_strings_buffer=8/opcache.interned_strings_buffer=24/g" /usr/local/etc/php/php.ini && \
sed -i "s/;opcache.max_accelerated_files=10000/opcache.max_accelerated_files=100000/g" /usr/local/etc/php/php.ini && \
sed -i "s/opcache.validate_timestamps=1/opcache.validate_timestamps=0/g" /usr/local/etc/php/php.ini && \
sed -i "s/session.gc_maxlifetime = 1440/session.gc_maxlifetime = 604800/g" /usr/local/etc/php/php.ini && \
mkdir -p /opt/kimai/var/logs && chmod 777 /opt/kimai/var/logs && \
sed "s/128M/-1/g" /usr/local/etc/php/php.ini-development > /opt/kimai/php-cli.ini && \
chown -R www-data:www-data /opt/kimai /usr/local/etc/php/php.ini && \
/opt/kimai/bin/console kimai:version | awk '{print $2}' > /opt/kimai/version.txt
ENV APP_ENV=prod
ENV DATABASE_URL=
ENV memory_limit=512M

View File

@@ -21,6 +21,15 @@ authentication via SAML/LDAP/Database, two-factor authentication (2FA) with TOTP
user/customer/project specific rates, advanced search & filtering, money and time budgets, advanced reporting, support for [plugins](https://www.kimai.org/store/)
and so much more.
### Versions
There are two [versions](https://www.kimai.org/documentation/versions.html) of Kimai existing:
- [Version 2](https://github.com/kimai/kimai) — the current stable release (PHP 8.1+)
- [Version 1](https://github.com/kimai/kimai/tree/1.x) — EOL since mid of 2023 (PHP 7.4)
Do **NOT** use Version 1, it won't get any more updates!
### Links
- [Home](https://www.kimai.org) — Kimai project homepage
@@ -29,22 +38,23 @@ and so much more.
### Requirements
- PHP 8.1.3 minimum with support for 8.2, 8.3, 8.4, 8.5
- PHP 8.1 minimum
- MariaDB or MySQL
- A webserver and subdomain (subdirectory is not supported)
- PHP extensions: `gd`, `intl`, `json`, `mbstring`, `pdo`, `tokenizer`, `xml`, `xsl`, `zip`
## Installation
- Caddy with Docker-Compose at [Hetzner](https://www.kimai.org/documentation/hosting-hetzner-cloud.html) and [DigitalOcean](https://www.kimai.org/documentation/hosting-digital-ocean.html)
- [SSH setup](https://www.kimai.org/documentation/installation.html) with Git and Composer
- [Docker images](https://hub.docker.com/r/kimai/kimai2) with FPM only or incl. Apache
- [Synology](https://www.kimai.org/documentation/synology.html) user can host the Docker version
- [Developer setups](https://www.kimai.org/documentation/developers.html) if you want to create Kimai integrations
- [Recommended setup](https://www.kimai.org/documentation/installation.html#recommended-setup) — with Git and Composer
- [Docker](https://hub.docker.com/r/kimai/kimai2) — containerized by [@tobybatch](https://github.com/tobybatch/kimai2)
There are more documented ways for [on-premise hosting](https://www.kimai.org/documentation/chapter-on-premise.html).
There are also documentations for:
- [developer setups](https://www.kimai.org/documentation/developers.html) — on your local machine
- [shared hostings](https://www.kimai.org/documentation/installation.html#shared-hosting) — the least favorable option
- [Synology](https://www.kimai.org/documentation/synology.html) — you could try to host the Docker version instead
- [1-click installer](https://www.kimai.org/documentation/installation.html#hosting-and-1-click-installations) — hosted environments
And if you don't want to host Kimai, you can use the [Cloud version](https://www.kimai.cloud/) of it.
And if you don't want to host Kimai, you can use [the Cloud version](https://www.kimai.cloud/) of it.
### Updating Kimai
@@ -53,12 +63,13 @@ And if you don't want to host Kimai, you can use the [Cloud version](https://www
### Plugins
- [Plugins](https://www.kimai.org/store/) — paid and free plugin marketplace
- [Plugin marketplace](https://www.kimai.org/store/) — find existing plugins here
- [Developer documentation](https://www.kimai.org/documentation/developers.html) — how to create a plugin
## Roadmap and releases
You can see a rough development [roadmap](https://github.com/orgs/kimai/projects/2), which is open for changes and input from the community, your [ideas](https://github.com/kimai/kimai/issues) are welcome.
You can see a rough development roadmap in the [Milestones](https://github.com/kimai/kimai/milestones) sections.
It is open for changes and input from the community, your [ideas and questions](https://github.com/kimai/kimai/issues) are welcome.
Release versions will be created on a regular basis, every couple of weeks latest.
Every code change, whether it's a new feature or a bugfix, will be done on the `main` branch.
@@ -70,7 +81,7 @@ The best way to start is to [open a new issue](https://github.com/kimai/kimai/is
In case you want to contribute, but you wouldn't know how, here are some suggestions:
- Spread the word: Please [write a testimonial for our Wall of love](https://love.kimai.org), vote for Kimai on any software platform, you can toot or tweet about it, share it on LinkedIn, Reddit and any other social media platform!
- Spread the word: More user means more people testing and contributing to Kimai, which in turn means better stability and more and better features. Please vote for Kimai on any software platform, you can toot or tweet about it, share it on LinkedIn, Reddit or any of your favorite social media platforms. Every bit helps!
- Answer questions: You know the answer to another user's problem? Share your knowledge.
- Something can be done better? An essential feature is missing? Create a feature request.
- Report bugs makes Kimai better for everyone.

View File

@@ -1,10 +1,27 @@
# Security Policy
As announced in the [README](README.md) security fixes will only be added to the `main` branch.
## Supported Versions
As announced in the [README](README.md) I only support the latest available release and `main` branch.
| Version | Supported |
|----------------------|--------------------|
| main branch | :white_check_mark: |
| latest minor release | :white_check_mark: |
| older releases | :x: |
You find all information in our [Bughunter documentation](https://www.kimai.org/documentation/bughunter.html).
## Reporting a Vulnerability
Please read the [Bughunter](https://www.kimai.org/documentation/bughunter.html) documentation before posting.
You can report any security related vulnerability in the [advisory section at GitHub](https://github.com/kimai/kimai/security/advisories)
or via email to [support@kimai.org](mailto:support@kimai.org).
I will work as fast as I can to fix the problem and publish a bugfix release / security update.
Depending on the size of the required fixes, this might take a couple of hours or a couple of days.
You can expect that your message will be answered ASAP.
You will be mentioned in the release notes if your issue is valid.
I am grateful for any (discrete) disclosure of vulnerabilities!
Please note: if you are asking for money, I will not reply. I receive these scam messages every day...

View File

@@ -1,50 +0,0 @@
# Upgrading Kimai - Version 2.x
_Make sure to create a backup before you start!_
Read the [updates documentation](https://www.kimai.org/documentation/updates.html) to find out how
you can upgrade your Kimai installation to the latest stable release.
Check below if there are more version specific steps required, which need to be executed after the normal update process.
Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation.
## [2.0.30](https://github.com/kimai/kimai/releases/tag/2.0.30)
The `DATABASE_URL` in your environment settings (e.g. [.env](https://github.com/kimai/kimai/issues/4246), [docker-compose.yaml](https://github.com/tobybatch/kimai2/issues/531) or webserver config)
now requires the `charset` and `serverVersion` params, e.g.: `DATABASE_URL=mysql://user:password@127.0.0.1:3306/database?charset=utf8mb4&serverVersion=10.5.8-MariaDB` (examples in `.env`).
## [2.0](https://github.com/kimai/kimai/releases/tag/2.0)
**!! This release requires minimum PHP version to 8.1 !!**
### Breaking changes
- All plugins need to be updated: delete all previous version from your installation (`rm -r var/plugins/*`) before updating!
- The `local.yaml` is not compatible with old version, remove it before the update and then re-create it after everything works
- removed: configuring the `dashboard` is not supported any longer
- removed: custom translation files via `theme.branding.translation`
- removed: changing the plugin directory via `kimai.plugin_dir`
### Developer
Developer read the full documentation at [https://www.kimai.org/documentation/migration-v2.html](https://www.kimai.org/documentation/migration-v2.html).
- Invoice renderer and templates for XML, JSON and TEXT were moved to the [Extended invoicing plugin](https://www.kimai.org/store/invoice-bundle.html) (install if you use one of those)
- Moved `company.docx` to [external repo](https://github.com/kimai/invoice-templates/tree/main/docx-company) (needs to be re-uploaded if you want to keep on using it!)
- Role names are forced to be uppercase
- Removed unused `public/avatars/` directory
- Time-tracking mode `duration_only` was removed, existing installations will be switched to `duration_fixed_begin`
- Removed Twig filters. You might have to replace them in your custom export/invoice templates:
- `date_full` => `date_time`
- `duration_decimal` => `duration(true)`
- `currency` => `currency_name`
- `country` => `country_name`
- `language` => `language_name`
- Removed support for custom translation files (use [TranslationBundle](https://www.kimai.org/store/translation-bundle.html) instead or write your own plugin)
- Removed all 3rd party mailer packages, you need to install them manually (ONLY if you used a short syntax to configure the `MAILER_URL` in `.env`):
- `composer require symfony/amazon-mailer`
- `composer require symfony/google-mailer`
- `composer require symfony/mailchimp-mailer`
- `composer require symfony/mailgun-mailer`
- `composer require symfony/postmark-mailer`
- `composer require symfony/sendgrid-mailer`

View File

@@ -1,37 +0,0 @@
# Upgrading Kimai - Version 3.x
_Make sure to create a backup before you start!_
Read the [updates documentation](https://www.kimai.org/documentation/updates.html) to find out how you can upgrade your Kimai installation to the latest stable release.
Check below if there are more version specific steps required, which need to be executed after the normal update process.
Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation.
## 3.0
**!! This release requires minimum PHP version 8.4 !!**
### Rename .env
Rename your file `.env` to `.env.local` or even better: move all variables to your webserver/container environment.
### Developer
Do not use method chaining: all fluent interface, especially in Entities, are no longer supported.
- Require PHP 8.4
- Bump to Symfony 7.4
- Removed old API token `X-AUTH-USER` and `X-AUTH-TOKEN`
- Removed `TimesheetConstraint` - use a normal `Constraint` as base class and attach the `#[App\Validator\Attribute\TimesheetConstraint]` attribute
- Removed `ProjectConstraint` - use a `FormExtension` and attach your custom constraints
- Interface `MetaTableTypeInterface` has new methods: `getSection()`, `setSection()`
- Interface `ExportRendererInterface` has new methods: `getType()`, `isInternal()`
- Interface `ExportableItem` has new methods: `getTags()`, `getBreak()`
- Removed and renamed translations, most important `action.edit` => `edit`, `my.profile` => `user_profile`
- Removed `User::isExportDecimal()`
- Use duration format `HH:mm` in default PDF exports
- Replace Twig `AppVariable` with custom implementation
- You need to adjust your templates if you access anything else then `app.locale`, `app.user`. `app.current_route`.
- Most often used:
- Replace `app.request.locale` with `app.locale`
- Replace `app.request.attributes.get('_route')` with `app.current_route`

View File

@@ -1,3 +1,50 @@
# Upgrading Kimai
# Upgrading Kimai - Version2.x
The most recent version is Kimai 3, please read the changelog at [UPGRADING-3.md](UPGRADING-3.md).
_Make sure to create a backup before you start!_
Read the [updates documentation](https://www.kimai.org/documentation/updates.html) to find out how
you can upgrade your Kimai installation to the latest stable release.
Check below if there are more version specific steps required, which need to be executed after the normal update process.
Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation.
## [2.0.30](https://github.com/kimai/kimai/releases/tag/2.0.30)
The `DATABASE_URL` in your environment settings ([.env](https://github.com/kimai/kimai/issues/4246) or in your [Docker](https://github.com/tobybatch/kimai2/issues/531) or webserver config)
now requires the `charset` and `serverVersion` params, e.g.: `DATABASE_URL=mysql://user:password@127.0.0.1:3306/database?charset=utf8mb4&serverVersion=10.5.8-MariaDB` (examples in `.env`).
## [2.0](https://github.com/kimai/kimai/releases/tag/2.0)
**!! This release requires minimum PHP version to 8.1 !!**
### Breaking changes
- All plugins need to be updated: delete all previous version from your installation (`rm -r var/plugins/*`) before updating!
- The `local.yaml` is not compatible with old version, remove it before the update and then re-create it after everything works
- removed: configuring the `dashboard` is not supported any longer
- removed: custom translation files via `theme.branding.translation`
- removed: changing the plugin directory via `kimai.plugin_dir`
### Developer
Developer read the full documentation at [https://www.kimai.org/documentation/migration-v2.html](https://www.kimai.org/documentation/migration-v2.html).
- Invoice renderer and templates for XML, JSON and TEXT were moved to the [Extended invoicing plugin](https://www.kimai.org/store/invoice-bundle.html) (install if you use one of those)
- Moved `company.docx` to [external repo](https://github.com/kimai/invoice-templates/tree/main/docx-company) (needs to be re-uploaded if you want to keep on using it!)
- Role names are forced to be uppercase
- Removed unused `public/avatars/` directory
- Time-tracking mode `duration_only` was removed, existing installations will be switched to `duration_fixed_begin`
- Removed Twig filters. You might have to replace them in your custom export/invoice templates:
- `date_full` => `date_time`
- `duration_decimal` => `duration(true)`
- `currency` => `currency_name`
- `country` => `country_name`
- `language` => `language_name`
- Removed support for custom translation files (use [TranslationBundle](https://www.kimai.org/store/translation-bundle.html) instead or write your own plugin)
- Removed all 3rd party mailer packages, you need to install them manually (ONLY if you used a short syntax to configure the `MAILER_URL` in `.env`):
- `composer require symfony/amazon-mailer`
- `composer require symfony/google-mailer`
- `composer require symfony/mailchimp-mailer`
- `composer require symfony/mailgun-mailer`
- `composer require symfony/postmark-mailer`
- `composer require symfony/sendgrid-mailer`

View File

@@ -1 +0,0 @@
require('./sass/_app-rtl.scss');

View File

@@ -23,7 +23,6 @@ import {
Legend,
Title,
Tooltip,
Colors,
// SubTitle
} from 'chart.js';
@@ -51,7 +50,6 @@ Chart.register(
Legend,
Title,
Tooltip,
Colors
// SubTitle
);

View File

@@ -1,5 +0,0 @@
require('highlight.js/styles/github-dark.css');
const hljs = require('highlight.js/lib/common');
global.hljs = hljs;

View File

@@ -1,4 +1,2 @@
/**
* @deprecated use invoice-pdf instead
*/
require('./sass/_invoice.scss');

View File

@@ -42,8 +42,6 @@ import KimaiNotification from "./plugins/KimaiNotification";
import KimaiHotkeys from "./plugins/KimaiHotkeys";
import KimaiRemoteModal from "./plugins/KimaiRemoteModal";
import KimaiUser from "./plugins/KimaiUser";
import KimaiAutocompleteTags from "./forms/KimaiAutocompleteTags";
import KimaiMonthPicker from "./forms/KimaiMonthPicker";
export default class KimaiLoader {
@@ -72,12 +70,10 @@ export default class KimaiLoader {
kimai.registerPlugin(new KimaiDateRangePicker('input[data-daterangepicker="on"]'));
kimai.registerPlugin(new KimaiDatePicker('input[data-datepicker="on"]'));
kimai.registerPlugin(new KimaiAutocomplete());
kimai.registerPlugin(new KimaiAutocompleteTags());
kimai.registerPlugin(new KimaiTimesheetForm());
kimai.registerPlugin(new KimaiTeamForm());
kimai.registerPlugin(new KimaiCopyDataForm());
kimai.registerPlugin(new KimaiDateNowForm());
kimai.registerPlugin(new KimaiMonthPicker());
kimai.registerPlugin(new KimaiForm());
kimai.registerPlugin(new KimaiHotkeys());
@@ -87,7 +83,7 @@ export default class KimaiLoader {
kimai.registerPlugin(new KimaiDatatable('section.content', 'table.dataTable'));
kimai.registerPlugin(new KimaiToolbar('form.searchform', 'toolbar-action'));
kimai.registerPlugin(new KimaiAlternativeLinks('.alternative-link'));
kimai.registerPlugin(new KimaiAjaxModalForm('.modal-ajax-form', ['td.multiCheckbox', 'td.actions']));
kimai.registerPlugin(new KimaiAjaxModalForm('.modal-ajax-form'));
kimai.registerPlugin(new KimaiRemoteModal());
kimai.registerPlugin(new KimaiActiveRecords());
kimai.registerPlugin(new KimaiAPILink('api-link'));

View File

@@ -154,7 +154,7 @@ export default class KimaiPlugin {
const width = Math.max(
document.documentElement.clientWidth,
window.innerWidth || 0
);
)
return width < 576;
}

View File

@@ -6,12 +6,13 @@
*/
import TomSelect from 'tom-select';
import KimaiFormTomselectPlugin from "./KimaiFormTomselectPlugin";
import KimaiFormPlugin from "./KimaiFormPlugin";
/**
* Supporting auto-complete fields via API.
* Used for timesheet tagging in toolbar and edit dialogs.
*/
export default class KimaiAutocomplete extends KimaiFormTomselectPlugin {
export default class KimaiAutocomplete extends KimaiFormPlugin {
init()
{
@@ -27,23 +28,11 @@ export default class KimaiAutocomplete extends KimaiFormTomselectPlugin {
return true;
}
loadData(apiUrl, query, callback) {
activateForm(form)
{
/** @type {KimaiAPI} API */
const API = this.getContainer().getPlugin('api');
API.get(apiUrl, {'name': query}, (data) => {
let results = [];
for (let item of data) {
results.push({text: item.name, value: item.name});
}
callback(results);
}, () => {
callback();
});
}
activateForm(form)
{
[].slice.call(form.querySelectorAll(this.selector)).map((node) => {
const apiUrl = node.dataset['autocompleteUrl'];
let minChars = 3;
@@ -51,7 +40,7 @@ export default class KimaiAutocomplete extends KimaiFormTomselectPlugin {
minChars = parseInt(node.dataset['minimumCharacter']);
}
let options = {
new TomSelect(node, {
// see https://github.com/orchidjs/tom-select/issues/543#issuecomment-1664342257
onItemAdd: function(){
// remove remaining characters from input after selecting an item
@@ -69,21 +58,36 @@ export default class KimaiAutocomplete extends KimaiFormTomselectPlugin {
return query.length >= minChars;
},
load: (query, callback) => {
this.loadData(apiUrl, query, callback);
API.get(apiUrl, {'name': query}, (data) => {
const results = [].slice.call(data).map((result) => {
return {text: result, value: result};
});
callback(results);
}, () => {
callback();
});
},
};
let render = {
// eslint-disable-next-line
not_loading: (data, escape) => {
// no default content
render: {
// eslint-disable-next-line
not_loading: (data, escape) => {
// no default content
},
option_create: (data, escape) => {
const name = escape(data.input);
if (name.length < 3) {
return null;
}
const tpl = this.translate('select.search.create');
const tplReplaced = tpl.replace('%input%', '<strong>' + name + '</strong>')
return '<div class="create">' + tplReplaced + '</div>';
},
no_results: (data, escape) => {
const tpl = this.translate('select.search.notfound');
const tplReplaced = tpl.replace('%input%', '<strong>' + escape(data.input) + '</strong>')
return '<div class="no-results">' + tplReplaced + '</div>';
},
},
};
const rendererType = (node.dataset['renderer'] !== undefined) ? node.dataset['renderer'] : 'default';
options.render = {...render, ...this.getRenderer(rendererType)};
new TomSelect(node, options);
});
});
}

View File

@@ -1,34 +0,0 @@
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import KimaiAutocomplete from "./KimaiAutocomplete";
/**
* Used for timesheet tagging in toolbar and edit dialogs.
*/
export default class KimaiAutocompleteTags extends KimaiAutocomplete {
init()
{
this.selector = '[data-form-widget="tags"]';
}
loadData(apiUrl, query, callback) {
/** @type {KimaiAPI} API */
const API = this.getContainer().getPlugin('api');
API.get(apiUrl, {'name': query}, (data) => {
let results = [];
for (let item of data) {
results.push({text: item.name, value: item.name, color: item['color-safe']});
}
callback(results);
}, () => {
callback();
});
}
}

View File

@@ -29,7 +29,7 @@ export default class KimaiCopyDataForm extends KimaiFormPlugin {
/**
* @param {HTMLFormElement} form
*/
activateForm(form)
activateForm(form) // eslint-disable-line no-unused-vars
{
if (this._eventHandler === undefined) {
this._eventHandler = (event) => {
@@ -48,10 +48,10 @@ export default class KimaiCopyDataForm extends KimaiFormPlugin {
if (element.dataset.event !== undefined) {
for (const event of element.dataset.event.split(' ')) {
target.dispatchEvent(new Event(event));
const form = target.closest('form');
if (form !== null) {
form.dispatchEvent(new Event(event));
}
}
} else if (element.dataset.eventBubbles !== undefined) {
for (const event of element.dataset.eventBubbles.split(' ')) {
target.dispatchEvent(new Event(event, {bubbles: true}));
}
}
event.preventDefault();
@@ -63,7 +63,7 @@ export default class KimaiCopyDataForm extends KimaiFormPlugin {
/**
* @param {HTMLFormElement} form
*/
destroyForm(form)
destroyForm(form) // eslint-disable-line no-unused-vars
{
form.removeEventListener('click', this._eventHandler);
}

View File

@@ -43,10 +43,7 @@ export default class KimaiDateNowForm extends KimaiFormPlugin {
const formElement = document.getElementById(linkTarget.dataset.target);
if (!formElement.disabled) {
formElement.value = this.getDateUtils().format(linkTarget.dataset.format, null);
// this should usually work
formElement.dispatchEvent(new Event('change', {bubbles: true}));
// this is required for Litepicker to pick the new date (with autoRefresh option)
formElement.dispatchEvent(new Event('keyup', {bubbles: true}));
}
event.preventDefault();

View File

@@ -57,16 +57,6 @@ export default class KimaiDatePicker extends KimaiFormPlugin {
if (element.dataset.format === undefined) {
console.log('Trying to bind litepicker to an element without data-format attribute');
}
if (element.hasAttribute('min') !== undefined) {
options = {...options, ...{
'minDate': element.getAttribute('min'),
}};
}
if (element.hasAttribute('max') !== undefined) {
options = {...options, ...{
'maxDate': element.getAttribute('max'),
}};
}
options = {...options, ...{
format: element.dataset.format,
showTooltip: false,
@@ -76,7 +66,7 @@ export default class KimaiDatePicker extends KimaiFormPlugin {
firstDay: FIRST_DOW, // Litepicker: 0 = Sunday, 1 = Monday
setup: (picker) => {
// nasty hack, because litepicker does not trigger change event on the input and the available
// event "selected" is triggered way to often, even when moving the cursor inside the input
// event "selected" is triggered why to often, even when moving the cursor inside the input
// element (not even typing is necessary) and so we have to make sure that the manual "click" event
// (works for touch as well) happened before we actually dispatch the change event manually ...
// what? report forms would be submitted upon cursor move without the "preselect” check

View File

@@ -10,9 +10,9 @@
*/
import TomSelect from 'tom-select';
import KimaiFormTomselectPlugin from "./KimaiFormTomselectPlugin";
import KimaiFormPlugin from "./KimaiFormPlugin";
export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
export default class KimaiFormSelect extends KimaiFormPlugin {
constructor(selector, apiSelects)
{
@@ -62,10 +62,13 @@ export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
plugins.push('remove_button');
}
if (node.dataset['order'] !== undefined && node.dataset['order'] === '1') {
//plugins.push('caret_position');
/*
const isOrdering = false;
if (isOrdering) {
plugins.push('caret_position');
plugins.push('drag_drop');
}
*/
let options = {
// see https://github.com/orchidjs/tom-select/issues/543#issuecomment-1664342257
@@ -79,23 +82,29 @@ export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
plugins: plugins,
// if there are more than X entries, the other ones are hidden and can only be found
// by typing some characters to trigger the internal option search
// see App\Form\Type\TagsType::MAX_AMOUNT_SELECT
// TODO make this value configurable with a data attribute
maxOptions: 500,
sortField:[{field: '$order'}, {field: '$score'}],
// required so it works in table.responsive, but requires z-index 1056, because bootstrap modal would otherwise hide it
dropdownParent: 'body',
};
let render = {
option_create: (data, escape) => {
const name = escape(data.input);
if (name.length < 3) {
return null;
}
const tpl = this.translate('select.search.create');
const tplReplaced = tpl.replace('%input%', '<strong>' + name + '</strong>');
return '<div class="create">' + tplReplaced + '</div>';
},
no_results: (data, escape) => {
const tpl = this.translate('select.search.notfound');
const tplReplaced = tpl.replace('%input%', '<strong>' + escape(data.input) + '</strong>');
return '<div class="no-results">' + tplReplaced + '</div>';
},
onOptionAdd: (value) => {
node.dispatchEvent(new CustomEvent('create', {detail: {'value': value}}));
},
};
const rendererType = (node.dataset['renderer'] !== undefined) ? node.dataset['renderer'] : 'default';
options.render = {...render, ...this.getRenderer(rendererType)};
if (node.dataset['create'] !== undefined) {
options = {...options, ...{
persist: true,
@@ -114,6 +123,44 @@ export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
}};
}
if (node.dataset['renderer'] !== undefined && node.dataset['renderer'] === 'color') {
options.render = {...render, ...{
option: function(data, escape) {
let item = '<div class="list-group-item border-0 p-1 ps-2 text-nowrap">';
if (data.color !== undefined) {
item += '<span style="background-color:' + data.color + '" class="color-choice-item">&nbsp;</span>';
} else {
item += '<span class="color-choice-item">&nbsp;</span>';
}
item += escape(data.text) + '</div>';
return item;
},
item: function(data, escape) {
let item = '<div class="text-nowrap">';
if (data.color !== undefined) {
item += '<span style="background-color:' + data.color + '" class="color-choice-item">&nbsp;</span>';
} else {
item += '<span class="color-choice-item">&nbsp;</span>';
}
item += escape(data.text) + '</div>';
return item;
}
}};
} else {
options.render = {...render, ...{
// the empty entry would collapse and only show as a tiny 5px line if there is no content inside
option: function(data, escape) {
let text = data.text;
if (text === null || text.trim() === '') {
text = '&nbsp;';
} else {
text = escape(text);
}
return '<div>' + text + '</div>';
}
}};
}
const select = new TomSelect(node, options);
node.addEventListener('data-reloaded', (event) => {
select.clear(true);
@@ -236,13 +283,8 @@ export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
options.push(optGroup);
}
// log the one with a group name first (e.g. non-global activities)
options.forEach(child => node.appendChild(child));
// append the ones with no parent at the end (e.g. global activities)
const optGroupEmpty = this._createOptgroup('');
emptyOpts.forEach(child => optGroupEmpty.appendChild(child));
node.appendChild(optGroupEmpty);
emptyOpts.forEach(child => node.appendChild(child));
// if available, re-select the previous selected option (mostly usable for global activities)
node.value = selectedValue;
@@ -440,7 +482,7 @@ export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
targetSelect.dataset['reloading'] = '0';
targetSelect.disabled = false;
});
};
}
document.addEventListener('change', this._eventHandlerApiSelects);
}
@@ -476,14 +518,14 @@ export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
newValue = [...targetField.selectedOptions].map(o => o.value);
} else if (newValue !== '') {
if (targetField.type === 'date') {
const timeId = targetField.id.replace('_date', '_time');
const timeId = targetField.id.replace('_date', '_time')
const timeElement = document.getElementById(timeId);
const time = timeElement === null ? '12:00:00' : timeElement.value;
// using 12:00 as fallback, because timezone handling might change the date if we use 00:00
const newDate = this.getDateUtils().fromHtml5Input(newValue, time);
newValue = this.getDateUtils().formatForAPI(newDate, false);
} else if (targetField.type === 'text' && targetField.name.includes('date')) {
const timeId = targetField.id.replace('_date', '_time');
const timeId = targetField.id.replace('_date', '_time')
const timeElement = document.getElementById(timeId);
// using 12:00 as fallback, because timezone handling might change the date if we use 00:00
let time = '12:00:00';

View File

@@ -1,80 +0,0 @@
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
/*!
* [KIMAI] KimaiFormTomselectPlugin: base form plugin for everyone using tomselect
*/
import KimaiFormPlugin from './KimaiFormPlugin';
export default class KimaiFormTomselectPlugin extends KimaiFormPlugin {
/**
* @param {string} rendererType
* @return array
*/
getRenderer(rendererType)
{
// default renderer
let render = {
option_create: (data, escape) => {
const name = escape(data.input);
if (name.length < 3) {
return null;
}
const tpl = this.translate('select.search.create');
const tplReplaced = tpl.replace('%input%', '<strong>' + name + '</strong>');
return '<div class="create">' + tplReplaced + '</div>';
},
no_results: (data, escape) => {
const tpl = this.translate('select.search.notfound');
const tplReplaced = tpl.replace('%input%', '<strong>' + escape(data.input) + '</strong>');
return '<div class="no-results">' + tplReplaced + '</div>';
},
};
if (rendererType === 'color') {
render = {...render, ...{
option: function(data, escape) {
let item = '<div class="list-group-item border-0 p-1 ps-2 text-nowrap">';
// if no color is set, do NOT add an empty placeholder
if (data.color !== undefined) {
item += '<span style="background-color:' + data.color + '" class="color-choice-item me-2">&nbsp;</span>';
}
item += escape(data.text) + '</div>';
return item;
},
item: function(data, escape) {
let item = '<div class="text-nowrap">';
// if no color is set, do NOT add an empty placeholder
if (data.color !== undefined) {
item += '<span style="background-color:' + data.color + '" class="color-choice-item me-2">&nbsp;</span>';
}
item += escape(data.text) + '</div>';
return item;
}
}};
} else {
render = {...render, ...{
// the empty entry would collapse and only show as a tiny 5px line if there is no content inside
option: function(data, escape) {
let text = data.text;
if (text === null || text.trim() === '') {
text = '&nbsp;';
} else {
text = escape(text);
}
return '<div>' + text + '</div>';
}
}};
}
return render;
}
}

View File

@@ -1,44 +0,0 @@
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
/*!
* [KIMAI] KimaiDatePicker: single date selects (currently unused)
*/
import KimaiFormPlugin from "./KimaiFormPlugin";
export default class KimaiMonthPicker extends KimaiFormPlugin {
/**
* @param {HTMLFormElement} form
* @return boolean
*/
supportsForm(form) // eslint-disable-line no-unused-vars
{
return true;
}
/**
* @param {HTMLFormElement} form
*/
activateForm(form)
{
const input = document.createElement('input');
input.setAttribute('type','month');
const notADateValue = 'not-a-month';
input.setAttribute('value', notADateValue);
if (input.value === notADateValue) {
const polyfills = form.querySelectorAll('a.input-month-polyfill');
polyfills.forEach(i => i.classList.toggle('d-none'));
const inputs = form.querySelectorAll('input[type="month"]');
inputs.forEach(i => i.classList.toggle('d-none'));
}
}
}

View File

@@ -40,27 +40,17 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
if (this._beginTime !== undefined) {
this._beginTime.removeEventListener('change', this._beginListener);
delete this._beginListener;
this._beginTime.removeEventListener('blur', this._beginBlurListener);
delete this._beginBlurListener;
delete this._beginTime;
}
if (this._endTime !== undefined) {
this._endTime.removeEventListener('change', this._endListener);
delete this._endListener;
this._endTime.removeEventListener('blur', this._endBlurListener);
delete this._endBlurListener;
delete this._endTime;
}
if (this._duration !== undefined) {
this._duration.removeEventListener('change', this._durationListener);
delete this._durationListener;
this._duration.removeEventListener('keydown', this._durationKeyListener);
delete this._durationKeyListener;
this._duration.removeEventListener('blur', this._durationBlurListener);
delete this._durationBlurListener;
delete this._duration;
}
@@ -118,21 +108,13 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
}
this._beginListener = () => this._changedBegin();
this._beginBlurListener = () => this._parseBeginTime();
this._endListener = () => this._changedEnd();
this._endBlurListener = () => this._parseEndTime();
this._durationListener = () => this._changedDuration();
this._durationKeyListener = (event) => this._changeDurationOnKeypress(event);
this._durationBlurListener = () => this._parseDuration();
this._beginDate.addEventListener('change', this._beginListener);
this._beginTime.addEventListener('change', this._beginListener);
this._beginTime.addEventListener('blur', this._beginBlurListener);
this._endTime.addEventListener('change', this._endListener);
this._endTime.addEventListener('blur', this._endBlurListener);
this._duration.addEventListener('change', this._durationListener);
this._duration.addEventListener('keydown', this._durationKeyListener);
this._duration.addEventListener('blur', this._durationBlurListener);
if (this._duration !== null && this._durationToggle !== null) {
this._durationToggleListener = () => {
@@ -142,159 +124,6 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
}
}
_parseBeginTime()
{
if (this._beginTime.value === '') {
return;
}
let newBeginTime = this._formatTimeForParsing(this._beginTime.value, this._beginTime.dataset['format']);
if (newBeginTime !== this._beginTime.value) {
this._beginTime.value = newBeginTime;
this._changedBegin();
}
}
_parseEndTime()
{
if (this._endTime.value === '') {
return;
}
let newEndTime = this._formatTimeForParsing(this._endTime.value, this._endTime.dataset['format']);
if (newEndTime !== this._endTime.value) {
this._endTime.value = newEndTime;
this._changedEnd();
}
}
_parseDuration()
{
if (this._duration.value === '') {
return;
}
this._setDurationAsString(this._getParsedDuration());
}
/**
* Receives a time, written by a human, probably in an invalid format.
* This method supports 12-hour or 24-hour format, the format string contains an uppercase "A" in case of the 12-hour format.
* If it is 12-hour format, then always en-US locallized with AM/PM.
*
* Ruleset:
* - Some locales use a dot instead of a colon, always replace the dot in HH.mm with a colon as in HH:mm
* - If there is an "am" or "pm", always uppercase them
* - Split the string into time and prefix: if AM/PM is included remove it and remember for later
* - If the time is a 1 or 2 character long number: use as hours
* - If the time now is 3 character long: use the 1 char as hour and the 2 and 3 char as minute
* - If the time now is 4 character long: use the 1 and 2 char as hour and the 3 and 4 char as minutes
* - If the format is 12-hour: try to identify the correct time and suffix
* - If the format is 12-hour and misses the AM/PM: try to detect whether it
* - If the time contains AM or PM, make sure that it is always prefixed by a space character
*
* @param {string} time
* @param {string} format
* @returns {string}
* @private
*/
_formatTimeForParsing(time, format)
{
let formatted = time.trim();
// replace invalid separators with colon
formatted = formatted.replace(/\.|;|,/g, ':');
// uppercase 12-hour format
formatted = formatted.replace(/am/i, 'AM');
formatted = formatted.replace(/pm/i, 'PM');
// Split time and AM/PM suffix if present
let suffix = '';
let hour = 0;
let minute = 0;
let timePart = formatted;
const ampmMatch = formatted.match(/\s*(AM|PM)$/i);
if (ampmMatch) {
suffix = ampmMatch[1].toUpperCase();
timePart = formatted.replace(/\s*(AM|PM)$/i, '').trim();
}
if (timePart.indexOf(':') !== -1) {
const match = timePart.match(/(?:(\d+):)?(\d+)/);
hour = parseInt(match?.[1] || 0, 10);
minute = parseInt(match?.[2] || 0, 10);
} else {
timePart = timePart.replace(/:/, '');
if (/^\d{1,2}$/.test(timePart)) {
hour = timePart;
}
if (/^\d{3}$/.test(timePart)) {
hour = timePart.slice(0, 1);
minute = timePart.slice(1);
}
if (/^\d{4}$/.test(timePart)) {
hour = timePart.slice(0, 2);
minute = timePart.slice(2);
}
}
hour = parseInt(hour);
minute = parseInt(minute);
// just in case a person entered a wrong time like 35 hours
hour = hour % 24;
minute = minute % 60;
// format is 12-hour
if (format.toUpperCase().indexOf('A') !== -1) {
// time entered in 24-hour: convert to 12-hour format
if (hour > 12 && hour < 24) {
hour = hour - 12;
suffix = 'PM';
}
// if the person forgot to add a suffix, calculate it and convert time
if (suffix === '') {
if (hour === 0) {
hour = 12;
suffix = 'AM';
} else if (hour === 12) {
suffix = 'PM';
} else {
suffix = 'AM';
}
}
if (suffix === 'PM' && hour === 0) {
hour = 12;
}
} else {
// this is the 34-hour format branch
// check if the person entered time in 12-hour format and convert it
if (suffix === 'AM' && hour === 12) {
hour = 0;
} else if (suffix === 'PM' && hour !== 12) {
hour = (hour + 12) % 24;
}
// make sure we have no suffix
suffix = '';
}
formatted = hour + ':' + minute.toString().padStart(2, '0');
if (suffix !== '') {
formatted = formatted + ' ' + suffix.trim();
}
return formatted;
}
_isDurationConnected()
{
if (this._duration === null && this._durationToggle === null) {
@@ -458,14 +287,14 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
/**
* Ruleset:
* - invalid or empty duration => skip
* - invalid duration => skip
* - if begin and end are empty: set begin to now and end to duration
* - if begin is empty and end is not empty: set begin to end minus duration
* - if begin is not empty and end is empty and duration is > 0 (running records = 0): set end to begin plus duration
*/
_changedDuration()
{
if (!this._isDurationConnected() || this._duration.value === '') {
if (!this._isDurationConnected()) {
return;
}
@@ -486,11 +315,11 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
if (begin === null && end === null) {
const newBegin = DateTime.now();
this._applyDateToField(newBegin, this._beginDate, this._beginTime);
this._addSecondsToEndDate(newBegin, seconds);
this._applyDateToField(newBegin.plus({seconds: seconds}), null, this._endTime);
} else if (begin === null && end !== null) {
this._applyDateToField(end.minus({seconds: seconds}), this._beginDate, this._beginTime);
} else if (begin !== null && seconds >= 0) {
this._addSecondsToEndDate(begin, seconds);
this._applyDateToField(begin.plus({seconds: seconds}), null, this._endTime);
}
}
@@ -538,23 +367,7 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
*/
_getParsedDuration()
{
return this.getDateUtils().parseDuration(this._duration.value);
}
/**
* @param {DateTime} dateTime
* @param {int} seconds
* @private
*/
_addSecondsToEndDate(dateTime, seconds)
{
// if the duration is longer than one day, the end field should be empty
// so kimai can calculate it after submitting the data from start + duration
if (seconds < 86400) {
this._applyDateToField(dateTime.plus({seconds: seconds}), null, this._endTime);
} else {
this._endTime.value = '';
}
return this.getDateUtils().parseDuration(this._duration.value.toUpperCase());
}
/**
@@ -577,122 +390,4 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
timeField.value = this.getDateUtils().format(timeField.dataset['format'], dateTime);
}
/**
* @param {KeyboardEvent} event
* @private
*/
_changeDurationOnKeypress(event)
{
switch (event.key) {
case 'ArrowUp':
case 'ArrowDown':
case 'PageUp':
case 'PageDown':
case 'Home':
case 'End':
this._setDurationAsString(this._getParsedDuration());
break;
default:
return; // Ignore other keys
}
this._changeTimeOnKeypress(event, this._duration, 99999, this._durationListener);
}
/**
* This method helps the user to change a duration field with simple keyboard interaction:
* - Read the current duration from the given timeField input in format HH:MM (no seconds)
* - Change the duration based on the rules below
* - Write the new duration back to the field
* - If the field is empty or invalid it uses 00:00 as start-time
* - Duration cannot exceed maxtime (which is given in minutes)
* - Duration cannot drop below 00:00
* - Read the position of the cursor and decide whether to increase minutes or hours: if the cursor is in the hour section (before the colon) change hours, if the cursor is in the minute section (after the colon) change minutes
* - It reads the pressed key from the given KeyboardEvent and changes the duration accordingly to the rules below
*
* Rules to apply when a key is pressed:
* - ArrowUp key to increase the duration (either 5 minutes or 1 hour, depending on the cursor position)
* - ArrowDown key to decrease the duration (either 5 minutes or 1 hour, depending on the cursor position)
* - PageUp key to increase the duration by 1 hour
* - PageDown key to decrease the duration by 1 hour
* - Home key to set the duration to 08:00
* - End key to set the duration to 00:00
* - all other keys are ignored
*
* @param {KeyboardEvent} event
* @param {HTMLElement} timeField
* @param {int} maxTime
* @param {function} changeCallback
* @private
*/
_changeTimeOnKeypress(event, timeField, maxTime, changeCallback)
{
// Parse current value or default to 00:00
let value = timeField.value || '00:00';
let [hours, minutes] = value.split(':').map(Number);
if (isNaN(hours)) { hours = 0; }
if (isNaN(minutes)) { minutes = 0; }
// Cursor position: before or after colon
const cursorPos = timeField.selectionStart || 0;
const colonPos = value.indexOf(':');
const inHour = cursorPos <= colonPos;
// Helper to clamp values
const clamp = (h, m) => {
let total = h * 60 + m;
if (total < 0) { total = 0; }
if (total > maxTime) { total = maxTime; }
h = Math.floor(total / 60);
m = total % 60;
return [h, m];
};
switch (event.key) {
case 'ArrowUp':
if (inHour) {
[hours, minutes] = clamp(hours + 1, minutes);
} else {
[hours, minutes] = clamp(hours, minutes + 5);
}
break;
case 'ArrowDown':
if (inHour) {
[hours, minutes] = clamp(hours - 1, minutes);
} else {
[hours, minutes] = clamp(hours, minutes - 5);
}
break;
case 'PageUp':
[hours, minutes] = clamp(hours + 1, minutes);
event.preventDefault();
break;
case 'PageDown':
[hours, minutes] = clamp(hours - 1, minutes);
event.preventDefault();
break;
case 'Home':
// TODO this should use the configured working time for today
hours = 8;
minutes = 0;
event.preventDefault();
break;
case 'End':
hours = 0;
minutes = 0;
event.preventDefault();
break;
default:
return; // Ignore other keys
}
// Format and set value
timeField.value = `${hours}:${minutes.toString().padStart(2, '0')}`;
// trigger update of linked fields
changeCallback(timeField);
// Move cursor to original position if possible
setTimeout(() => {
timeField.setSelectionRange(cursorPos, cursorPos);
}, 0);
}
}

View File

@@ -19,6 +19,7 @@ export default class KimaiAPI extends KimaiPlugin {
_headers() {
const headers = new Headers();
headers.append('X-AUTH-SESSION', '1');
headers.append('Content-Type', 'application/json');
return headers;

View File

@@ -73,7 +73,6 @@ export default class KimaiAPILink extends KimaiPlugin {
const ALERT = this.getContainer().getPlugin('alert');
const successHandle = () => {
EVENTS.trigger(eventName);
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
if (attributes['msgSuccess'] !== undefined) {
ALERT.success(attributes['msgSuccess']);
}
@@ -83,7 +82,6 @@ export default class KimaiAPILink extends KimaiPlugin {
if (attributes['msgError'] !== undefined) {
message = attributes['msgError'];
}
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
API.handleError(message, error);
};
@@ -92,8 +90,6 @@ export default class KimaiAPILink extends KimaiPlugin {
data = attributes['payload'];
}
document.dispatchEvent(new CustomEvent('kimai.reloadContent'));
if (method === 'PATCH') {
API.patch(url, data, successHandle, errorHandle);
} else if (method === 'POST') {

View File

@@ -17,10 +17,9 @@ import { Modal } from 'bootstrap';
export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
constructor(selector, stopSelector) {
constructor(selector) {
super();
this._selector = selector;
this._stopSelector = stopSelector;
}
getId()
@@ -64,12 +63,12 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
this.addClickHandler(this._selector, (href) => {
this.openUrlInModal(href);
}, this._stopSelector);
});
}
_getModal()
{
return Modal.getOrCreateInstance(this._getModalElement());
return Modal.getOrCreateInstance(this._getModalElement())
}
/**
@@ -217,6 +216,8 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
const eventName = form.dataset['formEvent'];
/** @type {KimaiEvent} alert */
const events = this.getContainer().getPlugin('event');
/** @type {KimaiAlert} alert */
const alert = this.getContainer().getPlugin('alert');
event.preventDefault();
event.stopPropagation();
@@ -257,8 +258,15 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
} else {
events.trigger(eventName);
// try to find form defined message first, but
let msg = form.dataset['msgSuccess'];
// if that is not available: use a generic fallback message
if (msg === null || msg === undefined || msg === '') {
msg = 'action.update.success';
}
this._isDirty = false;
this._getModal().hide();
alert.success(msg);
}
});
})
@@ -268,8 +276,6 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
message = 'action.update.error';
}
/** @type {KimaiAlert} alert */
const alert = this.getContainer().getPlugin('alert');
alert.error(message, error.message);
// this is useful for changing form fields and retrying to save (and in development to test form changes)

View File

@@ -52,7 +52,7 @@ export default class KimaiAlert extends KimaiPlugin {
}
const html = `
<div class="modal" id="` + id + `" tabindex="-1" role="dialog">
<div class="modal modal-blur fade" id="` + id + `" tabindex="-1" role="dialog">
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-status bg-` + this._mapClass('danger') + `"></div>
@@ -128,7 +128,7 @@ export default class KimaiAlert extends KimaiPlugin {
}
const html = `
<div class="modal fade" tabindex="-1" role="dialog">
<div class="modal modal-blur fade" tabindex="-1" role="dialog">
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-status bg-` + this._mapClass(type) + `"></div>
@@ -208,7 +208,7 @@ export default class KimaiAlert extends KimaiPlugin {
const toast = new Toast(element);
element.addEventListener('hidden.bs.toast', function () {
container.removeChild(element);
});
})
toast.show();
}
@@ -227,7 +227,7 @@ export default class KimaiAlert extends KimaiPlugin {
const css = this._mapClass('info');
const html = `
<div class="modal fade" tabindex="-1" role="dialog" data-bs-backdrop="static">
<div class="modal modal-blur fade" tabindex="-1" role="dialog" data-bs-backdrop="static">
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-status bg-` + css + `"></div>

View File

@@ -24,7 +24,7 @@ export default class KimaiAlternativeLinks extends KimaiReducedClickHandler {
init() {
this.addClickHandler(this._selector, function(href) {
window.location = href;
}, []);
});
}
}

View File

@@ -273,10 +273,7 @@ export default class KimaiDateUtils extends KimaiPlugin {
let luxonDuration = null;
if (duration.indexOf(':') !== -1) {
const match = duration.match(/(?:(\d+):)?(\d+)(?::(\d+))?/);
const hours = parseInt(match?.[1] || 0, 10);
const minutes = parseInt(match?.[2] || 0, 10);
const seconds = parseInt(match?.[3] || 0, 10);
const [, hours, minutes, seconds] = duration.match(/(\d+):(\d+)(?::(\d+))*/);
luxonDuration = Duration.fromObject({hours: hours, minutes: minutes, seconds: seconds});
} else if (duration.indexOf('.') !== -1 || duration.indexOf(',') !== -1) {
duration = duration.replace(/,/, '.');

View File

@@ -36,7 +36,7 @@ export default class KimaiHotkeys extends KimaiPlugin {
elements[0].click();
}
}
});
})
}
// adopted from Bootstrap 5.1.1, MIT

View File

@@ -13,14 +13,9 @@ import KimaiPlugin from '../KimaiPlugin';
export default class KimaiMultiUpdateTable extends KimaiPlugin {
getId()
{
return 'datatable-batch-action';
}
init()
{
if (document.getElementsByClassName('multi_update_all').length === 0) {
if (document.getElementById('multi_update_all') === null) {
return;
}
@@ -28,101 +23,64 @@ export default class KimaiMultiUpdateTable extends KimaiPlugin {
// via KimaiDatable and everything inside will be removed, including event listeners
const element = document.querySelector('div.page-body');
element.addEventListener('change', (event) => {
if (event.target.matches('.multi_update_all')) {
this.toggle(event.target.checked, event.target.closest('table'));
if (event.target.matches('#multi_update_all')) {
// the "check all" checkbox in the upper start corner of the table
const checked = event.target.checked;
for (const element of document.querySelectorAll('.multi_update_single')) {
element.checked = checked;
}
this._toggleForm();
event.stopPropagation();
} else if (event.target.matches('.multi_update_single')) {
// single checkboxes in front of each row
this._toggleDatatable(event.target.closest('table'));
this._toggleForm();
event.stopPropagation();
}
});
element.addEventListener('click', (event) => {
if (event.target.matches('.multi_update_table_action')) {
const selectedButton = event.target;
const form = selectedButton.form;
const ids = form.querySelector('.multi_update_ids').value.split(',');
const question = form.dataset['question'].replace(/%action%/, selectedButton.textContent).replace(/%count%/, ids.length.toString());
const selectedItem = event.target;
const ids = this._getSelectedIds();
const form = document.getElementById('multi_update_form');
const question = form.dataset['question'].replace(/%action%/, selectedItem.textContent).replace(/%count%/, ids.length.toString());
/** @type {KimaiAlert} ALERT */
const ALERT = this.getPlugin('alert');
ALERT.question(question, function(value) {
if (value) {
form.action = selectedButton.dataset['href'];
const form = document.getElementById('multi_update_form');
form.action = selectedItem.dataset['href'];
form.submit();
}
});
}
});
}
/**
* @param {boolean} checked
* @param {HTMLTableElement} table
*/
toggle(checked, table)
_getSelectedIds()
{
for (const element of table.querySelectorAll('.multi_update_single')) {
element.checked = checked;
}
this._toggleDatatable(table);
}
/**
* @param {boolean} checked
*/
toggleAll(checked)
{
for (const element of document.querySelectorAll('.multi_update_all')) {
this._toggleAll(checked, element);
}
}
/**
* @param {boolean} checked
* @param {string} name
*/
toggleByName(checked, name)
{
for (const element of document.querySelectorAll('#multi_update_all_' + name)) {
this._toggleAll(checked, element);
}
}
/**
* @param {boolean} checked
* @param {Element} name
*/
_toggleAll(checked, element)
{
element.checked = checked;
this.toggle(checked, element.closest('table'));
}
/**
* @param {HTMLTableElement} table
* @private
*/
_toggleDatatable(table)
{
const card = table.closest('div.card.data_table');
let ids = [];
for (const box of table.querySelectorAll('input.multi_update_single:checked')) {
for (const box of document.querySelectorAll('input.multi_update_single:checked')) {
ids.push(box.value);
}
card.querySelector('.multi_update_ids').value = ids.join(',');
return ids;
}
_toggleForm()
{
const ids = this._getSelectedIds();
document.getElementById('multi_update_table_entities').value = ids.join(',');
if (ids.length > 0) {
for (const element of card.querySelectorAll('.multi_update_form_hide')) {
for (const element of document.getElementsByClassName('multi_update_form_hide')) {
element.style.setProperty('display', 'none', 'important');
}
card.querySelector('form.multi_update_form').style.display = null;//'block';
document.getElementById('multi_update_form').style.display = null;//'block';
} else {
card.querySelector('form.multi_update_form').style.setProperty('display', 'none', 'important');
for (const element of card.querySelectorAll('.multi_update_form_hide')) {
document.getElementById('multi_update_form').style.setProperty('display', 'none', 'important');
for (const element of document.getElementsByClassName('multi_update_form_hide')) {
element.style.display = null;
}
}

View File

@@ -43,7 +43,7 @@ export default class KimaiNotification extends KimaiPlugin {
callback(false);
}
});
} catch (e) { // eslint-disable-line no-unused-vars
} catch (e) {
Notification.requestPermission((permission) => {
if (permission === "granted") {
callback(true);

View File

@@ -15,11 +15,10 @@ export default class KimaiReducedClickHandler extends KimaiPlugin {
/**
* No _underscore naming for now, as it would be mangled otherwise
* @param {string} selector
* @param {callback} callback
* @param {array<string>} stopSelector
* @param selector
* @param callback
*/
addClickHandler(selector, callback, stopSelector) {
addClickHandler(selector, callback) {
document.body.addEventListener('click', (event) => {
// event.currentTarget is ALWAYS the body
@@ -39,12 +38,6 @@ export default class KimaiReducedClickHandler extends KimaiPlugin {
return;
}
for (let x of stopSelector) {
if (target.matches(x)) {
return;
}
}
target = target.parentNode;
}
@@ -61,12 +54,6 @@ export default class KimaiReducedClickHandler extends KimaiPlugin {
return;
}
for (let x of stopSelector) {
if (target.matches(x)) {
return;
}
}
event.preventDefault();
event.stopPropagation();

View File

@@ -6,17 +6,12 @@
*/
/*!
* [KIMAI] KimaiRemoteModal: load remote content (without forms) into a modal
* [KIMAI] KimaiRecentActivities: responsible to reload the users recent activities
*/
import KimaiPlugin from '../KimaiPlugin';
import { Modal } from 'bootstrap';
/**
* Use like this:
* <a href="{{ path('your-route') }}" class="remote-modal-load" data-modal-id="remote_modal" data-modal-class="p-0" data-modal-title="Some title" title="Some title">Modal</a>
* <a href="{{ path('your-route') }}" class="remote-modal-load" data-modal-title="Some title" title="Some title">Modal</a>
*/
export default class KimaiRemoteModal extends KimaiPlugin {
constructor()
@@ -39,11 +34,43 @@ export default class KimaiRemoteModal extends KimaiPlugin {
this._showModal(event.currentTarget);
event.stopPropagation();
event.preventDefault();
};
}
for (let link of document.querySelectorAll(this._selector)) {
link.addEventListener('click', this.handle);
}
document.addEventListener('kimai.closeRemoteModal', () => { this._hide(); });
}
/**
* @param {HTMLElement} element
* @private
*/
_initElement(element)
{
for (let link of element.querySelectorAll('a.remote-modal-reload')) {
link.addEventListener('click', this.handle);
}
}
_hide()
{
this._getModal().hide();
}
_getModalElement()
{
return document.getElementById('remote_modal');
}
/**
* @returns {Modal}
* @private
*/
_getModal()
{
return Modal.getOrCreateInstance(this._getModalElement());
}
/**
@@ -58,33 +85,21 @@ export default class KimaiRemoteModal extends KimaiPlugin {
return;
}
let modalSelector = 'remote_modal';
if (element.dataset['modalId'] !== undefined) {
modalSelector = element.dataset['modalId'];
}
const modalElement = document.getElementById(modalSelector);
if (modalElement === null) {
console.log('Could not find modal with ID: ' + modalSelector);
}
return response.text().then(html => {
const modalBody = document.createElement('div');
modalBody.classList.add('modal-body');
if (element.dataset['modalClass'] !== undefined) {
modalBody.classList.add(element.dataset['modalClass']);
}
modalBody.innerHTML = html;
const newFormHtml = document.createElement('div');
newFormHtml.classList.add('modal-body');
newFormHtml.classList.add('p-0');
newFormHtml.innerHTML = html;
for (let link of modalBody.querySelectorAll('a.remote-modal-reload')) {
link.addEventListener('click', this.handle);
}
this._initElement(newFormHtml);
modalElement.querySelector('.modal-body').replaceWith(modalBody);
const modal = this._getModalElement();
modal.querySelector('.modal-body').replaceWith(newFormHtml);
if (element.dataset['modalTitle'] !== undefined) {
modalElement.querySelector('.modal-title').textContent = element.dataset['modalTitle'];
modal.querySelector('.modal-title').textContent = element.dataset['modalTitle'];
}
Modal.getOrCreateInstance(modalElement).show();
this._getModal().show();
});
})
.catch((reason) => {

View File

@@ -9,7 +9,7 @@
* [KIMAI] KimaiThemeInitializer: initialize theme functionality
*/
import { Tooltip, Offcanvas } from 'bootstrap';
import { Tooltip } from 'bootstrap';
import KimaiPlugin from '../KimaiPlugin';
export default class KimaiThemeInitializer extends KimaiPlugin {
@@ -21,10 +21,6 @@ export default class KimaiThemeInitializer extends KimaiPlugin {
return new Tooltip(tooltipTriggerEl);
});
// support for offcanvas elements
const offcanvasElementList = document.querySelectorAll('.offcanvas');
[...offcanvasElementList].map(offcanvasEl => new Offcanvas(offcanvasEl));
// activate all form plugins
/** @type {KimaiForm} FORMS */
const FORMS = this.getContainer().getPlugin('form');
@@ -42,13 +38,13 @@ export default class KimaiThemeInitializer extends KimaiPlugin {
}
// at which element we append the loading screen
let container = 'div.page-wrapper';
let container = 'body';
if (event.detail !== undefined && event.detail !== null) {
container = event.detail;
}
const temp = document.createElement('div');
temp.innerHTML = '<div class="overlay"><div class="progress progress-sm"><div class="progress-bar progress-bar-indeterminate"></div></div></div>';
temp.innerHTML = '<div class="overlay"><div class="fas fa-sync fa-spin"></div></div>';
this.overlay = temp.firstElementChild;
document.querySelector(container).append(this.overlay);
});

View File

@@ -49,11 +49,4 @@ export default class KimaiUser extends KimaiPlugin {
return this.user.superAdmin;
}
/**
* @returns {array}
*/
getRoles() {
return this.user.roles;
}
}

View File

@@ -14,7 +14,7 @@ import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import bootstrap5Plugin, { BootstrapTheme } from '@fullcalendar/bootstrap5';
import googlePlugin from '@fullcalendar/google-calendar';
import iCalendarPlugin from '@fullcalendar/icalendar';
import iCalendarPlugin from '@fullcalendar/icalendar'
import interactionPlugin, { Draggable } from '@fullcalendar/interaction';
import arLocale from '@fullcalendar/core/locales/ar';
import csLocale from '@fullcalendar/core/locales/cs';
@@ -49,7 +49,6 @@ import enGbLocale from '@fullcalendar/core/locales/en-gb';
import enUsLocale from '@fullcalendar/core/locales/en-gb';
import KimaiColor from './KimaiColor';
import KimaiContextMenu from "./KimaiContextMenu";
import { DateTime } from 'luxon';
export default class KimaiCalendar {
@@ -70,6 +69,23 @@ export default class KimaiCalendar {
const DATES = this.kimai.getPlugin('date');
/** @type {KimaiAjaxModalForm} MODAL */
const MODAL = this.kimai.getPlugin('modal');
/** @type {KimaiAlert} ALERT */
const ALERT = this.kimai.getPlugin('alert');
let initialView = 'dayGridMonth';
switch (options['initialView']) {
case 'month':
initialView = 'dayGridMonth';
break;
case 'agendaWeek':
case 'week':
initialView = 'timeGridWeek';
break;
case 'agendaDay':
case 'day':
initialView = 'timeGridDay';
break;
}
// Instead of using "buttonIcons" the theme needs to be adjusted directly
// https://fullcalendar.io/docs/buttonIcons
@@ -93,18 +109,18 @@ export default class KimaiCalendar {
nextYear: this.options['icons']['nextYear'],
};
BootstrapTheme.prototype.rtlIconClasses = {
prev: this.options['icons']['previous'],
next: this.options['icons']['next'],
prevYear: this.options['icons']['previousYear'],
nextYear: this.options['icons']['nextYear'],
prev: this.options['icons']['next'],
next: this.options['icons']['previous'],
prevYear: this.options['icons']['nextYear'],
nextYear: this.options['icons']['previousYear'],
};
let calendarOptions = {
locales: [ enGbLocale, enUsLocale, arLocale, csLocale, daLocale, deLocale, deAtLocale, elLocale,
esLocale, euLocale, faLocale, fiLocale, frLocale, heLocale, hrLocale, huLocale, itLocale, jaLocale, koLocale,
nbLocale, nlLocale, plLocale, ptLocale, ptBrLocale, roLocale, ruLocale, skLocale, svLocale, trLocale, zhLocale, viLocale ],
plugins: [ bootstrap5Plugin, dayGridPlugin, timeGridPlugin, googlePlugin, iCalendarPlugin, interactionPlugin ],
initialView: this.toInternalViewName(this.options['initialView']),
initialDate: this.options['initialDate'],
initialView: initialView,
// https://fullcalendar.io/docs/theming
themeSystem: 'bootstrap5',
// https://fullcalendar.io/docs/headerToolbar
@@ -139,9 +155,8 @@ export default class KimaiCalendar {
slotMinTime: this.options['timeframeBegin'] + ':00',
slotMaxTime: this.options['timeframeEnd'] === '23:59' ? '24:00:00' : (this.options['timeframeEnd'] + ':59'),
// deactivate for auto calculation, which does a good job.
// but 1h seems to be a "normal distance" for calendar apps (like Google and Apple)
slotLabelInterval: '1:00',
// auto calculation seems to do the better job, therefor deactivated
//slotLabelInterval: this.options['slotDuration'],
// how long should entries look like when they don't have an end
defaultTimedEventDuration: this.options['slotDuration'],
@@ -157,20 +172,14 @@ export default class KimaiCalendar {
// once we can configure working days
// hiddenDays: [ 2, 4 ]
// when we support holidays and other full day events
// allDaySlot: false,
// dropAccept
dayMaxEventRows: true,
eventMaxStack: this.options['dayLimit'],
dayMaxEvents: this.options['dayLimit'],
// the callbacks "viewDidMount" and "viewWillUnmount" are only called when switching between month and others, not between week and day
datesSet: (dateInfo) => {
document.dispatchEvent(new CustomEvent('kimai.calendar.changeDate', {detail: {
view: this.toExternalViewName(dateInfo.view.type),
date: dateInfo.start.toISOString().split('T')[0],
}}));
},
views: {
dayGrid: {
dayMaxEventRows: this.options['dayLimit']
@@ -256,14 +265,8 @@ export default class KimaiCalendar {
contextMenu.createFromApi(jsEvent, result);
}, (e) => { console.log('Failed to load actions for context menu', e); });
}
});
})
},
// called after all events of one source were set, so this can
// and will be called multiple times before the calendar is initialized
eventsSet: (events) => {
this._renderDayAndWeekSum(this.getCalendar().getCurrentData().viewSpec.type, events);
}
};
// ============= DRAG & DROP =============
@@ -280,7 +283,6 @@ export default class KimaiCalendar {
droppable: true,
// drop function handles external draggable events
drop: (dropInfo) => {
document.dispatchEvent(new CustomEvent('kimai.reloadContent'));
const entry = dropInfo.draggedEl;
const source = entry.parentElement;
let data = JSON.parse(entry.dataset.entry);
@@ -323,7 +325,7 @@ export default class KimaiCalendar {
(result) => {
const newItem = this.convertSourceForCalendar(result);
this.getCalendar().addEvent(newItem, true);
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
ALERT.success('action.update.success');
}
);
} else {
@@ -333,7 +335,7 @@ export default class KimaiCalendar {
(result) => {
const newItem = this.convertSourceForCalendar(result);
this.getCalendar().addEvent(newItem, true);
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
ALERT.success('action.update.success');
}
);
}
@@ -408,13 +410,13 @@ export default class KimaiCalendar {
this.hidePopover(info.el);
},
eventDrop: (eventDropInfo) => {
this.changeHandler(eventDropInfo);
this.changeHandler(eventDropInfo)
},
eventResizeStart: (info) => {
this.hidePopover(info.el);
},
eventResize: (eventResizeInfo) => {
this.changeHandler(eventResizeInfo);
this.changeHandler(eventResizeInfo)
},
}};
}
@@ -527,44 +529,6 @@ export default class KimaiCalendar {
return (event.source.id.indexOf('kimai-') === 0);
}
/**
* @param {string} viewName
* @returns {string}
*/
toExternalViewName(viewName) {
switch(viewName) {
case 'timeGridDay':
return 'day';
case 'timeGridWeek':
return 'week';
case 'dayGridMonth':
default:
return 'month';
}
}
/**
* @param {string} viewName
* @returns {string}
*/
toInternalViewName(viewName) {
switch(viewName) {
case 'day':
case 'agendaDay':
case 'timeGridDay':
return 'timeGridDay';
case 'week':
case 'agendaWeek':
case 'timeGridWeek':
return 'timeGridWeek';
case 'month':
case 'agendaMonth':
case 'dayGridMonth':
default:
return 'dayGridMonth';
}
}
/**
* @param {string} name
* @return {boolean}
@@ -605,7 +569,7 @@ export default class KimaiCalendar {
color = apiItem.project.customer.color;
}
}
if (color === null) {
if (color == null) {
color = defaultColor;
}
@@ -702,6 +666,8 @@ export default class KimaiCalendar {
/** @type {KimaiAPI} API */
const API = this.kimai.getPlugin('api');
/** @type {KimaiAlert} ALERT */
const ALERT = this.kimai.getPlugin('alert');
/** @type {KimaiDateUtils} DATE */
const DATES = this.kimai.getPlugin('date');
@@ -713,85 +679,13 @@ export default class KimaiCalendar {
payload.end = null;
}
document.dispatchEvent(new CustomEvent('kimai.reloadContent'));
const updateUrl = this.options.url.update(event.id);
API.patch(updateUrl, JSON.stringify(payload), () => {
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
ALERT.success('action.update.success');
}, (error) => {
eventArg.revert();
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
API.handleError('action.update.error', error);
});
}
/**
* @param {string} view
* @param {EventApi[]} events
* @private
*/
_renderDayAndWeekSum(view, events) {
if (view === 'dayGridMonth') {
// currently we do not display totals in month view
return;
}
/** @type {KimaiDateUtils} DATES */
const DATES = this.kimai.getPlugin('date');
const durations = {};
if (view === 'timeGridWeek') {
// make sure we have an entry for every day of the week, even days without timesheets
document.querySelectorAll(`th.fc-col-header-cell[data-date]`).forEach(cell => {
durations[cell.dataset.date] = 0;
});
}
events.forEach(item => {
const start = DateTime.fromJSDate(item.start).toUTC();
const dateStr = start.toFormat('yyyy-MM-dd');
if (!durations[dateStr]) {
durations[dateStr] = 0;
}
// absences or public holidays are all day
if (item.end !== null) {
const end = DateTime.fromJSDate(item.end).toUTC();
const duration = end.diff(start, 'hours').as('seconds');
durations[dateStr] += duration;
}
});
const dailyTotals = document.querySelectorAll('.fc-dailytotal');
dailyTotals.forEach(element => element.remove());
for (const dateValue in durations) {
const durationValue = durations[dateValue];
if (view === 'timeGridWeek') { // this is the week view
const headerCells = document.querySelectorAll(`th.fc-col-header-cell[data-date="${dateValue}"]`);
headerCells.forEach(cell => {
const newElement = document.createElement('div');
newElement.classList.add('fc-dailytotal');
newElement.textContent = DATES.formatSeconds(durationValue);
cell.appendChild(newElement);
});
}
}
// this is the day view
if (view === 'timeGridDay') {
const dayEl = document.querySelector('th.fc-day');
const dayDate = dayEl.dataset.date;
const dayTotal = document.querySelectorAll('.fc-dailytotal');
dayTotal.forEach(element => element.remove());
const newElement = document.createElement('div');
newElement.classList.add('fc-dailytotal');
newElement.textContent = DATES.formatSeconds(durations[dayDate]);
dayEl.appendChild(newElement);
}
}
}
}

View File

@@ -70,10 +70,7 @@ export default class KimaiContextMenu {
{
const dropdownElement = this.getContextMenuElement();
if (!dropdownElement.classList.contains('action-dropdown')) {
dropdownElement.classList.add('action-dropdown');
}
dropdownElement.style.zIndex = '1021'; // stay on top of sticky elements (like table header)
dropdownElement.innerHTML = html;
dropdownElement.style.position = 'fixed';
dropdownElement.style.top = (event.clientY) + 'px';
@@ -89,7 +86,7 @@ export default class KimaiContextMenu {
}
dropdownElement.removeEventListener('click', dropdownListener);
document.removeEventListener('click', dropdownListener);
};
}
dropdownElement.addEventListener('click', dropdownListener);
document.addEventListener('click', dropdownListener);

View File

@@ -16,6 +16,7 @@ export default class KimaiPaginatedBoxWidget {
constructor(boxId) {
this.selector = boxId;
const widget = document.querySelector(this.selector);
this.href = widget.dataset['href'];
if (widget.dataset['reload'] !== undefined) {
this.events = widget.dataset['reload'].split(' ');
@@ -60,7 +61,7 @@ export default class KimaiPaginatedBoxWidget {
// and this event will hide it afterwards
const hideOverlay = () => {
document.dispatchEvent(new Event('kimai.reloadedContent'));
};
}
window.kimai.getPlugin('fetch').fetch(url)
.then(response => {
@@ -92,7 +93,7 @@ export default class KimaiPaginatedBoxWidget {
if (node.tagName !== undefined && node.tagName === 'SCRIPT') {
const script = document.createElement('script');
script.text = node.innerHTML;
node.parentNode.replaceChild(script, node);
node.parentNode.replaceChild(script, node );
} else {
for (const child of node.childNodes) {
this._makeScriptExecutable(child);

View File

@@ -1,37 +0,0 @@
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
$modal-backdrop-opacity: 0.7 !default;
@import "~@tabler/core/scss/config";
/*
THIS DOES NOT WORK AS EXPECTED, AS TABLER IS NOT REGENERATED HERE.
SO WE CANNOT CHANGE VARIABLES, LIKE THE ABOVE...
*/
@import '~@tabler/core/dist/css/tabler.rtl.css';
@import '~@tabler/core/dist/css/tabler-themes.rtl.css';
@import '~tom-select/dist/scss/tom-select';
@import '~tom-select/dist/scss/tom-select.bootstrap5';
@import '~@tabler/core/dist/css/tabler-vendors.rtl.css';
/**
* Everyone needs icons
*/
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts/";
@import '~@fortawesome/fontawesome-free/scss/fontawesome';
@import '~@fortawesome/fontawesome-free/scss/regular';
@import '~@fortawesome/fontawesome-free/scss/solid';
@import '~@fortawesome/fontawesome-free/scss/brands';
/**
* Kimai related stuff
*/
@import "kimai";
@import "rtl";

View File

@@ -5,28 +5,33 @@
* file that was distributed with this source code.
*/
$modal-backdrop-opacity: 0.7 !default;
@import "bootstrap/scss/functions";
@import '~@tabler/core/scss/tabler';
@import '~@tabler/core/scss/tabler-themes';
@import '~@tabler/core/scss/vendor/litepicker';
@import "@tabler/core/src/scss/variables";
@import "@tabler/core/src/scss/variables-dark";
@import "@tabler/core/src/scss/mixins";
@import '~tom-select/dist/scss/tom-select.bootstrap5';
@import '~@tabler/core/scss/vendor/tom-select';
@import "bootstrap/scss/variables";
@import "bootstrap/scss/variables-dark";
@import "bootstrap/scss/maps";
@import "bootstrap/scss/mixins";
@import "bootstrap/scss/utilities";
/**
* Everyone needs icons
*/
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts/";
@import '~@fortawesome/fontawesome-free/scss/fontawesome';
@import '~@fortawesome/fontawesome-free/scss/regular';
@import '~@fortawesome/fontawesome-free/scss/solid';
@import '~@fortawesome/fontawesome-free/scss/brands';
/**
* Kimai specific additions
*/
@import "kimai";
@import "layout";
@import "error-page";
@import "print";
@import "content";
@import "loading-spinner";
@import "tables";
@import "calendar";
@import "ticktac";
@import "selectpicker";
@import "forms";
@import "modal";
@import "progressbar";
@import "avatar";
@import "theme-dark";
@import "pages";
@import "tabler-fixes";
@import "help";
@import "rtl";

View File

@@ -5,30 +5,111 @@
* file that was distributed with this source code.
*/
body { font-family: sans-serif; font-size: 10pt; margin: 4px; padding: 0; }
table, tr, td, th, p, h1, h2, h3, h4, h5 { padding: 0; margin: 0;}
th { border: none; text-align: left; }
td { vertical-align: top; }
p { margin-bottom: 12px; }
.text-small { font-size: 7pt; }
.padding-left { padding-left: 15px; }
.text-center { text-align: center; }
.text-left, .text-begin { text-align: left; }
.text-right, .text-end { text-align: right; }
.text-nowrap { white-space: nowrap; }
.date { font-size: 80%; text-align: right; padding-top: 6px; }
table { width: 100%; border-spacing: 0; }
table.addresses, p { line-height: 14pt; }
table.addresses { margin-bottom: 30px; }
table.header { padding-bottom: 4px; border-bottom: 1px solid #999; }
table.header .title { font-weight: normal; font-size: 140%; }
table.footer { border-top: 1px solid #999; font-size: 80%; padding-top: 8px; line-height: 14px; }
table.footer td { vertical-align: top; }
table.items { border-collapse: collapse; margin-top: 50px; margin-bottom: 60px; }
table.items thead th, table.items tbody td { border-bottom: 1px solid #ccc; }
table.items thead th.first, table.items tbody td.first { padding-left: 0; }
table.items thead th.last, table.items tbody td.last, table.items tfoot td.last { padding-right: 0; }
table.items td, table.items th { padding: 8px 6px; }
table.items tfoot td, table.items tfoot th { padding: 20px 0 0 0; line-height: 1px; }
table.items tfoot td.last { padding-left: 10px; }
table.items tr.odd { background-color: #f5f5f5; }
body {
font-family: sans-serif;
font-size: 10pt;
margin: 4px;
padding: 0;
}
table, tr, td, th, p, h1, h2, h3, h4, h5 {
padding: 0;
margin: 0;
}
th {
border: none;
text-align: left;
}
td {
vertical-align: top;
}
p {
margin-bottom: 12px;
}
.text-small {
font-size: 7pt;
}
.padding-left {
padding-left: 15px;
}
.text-center {
text-align: center;
}
.text-left, .text-begin {
text-align: left;
}
.text-right, .text-end {
text-align: right;
}
.text-nowrap {
white-space: nowrap;
}
.date {
font-size: 80%;
text-align: right;
padding-top: 6px;
}
table {
width: 100%;
border-spacing: 0;
}
table.addresses, p {
line-height: 14pt;
}
/* The address table */
table.addresses {
margin-bottom: 30px;
}
/* The PDF header */
table.header {
padding-bottom: 4px;
border-bottom: 1px solid #999;
}
table.header .title {
font-weight: normal;
font-size: 140%;
}
/* The PDF footer */
table.footer {
border-top: 1px solid #999;
font-size: 80%;
padding-top: 8px;
line-height: 14px;
}
table.footer td {
vertical-align: top;
}
/* The invoice items list */
table.items {
border-collapse: collapse;
margin-top: 50px;
margin-bottom: 60px;
}
table.items thead th,
table.items tbody td {
border-bottom: 1px solid #ccc;
}
table.items thead th.first,
table.items tbody td.first {
padding-left: 0;
}
table.items thead th.last,
table.items tbody td.last,
table.items tfoot td.last {
padding-right: 0;
}
table.items td,
table.items th {
padding: 8px 6px;
}
table.items tfoot td,
table.items tfoot th {
padding: 20px 0 0 0;
line-height: 1px;
}
table.items tfoot td.last {
padding-left: 10px;
}
table.items tr.odd {
background-color: #f5f5f5;
}

View File

@@ -1,17 +0,0 @@
@import "layout";
@import "error-page";
@import "print";
@import "content";
@import "loading-spinner";
@import "tables";
@import "calendar";
@import "ticktac";
@import "selectpicker";
@import "forms";
@import "modal";
@import "progressbar";
@import "avatar";
@import "weekly-hours";
@import "tabler-fixes";
@import "help";
@import "rtl";

View File

@@ -52,13 +52,6 @@ table.dataTable thead > tr > th.hw-min {
width: 1%;
white-space: normal;
}
@include media-breakpoint-up(sm) {
th.w-sm-min,
td.w-sm-min {
width: 1%;
white-space: nowrap;
}
}
/* If a table column contains ONLY avatar <img> it will collapse, so make it a defined width */
.w-avatar {
width: 40px;
@@ -93,19 +86,3 @@ table.dataTable thead > tr > th.hw-min {
width: 30%;
}
}
.markdown {
blockquote {
p {
/* Parsedown wraps content in a <p> like <blockquote><p> */
margin: 0;
}
/* Tabler doesn't style blockquotes */
border-left: 15px var(--tblr-border-style) var(--tblr-border-color);
background-color: var(--tblr-bg-surface-secondary);
}
pre code.hljs {
padding: 0;
background-color: var(--tblr-bg-surface-dark);
}
}

View File

@@ -26,9 +26,6 @@
margin-bottom: 0;
}
}
.input-group {
width: unset;
}
}
}
@@ -36,6 +33,7 @@
width: 20px;
height: 20px;
display: inline-block;
margin-right: 10px;
border-radius: var(--tblr-border-radius);
}
@@ -55,19 +53,4 @@ fieldset > .mb-3.row:last-child {
.form-fieldset-light:last-child {
border:none;
}
.dropdown-item {
.dropdown-action {
visibility: hidden;
opacity: 0.5;
}
&:hover {
.dropdown-action {
visibility: visible;
&:hover {
opacity: 1.0;
}
}
}
}

View File

@@ -1,5 +1,9 @@
.inline-search {
max-width: 200px;
/* the entire inline-search should actually use .input-group-flat, but that causes some weird css problems inside the dropdown menu */
#searchTerm {
border-right: 0;
}
}
@media (min-width: 360px) {
@@ -39,21 +43,3 @@ h1.navbar-brand a {
.navbar {
--tblr-navbar-brand-font-size: 1rem;
}
/* user shortcuts dropdown */
.user-shortcuts {
.dropdown-menu-card {
min-width: 260px;
}
.user-shortcuts-icon {
background-color: var(--tblr-bg-surface-secondary);
color: var(--tblr-gray-500);
height: 3.125rem;
width: 3.125rem;
margin-left: auto;
margin-right: auto;
display: flex;
align-items: center;
justify-content: center;
}
}

View File

@@ -12,6 +12,14 @@
width: 100%;
height: 100%;
z-index: 1021;
/*background-color: var(--tblr-backdrop-bg);*/
opacity: $modal-backdrop-opacity;
background: rgba(255, 255, 255, 0.7);
> .fas {
position: absolute;
top: 50vh;
left: 50vw;
margin-left: -15px;
margin-top: -15px;
color: #000;
font-size: 30px;
}
}

39
assets/sass/pages.scss Normal file
View File

@@ -0,0 +1,39 @@
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
/*
----------- This file contains rules that only page to certain pages -----------
*/
/* weekly working times AKA quick-entries form */
section.quick-entry-page {
#quick_entry_form {
display: none;
}
/* shrinks the dropdown elements for project and activity to fit into the table, instead of adjusting to the longest option element */
.ts-wrapper {
display: table;
table-layout: fixed;
}
/* make the duration field and dropdown smaller */
#quick_entry_box {
.duration-widget {
.input-group {
min-width: 85px;
}
.btn-duration-preset {
padding: 7px 8px 7px 5px;
}
input {
padding: 7px 3px 7px 7px;
}
}
}
}

View File

@@ -15,11 +15,12 @@
.content-wrapper {
margin: 0!important;
}
section.content {
margin: 0;
padding: 0;
}
span i.dot {
display: none;
margin-right: 0!important;
}
.float-help {
display: none;
}
}

View File

@@ -1,11 +1,7 @@
/*
1. bootstrap thead.sticky-top has z-index 1020 and tomselect dropdowns often hide behind that: use 1021
2. bootstrap modal uses 1055, tomselect with dropdownParent: 'body' (required for use in table.responsive, e.g quick.entry on small devices) needs 1056, so it works in modals
*/
/* bootstrap thead.sticky-top has z-index 1020 and tomselect dropdowns often hide behind that */
.ts-dropdown {
z-index: 1056;
z-index: 1021;
}
/* Tabler defines a min-width of 7rem, what makes many input fields become a two-line field

View File

@@ -4,7 +4,7 @@ fieldset legend.col-form-label {
}
/* See project assignment to teams */
fieldset.form-fieldset > legend {
fieldset.form-fieldset legend {
font-size: 1rem;
font-weight: bold;
}
@@ -17,43 +17,18 @@ fieldset.form-fieldset > legend {
--tblr-dropdown-divider-margin-y: .5rem;
}
:host,:root,[data-bs-theme=light] {
/* remove the shadow from all form element groups */
--tblr-box-shadow-input: none;
/*
fixes translucent scroll border, e.g. in timesheet modal duration dropdown
https://github.com/tabler/tabler/issues/1606
*/
.dropdown-menu {
background-clip: border-box;
}
.navbar[data-bs-theme=dark] {
/* give navigation elements slightly higher contrast */
--tblr-navbar-color: hsla(0,0%,100%,.8);
}
/* this is not a bugfix, but it adds a border for better visual segregation */
.nav-pills .nav-link.active {
border-width: 1px;
border-style: solid;
}
/* fixes contrast of batch-update checkboxes - https://github.com/kimai/kimai/issues/5146 */
.multiupdater[type=checkbox] {
--tblr-border-color-translucent: rgba(4, 32, 69, .2);
}
/* fixes height for long tags https://github.com/kimai/kimai/issues/5169 */
.tag {
--tblr-tag-height: unset;
}
/* Page title (e.g. Dashboard) does not work properly in navbar, just outside */
.navbar {
.page-title {
color: var(--tblr-body-color);
}
}
/* Highlighted text is not visible - https://github.com/tabler/tabler/issues/2603 */
[data-bs-theme=dark] {
::selection,
.text-selected {
background-color: var(--#{$prefix}primary);
}
/*
hide empty fieldset
https://github.com/tabler/tabler/issues/1650
*/
fieldset:empty {
display: none;
}

View File

@@ -17,18 +17,22 @@ table.dataTable {
position: relative;
}
thead .sorting_asc,
thead .sorting_desc {
font-weight: bold;
}
thead .sorting:after,
thead .sorting_asc:after,
thead .sorting_desc:after {
padding-left: 5px;
font-family: 'Font Awesome\ 6 Free';
font-family: 'Font Awesome 5 Free';
opacity: 0.5;
font-size: 12px;
font-weight: 700
}
thead .sorting:after {
opacity: 0.2;
content: "\f0dc";
content: "\f0dc"; /* sort */
}
thead .sorting_asc:after {
content: "\f077";
@@ -38,7 +42,7 @@ table.dataTable {
}
thead > tr > th {
vertical-align: middle;
vertical-align: top;
white-space: nowrap;
}
@@ -88,13 +92,13 @@ table.dataTable {
&.summary td {
font-weight: bold;
border: 0;
background-color: var(--tblr-secondary-lt);
background-color: var(--tblr-bg-surface-secondary);
}
th.multiCheckbox {
width: 15px;
}
&.overlapping {
border-top: 2px solid rgba(214,57,57,.2);
border-top: 2px solid rgba(214,57,57,.1);
}
&.exported {
opacity: 0.7;
@@ -119,7 +123,7 @@ table.dataTable {
}
th.weekend,
td.weekend {
background-color: var(--tblr-bg-surface-tertiary);
background-color: var(--tblr-bg-surface-secondary);
}
/* order is important, "today” should overwrite "weekend" therefor later in the file */
th.today {
@@ -131,6 +135,14 @@ table.dataTable {
}
}
/* Quick entry form */
.form-dataTable {
table.dataTable {
.form-group {
margin-bottom: 0;
}
}
}
table.table-hover {
tr {
@@ -141,10 +153,3 @@ table.table-hover {
}
}
}
/* For the context menu */
.action-dropdown,
.actions .dropdown .dropdown-menu {
// stay on top of sticky elements (like table header)
z-index: 1021;
}

View File

@@ -0,0 +1,15 @@
@include color-mode(dark, true) {
table.dataTable {
th.today {
color: var(--tblr-muted);
}
}
.overlay {
background: rgba(0, 0, 0, 0.5);
> .fas {
color: var(--tblr-white);
}
}
}

View File

@@ -1,178 +0,0 @@
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
/* This file contains rules for the "weekly-hours" AKA "quick-entries" form */
.quick-entry-page {
.form-dataTable {
table.dataTable {
.form-group {
margin-bottom: 0;
}
}
}
#quick_entry_form {
display: none;
}
.ts-wrapper {
min-height: 36px;
max-height: 36px;
max-width: 150px;
.ts-control {
white-space: nowrap;
.item {
overflow: hidden;
}
}
}
.table tbody td:not(.total) {
padding: 3px;
}
.duration-widget input.duration-input {
min-width: 40px;
}
/* make the duration field and dropdown smaller */
#quick_entry_box {
.duration-widget {
.input-group {
max-width: 50px;
min-width: 50px;
}
.btn-duration-preset {
display: none;
min-width: 30px;
}
input.duration-input {
padding: 7px 5px;
max-width: 50px;
border-bottom-right-radius: var(--tblr-border-radius);
border-top-right-radius: var(--tblr-border-radius);
}
}
}
/* make sure dropdown never hides behind table responsive */
.dropdown-menu {
z-index: 1056;
}
}
@media (min-width: 900px) {
.quick-entry-page {
.ts-wrapper {
max-width: 170px;
}
}
}
@media (min-width: 992px) {
.quick-entry-page {
.ts-wrapper {
max-width: 100px;
}
}
}
@media (min-width: 1100px) {
.quick-entry-page {
.ts-wrapper {
max-width: 160px;
}
}
}
@media (min-width: 1200px) {
.quick-entry-page {
.ts-wrapper {
max-width: 165px;
}
.duration-widget {
max-width: 100%;
}
}
}
@media (min-width: 1250px) {
.quick-entry-page {
#quick_entry_box {
.duration-widget {
.input-group {
max-width: 85px;
min-width: 75px;
}
.btn-duration-preset {
display: flex;
}
input.duration-input {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}
}
}
}
}
@media (min-width: 1300px) {
.quick-entry-page {
#quick_entry_box {
.duration-widget {
input.duration-input {
max-width: 100%;
}
}
}
.ts-wrapper {
max-width: 190px;
}
}
}
@media (min-width: 1400px) {
.quick-entry-page {
.ts-wrapper {
max-width: 235px;
}
}
}
@media (min-width: 1500px) {
.quick-entry-page {
.ts-wrapper {
max-width: 290px;
}
}
}
@media (min-width: 1600px) {
.quick-entry-page {
.ts-wrapper {
max-width: 340px;
}
}
}
@media (min-width: 1800px) {
.quick-entry-page {
.ts-wrapper {
max-width: 450px;
}
#quick_entry_box {
.duration-widget {
.input-group {
max-width: 100%;
}
}
}
}
}

17
babel.config.js Normal file
View File

@@ -0,0 +1,17 @@
module.exports = {
"sourceType": "unambiguous",
"presets": [
[
"@babel/preset-env",
{
"modules": false,
"targets": {},
"useBuiltIns": "usage",
"corejs": 3
}
]
],
"plugins": [
"@babel/plugin-syntax-dynamic-import"
]
}

View File

@@ -6,8 +6,8 @@ use App\ConsoleApplication;
set_time_limit(0);
if (!is_dir(dirname(__DIR__).'/vendor')) {
throw new LogicException('Dependencies are missing. Try running "composer install".');
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';

View File

@@ -14,7 +14,7 @@
}
],
"require": {
"php": "8.4.*||8.5.*",
"php": "8.1.*||8.2.*",
"ext-gd": "*",
"ext-intl": "*",
"ext-json": "*",
@@ -28,55 +28,48 @@
"azuyalabs/yasumi": "^2.6",
"composer/semver": "^3.3",
"doctrine/doctrine-bundle": "^2.7",
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^3.0",
"easybill/zugferd-php": "^2.1",
"endroid/qr-code": "^6.0",
"friendsofsymfony/rest-bundle": "3.9.0-beta1",
"doctrine/doctrine-migrations-bundle": "^3.0",
"doctrine/orm": "^2.8",
"endroid/qr-code": "^4.8",
"erusev/parsedown": "^1.6",
"friendsofsymfony/rest-bundle": "^3.0",
"gedmo/doctrine-extensions": "^3.6",
"horstoeko/zugferd": "^1.0",
"horstoeko/zugferdublbridge": "^1.0",
"jms/serializer-bundle": "^5.0",
"kevinpapst/tabler-bundle": "^2.0",
"kevinpapst/tabler-bundle": "^1.0",
"league/csv": "^9.4",
"mpdf/mpdf": "^8.0",
"nelmio/api-doc-bundle": "^5.0",
"nelmio/api-doc-bundle": "^4.0",
"nelmio/cors-bundle": "^2.0",
"onelogin/php-saml": "^4.0",
"openspout/openspout": "^4.0",
"pagerfanta/pagerfanta": "^4.0",
"parsedown/parsedown": "^1.6",
"phpoffice/phpspreadsheet": "^2.0",
"pagerfanta/pagerfanta": "^3.0",
"phpoffice/phpspreadsheet": "^1.16",
"phpoffice/phpword": "^1.0",
"psr/container": "^2.0",
"psr/log": "^3.0",
"scheb/2fa-backup-code": "^7.11",
"scheb/2fa-bundle": "^7.11",
"scheb/2fa-totp": "^7.11",
"symfony/asset": "^7.0",
"symfony/console": "^7.0",
"symfony/dependency-injection": "^7.0",
"symfony/dotenv": "^7.0",
"symfony/expression-language": "^7.0",
"scheb/2fa-backup-code": "^6.2",
"scheb/2fa-bundle": "^6.2",
"scheb/2fa-totp": "^6.2",
"symfony/asset": "^6.0",
"symfony/console": "^6.0",
"symfony/dotenv": "^6.0",
"symfony/expression-language": "^6.0",
"symfony/flex": "^2",
"symfony/form": "^7.0",
"symfony/framework-bundle": "^7.0",
"symfony/http-client": "^7.0",
"symfony/intl": "^7.0",
"symfony/mailer": "^7.0",
"symfony/mime": "^7.0",
"symfony/form": "^6.0",
"symfony/framework-bundle": "^6.0",
"symfony/http-client": "^6.0",
"symfony/intl": "^6.0",
"symfony/mailer": "^6.0",
"symfony/monolog-bundle": "^3.4",
"symfony/process": "^7.0",
"symfony/rate-limiter": "^7.0",
"symfony/runtime": "^7.0",
"symfony/security-bundle": "^7.0",
"symfony/security-csrf": "^7.0",
"symfony/serializer": "^7.0",
"symfony/translation": "^7.0",
"symfony/twig-bundle": "^7.0",
"symfony/validator": "^7.0",
"symfony/rate-limiter": "^6.0",
"symfony/runtime": "^6.0",
"symfony/security-bundle": "^6.0",
"symfony/security-csrf": "^6.0",
"symfony/serializer": "^6.0",
"symfony/translation": "^6.0",
"symfony/twig-bundle": "^6.0",
"symfony/validator": "^6.0",
"symfony/webpack-encore-bundle": "^2.0",
"symfony/yaml": "^7.0",
"symfony/yaml": "^6.0",
"twig/cssinliner-extra": "^3.0",
"twig/extra-bundle": "^3.0",
"twig/inky-extra": "^3.0",
@@ -85,25 +78,24 @@
},
"require-dev": {
"ext-simplexml": "*",
"dama/doctrine-test-bundle": "^8.0",
"dama/doctrine-test-bundle": "^7.0",
"doctrine/doctrine-fixtures-bundle": "^3.2",
"fakerphp/faker": "^1.15",
"friendsofphp/php-cs-fixer": "^3.3",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpstan/phpstan-symfony": "^2.0",
"phpunit/phpunit": "^10.0",
"symfony/browser-kit": "^7.0",
"symfony/css-selector": "^7.0",
"symfony/debug-bundle": "^7.0",
"symfony/dom-crawler": "^7.0",
"symfony/phpunit-bridge": "^7.0",
"symfony/stopwatch": "^7.0",
"symfony/var-dumper": "^7.0",
"symfony/web-profiler-bundle": "^7.0"
"phpstan/phpstan": "^1.0",
"phpstan/phpstan-doctrine": "^1.0",
"phpstan/phpstan-phpunit": "^1.0",
"phpstan/phpstan-strict-rules": "^1.0",
"phpstan/phpstan-symfony": "^1.0",
"phpunit/phpunit": "9.5.*",
"symfony/browser-kit": "^6.0",
"symfony/css-selector": "^6.0",
"symfony/debug-bundle": "^6.0",
"symfony/dom-crawler": "^6.0",
"symfony/phpunit-bridge": "^6.0",
"symfony/stopwatch": "^6.0",
"symfony/var-dumper": "^6.0",
"symfony/web-profiler-bundle": "^6.0"
},
"repositories": [
{
@@ -118,7 +110,7 @@
},
"optimize-autoloader": true,
"platform": {
"php": "8.4.4"
"php": "8.1.3"
},
"preferred-install": {
"*": "dist"
@@ -140,14 +132,7 @@
"symfony/polyfill-ctype": "*",
"symfony/polyfill-mbstring": "*",
"symfony/polyfill-intl": "*",
"symfony/polyfill-intl-icu": "*",
"symfony/polyfill-intl-grapheme": "*",
"symfony/polyfill-intl-idn": "*",
"symfony/polyfill-intl-normalizer": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php84": "*",
"symfony/polyfill-php83": "*",
"symfony/polyfill-php82": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php74": "*",
@@ -184,11 +169,12 @@
"@tests-integration"
],
"linting": [
"composer validate --strict",
"bin/console lint:container",
"bin/console lint:yaml config --parse-tags",
"bin/console lint:twig templates --show-deprecations",
"bin/console doctrine:schema:validate --skip-sync -vvv --no-interaction",
"bin/console lint:xliff translations"
"bin/console lint:xliff translations",
"bin/console doctrine:schema:validate --skip-sync -vvv --no-interaction"
],
"tests": "vendor/bin/phpunit tests/",
"tests-unit": "vendor/bin/phpunit --exclude-group integration tests/",
@@ -210,10 +196,13 @@
"symfony/symfony": "*"
},
"extra": {
"branch-alias": {
"dev-main": "2.0.x-dev"
},
"symfony": {
"id": "01C3FWRDJJEX9K6Y3A4XDFXPBR",
"allow-contrib": true,
"require": "7.4.*"
"require": "6.3.*"
}
}
}

7822
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ return [
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true],
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
JMS\SerializerBundle\JMSSerializerBundle::class => ['all' => true],
FOS\RestBundle\FOSRestBundle::class => ['all' => true],

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
framework:
assets:
json_manifest_path: '%kernel.project_dir%/public/build/manifest.json'
packages:
avatars:
base_path: 'avatars'

View File

@@ -0,0 +1,17 @@
framework:
cache:
#app: cache.adapter.redis
#default_redis_provider: redis://127.0.0.1:6379
#app: cache.adapter.memcached
#default_memcached_provider: 'memcached://localhost'
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

View File

@@ -1,5 +0,0 @@
when@test:
dama_doctrine_test:
enable_static_connection: true
enable_static_meta_data_cache: true
enable_static_query_cache: true

View File

@@ -1,5 +0,0 @@
when@dev:
debug:
# Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser.
# See the "server:dump" command to start a new server.
dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%"

View File

@@ -10,31 +10,23 @@ doctrine:
default_connection: default
connections:
default:
profiling_collect_backtrace: '%kernel.debug%'
use_savepoints: true
# existing migrations will fail if the schema filter is activated
#schema_filter: ~^(?!(bundle_migration_|kimai2_sessions))~
url: '%env(resolve:DATABASE_URL)%'
url: '%env(DATABASE_URL)%'
driver: 'pdo_mysql'
charset: utf8mb4
default_table_options:
charset: utf8mb4
collation: utf8mb4_unicode_ci
collate: utf8mb4_unicode_ci
schema_manager_factory: doctrine.dbal.default_schema_manager_factory
types:
datetime: App\Doctrine\UTCDateTimeType
datetime_immutable: App\Doctrine\UTCDateTimeImmutableType
orm:
controller_resolver:
auto_mapping: false
auto_generate_proxy_classes: true
auto_generate_proxy_classes: '%kernel.debug%'
default_entity_manager: default
enable_native_lazy_objects: true
entity_managers:
default:
identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
validate_xml_mapping: true
report_fields_where_declared: true
connection: default
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
@@ -55,26 +47,17 @@ doctrine:
when@test:
doctrine:
dbal:
connections:
default:
logging: false
logging: false
when@prod:
doctrine:
orm:
auto_generate_proxy_classes: false
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
metadata_cache_driver:
type: pool
pool: doctrine.system_cache_pool
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

View File

@@ -1,11 +1,7 @@
doctrine_migrations:
migrations_paths:
# namespace is arbitrary but should be different from App\Migrations
# as migrations classes should NOT be autoloaded
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false
transactional: false
storage:
table_storage:
table_name: 'migration_versions'
custom_template: '%kernel.project_dir%/migrations/MigrationTemplate.txt'
migrations_paths:
'DoctrineMigrations': '%kernel.project_dir%/migrations'

View File

@@ -1,4 +1,3 @@
# Read the documentation: https://fosrestbundle.readthedocs.io/en/3.x/
fos_rest:
param_fetcher_listener:
enabled: true
@@ -40,5 +39,3 @@ fos_rest:
- { path: ^/api, prefer_extension: true, fallback_format: json, priorities: [ json ] }
zone:
- { path: ^/api/* }
service:
view_handler: App\API\ViewHandler

View File

@@ -1,33 +1,16 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
parameters:
env(TRUSTED_PROXIES): ''
env(TRUSTED_HOSTS): ''
framework:
secret: '%env(APP_SECRET)%'
csrf_protection: true
annotations: false
handle_all_throwables: true
disallow_search_engine_index: true
set_content_language_from_locale: true
serializer:
enable_attributes: true
trusted_proxies: '%env(string:TRUSTED_PROXIES)%'
trusted_hosts: '%env(string:TRUSTED_HOSTS)%'
exceptions:
App\Validator\ValidationFailedException:
log_level: debug
secret: '%env(APP_SECRET)%'
default_locale: en
csrf_protection: true
http_method_override: false
# Enables session support. Note that the session will ONLY be started if you read or write from it.
# Remove or comment this section to explicitly disable session support.
session:
name: KIMAI_SESSION
handler_id: App\Security\SessionHandler
cookie_secure: auto
cookie_samesite: lax
@@ -37,15 +20,11 @@ framework:
php_errors:
log: true
property_info:
with_constructor_extractor: true
mailer:
dsn: '%env(MAILER_URL)%'
validation:
email_validation_mode: html5
enable_attributes: true
when@dev:
framework:

View File

@@ -3,6 +3,10 @@ jms_serializer:
datetime:
default_format: 'Y-m-d\TH:i:sO' # DATE_ISO8601
visitors:
json_serialization:
options:
- JSON_UNESCAPED_SLASHES
- JSON_PRESERVE_ZERO_FRACTION
xml_serialization:
format_output: '%kernel.debug%'
metadata:
@@ -14,20 +18,3 @@ jms_serializer:
excluded: []
property_naming:
id: 'jms_serializer.identical_property_naming_strategy'
when@prod:
jms_serializer:
visitors:
json_serialization:
options:
- JSON_UNESCAPED_SLASHES
- JSON_PRESERVE_ZERO_FRACTION
when@dev:
jms_serializer:
visitors:
json_serialization:
options:
- JSON_PRETTY_PRINT
- JSON_UNESCAPED_SLASHES
- JSON_PRESERVE_ZERO_FRACTION

View File

@@ -105,22 +105,21 @@ kimai:
TEAMS: ['view_team','create_team','edit_team','delete_team']
LOCKDOWN: ['lockdown_grace_timesheet','lockdown_override_timesheet']
REPORTING: ['view_reporting','view_other_reporting','project_reporting','customer_reporting']
EVERYONE: ['api_access','hours_own_profile']
# permissions which are deactivated, as these features are hidden for now
# brave users can try to activate them and be surprised what happens
REGISTER_BETA: []
# mapping a "role name" to an array of "set names"
maps:
ROLE_USER: ['TIMESHEET','PROFILE', 'EVERYONE']
ROLE_TEAMLEAD: ['ACTIVITIES_TEAMLEAD','PROJECTS_TEAMLEAD','CUSTOMERS_TEAMLEAD','TIMESHEET_OTHER','INVOICE','TIMESHEET','PROFILE','EXPORT','BILLABLE','TAGS','REPORTING', 'EVERYONE']
ROLE_ADMIN: ['ACTIVITIES','PROJECTS','CUSTOMERS','INVOICE','INVOICE_ADMIN','TIMESHEET','TIMESHEET_OTHER','PROFILE','TEAMS','RATE','RATE_OTHER','EXPORT','BILLABLE','TAGS','LOCKDOWN','REPORTING', 'EVERYONE']
ROLE_SUPER_ADMIN: ['ACTIVITIES','PROJECTS','CUSTOMERS','INVOICE','INVOICE_ADMIN','TIMESHEET','TIMESHEET_OTHER','PROFILE','PROFILE_OTHER','USER','TEAMS','RATE','RATE_OTHER','EXPORT','BILLABLE','TAGS','LOCKDOWN','REPORTING', 'EVERYONE']
ROLE_USER: ['TIMESHEET','PROFILE']
ROLE_TEAMLEAD: ['ACTIVITIES_TEAMLEAD','PROJECTS_TEAMLEAD','CUSTOMERS_TEAMLEAD','TIMESHEET_OTHER','INVOICE','TIMESHEET','PROFILE','EXPORT','BILLABLE','TAGS','REPORTING']
ROLE_ADMIN: ['ACTIVITIES','PROJECTS','CUSTOMERS','INVOICE','INVOICE_ADMIN','TIMESHEET','TIMESHEET_OTHER','PROFILE','TEAMS','RATE','RATE_OTHER','EXPORT','BILLABLE','TAGS','LOCKDOWN','REPORTING']
ROLE_SUPER_ADMIN: ['ACTIVITIES','PROJECTS','CUSTOMERS','INVOICE','INVOICE_ADMIN','TIMESHEET','TIMESHEET_OTHER','PROFILE','PROFILE_OTHER','USER','TEAMS','RATE','RATE_OTHER','EXPORT','BILLABLE','TAGS','LOCKDOWN','REPORTING']
# mapping a "role name" to an array of "permission names"
roles:
ROLE_USER: ['view_team_member','time_team_project','create_tag','view_reporting']
ROLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member','hours_other_profile']
ROLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member','view_all_data','contract_other_profile','hours_other_profile','create_export_template']
ROLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','supervisor_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data','contract_other_profile','hours_other_profile','create_export_template']
ROLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member']
ROLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member','view_all_data','contract_other_profile']
ROLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','supervisor_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data','contract_other_profile']
# --------------------------------------------------------------------------------

View File

@@ -1,71 +1,48 @@
monolog:
channels:
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
when@dev:
monolog:
handlers:
main:
type: fingers_crossed
action_level: notice
handler: file_log
excluded_http_codes:
- {400: ['^/api/']}
- {401: ['^/api/']}
- 403
- 404
- 405
channels: ["!event", "!deprecation"]
file_log:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
formatter: monolog.formatter.kimai
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!deprecation"]
deprecation:
type: stream
channels: ["deprecation"]
path: "%kernel.logs_dir%/deprecations.log"
formatter: monolog.formatter.deprecation
when@test:
when@prod:
monolog:
channels: ["deprecation"]
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!event"]
excluded_http_codes: [403, 404]
nested:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
formatter: monolog.formatter.kimai
when@prod:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: file_log
excluded_http_codes:
- {400: ['^/api/']}
- {401: ['^/api/']}
- 403
- 404
- {405: ['/homepage$', '/login_check$', '/export/data$']}
channels: ["!deprecation"]
file_log:
type: stream
level: info
path: "%kernel.logs_dir%/%kernel.environment%.log"
formatter: monolog.formatter.kimai
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!deprecation"]
channels: ["!event", "!doctrine"]
deprecation:
type: stream
channels: ["deprecation"]
path: "%kernel.logs_dir%/deprecations.log"
when@dev:
monolog:
channels: ["deprecation"]
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: info
channels: ["!event"]
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
deprecation:
type: stream
channels: ["deprecation"]
path: "%kernel.logs_dir%/deprecations.log"
when@test:
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: info
channels: ["!event"]

View File

@@ -1,22 +1,21 @@
nelmio_api_doc:
operation_id_generation: conditionally_prepend
models:
use_jms: true
names:
- { alias: CustomerEditForm, type: App\Form\API\CustomerApiEditForm, groups: [Default, Entity, Customer, Customer_Entity] }
- { alias: CustomerEditForm, type: App\Form\API\CustomerApiEditForm, groups: [Default, Entity, Customer] }
- { alias: CustomerEntity, type: App\Entity\Customer, groups: [Default, Entity, Customer, Customer_Entity, Not_Expanded] }
- { alias: Customer, type: App\Entity\Customer, groups: [Default, Not_Expanded] }
- { alias: CustomerRate, type: App\Entity\CustomerRate, groups: [Default, Entity, Customer_Rate] }
- { alias: CustomerRateForm, type: App\Form\API\CustomerRateApiForm, groups: [Default, Entity, Customer_Rate] }
- { alias: CustomerCollection, type: App\Entity\Customer, groups: [Default, Collection, Customer] }
- { alias: ProjectEditForm, type: App\Form\API\ProjectApiEditForm, groups: [Default, Entity, Project, Project_Entity] }
- { alias: ProjectEditForm, type: App\Form\API\ProjectApiEditForm, groups: [Default, Entity, Project] }
- { alias: ProjectEntity, type: App\Entity\Project, groups: [Default, Entity, Project, Project_Entity, Not_Expanded] }
- { alias: Project, type: App\Entity\Project, groups: [Default, Not_Expanded] }
- { alias: ProjectExpanded, type: App\Entity\Project, groups: [Default, Expanded] }
- { alias: ProjectRate, type: App\Entity\ProjectRate, groups: [Default, Entity, Project_Rate] }
- { alias: ProjectRateForm, type: App\Form\API\ProjectRateApiForm, groups: [Default, Entity, Project_Rate] }
- { alias: ProjectCollection, type: App\Entity\Project, groups: [Default, Collection, Project] }
- { alias: ActivityEditForm, type: App\Form\API\ActivityApiEditForm, groups: [Default, Entity, Activity, Activity_Entity] }
- { alias: ActivityEditForm, type: App\Form\API\ActivityApiEditForm, groups: [Default, Entity, Activity] }
- { alias: ActivityEntity, type: App\Entity\Activity, groups: [Default, Entity, Activity, Activity_Entity, Not_Expanded] }
- { alias: Activity, type: App\Entity\Activity, groups: [Default, Not_Expanded] }
- { alias: ActivityExpanded, type: App\Entity\Activity, groups: [Default, Expanded] }
@@ -29,7 +28,7 @@ nelmio_api_doc:
- { alias: TimesheetEntity, type: App\Entity\Timesheet, groups: [Default, Entity, Timesheet, Timesheet_Entity, Not_Expanded] }
- { alias: TimesheetExpanded, type: App\Entity\Timesheet, groups: [Default, Entity, Timesheet, Timesheet_Entity, Expanded] }
- { alias: TimesheetCollection, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet, Not_Expanded] }
- { alias: TimesheetCollectionExpanded, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet, Expanded] }
- { alias: TimesheetCollectionExpanded, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet, Subresource, Expanded] }
- { alias: UserCreateForm, type: App\Form\API\UserApiCreateForm, groups: [Default, Entity, User, User_Entity] }
- { alias: UserEditForm, type: App\Form\API\UserApiEditForm, groups: [Default, Entity, User, User_Entity] }
- { alias: User, type: App\Entity\User, groups: [Default] }
@@ -41,31 +40,27 @@ nelmio_api_doc:
- { alias: TeamCollection, type: App\Entity\Team, groups: [Default, Collection, Team] }
- { alias: TeamMember, type: App\Entity\TeamMember, groups: [Team_Entity] }
- { alias: TeamMembership, type: App\Entity\TeamMember, groups: [User_Entity] }
- { alias: Invoice, type: App\Entity\Invoice, groups: [Default, Entity, Invoice, Invoice_Entity] }
- { alias: InvoiceCollection, type: App\Entity\Invoice, groups: [Default, Collection, Invoice] }
areas:
default:
path_patterns:
- ^/api(?!/doc)
security:
bearer:
type: 'http'
description: 'API Token'
bearerFormat: 'KIMAI'
scheme: 'bearer'
path_patterns:
- ^/api(?!/doc)
documentation:
info:
title: Kimai - API
title: Kimai - API Docs
description: |
JSON API for the Kimai time-tracking software. Find more infos in our [API documentation](https://www.kimai.org/documentation/rest-api.html).
version: '1.1'
JSON API for the Kimai time-tracking software: [API documentation](https://www.kimai.org/documentation/rest-api.html), [Swagger definition file](doc.json)
version: '0.7'
components:
securitySchemes:
apiUser:
type: apiKey
description: 'Value: {Username}'
name: X-AUTH-USER
in: header
apiToken:
type: apiKey
description: 'Value: {API Token}'
name: X-AUTH-TOKEN
in: header
security:
- bearer: []
html_config:
# assets_mode: cdn
# https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/
swagger_ui_config: []
# https://redocly.com/docs/redoc/config/
redocly_config: []
# https://docs.stoplight.io/docs/elements/b074dc47b2826-elements-configuration-options
stoplight_config: { basePath: '/api/doc', router: 'memory', logo: '/touch-icon-192x192.png', hideInternal: true }
- X-AUTH-USER: []
X-AUTH-TOKEN: []

View File

@@ -3,7 +3,12 @@ parameters:
nelmio_cors:
defaults:
# for security reasons we do not allow CORS by default
allow_credentials: false
# allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
# allow_headers: ['Content-Type', 'Authorization', 'X-AUTH-USER', 'X-AUTH-TOKEN']
# allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
# expose_headers: ['Link']
# max_age: 3600
allow_origin: []
allow_headers: []
allow_methods: []
@@ -13,17 +18,8 @@ nelmio_cors:
origin_regex: true
forced_allow_origin_value: ~
paths:
# only the API endpoints are accessible
'^/api/':
# allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_origin: ['*']
allow_headers: ['Content-Type', 'Authorization']
allow_headers: ['X-AUTH-USER', 'X-AUTH-TOKEN', 'Content-Type']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
# expose_headers: ['Link']
max_age: 3600
when@dev:
nelmio_cors:
paths:
'^/api/':
allow_private_network: true

View File

@@ -1,21 +0,0 @@
framework:
rate_limiter:
session_prediction:
policy: 'fixed_window'
limit: 250
interval: '1 hour'
lock_factory: null
reset_password:
policy: 'fixed_window'
limit: 10
interval: '1 hour'
lock_factory: null
when@test:
framework:
rate_limiter:
reset_password:
policy: 'fixed_window'
limit: 100
interval: '1 minute'
lock_factory: null

View File

@@ -1,6 +1,3 @@
parameters:
env(default_uri): 'http://localhost'
framework:
router:
utf8: true
@@ -8,7 +5,7 @@ framework:
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
default_uri: '%env(DEFAULT_URI)%'
#default_uri: http://localhost
when@test:
framework:

View File

@@ -8,10 +8,9 @@ scheb_two_factor:
totp:
enabled: true
template: security/2fa.html.twig # Overwritten template
leeway: 29 # How many seconds the code is valid
window: 1 # How many codes before/after the current one would be accepted as valid
issuer: Kimai # Issuer name used in QR code
two_factor_condition: App\Security\TwoFactorCondition
# TODO add: backup codes - https://symfony.com/bundles/SchebTwoFactorBundle/current/backup_codes.html
# TODO add: Trusted device feature - https://github.com/scheb/2fa/blob/6.x/doc/configuration.rst
# FIXME add backup codes - https://symfony.com/bundles/SchebTwoFactorBundle/current/backup_codes.html

View File

@@ -1,8 +1,7 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
App\Entity\User: auto
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
chain_provider:
chain:
@@ -12,85 +11,99 @@ security:
class: App\Entity\User
kimai_ldap:
id: App\Ldap\LdapUserProvider
firewalls:
dev:
# Ensure dev tools and static assets are always allowed
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
access_token:
token_handler: App\API\Authentication\AccessTokenHandler
success_handler: App\API\Authentication\AccessTokenSuccessHandler
remember_me: false
request_matcher: App\API\Authentication\ApiRequestMatcher
user_checker: App\Security\UserChecker
stateless: true
remember_me: false
provider: chain_provider
custom_authenticators:
- App\API\Authentication\TokenAuthenticator
secured_area:
kimai_ldap: ~
pattern: ^/
user_checker: App\Security\UserChecker
stateless: false
entry_point: form_login
custom_authenticators:
- App\API\Authentication\SessionAuthenticator
- App\Saml\SamlAuthenticator
remember_me:
name: KIMAI_REMEMBER
secret: '%kernel.secret%'
lifetime: 604800
path: /
always_remember_me: true
# activate all configured user provider
provider: chain_provider
form_login:
check_path: security_check
login_path: login
enable_csrf: true
two_factor:
auth_form_path: 2fa_login
check_path: 2fa_login_check
remember_me_sets_trusted: true
logout:
path: logout
target: homepage
enable_csrf: false
login_throttling:
max_attempts: 5
interval: '5 minutes'
login_link:
check_route: link_login_check
signature_properties: ['id']
lifetime: 900
max_uses: 3
lifetime: 300
max_uses: 1
access_decision_manager:
# only grants access if there is no voter denying access
strategy: unanimous
allow_if_all_abstain: false
role_hierarchy:
ROLE_USER: ~
ROLE_TEAMLEAD: ROLE_USER
ROLE_ADMIN: ROLE_TEAMLEAD
ROLE_USER: ~
ROLE_TEAMLEAD: ROLE_USER
ROLE_ADMIN: ROLE_TEAMLEAD
ROLE_SUPER_ADMIN: ROLE_ADMIN
# Note: Only the *first* matching rule is applied
access_control:
- {path: '^/auth/2fa', role: IS_AUTHENTICATED_2FA_IN_PROGRESS}
- {path: '^/auth', roles: PUBLIC_ACCESS}
- {path: '^/{_locale}$', role: PUBLIC_ACCESS}
- {path: '^/{_locale}/auth', role: PUBLIC_ACCESS}
- {path: '^/{_locale}/login', role: PUBLIC_ACCESS}
- {path: '^/{_locale}/register', role: PUBLIC_ACCESS}
- {path: '^/{_locale}/resetting', role: PUBLIC_ACCESS}
- {path: '^/{_locale}/', roles: ROLE_USER}
- {path: '^/api', roles: IS_AUTHENTICATED}
- { path: '^/auth/2fa', role: IS_AUTHENTICATED_2FA_IN_PROGRESS }
- { path: '^/auth', roles: PUBLIC_ACCESS }
- { path: '^/(%app_locales%)$', role: PUBLIC_ACCESS }
- { path: '^/(%app_locales%)/auth', role: PUBLIC_ACCESS }
- { path: '^/(%app_locales%)/login', role: PUBLIC_ACCESS }
- { path: '^/(%app_locales%)/register', role: PUBLIC_ACCESS }
- { path: '^/(%app_locales%)/resetting', role: PUBLIC_ACCESS }
- { path: '^/(%app_locales%)/', roles: ROLE_USER }
- { path: '^/api', roles: IS_AUTHENTICATED_REMEMBERED }
when@test:
# this configuration simplifies testing URLs protected by the security mechanism
# See https://symfony.com/doc/current/cookbook/testing/http_authentication.html
security:
password_hashers:
# Password hashers are resource-intensive by design to ensure security.
# In tests, it's safe to reduce their cost to improve performance.
App\Entity\User:
algorithm: auto
# see https://github.com/symfony/recipes/pull/1026
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon
firewalls:
secured_area:
http_basic: ~

Some files were not shown because too many files have changed in this diff Show More