Compare commits

..

25 Commits
2.46.0 ... main

Author SHA1 Message Date
Weblate (bot)
d456cd3ce2 Translated using Weblate (#5892)
Co-authored-by: Lasse Edsvik <lasse@lasseedsvik.se>
Co-authored-by: Milo Ivir <mail@milotype.de>
2026-04-13 21:23:49 +02:00
Kevin Papst
bad92d7215 Release 2.54 (#5896) 2026-04-13 21:22:06 +02:00
Kevin Papst
16703081cd Exporter/Invoice formula cleanup (#5899) 2026-04-12 09:27:05 +02:00
Kevin Papst
cbdf91f316 Team API docs (#5897)
* let view_team permission be handled by global ACLs
* code style and API docs
* improve permission check
2026-04-11 17:22:59 +02:00
Kevin Papst
999d820d4c Release 2.53 (#5878) 2026-04-10 18:09:27 +02:00
Weblate (bot)
fe4185ae45 Translated using Weblate (#5879)
Co-authored-by: C. H. <them4z@gmail.com>
Co-authored-by: Kevin Papst <kevin@kevinpapst.de>
Co-authored-by: Patryk <patryk230206@gmail.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: remo <remohexa@gmail.com>
Co-authored-by: தமிழ்நேரம் <tamilneram247@gmail.com>
2026-04-08 23:15:13 +02:00
Kevin Papst
cad9f58703 Release 2.52 (#5874) 2026-03-16 17:44:12 +01:00
Weblate (bot)
70b4fbcae8 Translated using Weblate (#5848)
Co-authored-by: AlaxLima <thanhkhoidangngoc@gmail.com>
Co-authored-by: Christopher Picón <ntrpc.tech@users.noreply.hosted.weblate.org>
Co-authored-by: Francisco Serrador <fserrador@gmail.com>
Co-authored-by: Kamborio <Kamborio15@users.noreply.hosted.weblate.org>
Co-authored-by: PizzaPoot <pizzapoot@users.noreply.hosted.weblate.org>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: clearstripe <sakurasan000510@gmail.com>
2026-03-16 17:39:50 +01:00
Kevin Papst
a0601c8cb2 check customer permissions on invoice api access (#5849) 2026-03-01 16:53:49 +01:00
Kevin Papst
5b320bf2ea Release 2.51 (#5847)
* bump packages
2026-03-01 16:21:07 +01:00
Kevin Papst
15110f78d8 fix sticky calendar popup (#5846) 2026-02-28 19:25:06 +01:00
Kevin Papst
4154de6bd1 Release 2.50 (#5835)
* replace p-0 class with fullsize embed option
* bump parsedown package
* remove support for file:// urls
* fix missing macro in export print template
* fix weekly hours with breaks
2026-02-25 21:07:40 +01:00
Weblate (bot)
8094fcf5b5 Translated using Weblate (#5831)
Co-authored-by: Francisco Serrador <fserrador@gmail.com>
Co-authored-by: Marat Ismailov <klippygames@gmail.com>
Co-authored-by: Sean Young <assanges@users.noreply.hosted.weblate.org>
2026-02-25 21:06:48 +01:00
Kevin Papst
77afa207e0 Allow to customize statistic queries (#5827)
* use TimesheetStatisticsQUery for all repository calls
* send event to customize statistics query
2026-02-24 13:27:48 +01:00
Kevin Papst
d9ef6dfcad Update database requirements in README (#5825) 2026-02-15 21:42:25 +01:00
Kevin Papst
ff6918fcab Release 2.49 (#5820)
* bump packages
* add column summarization per customer (#5808)
* clarify database requirements
* added helper methods to fetch original expected time

Co-authored-by: GregorB54321 <34287148+GregorB54321@users.noreply.github.com>
2026-02-15 21:31:06 +01:00
Weblate (bot)
f376b5c8a1 Translated using Weblate (#5804)
Co-authored-by: AlaxLima <thanhkhoidangngoc@gmail.com>
Co-authored-by: Arif Budiman <arifpedia@gmail.com>
Co-authored-by: Artem <artemkozhin80@gmail.com>
Co-authored-by: Eleni Diamantopoulou <elenidiama00@gmail.com>
Co-authored-by: Florent Berthelot <florentius.b@gmail.com>
Co-authored-by: Kehribar <103407696+dpentx@users.noreply.github.com>
Co-authored-by: Kevin Papst <kevin@kevinpapst.de>
Co-authored-by: Lasse Edsvik <lasse@lasseedsvik.se>
Co-authored-by: Levente Déri <derilevi@gmail.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Paul <snide-slum-partake@duck.com>
Co-authored-by: Posemartonis <weblate.drainage895@passmail.net>
Co-authored-by: Romhányi-Kakucska Viktor <viktor@romhanyi.dev>
Co-authored-by: irooniline <mart.styff@protonmail.com>
Co-authored-by: leonidovmob <leonidovmob@gmail.com>
2026-02-15 21:28:53 +01:00
Kevin Papst
b00fbd2516 Delete .opensourcefinder-verify 2026-02-14 18:56:38 +01:00
Kevin Papst
d97abc3f57 Add .opensourcefinder-verify file with claims (#5824) 2026-02-14 08:19:51 +01:00
Kevin Papst
3f184e42c8 missing translations 2026-01-31 10:28:30 +01:00
Kevin Papst
4a31411d69 Release 2.48 (#5789) 2026-01-30 16:44:26 +01:00
Weblate (bot)
3925eacf9f Translated using Weblate (#5748)
Co-authored-by: AlaxLima <thanhkhoidangngoc@gmail.com>
Co-authored-by: Amir <amearb@duck.com>
Co-authored-by: Fabio Gomes de lima <fabiogomesdelima598@gmail.com>
Co-authored-by: Heeheon Ryu <heeheon.ryu001@gmail.com>
Co-authored-by: Kevin Papst <kevin@kevinpapst.de>
Co-authored-by: Kolappan N <kolappan@users.noreply.hosted.weblate.org>
Co-authored-by: Lasse Edsvik <lasse@lasseedsvik.se>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Mauro F. T. <maurofroeltani@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: Pose marto <weblate.drainage895@passmail.net>
Co-authored-by: Serhii Horichenko <serhii@horichenko.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: avv-dev <vildan.abdullin@gmail.com>
Co-authored-by: m45ked <m45ked@users.noreply.hosted.weblate.org>
2026-01-30 13:14:21 +01:00
Kevin Papst
394f377890 Update header (#5799) 2026-01-28 20:10:33 +01:00
Milo Ivir
e8b0dc4969 Add translation links to README (#5798)
Co-authored-by: Kevin Papst <kevinpapst@users.noreply.github.com>
2026-01-28 19:58:12 +01:00
Kevin Papst
d429c56687 Release 2.47 (#5784) 2026-01-25 09:51:22 +01:00
379 changed files with 9403 additions and 5999 deletions

View File

@@ -16,13 +16,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{secrets.DOCKERHUB_USERNAME}}
password: ${{secrets.DOCKERHUB_PASSWORD}}
@@ -48,7 +48,7 @@ jobs:
echo "kimai_version=$version" >> $GITHUB_ENV
- name: FPM image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -60,11 +60,10 @@ jobs:
tags: |
kimai/kimai2:latest
kimai/kimai2:fpm
kimai/kimai2:fpm-${{ env.kimai_version }}
push: true
- name: Apache image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -74,12 +73,13 @@ jobs:
target: prod
platforms: linux/amd64,linux/arm64
tags: |
kimai/kimai2:stable
kimai/kimai2:apache
kimai/kimai2:apache-${{ env.kimai_version }}
push: true
- name: Development image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile

View File

@@ -16,7 +16,7 @@ jobs:
action:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
- uses: dessant/lock-threads@v6
with:
process-only: 'issues, prs'
github-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -20,11 +20,11 @@ jobs:
update_release_draft:
permissions:
contents: write # for release-drafter/release-drafter to create a github release
pull-requests: write # for release-drafter/release-drafter to add label to PR
contents: write # for release-drafter/release-drafter to create a github release
pull-requests: read
needs: correct_repository
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v6
- uses: release-drafter/release-drafter@v7
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -25,7 +25,7 @@ jobs:
steps:
- name: Clone Kimai
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
persist-credentials: false
@@ -47,7 +47,7 @@ jobs:
run: echo "composer_cache_directory=$(composer config cache-dir)" >> $GITHUB_ENV
- name: Cache Composer dependencies
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: "${{ env.composer_cache_directory }}"
key: ${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
@@ -104,7 +104,7 @@ jobs:
- name: Upload code coverage
if: matrix.php == '8.5'
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml

View File

@@ -34,7 +34,7 @@ jobs:
fi
- name: Emit repository_dispatch
uses: peter-evans/repository-dispatch@v3
uses: peter-evans/repository-dispatch@v4
with:
token: ${{ secrets.WEBSITE_ACCESS_TOKEN }}
repository: kimai/www.kimai.org

View File

@@ -6,11 +6,9 @@
<a href="https://github.com/kimai/kimai/actions"><img alt="CI Status" src="https://github.com/kimai/kimai/actions/workflows/testing.yaml/badge.svg"></a>
<a href="https://codecov.io/gh/kimai/kimai"><img alt="Code Coverage" src="https://codecov.io/gh/kimai/kimai/branch/main/graph/badge.svg"></a>
<a href="https://packagist.org/packages/kimai/kimai"><img alt="Latest stable version" src="https://poser.pugx.org/kimai/kimai/v/stable"></a>
<a href="https://www.gnu.org/licenses/agpl-3.0.en.html"><img alt="License" src="https://poser.pugx.org/kimai/kimai/license"></a>
<a href="https://phpc.social/@kimai" rel="me"><img alt="Mastodon" src="https://img.shields.io/badge/toot-%40kimai-8c8dff"></a>
</p>
<h1 align="center">Kimai - time-tracker</h1>
<h1 align="center">Kimai<br>#1 Open-Source Time-Tracker</h1>
Kimai is a professional grade time-tracking application, free and open-source.
It handles use-cases of freelancers as well as companies with dozens or hundreds of users.
@@ -30,7 +28,7 @@ and so much more.
### Requirements
- PHP 8.1.3 minimum with support for 8.2, 8.3, 8.4, 8.5
- MariaDB or MySQL
- MariaDB / MySQL: oldest maintained LTS release (MariaDB >= [10.6](https://endoflife.date/mariadb) or MySQL >= [8.4](https://endoflife.date/mysql)) or newer
- A webserver and subdomain (subdirectory is not supported)
- PHP extensions: `gd`, `intl`, `json`, `mbstring`, `pdo`, `tokenizer`, `xml`, `xsl`, `zip`
@@ -71,6 +69,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!
- [Translate Kimai into your language](https://hosted.weblate.org/engage/kimai/), or help to improve the existing translations, many languages look for a contributor
- 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.
@@ -79,6 +78,12 @@ In case you want to contribute, but you wouldn't know how, here are some suggest
There is one simple rule in our "Code of conduct": Don't be an ass!
## Follow Kimai
- Mastodon: [@kimai](https://phpc.social/@kimai)
- Youtube: [@kimai_org](https://www.youtube.com/@kimai_org)
- LinkedIn: [@kimai-org](https://www.linkedin.com/company/kimai-org/)
### Credits
Kimai is based on modern technologies and frameworks such as [PHP](https://www.php.net/),

View File

@@ -96,6 +96,7 @@ export default class KimaiPlugin {
}
/**
* @deprecated use the plugin directly
* @param {string} title
* @returns {string}
*/

View File

@@ -83,7 +83,7 @@ export default class KimaiTeamForm extends KimaiFormPlugin {
prototype.dataset['widgetCounter'] = (++counter).toString();
const temp = document.createElement('div');
temp.innerHTML = newWidget;
temp.innerHTML = ESCAPER.sanitize(newWidget);
temp.querySelector('input[type=hidden]').value = option.value;
const newNode = temp.firstElementChild;

View File

@@ -73,15 +73,14 @@ export default class KimaiAPILink extends KimaiPlugin {
const ALERT = this.getContainer().getPlugin('alert');
const successHandle = () => {
EVENTS.trigger(eventName);
if (attributes['msgSuccess'] !== undefined) {
ALERT.success(attributes['msgSuccess']);
}
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
};
const errorHandle = (error) => {
let message = 'action.update.error';
if (attributes['msgError'] !== undefined) {
message = attributes['msgError'];
}
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
API.handleError(message, error);
};
@@ -90,6 +89,8 @@ 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

@@ -217,8 +217,6 @@ 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();
@@ -259,15 +257,8 @@ 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);
}
});
})
@@ -277,6 +268,8 @@ 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

@@ -10,6 +10,7 @@
*/
import KimaiPlugin from "../KimaiPlugin";
import DOMPurify from "dompurify";
export default class KimaiEscape extends KimaiPlugin {
@@ -26,14 +27,23 @@ export default class KimaiEscape extends KimaiPlugin {
return '';
}
const tagsToReplace = {
const charToReplace = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
};
return title.replace(/[&<>]/g, function(tag) {
return tagsToReplace[tag] || tag;
return title.replace(/[&<>"]/g, function(tag) {
return charToReplace[tag] || tag;
});
}
/**
* @param {string} html
* @returns {string}
*/
sanitize(html) {
return DOMPurify.sanitize(html);
}
}

View File

@@ -42,7 +42,7 @@ export default class KimaiThemeInitializer extends KimaiPlugin {
}
// at which element we append the loading screen
let container = 'body';
let container = 'div.page-wrapper';
if (event.detail !== undefined && event.detail !== null) {
container = event.detail;
}

View File

@@ -70,8 +70,6 @@ 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');
// Instead of using "buttonIcons" the theme needs to be adjusted directly
// https://fullcalendar.io/docs/buttonIcons
@@ -197,7 +195,7 @@ export default class KimaiCalendar {
if (!this.isKimaiSource(unmountInfo.event)) {
return;
}
const popover = Popover.getInstance(unmountInfo.element);
const popover = Popover.getInstance(unmountInfo.el);
if (popover !== null) {
popover.dispose();
}
@@ -282,6 +280,7 @@ 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);
@@ -324,7 +323,7 @@ export default class KimaiCalendar {
(result) => {
const newItem = this.convertSourceForCalendar(result);
this.getCalendar().addEvent(newItem, true);
ALERT.success('action.update.success');
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
}
);
} else {
@@ -334,7 +333,7 @@ export default class KimaiCalendar {
(result) => {
const newItem = this.convertSourceForCalendar(result);
this.getCalendar().addEvent(newItem, true);
ALERT.success('action.update.success');
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
}
);
}
@@ -664,7 +663,7 @@ export default class KimaiCalendar {
}
}
return `
return escaper.sanitize(`
<div class="calendar-entry">
<ul>
<li>` + this.options['translations']['customer'] + `: ` + escaper.escapeForHtml(eventObj.customer) + `</li>
@@ -673,7 +672,7 @@ export default class KimaiCalendar {
</ul>` +
(eventObj.description !== null || eventObj.tags.length > 0 ? '<hr>' : '') +
(eventObj.description ? '<div>' + escaper.escapeForHtml(eventObj.description) + '</div>' : '') + tags + `
</div>`;
</div>`);
}
/**
@@ -703,8 +702,6 @@ 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');
@@ -716,11 +713,14 @@ 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), () => {
ALERT.success('action.update.success');
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
}, (error) => {
eventArg.revert();
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
API.handleError('action.update.error', error);
});
}

View File

@@ -1,3 +1,4 @@
@import "variables";
@import "layout";
@import "error-page";
@import "print";

View File

@@ -12,6 +12,6 @@
width: 100%;
height: 100%;
z-index: 1021;
background-color: var(--tblr-backdrop-bg);
/*background-color: var(--tblr-backdrop-bg);*/
opacity: $modal-backdrop-opacity;
}

View File

@@ -48,4 +48,12 @@ fieldset.form-fieldset > legend {
.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);
}
}

View File

@@ -0,0 +1,33 @@
:root {
--kimai-public-holiday: var(--tblr-lime);
--kimai-holiday: var(--tblr-green);
--kimai-sickness: var(--tblr-yellow);
--kimai-time-off: var(--tblr-blue);
--kimai-other: var(--tblr-purple);
--kimai-public-holiday-bg: var(--tblr-lime-lt);
--kimai-holiday-bg: var(--tblr-green-lt);
--kimai-sickness-bg: var(--tblr-yellow-lt);
--kimai-time-off-bg: var(--tblr-blue-lt);
--kimai-other-bg: var(--tblr-purple-lt);
--kimai-unexpected-bg: var(--tblr-pink-lt);
--kimai-missing-bg: var(--tblr-pink-lt);
--kimai-weekend-bg: var(--tblr-bg-surface-tertiary);
}
.public-holiday { color: var(--kimai-public-holiday); }
.holiday { color: var(--kimai-holiday); }
.sickness, .sickness-child { color: var(--kimai-sickness); }
.time-off { color: var(--kimai-time-off); }
.other, .parental, .unpaid-vacation { color: var(--kimai-other); }
.bg-public-holiday { background-color: var(--kimai-public-holiday-bg); --tblr-table-bg: var(--kimai-public-holiday-bg); i.fas{ color: var(--kimai-public-holiday); } };
.bg-holiday { background-color: var(--kimai-holiday-bg); --tblr-table-bg: var(--kimai-holiday-bg); i.fas{ color: var(--kimai-holiday); } };
.bg-sickness, .bg-sickness-child { background-color: var(--kimai-sickness-bg); --tblr-table-bg: var(--kimai-sickness-bg); i.fas{ color: var(--kimai-sickness); } };
.bg-time-off { background-color: var(--kimai-time-off-bg); --tblr-table-bg: var(--kimai-time-off-bg); i.fas{ color: var(--kimai-time-off); } };
.bg-other, .bg-parental, .bg-unpaid-vacation { background-color: var(--kimai-other-bg); --tblr-table-bg: var(--kimai-other-bg); i.fas{ color: var(--kimai-other); } };
.bg-unexpected { background-color: var(--kimai-unexpected-bg); --tblr-table-bg: var(--kimai-unexpected-bg); };
.bg-missing { background-color: var(--kimai-missing-bg); --tblr-table-bg: var(--kimai-missing-bg); };
.bg-weekend { background-color: var(--kimai-weekend-bg); --tblr-table-bg: var(--kimai-weekend-bg); };

1277
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,10 @@
framework:
rate_limiter:
old_api_tokens:
policy: 'fixed_window'
limit: 5
interval: '1 minute'
lock_factory: null
session_prediction:
policy: 'fixed_window'
limit: 250

View File

@@ -75,6 +75,7 @@ tabler:
fax: fas fa-fax
filter: fas fa-filter
help: far fa-question-circle
holiday: fas fa-umbrella-beach
home: fas fa-home
info: fas fa-info-circle
import: fas fa-file-import
@@ -95,6 +96,7 @@ tabler:
ods: fas fa-table
off: fas fa-toggle-off
on: fas fa-toggle-on
other: fas fa-file-alt
password: fas fa-key
pause: fas fa-pause
pause-small: far fa-pause-circle
@@ -108,6 +110,7 @@ tabler:
profile: fas fa-user-edit
profile-stats: far fa-chart-bar
project: fas fa-briefcase
public-holiday: fas fa-calendar-day
repeat: fas fa-repeat
reporting: far fa-chart-bar
report: far fa-chart-bar
@@ -119,6 +122,7 @@ tabler:
save: far fa-save
search: fas fa-search
settings: fas fa-cog
sickness: fas fa-prescription-bottle-medical
shop: fas fa-shopping-cart
spinner: fas fa-spinner
start: fas fa-play
@@ -130,6 +134,7 @@ tabler:
team: fas fa-users
timesheet: fas fa-clock
timesheet-team: fas fa-user-clock
time-off: fas fa-couch
trash: far fa-trash-alt
unlocked: fas fa-unlock-alt
upload: fas fa-upload

View File

@@ -42,6 +42,7 @@
"bootstrap": "^5.3",
"chart.js": "^4",
"core-js": "^3",
"dompurify": "^3",
"eslint": "^9",
"globals": "^15",
"gridstack": "^7",

View File

@@ -27,6 +27,7 @@ parameters:
numericOperandsInArithmeticOperators: true
switchConditionsMatchingType: true
noVariableVariables: false
reportNonIntStringArrayKey: false
paths:
- src
tmpDir: %rootDir%/../../../var/cache/phpstan
@@ -1664,11 +1665,6 @@ parameters:
count: 1
path: src/Form/Extension/SelectWithApiDataExtension.php
-
message: "#^Parameter \\#1 \\$name of method Symfony\\\\Component\\\\Routing\\\\Generator\\\\UrlGeneratorInterface\\:\\:generate\\(\\) expects string, mixed given\\.$#"
count: 1
path: src/Form/Extension/SelectWithApiDataExtension.php
-
message: "#^Property App\\\\Form\\\\Helper\\\\ActivityHelper\\:\\:\\$pattern \\(string\\|null\\) does not accept bool\\|float\\|int\\|string\\|null\\.$#"
count: 1
@@ -2644,16 +2640,6 @@ parameters:
count: 1
path: src/Form/Type/UserPreferenceType.php
-
message: "#^PHPDoc tag @var for variable \\$collection contains generic class Doctrine\\\\Common\\\\Collections\\\\ArrayCollection but does not specify its types\\: TKey, T$#"
count: 1
path: src/Form/Type/UserPreferencesCollectionType.php
-
message: "#^Parameter \\#1 \\$key of method Doctrine\\\\Common\\\\Collections\\\\ArrayCollection\\<\\(int\\|string\\),mixed\\>\\:\\:set\\(\\) expects \\(int\\|string\\), string\\|null given\\.$#"
count: 1
path: src/Form/Type/UserPreferencesCollectionType.php
-
message: "#^Cannot access offset mixed on mixed\\.$#"
count: 1
@@ -2796,7 +2782,7 @@ parameters:
-
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, mixed given\\.$#"
count: 5
count: 4
path: src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
-
@@ -3809,71 +3795,11 @@ parameters:
count: 1
path: src/Utils/ParsedownExtension.php
-
message: "#^Property App\\\\Utils\\\\ParsedownExtension\\:\\:\\$safeLinksWhitelist has no type specified\\.$#"
count: 1
path: src/Utils/ParsedownExtension.php
-
message: "#^Parameter \\#1 \\$profile of method App\\\\Utils\\\\ProfileManager\\:\\:getProfile\\(\\) expects string, mixed given\\.$#"
count: 1
path: src/Utils/ProfileManager.php
-
message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Cannot access offset 0 on mixed\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Cannot access property \\$body on mixed\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Cannot access property \\$draft on mixed\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Cannot access property \\$html_url on mixed\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Cannot access property \\$prerelease on mixed\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Cannot access property \\$published_at on mixed\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Cannot access property \\$tag_name on mixed\\.$#"
count: 2
path: src/Utils/ReleaseVersion.php
-
message: "#^Cannot access property \\$zipball_url on mixed\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Method App\\\\Utils\\\\ReleaseVersion\\:\\:getReleasesFromGithub\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Parameter \\#1 \\$json of function json_decode expects string, string\\|false given\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Cannot access offset 'pattern' on mixed\\.$#"
count: 1
@@ -4008,8 +3934,3 @@ parameters:
message: "#^Method App\\\\Form\\\\MultiUpdate\\\\MultiUpdateTableDTO\\:\\:setEntities\\(\\) has parameter \\$entities with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection but does not specify its types\\: TKey, T$#"
count: 1
path: src/Form/MultiUpdate/MultiUpdateTableDTO.php
-
message: "#^Method App\\\\Entity\\\\InvoiceTemplate\\:\\:getMetaFields\\(\\) return type with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection does not specify its types\\: TKey, T$#"
count: 1
path: src/Entity/InvoiceTemplate.php

View File

@@ -14,7 +14,7 @@
<ini name="error_reporting" value="-1"/>
<ini name="max_execution_time" value="-1"/>
<ini name="date.timezone" value="UTC"/>
<ini name="intl.default_locale" value="en_US"/>
<ini name="intl.default_locale" value="en"/>
<ini name="date.timezone" value="Europe/Vienna"/>
<env name="KERNEL_CLASS" value="App\Kernel" force="true"/>
<env name="SYMFONY_DEPRECATIONS_HELPER" value="weak"/>

View File

@@ -0,0 +1 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[113],{876:function(i,n,u){"use strict";u.r(n)},2395:function(i,n,u){u(876)}},function(i){var n;n=2395,i(i.s=n)}]);

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[113],{177:function(i,n,u){u(7996)},7996:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=177,i(i.s=n)}]);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -175,3 +175,5 @@
/*!
* [KIMAI] Wrapper class for loading Kimai app in browser script scope
*/
/*! @license DOMPurify 3.3.3 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.3.3/LICENSE */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
/*!
* @kurkle/color v0.3.2
* @kurkle/color v0.3.4
* https://github.com/kurkle/color#readme
* (c) 2023 Jukka Kurkela
* (c) 2024 Jukka Kurkela
* Released under the MIT License
*/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,26 +2,26 @@
"entrypoints": {
"app": {
"js": [
"/build/runtime.6c399d29.js",
"/build/app.307ea672.js"
"/build/runtime.684e9f6d.js",
"/build/app.f0f8091d.js"
],
"css": [
"/build/app.c18ba3c6.css"
"/build/app.99ea4166.css"
]
},
"app-rtl": {
"js": [
"/build/runtime.6c399d29.js",
"/build/app-rtl.fe7c5bf2.js"
"/build/runtime.684e9f6d.js",
"/build/app-rtl.15853b82.js"
],
"css": [
"/build/app-rtl.2003dce5.css"
"/build/app-rtl.16262d9a.css"
]
},
"export-pdf": {
"js": [
"/build/runtime.6c399d29.js",
"/build/export-pdf.5905454a.js"
"/build/runtime.684e9f6d.js",
"/build/export-pdf.395749ab.js"
],
"css": [
"/build/export-pdf.d8a6c23b.css"
@@ -29,8 +29,8 @@
},
"invoice": {
"js": [
"/build/runtime.6c399d29.js",
"/build/invoice.0217cc18.js"
"/build/runtime.684e9f6d.js",
"/build/invoice.42b319e4.js"
],
"css": [
"/build/invoice.36018785.css"
@@ -38,8 +38,8 @@
},
"invoice-pdf": {
"js": [
"/build/runtime.6c399d29.js",
"/build/invoice-pdf.1b6e735c.js"
"/build/runtime.684e9f6d.js",
"/build/invoice-pdf.26d98626.js"
],
"css": [
"/build/invoice-pdf.2b749265.css"
@@ -47,14 +47,14 @@
},
"chart": {
"js": [
"/build/runtime.6c399d29.js",
"/build/chart.2c85f027.js"
"/build/runtime.684e9f6d.js",
"/build/chart.56f16a68.js"
]
},
"calendar": {
"js": [
"/build/runtime.6c399d29.js",
"/build/calendar.cef61816.js"
"/build/runtime.684e9f6d.js",
"/build/calendar.13247e65.js"
],
"css": [
"/build/calendar.d757753e.css"
@@ -62,8 +62,8 @@
},
"dashboard": {
"js": [
"/build/runtime.6c399d29.js",
"/build/dashboard.faf42d4e.js"
"/build/runtime.684e9f6d.js",
"/build/dashboard.9708ae5e.js"
],
"css": [
"/build/dashboard.b7129fa1.css"
@@ -71,8 +71,8 @@
},
"highlight": {
"js": [
"/build/runtime.6c399d29.js",
"/build/highlight.718fe73d.js"
"/build/runtime.684e9f6d.js",
"/build/highlight.0296a734.js"
],
"css": [
"/build/highlight.98bf3927.css"
@@ -80,23 +80,23 @@
}
},
"integrity": {
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
"/build/app.307ea672.js": "sha384-XNjtkar2JbWyJQan/C6jHZAYFI9X5rPcExLeDV86FYZMPJl0vNWMh8Pa8NgXX/EB",
"/build/app.c18ba3c6.css": "sha384-qkIgqLzngG2NchdFVImbGMU49lWlZg6Y9D0z2P0u1j2NQstDicA4KqIkkNYcpErm",
"/build/app-rtl.fe7c5bf2.js": "sha384-UyLXlR/3/H6oFjRrIMH04PA8bAjVQ8rdVWkWoiO4LLYasg4H3UNWJ+Xh+FrYekKh",
"/build/app-rtl.2003dce5.css": "sha384-pl8GyGo8sRRw1zLh9D42ZAvo450onfxeszZWocUUuzq70jAOwOlaoPYh/yyc20U7",
"/build/export-pdf.5905454a.js": "sha384-AkNj018YEQJSxC1e/3jIvlh0uR5tHnlUe9ciBbHKIoRIwj7658tCLNVaT98SNFEX",
"/build/runtime.684e9f6d.js": "sha384-suKiEX2de4fdNqQzdYbUd6osp4AepD9FiMXl+1QdvgMW9dcQqUWQNQasf3KWzwLr",
"/build/app.f0f8091d.js": "sha384-F6UUWeiIwbFffkZMRmbczLyw5tuOvtUNLuX1/iY6ZfncN1vYGSgalEumAp4HXFvX",
"/build/app.99ea4166.css": "sha384-OF0ozOygdShRhEacN7Tb8YtDSXABczVqjmXRYQjX1YGTmuRQMNCipFTqzHd9IyCH",
"/build/app-rtl.15853b82.js": "sha384-UnKKgLMu9FnRT+CFE0no/+UiUks012bYriQdUWa6f02mo6Lswl947mPybjvKL503",
"/build/app-rtl.16262d9a.css": "sha384-CjN7UFkBszmM9k6xfN8LWH79IOsgpVTwwHoffvAOc04j9+y904uDw/Y+LnSQmrDj",
"/build/export-pdf.395749ab.js": "sha384-3Hjvmu4FC/0dhHnR8kyRBU7k2xMNy1lxBpGgOkrw8PxXnwyQDM8/5bQmkJbjVT1+",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.0217cc18.js": "sha384-ORySQp4DBSYc7so90AAb8h5abgQtz3tdoO+Zv1uHJ2XqSPMAGtFBrb+Rdzc6sZg1",
"/build/invoice.42b319e4.js": "sha384-xxK7sCe/ZhTjMPFPeX1xvILURxNRZz2hJHZxAGVaw9zE6TC++2/6y2eKqg8cz852",
"/build/invoice.36018785.css": "sha384-jukM9uZ6pexDxXKgZThSxiqXimzsxzniBMHz08N9x8ryXZYkM5r/ZgaainCV0+J6",
"/build/invoice-pdf.1b6e735c.js": "sha384-8I5YMkETBNl7wD12V/hKX2YGfwH+V6PXWsu4I+1xXXx9bDzqDhtq+S7x/jey2/Wr",
"/build/invoice-pdf.26d98626.js": "sha384-gwNzQiU1y6qU/M9DPGiNW0MVZkLctEHk37sCES2X9ov+zugEaDABdkMjKBYOC9lz",
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
"/build/chart.2c85f027.js": "sha384-5Dm9aFCCSzlh5QY/vtU7/UrBkKt6aWnTccjljmpbQVFfzSEfFh0wUUV8qlXqyFJj",
"/build/calendar.cef61816.js": "sha384-qCpJrSB6fZLXT94oiWHofGbX8C0TFl86oCMTdeKWqCmNGJxABkIVo+8k19p7sGlY",
"/build/chart.56f16a68.js": "sha384-SWnYjAbZ8OWEvTP+IZfGuBMWJIcH9OZWLYQ4p38KMPmqfufsc2zhzqWkALO3mCBO",
"/build/calendar.13247e65.js": "sha384-8b3wBuxn8m2FsxLMtbIpZTg1SGBX9b+0dYRTnF63mGQfcXhRGHKtUs5REwVyhYiP",
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ",
"/build/dashboard.faf42d4e.js": "sha384-xkM5D8wBK1sJekLI9DUQBxTjxjomfPQWWtWCo7NX03jNnFZtMv5anM0IISLWaFY0",
"/build/dashboard.9708ae5e.js": "sha384-QN7XIQuxFZu76sHVrgdpZL81+Q2VTwcgF3aI2CnpwYZiMoPbzrcTGfPL3RDy696t",
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS",
"/build/highlight.718fe73d.js": "sha384-eHb21blXJ7SKqCFlMSxl4oblpusDif1B0Tsr2Iw1xQfUqxZxwtFwYYvMPllfw7BY",
"/build/highlight.0296a734.js": "sha384-vGFKI/KM+uyGIlzKsKhQh4w41hQteB4u14oRYqObMT03/YTZXq6Re5nUN6g3sGQH",
"/build/highlight.98bf3927.css": "sha384-YgweSwDwN0dI4DEmh478xYVw/TewJYvCO1QTbWHpaHFDPuLrZvYMR0Tc+QfFxVPE"
}
}

View File

@@ -0,0 +1 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[872],{878:function(i,n,u){u(3385)},3385:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=878,i(i.s=n)}]);

View File

@@ -1 +0,0 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[872],{585:function(i,n,u){"use strict";u.r(n)},2352:function(i,n,u){u(585)}},function(i){var n;n=2352,i(i.s=n)}]);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[117],{2049:function(i,n,u){u(4860)},4860:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=2049,i(i.s=n)}]);

View File

@@ -0,0 +1 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[117],{1555:function(i,n,u){u(6620)},6620:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=1555,i(i.s=n)}]);

View File

@@ -1 +0,0 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[896],{239:function(i,n,u){"use strict";u.r(n)},8478:function(i,n,u){u(239)}},function(i){var n;n=8478,i(i.s=n)}]);

View File

@@ -0,0 +1 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[896],{3631:function(i,n,u){"use strict";u.r(n)},4820:function(i,n,u){u(3631)}},function(i){var n;n=4820,i(i.s=n)}]);

View File

@@ -1,22 +1,22 @@
{
"build/app.css": "/build/app.c18ba3c6.css",
"build/app.js": "/build/app.307ea672.js",
"build/app-rtl.css": "/build/app-rtl.2003dce5.css",
"build/app-rtl.js": "/build/app-rtl.fe7c5bf2.js",
"build/app.css": "/build/app.99ea4166.css",
"build/app.js": "/build/app.f0f8091d.js",
"build/app-rtl.css": "/build/app-rtl.16262d9a.css",
"build/app-rtl.js": "/build/app-rtl.15853b82.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.5905454a.js",
"build/export-pdf.js": "/build/export-pdf.395749ab.js",
"build/invoice.css": "/build/invoice.36018785.css",
"build/invoice.js": "/build/invoice.0217cc18.js",
"build/invoice.js": "/build/invoice.42b319e4.js",
"build/invoice-pdf.css": "/build/invoice-pdf.2b749265.css",
"build/invoice-pdf.js": "/build/invoice-pdf.1b6e735c.js",
"build/chart.js": "/build/chart.2c85f027.js",
"build/invoice-pdf.js": "/build/invoice-pdf.26d98626.js",
"build/chart.js": "/build/chart.56f16a68.js",
"build/calendar.css": "/build/calendar.d757753e.css",
"build/calendar.js": "/build/calendar.cef61816.js",
"build/calendar.js": "/build/calendar.13247e65.js",
"build/dashboard.css": "/build/dashboard.b7129fa1.css",
"build/dashboard.js": "/build/dashboard.faf42d4e.js",
"build/dashboard.js": "/build/dashboard.9708ae5e.js",
"build/highlight.css": "/build/highlight.98bf3927.css",
"build/highlight.js": "/build/highlight.718fe73d.js",
"build/runtime.js": "/build/runtime.6c399d29.js",
"build/highlight.js": "/build/highlight.0296a734.js",
"build/runtime.js": "/build/runtime.684e9f6d.js",
"build/fonts/fa-solid-900.ttf": "/build/fonts/fa-solid-900.2582b0e4.ttf",
"build/fonts/fa-brands-400.ttf": "/build/fonts/fa-brands-400.1815e004.ttf",
"build/fonts/fa-solid-900.woff2": "/build/fonts/fa-solid-900.2463b90d.woff2",

View File

@@ -0,0 +1 @@
!function(){"use strict";var e,r={},n={};function t(e){var o=n[e];if(void 0!==o)return o.exports;var i=n[e]={id:e,loaded:!1,exports:{}};return r[e].call(i.exports,i,i.exports,t),i.loaded=!0,i.exports}t.m=r,t.amdO={},e=[],t.O=function(r,n,o,i){if(!n){var u=1/0;for(l=0;l<e.length;l++){n=e[l][0],o=e[l][1],i=e[l][2];for(var f=!0,a=0;a<n.length;a++)(!1&i||u>=i)&&Object.keys(t.O).every(function(e){return t.O[e](n[a])})?n.splice(a--,1):(f=!1,i<u&&(u=i));if(f){e.splice(l--,1);var c=o();void 0!==c&&(r=c)}}return r}i=i||0;for(var l=e.length;l>0&&e[l-1][2]>i;l--)e[l]=e[l-1];e[l]=[n,o,i]},t.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(r,{a:r}),r},t.d=function(e,r){for(var n in r)t.o(r,n)&&!t.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:r[n]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),t.hmd=function(e){return(e=Object.create(e)).children||(e.children=[]),Object.defineProperty(e,"exports",{enumerable:!0,set:function(){throw new Error("ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: "+e.id)}}),e},t.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},function(){var e={121:0};t.O.j=function(r){return 0===e[r]};var r=function(r,n){var o,i,u=n[0],f=n[1],a=n[2],c=0;if(u.some(function(r){return 0!==e[r]})){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(a)var l=a(t)}for(r&&r(n);c<u.length;c++)i=u[c],t.o(e,i)&&e[i]&&e[i][0](),e[i]=0;return t.O(l)},n=self.webpackChunkkimai=self.webpackChunkkimai||[];n.forEach(r.bind(null,0)),n.push=r.bind(null,n.push.bind(n))}()}();

View File

@@ -1 +0,0 @@
!function(){"use strict";var e,r={},n={};function t(e){var o=n[e];if(void 0!==o)return o.exports;var i=n[e]={id:e,loaded:!1,exports:{}};return r[e].call(i.exports,i,i.exports,t),i.loaded=!0,i.exports}t.m=r,t.amdO={},e=[],t.O=function(r,n,o,i){if(!n){var u=1/0;for(l=0;l<e.length;l++){n=e[l][0],o=e[l][1],i=e[l][2];for(var f=!0,a=0;a<n.length;a++)(!1&i||u>=i)&&Object.keys(t.O).every((function(e){return t.O[e](n[a])}))?n.splice(a--,1):(f=!1,i<u&&(u=i));if(f){e.splice(l--,1);var c=o();void 0!==c&&(r=c)}}return r}i=i||0;for(var l=e.length;l>0&&e[l-1][2]>i;l--)e[l]=e[l-1];e[l]=[n,o,i]},t.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(r,{a:r}),r},t.d=function(e,r){for(var n in r)t.o(r,n)&&!t.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:r[n]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),t.hmd=function(e){return(e=Object.create(e)).children||(e.children=[]),Object.defineProperty(e,"exports",{enumerable:!0,set:function(){throw new Error("ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: "+e.id)}}),e},t.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},function(){var e={121:0};t.O.j=function(r){return 0===e[r]};var r=function(r,n){var o,i,u=n[0],f=n[1],a=n[2],c=0;if(u.some((function(r){return 0!==e[r]}))){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(a)var l=a(t)}for(r&&r(n);c<u.length;c++)i=u[c],t.o(e,i)&&e[i]&&e[i][0](),e[i]=0;return t.O(l)},n=self.webpackChunkkimai=self.webpackChunkkimai||[];n.forEach(r.bind(null,0)),n.push=r.bind(null,n.push.bind(n))}()}();

View File

@@ -71,6 +71,7 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[OA\Get(x: ['internal' => true])]
#[Route(methods: ['GET'], path: '/timesheet/{id}/{view}/{locale}', name: 'get_timesheet_actions', requirements: ['id' => '\d+'])]
#[IsGranted('view', 'timesheet')]
public function getTimesheetActions(Timesheet $timesheet, string $view, string $locale): Response
{
$event = new PageActionsEvent($this->getUser(), ['timesheet' => $timesheet], 'timesheet', $view);
@@ -90,6 +91,7 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[OA\Get(x: ['internal' => true])]
#[Route(methods: ['GET'], path: '/activity/{id}/{view}/{locale}', name: 'get_activity_actions', requirements: ['id' => '\d+'])]
#[IsGranted('view', 'activity')]
public function getActivityActions(Activity $activity, string $view, string $locale): Response
{
$event = new PageActionsEvent($this->getUser(), ['activity' => $activity], 'activity', $view);
@@ -109,6 +111,7 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[OA\Get(x: ['internal' => true])]
#[Route(methods: ['GET'], path: '/project/{id}/{view}/{locale}', name: 'get_project_actions', requirements: ['id' => '\d+'])]
#[IsGranted('view', 'project')]
public function getProjectActions(Project $project, string $view, string $locale): Response
{
$event = new PageActionsEvent($this->getUser(), ['project' => $project], 'project', $view);
@@ -128,6 +131,7 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[OA\Get(x: ['internal' => true])]
#[Route(methods: ['GET'], path: '/customer/{id}/{view}/{locale}', name: 'get_customer_actions', requirements: ['id' => '\d+'])]
#[IsGranted('view', 'customer')]
public function getCustomerActions(Customer $customer, string $view, string $locale): Response
{
$event = new PageActionsEvent($this->getUser(), ['customer' => $customer], 'customer', $view);

View File

@@ -72,15 +72,14 @@ final class ActivityController extends BaseApiController
/** @var array<int> $projects */
$projects = $paramFetcher->get('projects');
$project = $paramFetcher->get('project');
if (\is_string($project) && $project !== '') {
$projects[] = $project;
$pr = $paramFetcher->get('project');
if (\is_string($pr) && $pr !== '') {
$projects[] = $pr;
}
foreach (array_unique($projects) as $projectId) {
$project = $projectRepository->find($projectId);
if ($project === null) {
throw $this->createNotFoundException('Unknown project: ' . $projectId);
foreach ($projectRepository->findByIds(array_unique($projects)) as $project) {
if (!$this->isGranted('access', $project)) {
throw $this->createAccessDeniedException('Cannot access Project: ' . $project->getId());
}
$query->addProject($project);
}

View File

@@ -36,8 +36,8 @@ final class ApiRequestMatcher implements RequestMatcherInterface
}
// let's use this firewall if the deprecated username & token combination is available
if ($request->headers->has(TokenAuthenticator::HEADER_USERNAME) &&
$request->headers->has(TokenAuthenticator::HEADER_TOKEN)) {
if ($request->headers->has(TokenAuthenticator::HEADER_USERNAME) && // @phpstan-ignore classConstant.deprecatedClass
$request->headers->has(TokenAuthenticator::HEADER_TOKEN)) { // @phpstan-ignore classConstant.deprecatedClass
return true;
}
// ------------------------------------------------------------------------------------

View File

@@ -14,6 +14,9 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
/**
* @deprecated since 2.54 - see https://www.kimai.org/en/blog/2026/removing-api-passwords
*/
final class ApiTokenMigratingListener implements EventSubscriberInterface
{
public function __construct(private PasswordHasherFactoryInterface $hasherFactory)

View File

@@ -13,6 +13,9 @@ use Symfony\Component\Security\Core\Exception\LogicException;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
/**
* @deprecated since 2.54 - see https://www.kimai.org/en/blog/2026/removing-api-passwords
*/
final class ApiTokenUpgradeBadge implements BadgeInterface
{
public function __construct(private ?string $plaintextApiToken, private readonly PasswordUpgraderInterface $passwordUpgrader)

View File

@@ -13,17 +13,24 @@ use App\Entity\User;
use App\Repository\ApiUserRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
/**
* @deprecated since 2.54 - see https://www.kimai.org/en/blog/2026/removing-api-passwords
*/
final class TokenAuthenticator extends AbstractAuthenticator
{
public const HEADER_USERNAME = 'X-AUTH-USER';
@@ -31,7 +38,9 @@ final class TokenAuthenticator extends AbstractAuthenticator
public function __construct(
private readonly ApiUserRepository $userProvider,
private readonly PasswordHasherFactoryInterface $passwordHasherFactory
private readonly PasswordHasherFactoryInterface $passwordHasherFactory,
private readonly RateLimiterFactory $oldApiTokensLimiter,
private readonly RequestStack $requestStack,
)
{
}
@@ -44,8 +53,6 @@ final class TokenAuthenticator extends AbstractAuthenticator
}
if ($request->headers->has(self::HEADER_USERNAME) && $request->headers->has(self::HEADER_TOKEN)) {
@trigger_error('You are using deprecated API access, please upgrade your APP to use API tokens instead.', E_USER_DEPRECATED);
return true;
}
}
@@ -77,10 +84,12 @@ final class TokenAuthenticator extends AbstractAuthenticator
$checkCredentials = function (?string $presentedPassword, User $user) {
if ('' === $presentedPassword) {
$this->rateLimitInvalidLogin();
throw new BadCredentialsException('The presented password cannot be empty.');
}
if (null === $user->getApiToken()) {
$this->rateLimitInvalidLogin();
throw new BadCredentialsException('The user has no activated API account.');
}
@@ -88,11 +97,17 @@ final class TokenAuthenticator extends AbstractAuthenticator
return true;
}
$this->rateLimitInvalidLogin();
throw new BadCredentialsException('The presented password is invalid.');
};
// users should really move away from this auth endpoint
// see https://www.kimai.org/en/blog/2026/removing-api-passwords
@trigger_error('Using deprecated API passwords, upgrade your APP to use API tokens instead.', E_USER_DEPRECATED);
usleep(mt_rand(200000, 500000));
$passport = new Passport(
new UserBadge($credentials['username'], [$this->userProvider, 'loadUserByIdentifier']),
new UserBadge($credentials['username'], [$this, 'loadUserByIdentifier']),
new CustomCredentials($checkCredentials, $credentials['password'])
);
@@ -101,6 +116,30 @@ final class TokenAuthenticator extends AbstractAuthenticator
return $passport;
}
public function loadUserByIdentifier(string $identifier): ?UserInterface
{
$user = $this->userProvider->loadUserByIdentifier($identifier);
if ($user === null) {
// we could use usleep(500000); to slow down potential attacks, but using a hashing makes timing attacks more difficult
$this->passwordHasherFactory->getPasswordHasher(User::class)->verify('$2y$13$vwn35gUbbivoS75wcByBzObCNjX4vwkBihbdXQuK23HzK1R6J5WKW', uniqid());
$this->rateLimitInvalidLogin();
}
return $user;
}
private function rateLimitInvalidLogin(): void
{
$limiter = $this->oldApiTokensLimiter->create($this->requestStack->getMainRequest()?->getClientIp());
$limit = $limiter->consume();
if (false === $limit->isAccepted()) {
throw new BadRequestHttpException('Too many API requests with invalid username. Possible attack?');
}
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;

View File

@@ -18,6 +18,7 @@ use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -76,6 +77,9 @@ final class InvoiceController extends BaseApiController
/** @var array<int> $customers */
$customers = $paramFetcher->get('customers');
foreach ($customerRepository->findByIds(array_unique($customers)) as $customer) {
if (!$this->isGranted('access', $customer)) {
throw $this->createAccessDeniedException('Cannot access Customer: ' . $customer->getId());
}
$query->addCustomer($customer);
}
@@ -90,6 +94,7 @@ final class InvoiceController extends BaseApiController
* Fetch invoice
*/
#[IsGranted('view_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
#[OA\Response(response: 200, description: 'Returns one invoice', content: new OA\JsonContent(ref: '#/components/schemas/Invoice'))]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_invoice', requirements: ['id' => '\d+'])]
public function getAction(Invoice $invoice): Response

View File

@@ -85,15 +85,14 @@ final class ProjectController extends BaseApiController
/** @var array<int> $customers */
$customers = $paramFetcher->get('customers');
$customer = $paramFetcher->get('customer');
if (\is_string($customer) && $customer !== '') {
$customers[] = $customer;
$cu = $paramFetcher->get('customer');
if (\is_string($cu) && $cu !== '') {
$customers[] = $cu;
}
foreach (array_unique($customers) as $customerId) {
$customer = $customerRepository->find($customerId);
if ($customer === null) {
throw $this->createNotFoundException('Unknown customer: ' . $customerId);
foreach ($customerRepository->findByIds(array_unique($customers)) as $customer) {
if (!$this->isGranted('access', $customer)) {
throw $this->createAccessDeniedException('Cannot access Customer: ' . $customer->getId());
}
$query->addCustomer($customer);
}

View File

@@ -71,7 +71,7 @@ final class TeamController extends BaseApiController
* Fetch team
*/
#[IsGranted('view_team')]
#[OA\Response(response: 200, description: 'Returns one team entity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))]
#[OA\Response(response: 200, description: 'Returns the team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_team', requirements: ['id' => '\d+'])]
public function getAction(Team $team): Response
{
@@ -84,8 +84,8 @@ final class TeamController extends BaseApiController
/**
* Delete team
*/
#[IsGranted('delete_team')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one team')])]
#[IsGranted('delete', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Empty')])]
#[OA\Parameter(name: 'id', in: 'path', description: 'Team ID to delete', required: true)]
#[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_team', requirements: ['id' => '\d+'])]
public function deleteAction(Team $team): Response
@@ -129,7 +129,7 @@ final class TeamController extends BaseApiController
/**
* Update team
*/
#[IsGranted('edit_team')]
#[IsGranted('edit', 'team')]
#[OA\Patch(description: 'Update an existing team, you can pass all or just a subset of all attributes (passing members will replace all existing ones)', responses: [new OA\Response(response: 200, description: 'Returns the updated team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TeamEditForm'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Team ID to update', required: true)]
@@ -169,7 +169,7 @@ final class TeamController extends BaseApiController
/**
* Add team member
*/
#[IsGranted('edit_team')]
#[IsGranted('edit', 'team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new user to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team which will receive the new member', required: true)]
#[OA\Parameter(name: 'userId', in: 'path', description: 'The team member to add (User ID)', required: true)]
@@ -193,7 +193,7 @@ final class TeamController extends BaseApiController
/**
* Remove team member
*/
#[IsGranted('edit_team')]
#[IsGranted('edit', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a user from the team. The teamlead cannot be removed.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team from which the member will be removed', required: true)]
#[OA\Parameter(name: 'userId', in: 'path', description: 'The team member to remove (User ID)', required: true)]
@@ -219,10 +219,12 @@ final class TeamController extends BaseApiController
}
/**
* Grant team access to customer
* Grant customer access
*
* The team is granted access to the customer.
*/
#[IsGranted('edit_team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new customer to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[IsGranted('edit', 'team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the team including the customer', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
#[OA\Parameter(name: 'customerId', in: 'path', description: 'The customer to grant acecess to (Customer ID)', required: true)]
#[Route(methods: ['POST'], path: '/{id}/customers/{customerId}', name: 'post_team_customer', requirements: ['id' => '\d+', 'customerId' => '\d+'])]
@@ -242,10 +244,12 @@ final class TeamController extends BaseApiController
}
/**
* Revoke customer access from team
* Revoke customer access
*
* This removes access to the customer from the team.
*/
#[IsGranted('edit_team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a customer from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[IsGranted('edit', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Returns the team without the customer', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)]
#[OA\Parameter(name: 'customerId', in: 'path', description: 'The customer to remove (Customer ID)', required: true)]
#[Route(methods: ['DELETE'], path: '/{id}/customers/{customerId}', name: 'delete_team_customer', requirements: ['id' => '\d+', 'customerId' => '\d+'])]
@@ -265,10 +269,12 @@ final class TeamController extends BaseApiController
}
/**
* Grant team access to project
* Grant project access
*
* The team is granted access to the project.
*/
#[IsGranted('edit_team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new project to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[IsGranted('edit', 'team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the team including the project', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
#[OA\Parameter(name: 'projectId', in: 'path', description: 'The project to grant acecess to (Project ID)', required: true)]
#[Route(methods: ['POST'], path: '/{id}/projects/{projectId}', name: 'post_team_project', requirements: ['id' => '\d+', 'projectId' => '\d+'])]
@@ -288,10 +294,12 @@ final class TeamController extends BaseApiController
}
/**
* Revoke project access from team
* Revoke project access
*
* This removes access to the project from the team.
*/
#[IsGranted('edit_team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a project from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[IsGranted('edit', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Returns the team without the project', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)]
#[OA\Parameter(name: 'projectId', in: 'path', description: 'The project to remove (Project ID)', required: true)]
#[Route(methods: ['DELETE'], path: '/{id}/projects/{projectId}', name: 'delete_team_project', requirements: ['id' => '\d+', 'projectId' => '\d+'])]
@@ -311,10 +319,12 @@ final class TeamController extends BaseApiController
}
/**
* Grant team access to activity
* Grant activity access
*
* The team is granted access to the activity.
*/
#[IsGranted('edit_team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new activity to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[IsGranted('edit', 'team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the team including the activity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
#[OA\Parameter(name: 'activityId', in: 'path', description: 'The activity to grant acecess to (Activity ID)', required: true)]
#[Route(methods: ['POST'], path: '/{id}/activities/{activityId}', name: 'post_team_activity', requirements: ['id' => '\d+', 'activityId' => '\d+'])]
@@ -334,10 +344,12 @@ final class TeamController extends BaseApiController
}
/**
* Revoke activity access from team
* Revoke activity access
*
* This removes access to the activity from the team.
*/
#[IsGranted('edit_team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a activity from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[IsGranted('edit', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Returns the team without the activity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)]
#[OA\Parameter(name: 'activityId', in: 'path', description: 'The activity to remove (Activity ID)', required: true)]
#[Route(methods: ['DELETE'], path: '/{id}/activities/{activityId}', name: 'delete_team_activity', requirements: ['id' => '\d+', 'activityId' => '\d+'])]

View File

@@ -126,45 +126,42 @@ final class TimesheetController extends BaseApiController
/** @var array<int> $customers */
$customers = $paramFetcher->get('customers');
$customer = $paramFetcher->get('customer');
if (\is_string($customer) && $customer !== '') {
$customers[] = $customer;
$cu = $paramFetcher->get('customer');
if (\is_string($cu) && $cu !== '') {
$customers[] = $cu;
}
foreach (array_unique($customers) as $customerId) {
$customer = $customerRepository->find($customerId);
if ($customer === null) {
throw $this->createNotFoundException('Unknown customer: ' . $customerId);
foreach ($customerRepository->findByIds(array_unique($customers)) as $customer) {
if (!$this->isGranted('access', $customer)) {
throw $this->createAccessDeniedException('Cannot access Customer: ' . $customer->getId());
}
$query->addCustomer($customer);
}
/** @var array<int> $projects */
$projects = $paramFetcher->get('projects');
$project = $paramFetcher->get('project');
if (\is_string($project) && $project !== '') {
$projects[] = $project;
$pr = $paramFetcher->get('project');
if (\is_string($pr) && $pr !== '') {
$projects[] = $pr;
}
foreach (array_unique($projects) as $projectId) {
$project = $projectRepository->find($projectId);
if ($project === null) {
throw $this->createNotFoundException('Unknown project: ' . $project);
foreach ($projectRepository->findByIds(array_unique($projects)) as $project) {
if (!$this->isGranted('access', $project)) {
throw $this->createAccessDeniedException('Cannot access Project: ' . $project->getId());
}
$query->addProject($project);
}
/** @var array<int> $activities */
$activities = $paramFetcher->get('activities');
$activity = $paramFetcher->get('activity');
if (\is_string($activity) && $activity !== '') {
$activities[] = $activity;
$ac = $paramFetcher->get('activity');
if (\is_string($ac) && $ac !== '') {
$activities[] = $ac;
}
foreach (array_unique($activities) as $activityId) {
$activity = $activityRepository->find($activityId);
if ($activity === null) {
throw $this->createNotFoundException('Unknown activity: ' . $activity);
foreach ($activityRepository->findByIds(array_unique($activities)) as $activity) {
if (!$this->isGranted('access', $activity)) {
throw $this->createAccessDeniedException('Cannot access Activity: ' . $activity->getId());
}
$query->addActivity($activity);
}
@@ -234,7 +231,6 @@ final class TimesheetController extends BaseApiController
}
$data = $this->repository->getPagerfantaForQuery($query);
$view = new View($data, 200);
$full = $paramFetcher->get('full');
@@ -486,8 +482,7 @@ final class TimesheetController extends BaseApiController
$copy = $paramFetcher->get('copy');
if ($copy === 'all') {
$copyTimesheet->setHourlyRate($timesheet->getHourlyRate());
$copyTimesheet->setFixedRate($timesheet->getFixedRate());
// we do NOT copy rates, as those should always be calculated from the configured settings
$copyTimesheet->setDescription($timesheet->getDescription());
$copyTimesheet->setBillable($timesheet->isBillable());

View File

@@ -236,16 +236,18 @@ final class UserController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'User ID to set the custom-field value for', required: true)]
#[OA\RequestBody(required: true, content: new OA\JsonContent(type: 'array', items: new OA\Items(new Model(type: UserPreference::class))))]
#[Route(methods: ['PATCH'], path: '/{id}/preferences', requirements: ['id' => '\d+'])]
public function updateUserPreference(User $profile, Request $request, EventDispatcherInterface $dispatcher): Response
public function updateUserPreference(User $profile, Request $request, EventDispatcherInterface $dispatcher, UserService $userService): Response
{
$event = new PrepareUserEvent($profile, false);
$dispatcher->dispatch($event);
$dirty = false;
foreach ($request->request->all() as $preference) {
// why is this not handled by FosRestBundle ?
if (!\is_array($preference)) {
throw new BadRequestHttpException('Invalid request, array expected');
}
if (!\array_key_exists('name', $preference) || !\array_key_exists('value', $preference)) {
throw new BadRequestHttpException('Missing required parameter "name" or "value"');
}
@@ -253,14 +255,23 @@ final class UserController extends BaseApiController
$name = $preference['name'];
$value = $preference['value'];
// TODO allow to update preferences that are used internally but not registered via PrepareUserEvent
if (null === ($meta = $profile->getPreference($name))) {
throw $this->createNotFoundException(\sprintf('Unknown custom-field "%s" requested', $name));
}
if (!$meta->isEnabled()) {
throw $this->createAccessDeniedException('User tried to update preference: ' . $name);
}
$meta->setValue($value);
$dirty = true;
}
$this->repository->saveUser($profile);
if ($dirty) {
$userService->saveUser($profile);
}
$view = new View($profile, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);

View File

@@ -147,7 +147,6 @@ final class ResetTestCommand extends AbstractResetCommand
$userSuperAdmin->setPreferenceValue(UserPreference::HOURLY_RATE, 46);
$userSuperAdmin->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$userSuperAdmin->setTitle('Super Administrator');
$userSuperAdmin->setAvatar('/bundles/avanzuadmintheme/img/avatar.png');
$userSuperAdmin->setEnabled(true);
$userSuperAdmin->setRoles(['ROLE_SUPER_ADMIN']);
$userSuperAdmin->setUserIdentifier(UserFixtures::USERNAME_SUPER_ADMIN);

View File

@@ -508,7 +508,7 @@ final class TranslationCommand extends Command
\sprintf('Missing english translation for key: %s in file %s', $key, $file)
);
}
$unit->target[0] = $translations[$key];
$unit->target[0] = $translations[$key]; // @phpstan-ignore-line
$unit->target['state'] = 'needs-translation';
$foundEmpty = true;
}

View File

@@ -11,7 +11,7 @@ namespace App\Configuration;
final class SamlConfiguration implements SamlConfigurationInterface
{
public function __construct(private SystemConfiguration $configuration)
public function __construct(private readonly SystemConfiguration $configuration)
{
}
@@ -54,4 +54,9 @@ final class SamlConfiguration implements SamlConfigurationInterface
{
return $this->configuration->getSamlConnection();
}
public function cleanupLongRelayState(): bool
{
return (bool) $this->configuration->find('saml.connection.cleanupLongRelayState');
}
}

View File

@@ -46,4 +46,7 @@ interface SamlConfigurationInterface
public function isRolesResetOnLogin(): bool;
public function getConnection(): array;
// TODO 3.0 activate me
//public function cleanupLongRelayState(): bool;
}

View File

@@ -91,7 +91,6 @@ final class SystemConfiguration
$array = &$replaced;
while (\count($keys) > 1) {
$search = array_shift($keys);
/* @phpstan-ignore-next-line */
if (!\array_key_exists($search, $array) || !\is_array($array[$search])) {
$array[$search] = [];
}

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.46.0';
public const VERSION = '2.54.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 24600;
public const VERSION_ID = 25400;
/**
* The software name
*/

View File

@@ -66,13 +66,20 @@ final class SamlController extends AbstractController
throw new ServiceUnavailableHttpException(message: 'Unknown firewall.');
}
// this can be an absolute URL including query parameters
$redirectTarget = $this->getTargetPath($session, $firewallName);
if ($redirectTarget === null || $redirectTarget === '') {
$redirectTarget = $this->generateUrl('homepage', [], UrlGeneratorInterface::ABSOLUTE_URL);
}
$url = $this->authFactory->create()->login($redirectTarget, [], false, false, true);
// the protocol defines max 80 byte for RelayState, even if most IdP support more - see #5752
if (method_exists($this->samlConfiguration, 'cleanupLongRelayState') && $this->samlConfiguration->cleanupLongRelayState()) {
if (\strlen($redirectTarget) > 80 && ($pos = stripos($redirectTarget, '?')) !== false) {
$redirectTarget = substr($redirectTarget, 0, $pos);
}
}
$url = $this->authFactory->create()->login($redirectTarget, [], false, false, true);
if ($url === null) {
throw new \RuntimeException('SAML login failed');
}

View File

@@ -26,19 +26,6 @@ use Symfony\Contracts\Cache\ItemInterface;
#[IsGranted('system_information')]
final class DoctorController extends AbstractController
{
/**
* Required PHP extensions for Kimai.
*/
public const REQUIRED_EXTENSIONS = [
'intl',
'json',
'mbstring',
'pdo',
'xml',
'xsl',
'zip',
];
/**
* Directories which need to be writable by the webserver.
*/
@@ -108,10 +95,32 @@ final class DoctorController extends AbstractController
'logLines' => $logLines,
'logSize' => $this->getLogSize(),
'composer' => $this->getComposerPackages(),
'release' => $latestRelease
'release' => $latestRelease,
'opcache' => $this->getOpcacheConfiguration()
]);
}
/**
* @return array{enabled: bool, status: false|array<mixed>}
*/
private function getOpcacheConfiguration(): array
{
$known = \function_exists('opcache_get_status');
$status = $known ? opcache_get_status() : false;
$enabled = \is_array($status) && $status['opcache_enabled'];
if ($enabled && \array_key_exists('scripts', $status)) {
unset($status['scripts']);
}
return [
'unknown' => !$known,
'enabled' => $enabled,
'status' => $status,
];
}
/**
* @return array<string, string>
*/
@@ -148,9 +157,27 @@ final class DoctorController extends AbstractController
*/
private function getLoadedExtensions(): array
{
$json = file_get_contents(__DIR__ . '/../../composer.json');
if ($json === false) {
return ['Failed loading composer.json' => false];
}
$composer = json_decode($json, true);
if (!\is_array($composer)) {
return ['Failed parsing composer.json' => false];
}
if (!\array_key_exists('require', $composer)) {
return ['Missing requirements in composer.json' => false];
}
$results = [];
foreach (self::REQUIRED_EXTENSIONS as $extName) {
foreach ($composer['require'] as $name => $version) {
if (!str_starts_with($name, 'ext-')) {
continue;
}
$extName = str_replace('ext-', '', $name);
$results[$extName] = false;
if (\extension_loaded($extName)) {
$results[$extName] = true;
@@ -264,7 +291,12 @@ final class DoctorController extends AbstractController
'sys_temp_dir',
'date.timezone',
'session.gc_maxlifetime',
'disable_functions'
'disable_functions',
'opcache.enable',
'opcache.memory_consumption',
'opcache.interned_strings_buffer',
'opcache.max_accelerated_files',
'opcache.validate_timestamps',
];
$settings = [];

View File

@@ -16,6 +16,7 @@ use App\Model\DateStatisticInterface;
use App\Model\Statistic\StatisticDate;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\TimesheetStatisticQuery;
use App\Timesheet\TimesheetStatisticService;
use DateTimeInterface;
@@ -37,7 +38,7 @@ abstract class AbstractUserReportController extends AbstractController
protected function getStatisticDataRaw(DateTimeInterface $begin, DateTimeInterface $end, User $user): array
{
return $this->statisticService->getDailyStatisticsGrouped($begin, $end, [$user]);
return $this->statisticService->getDailyStatisticsGrouped(new TimesheetStatisticQuery($begin, $end, [$user]));
}
protected function createStatisticModel(DateTimeInterface $begin, DateTimeInterface $end, User $user): DateStatisticInterface

View File

@@ -17,6 +17,7 @@ use App\Model\DateStatisticInterface;
use App\Model\MonthlyStatistic;
use App\Reporting\YearByUser\YearByUser;
use App\Reporting\YearByUser\YearByUserForm;
use App\Repository\Query\TimesheetStatisticQuery;
use DateTime;
use DateTimeInterface;
use PhpOffice\PhpSpreadsheet\Reader\Html;
@@ -125,7 +126,7 @@ final class UserYearController extends AbstractUserReportController
protected function getStatisticDataRaw(DateTimeInterface $begin, DateTimeInterface $end, User $user): array
{
return $this->statisticService->getMonthlyStatisticsGrouped($begin, $end, [$user]);
return $this->statisticService->getMonthlyStatisticsGrouped(new TimesheetStatisticQuery($begin, $end, [$user]));
}
protected function createStatisticModel(DateTimeInterface $begin, DateTimeInterface $end, User $user): DateStatisticInterface

View File

@@ -38,15 +38,9 @@ final class TeamController extends AbstractController
{
}
/**
* @param TeamRepository $repository
* @param Request $request
* @param int $page
* @return Response
*/
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_team', methods: ['GET'])]
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_team_paginated', methods: ['GET'])]
public function listTeams(TeamRepository $repository, Request $request, $page): Response
public function listTeams(int $page, TeamRepository $repository, Request $request): Response
{
$query = new TeamQuery();
$query->setPage($page);
@@ -81,10 +75,6 @@ final class TeamController extends AbstractController
]);
}
/**
* @param Request $request
* @return Response
*/
#[Route(path: '/create', name: 'admin_team_create', methods: ['GET', 'POST'])]
#[IsGranted('create_team')]
public function createTeam(Request $request): Response

View File

@@ -99,7 +99,7 @@ final class AppExtension extends Extension
foreach (range(0, $iterator->getDepth()) as $depth) {
$keys[] = $iterator->getSubIterator($depth)->key();
}
$newConfig[implode('.', $keys)] = $value;
$newConfig[implode('.', $keys)] = $value; // @phpstan-ignore argument.type
}
$container->setParameter('kimai.config', $newConfig);

View File

@@ -862,6 +862,9 @@ final class Configuration implements ConfigurationInterface
->scalarNode('baseurl')->end()
->booleanNode('strict')->end()
->booleanNode('debug')->end()
->booleanNode('cleanupLongRelayState')
->defaultFalse()
->end()
->arrayNode('idp')
->children()
->scalarNode('entityId')->end()

View File

@@ -60,6 +60,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
* Name of this activity
*/
#[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 150)]
#[Serializer\Expose]
@@ -117,6 +118,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[ORM\Column(name: 'invoice_text', type: Types::TEXT, nullable: true)]
private ?string $invoiceText = null;
#[ORM\Column(name: 'number', type: Types::STRING, length: 10, nullable: true)]
#[Constraints\NoSpecialCharacters]
#[Assert\Length(max: 10)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]

View File

@@ -45,6 +45,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Exporter\Expose(label: 'id', type: 'integer')]
private ?int $id = null;
#[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 150)]
#[Serializer\Expose]
@@ -52,6 +53,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Exporter\Expose(label: 'name')]
private ?string $name = null;
#[ORM\Column(name: 'number', type: Types::STRING, length: 50, nullable: true)]
#[Constraints\NoSpecialCharacters]
#[Assert\Length(max: 50)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
@@ -141,6 +143,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
* Contact email
*/
#[ORM\Column(name: 'email', type: Types::STRING, length: 75, nullable: true)]
#[Assert\Email(mode: 'html5')]
#[Assert\Length(max: 75)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
@@ -148,6 +151,8 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
private ?string $email = null;
#[ORM\Column(name: 'homepage', type: Types::STRING, length: 100, nullable: true)]
#[Assert\Length(max: 100)]
#[Assert\Url]
#[Assert\NoSuspiciousCharacters]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'homepage')]

View File

@@ -256,6 +256,18 @@ class ExportTemplate
return \is_string($font) ? $font : null;
}
public function isAvailableForAll(): bool
{
$isAllowed = $this->getOption('user_access', false);
return \is_bool($isAllowed) ? $isAllowed : false;
}
public function setAvailableForAll(bool $userAccess): void
{
$this->setOption('user_access', $userAccess);
}
public function __toString(): string
{
return $this->title ?? 'New';

View File

@@ -301,7 +301,7 @@ class InvoiceTemplate implements EntityWithMetaFields
}
/**
* @return Collection|MetaTableTypeInterface[]
* @return Collection<int, InvoiceTemplateMeta>
*/
public function getMetaFields(): Collection
{

View File

@@ -10,6 +10,7 @@
namespace App\Entity;
use App\Form\Type\YesNoType;
use App\Validator\Constraints as Constraints;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
@@ -30,6 +31,7 @@ trait MetaTableTypeTrait
* Name of the meta (custom) field
*/
#[ORM\Column(name: 'name', type: Types::STRING, length: 50, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotNull]
#[Assert\Length(min: 2, max: 50)]
#[Serializer\Expose]

View File

@@ -63,6 +63,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
* Project name
*/
#[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotNull]
#[Assert\Length(min: 2, max: 150)]
#[Serializer\Expose]
@@ -170,6 +171,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Serializer\Groups(['Default'])]
private bool $globalActivities = true;
#[ORM\Column(name: 'number', type: Types::STRING, length: 10, nullable: true)]
#[Constraints\NoSpecialCharacters]
#[Assert\Length(max: 10)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]

View File

@@ -10,6 +10,7 @@
namespace App\Entity;
use App\Repository\TagRepository;
use App\Validator\Constraints as Constraints;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
@@ -26,7 +27,7 @@ use Symfony\Component\Validator\Constraints as Assert;
class Tag
{
/**
* Internal Tag ID
* Tag ID
*/
#[ORM\Column(name: 'id', type: Types::INTEGER)]
#[ORM\Id]
@@ -35,9 +36,10 @@ class Tag
#[Serializer\Groups(['Default'])]
private ?int $id = null;
/**
* The tag name
* Tag name cannot contain the character: " < > = ,
*/
#[ORM\Column(name: 'name', type: Types::STRING, length: 100, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100, normalizer: 'trim')]
#[Assert\Regex(pattern: '/,/', message: 'Tag name cannot contain comma', match: false)]
@@ -52,10 +54,6 @@ class Tag
use ColorTrait;
public function __construct()
{
}
public function getId(): ?int
{
return $this->id;

View File

@@ -39,6 +39,7 @@ class Team
* Team name
*/
#[ORM\Column(name: 'name', type: Types::STRING, length: 100, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100)]
#[Serializer\Expose]

View File

@@ -81,6 +81,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
*/
#[ORM\Column(name: 'alias', type: Types::STRING, length: 60, nullable: true)]
#[Assert\Length(max: 60)]
#[Constraints\NoSpecialCharacters]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'alias')]
@@ -96,14 +97,16 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
*/
#[ORM\Column(name: 'title', type: Types::STRING, length: 50, nullable: true)]
#[Assert\Length(max: 50)]
#[Constraints\NoSpecialCharacters]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'title')]
private ?string $title = null;
/**
* URL to the user avatar, will be auto-generated if empty
* URL to the user avatar
*/
#[ORM\Column(name: 'avatar', type: Types::STRING, length: 255, nullable: true)]
#[Assert\Url]
#[Assert\Length(max: 255, groups: ['Profile'])]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
@@ -162,6 +165,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[Assert\NotBlank(groups: ['Registration', 'UserCreate', 'Profile'])]
#[Assert\Regex(pattern: '/\//', match: false, groups: ['Registration', 'UserCreate', 'Profile'])]
#[Assert\Length(min: 2, max: 64, groups: ['Registration', 'UserCreate', 'Profile'])]
#[Constraints\NoSpecialCharacters]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private ?string $username = null;
@@ -173,6 +177,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[Serializer\Groups(['Default'])]
private ?string $email = null;
#[ORM\Column(name: 'account', type: Types::STRING, length: 30, nullable: true)]
#[Constraints\NoSpecialCharacters]
#[Assert\Length(max: 30)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
@@ -376,14 +381,24 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
}
/**
* This method is called from the "edit user preferences" form.
* Therefor it just merges the values for existing preferences and adds new ones.
* But it will NOT remove existing preferences or replace the underlying collection.
*
* @param iterable<UserPreference> $preferences
*/
public function setPreferences(iterable $preferences): User
{
$this->preferences = new ArrayCollection();
foreach ($preferences as $preference) {
$this->addPreference($preference);
if (($name = $preference->getName()) === null) {
continue;
}
$p = $this->getPreference($name);
if ($p === null) {
$this->addPreference($preference);
} else {
$p->setValue($preference->getValue());
}
}
return $this;

View File

@@ -10,6 +10,7 @@
namespace App\Entity;
use App\Form\Type\YesNoType;
use App\Validator\Constraints as Constraints;
use App\WorkingTime\Calculator\WorkingTimeCalculatorDay;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
@@ -61,6 +62,7 @@ class UserPreference
#[Assert\NotNull]
private ?User $user = null;
#[ORM\Column(name: 'name', type: Types::STRING, length: 50, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotNull]
#[Assert\Length(min: 2, max: 50)]
#[Serializer\Expose]

View File

@@ -36,6 +36,7 @@ class WorkingTime
#[ORM\Column(name: 'expected', type: Types::INTEGER, nullable: false)]
#[Assert\NotNull]
private int $expectedTime = 0;
private ?int $originalExpectedTime = null;
#[ORM\Column(name: 'actual', type: Types::INTEGER, nullable: false)]
#[Assert\NotNull]
private int $actualTime = 0;
@@ -75,6 +76,39 @@ class WorkingTime
public function setExpectedTime(int $expectedTime): void
{
$this->expectedTime = $expectedTime;
$this->storeOriginalExpectedTime();
}
public function storeOriginalExpectedTime(): void
{
if ($this->originalExpectedTime === null) {
$this->originalExpectedTime = $this->expectedTime;
}
}
public function getOriginalExpectedTime(): int
{
$this->storeOriginalExpectedTime();
return $this->originalExpectedTime ?? 0;
}
public function halveExpectedTime(): int
{
$this->storeOriginalExpectedTime();
$reduceBy = ($this->getOriginalExpectedTime() / 2);
$this->expectedTime = $this->expectedTime - $reduceBy;
return $reduceBy;
}
public function emptyExpectedTime(): void
{
$this->storeOriginalExpectedTime();
$this->expectedTime = 0;
}
public function getActualTime(): int

View File

@@ -0,0 +1,25 @@
<?php
/*
* 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.
*/
namespace App\Event;
use Doctrine\ORM\QueryBuilder;
use Symfony\Contracts\EventDispatcher\Event;
final class TimesheetStatisticsQueryEvent extends Event
{
public function __construct(private readonly QueryBuilder $queryBuilder)
{
}
public function getQueryBuilder(): QueryBuilder
{
return $this->queryBuilder;
}
}

View File

@@ -34,7 +34,7 @@ final class TimesheetsSubscriber extends AbstractActionsSubscriber
if ($this->isGranted('export_own_timesheet')) {
foreach ($this->serviceExport->getTimesheetExporter() as $exporter) {
$event->addActionToSubmenu('export', $exporter->getId(), ['url' => $this->path('timesheet_export', ['exporter' => $exporter->getId()]), 'class' => 'toolbar-action', 'title' => 'button.' . $exporter->getId(), 'translation_domain' => 'messages']);
$event->addActionToSubmenu('export', $exporter->getId(), ['url' => $this->path('timesheet_export', ['exporter' => $exporter->getId()]), 'class' => 'toolbar-action', 'title' => $exporter->getTitle()]);
}
}
}

View File

@@ -35,7 +35,7 @@ final class TimesheetsTeamSubscriber extends AbstractActionsSubscriber
if ($this->isGranted('export_other_timesheet')) {
foreach ($this->serviceExport->getTimesheetExporter() as $exporter) {
$event->addActionToSubmenu('export', $exporter->getId(), ['url' => $this->path('admin_timesheet_export', ['exporter' => $exporter->getId()]), 'class' => 'toolbar-action', 'title' => 'button.' . $exporter->getId(), 'translation_domain' => 'messages']);
$event->addActionToSubmenu('export', $exporter->getId(), ['url' => $this->path('admin_timesheet_export', ['exporter' => $exporter->getId()]), 'class' => 'toolbar-action', 'title' => $exporter->getTitle()]);
}
}
}

View File

@@ -24,7 +24,7 @@ use App\Repository\Query\ActivityQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TimesheetQuery;
use App\Twig\SecurityPolicy\ExportPolicy;
use App\Twig\SecurityPolicy\StrictPolicy;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
use Symfony\Component\HttpFoundation\Response;
@@ -100,9 +100,12 @@ class HtmlRenderer implements ExportRendererInterface
$summary = $this->calculateSummary($exportItems);
// enable basic security measures
$sandbox = new SandboxExtension(new ExportPolicy());
if (!$this->twig->hasExtension(SandboxExtension::class)) {
$this->twig->addExtension(new SandboxExtension(new StrictPolicy()));
}
$sandbox = $this->twig->getExtension(SandboxExtension::class);
$sandbox->enableSandbox();
$this->twig->addExtension($sandbox);
$content = $this->twig->render($this->getTemplate(), array_merge([
'entries' => $exportItems,

View File

@@ -17,7 +17,7 @@ use App\Pdf\PdfContext;
use App\Pdf\PdfRendererTrait;
use App\Project\ProjectStatisticService;
use App\Repository\Query\TimesheetQuery;
use App\Twig\SecurityPolicy\ExportPolicy;
use App\Twig\SecurityPolicy\StrictPolicy;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
@@ -103,9 +103,12 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
$summary = $this->calculateSummary($exportItems);
// enable basic security measures
$sandbox = new SandboxExtension(new ExportPolicy());
if (!$this->twig->hasExtension(SandboxExtension::class)) {
$this->twig->addExtension(new SandboxExtension(new StrictPolicy()));
}
$sandbox = $this->twig->getExtension(SandboxExtension::class);
$sandbox->enableSandbox();
$this->twig->addExtension($sandbox);
$content = $this->twig->render($this->getTemplate(), array_merge([
'entries' => $exportItems,
@@ -116,6 +119,8 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
'pdfContext' => $context
], $this->getOptions($query)));
$sandbox->disableSandbox();
$pdfOptions = array_merge($context->getOptions(), $this->getPdfOptions());
$content = $this->converter->convertToPdf($content, $pdfOptions);

View File

@@ -11,12 +11,16 @@ namespace App\Export\Package\CellFormatter;
final class ArrayFormatter implements CellFormatterInterface
{
/**
* @param mixed|array<int, string> $value
* @return mixed
*/
public function formatValue(mixed $value): mixed
{
if (!\is_array($value)) {
throw new \InvalidArgumentException('Only arrays are supported');
}
return implode(', ', $value);
return implode(', ', $value); // @phpstan-ignore argument.type
}
}

View File

@@ -11,6 +11,7 @@ namespace App\Export\Package;
use App\Constants;
use OpenSpout\Common\Entity\Cell;
use OpenSpout\Common\Entity\Cell\StringCell;
use OpenSpout\Common\Entity\Row;
use OpenSpout\Common\Entity\Style\Border;
use OpenSpout\Common\Entity\Style\BorderPart;
@@ -98,7 +99,8 @@ class SpoutSpreadsheet implements SpreadsheetPackage
$style->setShouldWrapText(false);
$style->setShouldShrinkToFit(true);
if (\array_key_exists('totals', $options) && $options['totals'] === true) {
$isTotalsRow = \array_key_exists('totals', $options) && $options['totals'] === true;
if ($isTotalsRow) {
if ($this->writer instanceof CSVWriter) {
return;
}
@@ -109,7 +111,11 @@ class SpoutSpreadsheet implements SpreadsheetPackage
$tmp = [];
$i = 0;
foreach ($columns as $column) {
$tmp[] = Cell::fromValue($column, $this->styles[$i++]); // @phpstan-ignore argument.type
if (!$isTotalsRow && \is_string($column)) {
$tmp[] = new StringCell($column, $style);
} else {
$tmp[] = Cell::fromValue($column, $this->styles[$i++]); // @phpstan-ignore argument.type
}
}
$this->writer->addRow(new Row($tmp, $style));

View File

@@ -35,7 +35,7 @@ final class CsvRendererFactory
public function createDefault(): CsvRenderer
{
$template = new DefaultTemplate($this->eventDispatcher, 'csv');
$template = new DefaultTemplate($this->eventDispatcher, 'csv', 'en', 'csv');
return new CsvRenderer($this->converter, $this->translator, $template);
}

View File

@@ -25,7 +25,7 @@ final class HtmlRendererFactory
) {
}
public function create(string $id, string $template): HtmlRenderer
public function create(string $id, string $template, string $title = 'print'): HtmlRenderer
{
return new HtmlRenderer(
$this->twig,
@@ -33,7 +33,7 @@ final class HtmlRendererFactory
$this->projectStatisticService,
$this->activityStatisticService,
$id,
'print',
$title,
$template
);
}

View File

@@ -27,7 +27,7 @@ final class XlsxRendererFactory
public function createDefault(): XlsxRenderer
{
$template = new DefaultTemplate($this->eventDispatcher, 'xlsx');
$template = new DefaultTemplate($this->eventDispatcher, 'xlsx', 'en', 'xlsx');
return new XlsxRenderer($this->converter, $this->translator, $template);
}

View File

@@ -10,6 +10,7 @@
namespace App\Export;
use App\Entity\ExportableItem;
use App\Entity\ExportTemplate;
use App\Event\ExportItemsQueryEvent;
use App\Export\Renderer\CsvRendererFactory;
use App\Export\Renderer\HtmlRendererFactory;
@@ -74,6 +75,15 @@ final class ServiceExport
$this->renderer[] = $renderer;
}
private function filenameToTitle(string $title): string
{
if (str_contains($title, '.')) {
$title = explode('.', $title)[0];
}
return str_replace(['-', '_'], ' ', $title);
}
/**
* @return ExportRendererInterface[]
*/
@@ -82,32 +92,15 @@ final class ServiceExport
$renderer = [
$this->csvRendererFactory->createDefault(),
$this->xlsxRendererFactory->createDefault(),
$this->pdfRendererFactory->create('pdf', 'export/pdf-layout.html.twig', 'default'),
$this->htmlRendererFactory->create('html', 'export/print.html.twig'),
$this->pdfRendererFactory->create('pdf', 'export/pdf-layout.html.twig', 'pdf'),
$this->htmlRendererFactory->create('print', 'export/print.html.twig'),
];
foreach ($this->exportTemplateRepository->findAll() as $template) {
$tpl = new Template((string) $template->getId(), $template->getTitle()); // @phpstan-ignore argument.type
$tpl->setColumns($template->getColumns());
$tpl->setLocale($template->getLanguage());
$tpl->setOptions($template->getOptions());
switch ($template->getRenderer()) {
case 'csv':
$renderer[] = $this->csvRendererFactory->create($tpl);
break;
case 'xlsx':
$renderer[] = $this->xlsxRendererFactory->create($tpl);
break;
case 'pdf':
$renderer[] = $this->pdfRendererFactory->createFromTemplate($tpl);
break;
default:
$this->logger->error('Unknown export template type: ' . $template->getRenderer());
break;
try {
$renderer[] = $this->createTemplateFromExportTemplate($template);
} catch (\Exception $exception) {
$this->logger->error('Unknown export template type: ' . $template->getRenderer());
}
}
@@ -124,7 +117,7 @@ final class ServiceExport
continue;
}
$renderer[] = $this->htmlRendererFactory->create($tplName, '@export/' . $tplName);
$renderer[] = $this->htmlRendererFactory->create($tplName, '@export/' . $tplName, $this->filenameToTitle($tplName));
}
}
@@ -136,7 +129,7 @@ final class ServiceExport
continue;
}
$renderer[] = $this->pdfRendererFactory->create($tplName, '@export/' . $tplName);
$renderer[] = $this->pdfRendererFactory->create($tplName, '@export/' . $tplName, $this->filenameToTitle($tplName));
}
}
}
@@ -144,6 +137,28 @@ final class ServiceExport
return array_merge($this->renderer, $renderer);
}
private function createTemplateFromExportTemplate(ExportTemplate $template): ExportRendererInterface
{
$tpl = new Template((string) $template->getId(), $template->getTitle()); // @phpstan-ignore argument.type
$tpl->setColumns($template->getColumns());
$tpl->setLocale($template->getLanguage());
$tpl->setOptions($template->getOptions());
switch ($template->getRenderer()) {
case 'csv':
return $this->csvRendererFactory->create($tpl);
case 'xlsx':
return $this->xlsxRendererFactory->create($tpl);
case 'pdf':
return $this->pdfRendererFactory->createFromTemplate($tpl);
default:
throw new \Exception('Unknown export template type: ' . $template->getRenderer());
}
}
public function getRendererById(string $id): ?ExportRendererInterface
{
foreach ($this->getRenderer() as $renderer) {
@@ -165,6 +180,7 @@ final class ServiceExport
*/
public function getTimesheetExporter(): array
{
// TODO 3.0 cache the result, as this is one extra database query on the timesheet pages
$exporter = [
$this->pdfRendererFactory->create('pdf', '@export/timesheet.pdf.twig'),
$this->xlsxRendererFactory->createDefault(),
@@ -172,6 +188,17 @@ final class ServiceExport
$this->htmlRendererFactory->create('print', 'timesheet/export.html.twig'),
];
foreach ($this->exportTemplateRepository->findAll() as $template) {
if (!$template->isAvailableForAll()) {
continue;
}
try {
$exporter[] = $this->createTemplateFromExportTemplate($template);
} catch (\Exception $exception) {
$this->logger->error('Unknown export template type: ' . $template->getRenderer());
}
}
return array_merge($this->timesheetExporter, $exporter);
}

View File

@@ -26,6 +26,6 @@ final class ArrayFormatter implements CellFormatterInterface
throw new \InvalidArgumentException('Unsupported value given, only array is supported');
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), implode(';', $value));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), implode(';', $value)); // @phpstan-ignore argument.type
}
}

View File

@@ -15,6 +15,7 @@ use App\Form\Type\ExportRendererType;
use App\Form\Type\ExportSummaryColumnsType;
use App\Form\Type\LanguageType;
use App\Form\Type\PdfFontType;
use App\Form\Type\YesNoType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
@@ -22,6 +23,9 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
/**
* TODO rename with 3.0 to ExportTemplateForm
*/
class ExportTemplateSpreadsheetForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -77,6 +81,11 @@ class ExportTemplateSpreadsheetForm extends AbstractType
'row_attr' => ['data-type' => 'pdf'],
'required' => false,
]);
$builder->add('availableForAll', YesNoType::class, [
'label' => 'user_access_all',
'required' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void

View File

@@ -36,6 +36,7 @@ final class SelectWithApiDataExtension extends AbstractTypeExtension
return;
}
/** @var array{create: string, select: bool, route: string, route_params: array<string, string>, empty_route_params: array<string, string>, reload: string} $apiData */
$apiData = $options['api_data'];
if (!\is_array($apiData)) {

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