Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d456cd3ce2 | ||
|
|
bad92d7215 | ||
|
|
16703081cd | ||
|
|
cbdf91f316 | ||
|
|
999d820d4c | ||
|
|
fe4185ae45 | ||
|
|
cad9f58703 | ||
|
|
70b4fbcae8 | ||
|
|
a0601c8cb2 | ||
|
|
5b320bf2ea | ||
|
|
15110f78d8 | ||
|
|
4154de6bd1 | ||
|
|
8094fcf5b5 | ||
|
|
77afa207e0 | ||
|
|
d9ef6dfcad | ||
|
|
ff6918fcab | ||
|
|
f376b5c8a1 | ||
|
|
b00fbd2516 | ||
|
|
d97abc3f57 | ||
|
|
3f184e42c8 | ||
|
|
4a31411d69 | ||
|
|
3925eacf9f | ||
|
|
394f377890 | ||
|
|
e8b0dc4969 |
13
.github/workflows/docker.yaml
vendored
13
.github/workflows/docker.yaml
vendored
@@ -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
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
push: true
|
||||
|
||||
- name: Apache image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -73,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
|
||||
|
||||
2
.github/workflows/lock-threads.yaml
vendored
2
.github/workflows/lock-threads.yaml
vendored
@@ -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 }}
|
||||
|
||||
8
.github/workflows/release-drafter.yaml
vendored
8
.github/workflows/release-drafter.yaml
vendored
@@ -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 }}
|
||||
|
||||
6
.github/workflows/testing.yaml
vendored
6
.github/workflows/testing.yaml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/website.yaml
vendored
2
.github/workflows/website.yaml
vendored
@@ -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
|
||||
|
||||
13
README.md
13
README.md
@@ -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/),
|
||||
|
||||
@@ -96,6 +96,7 @@ export default class KimaiPlugin {
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use the plugin directly
|
||||
* @param {string} title
|
||||
* @returns {string}
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -74,9 +74,6 @@ export default class KimaiAPILink extends KimaiPlugin {
|
||||
const successHandle = () => {
|
||||
EVENTS.trigger(eventName);
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
if (attributes['msgSuccess'] !== undefined) {
|
||||
ALERT.success(attributes['msgSuccess']);
|
||||
}
|
||||
};
|
||||
const errorHandle = (error) => {
|
||||
let message = 'action.update.error';
|
||||
|
||||
@@ -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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,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();
|
||||
}
|
||||
@@ -663,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>
|
||||
@@ -672,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>`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@import "variables";
|
||||
@import "layout";
|
||||
@import "error-page";
|
||||
@import "print";
|
||||
|
||||
33
assets/sass/variables.scss
Normal file
33
assets/sass/variables.scss
Normal 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); };
|
||||
1105
composer.lock
generated
1105
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"bootstrap": "^5.3",
|
||||
"chart.js": "^4",
|
||||
"core-js": "^3",
|
||||
"dompurify": "^3",
|
||||
"eslint": "^9",
|
||||
"globals": "^15",
|
||||
"gridstack": "^7",
|
||||
|
||||
13
phpstan.neon
13
phpstan.neon
@@ -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
|
||||
@@ -2786,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
|
||||
|
||||
-
|
||||
@@ -3799,11 +3795,6 @@ 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
|
||||
|
||||
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
2
public/build/app.f0f8091d.js
Normal file
2
public/build/app.f0f8091d.js
Normal file
File diff suppressed because one or more lines are too long
@@ -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
@@ -3,10 +3,10 @@
|
||||
"app": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/app.41748d55.js"
|
||||
"/build/app.f0f8091d.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app.e64b4d08.css"
|
||||
"/build/app.99ea4166.css"
|
||||
]
|
||||
},
|
||||
"app-rtl": {
|
||||
@@ -15,7 +15,7 @@
|
||||
"/build/app-rtl.15853b82.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app-rtl.6b5a0ee9.css"
|
||||
"/build/app-rtl.16262d9a.css"
|
||||
]
|
||||
},
|
||||
"export-pdf": {
|
||||
@@ -54,7 +54,7 @@
|
||||
"calendar": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/calendar.36a92145.js"
|
||||
"/build/calendar.13247e65.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/calendar.d757753e.css"
|
||||
@@ -81,10 +81,10 @@
|
||||
},
|
||||
"integrity": {
|
||||
"/build/runtime.684e9f6d.js": "sha384-suKiEX2de4fdNqQzdYbUd6osp4AepD9FiMXl+1QdvgMW9dcQqUWQNQasf3KWzwLr",
|
||||
"/build/app.41748d55.js": "sha384-iVPHnOXYjVDuq8567h8I3U2gl5SCV3mNMFNQCJdgMiNuFMZ4KO7PXOLanjgcGzTt",
|
||||
"/build/app.e64b4d08.css": "sha384-Rapl6RNn58g7oayglp7MWOER7tDNPpChx6RKOOWe0mg6uFgaJjy3M51ZSkxXm+Hg",
|
||||
"/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.6b5a0ee9.css": "sha384-eW8BEaA6S1jAFPB8lUCBTvcm4wZZQ3SHbiHVZlxe814TXGT9L4/4BN8CuuGehZ6o",
|
||||
"/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.42b319e4.js": "sha384-xxK7sCe/ZhTjMPFPeX1xvILURxNRZz2hJHZxAGVaw9zE6TC++2/6y2eKqg8cz852",
|
||||
@@ -92,7 +92,7 @@
|
||||
"/build/invoice-pdf.26d98626.js": "sha384-gwNzQiU1y6qU/M9DPGiNW0MVZkLctEHk37sCES2X9ov+zugEaDABdkMjKBYOC9lz",
|
||||
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
|
||||
"/build/chart.56f16a68.js": "sha384-SWnYjAbZ8OWEvTP+IZfGuBMWJIcH9OZWLYQ4p38KMPmqfufsc2zhzqWkALO3mCBO",
|
||||
"/build/calendar.36a92145.js": "sha384-ZPLxxbep6OnPeUiIeBXRE1iscZFgbNuTpn3rx2Jpf1KR2l6sxIuNsieDUskbmQ3m",
|
||||
"/build/calendar.13247e65.js": "sha384-8b3wBuxn8m2FsxLMtbIpZTg1SGBX9b+0dYRTnF63mGQfcXhRGHKtUs5REwVyhYiP",
|
||||
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ",
|
||||
"/build/dashboard.9708ae5e.js": "sha384-QN7XIQuxFZu76sHVrgdpZL81+Q2VTwcgF3aI2CnpwYZiMoPbzrcTGfPL3RDy696t",
|
||||
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"build/app.css": "/build/app.e64b4d08.css",
|
||||
"build/app.js": "/build/app.41748d55.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.6b5a0ee9.css",
|
||||
"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.395749ab.js",
|
||||
@@ -11,7 +11,7 @@
|
||||
"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.36a92145.js",
|
||||
"build/calendar.js": "/build/calendar.13247e65.js",
|
||||
"build/dashboard.css": "/build/dashboard.b7129fa1.css",
|
||||
"build/dashboard.js": "/build/dashboard.9708ae5e.js",
|
||||
"build/highlight.css": "/build/highlight.98bf3927.css",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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+'])]
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,4 +46,7 @@ interface SamlConfigurationInterface
|
||||
public function isRolesResetOnLogin(): bool;
|
||||
|
||||
public function getConnection(): array;
|
||||
|
||||
// TODO 3.0 activate me
|
||||
//public function cleanupLongRelayState(): bool;
|
||||
}
|
||||
|
||||
@@ -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] = [];
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ final class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.47.0';
|
||||
public const VERSION = '2.54.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 24700;
|
||||
public const VERSION_ID = 25400;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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')]
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
25
src/Event/TimesheetStatisticsQueryEvent.php
Normal file
25
src/Event/TimesheetStatisticsQueryEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -19,9 +19,9 @@ final class ExportRendererType extends AbstractType
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'choices' => [
|
||||
'button.csv' => 'csv',
|
||||
'button.xlsx' => 'xlsx',
|
||||
'button.pdf' => 'pdf'
|
||||
'csv' => 'csv',
|
||||
'xlsx' => 'xlsx',
|
||||
'pdf' => 'pdf'
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ namespace App\Form\Type;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Email;
|
||||
|
||||
final class MailType extends AbstractType
|
||||
{
|
||||
@@ -20,9 +19,7 @@ final class MailType extends AbstractType
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'label' => 'email',
|
||||
'constraints' => [
|
||||
new Email(['mode' => 'html5'])
|
||||
],
|
||||
// no constraint by default, as the form or used entities should add that
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ final class QuickEntryTimesheetType extends AbstractType
|
||||
$duration = $data->getDuration(false);
|
||||
try {
|
||||
if (null !== $duration) {
|
||||
$duration += $data->getBreak();
|
||||
$end = clone $data->getBegin();
|
||||
$end->modify('+ ' . abs($duration) . ' seconds');
|
||||
$data->setEnd($end);
|
||||
|
||||
@@ -33,8 +33,9 @@ final class TeamType extends AbstractType
|
||||
return $team->getName();
|
||||
},
|
||||
'documentation' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Team ID',
|
||||
'type' => 'array',
|
||||
'items' => ['type' => 'integer', 'description' => 'Team IDs'],
|
||||
'description' => 'Array of Team IDs',
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
@@ -25,9 +25,17 @@ final class WeekDaysType extends AbstractType
|
||||
{
|
||||
$builder->addModelTransformer(new CallbackTransformer(
|
||||
function ($weekdays): array {
|
||||
if ($weekdays === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return explode(',', $weekdays);
|
||||
},
|
||||
function ($weekdays): string {
|
||||
if ($weekdays === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return implode(',', $weekdays);
|
||||
}
|
||||
));
|
||||
|
||||
@@ -77,7 +77,7 @@ class UserCreateType extends UserEditType
|
||||
parent::configureOptions($resolver);
|
||||
|
||||
$resolver->setDefaults([
|
||||
'validation_groups' => ['UserCreate', 'Registration'],
|
||||
'validation_groups' => ['UserCreate', 'Registration', 'Default'],
|
||||
'include_roles' => false,
|
||||
'include_teams' => false,
|
||||
]);
|
||||
|
||||
@@ -31,7 +31,7 @@ class UserEditType extends AbstractType
|
||||
{
|
||||
use ColorTrait;
|
||||
|
||||
public function __construct(private SystemConfiguration $configuration)
|
||||
public function __construct(private readonly SystemConfiguration $configuration)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ class UserEditType extends AbstractType
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'validation_groups' => ['Profile'],
|
||||
'validation_groups' => ['Profile', 'Default'],
|
||||
'data_class' => User::class,
|
||||
'csrf_protection' => true,
|
||||
'csrf_field_name' => '_token',
|
||||
|
||||
@@ -67,10 +67,6 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
|
||||
continue;
|
||||
}
|
||||
$replacer = null;
|
||||
$firstReplacerPos = stripos($value, '${');
|
||||
if ($firstReplacerPos === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stripos($value, '${entry.') !== false) {
|
||||
if ($sheetValues === false && isset($entries[$entryRow])) {
|
||||
@@ -94,13 +90,14 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
|
||||
if (stripos($value, $searchKey) === false) {
|
||||
continue;
|
||||
}
|
||||
if (\is_string($content) && str_starts_with($content, '=')) {
|
||||
// we ONLY check if the given replacer content contains a formula character
|
||||
if (\is_string($content) && \in_array($content[0], ['=', '-', '+', '@', "\t", "\r"])) {
|
||||
$contentLooksLikeFormula = true;
|
||||
}
|
||||
$value = str_replace($searchKey, $content ?? '', $value);
|
||||
}
|
||||
|
||||
if ($contentLooksLikeFormula && $firstReplacerPos === 0) {
|
||||
if ($contentLooksLikeFormula) {
|
||||
// see https://github.com/kimai/kimai/pull/2054
|
||||
$cell->setValueExplicit($value, DataType::TYPE_STRING);
|
||||
} else {
|
||||
|
||||
@@ -37,7 +37,7 @@ use Doctrine\ORM\QueryBuilder;
|
||||
class ActivityRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* @param int[] $activityIds
|
||||
* @param array<int, string|int> $activityIds
|
||||
* @return array<Activity>
|
||||
*/
|
||||
public function findByIds(array $activityIds): array
|
||||
|
||||
@@ -11,10 +11,14 @@ namespace App\Repository;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
|
||||
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* @deprecated since 2.54 - see https://www.kimai.org/en/blog/2026/removing-api-passwords
|
||||
*/
|
||||
class ApiUserRepository implements UserLoaderInterface, PasswordUpgraderInterface
|
||||
{
|
||||
public function __construct(private readonly UserRepository $userRepository)
|
||||
@@ -23,7 +27,11 @@ class ApiUserRepository implements UserLoaderInterface, PasswordUpgraderInterfac
|
||||
|
||||
public function loadUserByIdentifier(string $identifier): ?UserInterface
|
||||
{
|
||||
return $this->userRepository->loadUserByIdentifier($identifier);
|
||||
try {
|
||||
return $this->userRepository->loadUserByIdentifier($identifier);
|
||||
} catch (UserNotFoundException $ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function upgradePassword(PasswordAuthenticatedUserInterface|UserInterface $user, string $newHashedPassword): void
|
||||
|
||||
@@ -37,7 +37,7 @@ use Doctrine\ORM\QueryBuilder;
|
||||
class CustomerRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* @param int[] $customerIDs
|
||||
* @param array<int, string|int> $customerIDs
|
||||
* @return array<Customer>
|
||||
*/
|
||||
public function findByIds(array $customerIDs): array
|
||||
|
||||
@@ -40,7 +40,7 @@ use Doctrine\ORM\QueryBuilder;
|
||||
class ProjectRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* @param int[] $projectIds
|
||||
* @param array<int, string|int> $projectIds
|
||||
* @return array<Project>
|
||||
*/
|
||||
public function findByIds(array $projectIds): array
|
||||
|
||||
@@ -631,11 +631,13 @@ class TimesheetRepository extends EntityRepository
|
||||
|
||||
if ($query->hasProjects()) {
|
||||
$qb->andWhere($qb->expr()->in('t.project', ':project'))
|
||||
->setParameter('project', $query->getProjectIds());
|
||||
} elseif ($query->hasCustomers()) {
|
||||
->setParameter('project', $query->getProjects());
|
||||
}
|
||||
|
||||
if ($query->hasCustomers()) {
|
||||
$requiresCustomer = true;
|
||||
$qb->andWhere($qb->expr()->in('p.customer', ':customer'))
|
||||
->setParameter('customer', $query->getCustomerIds());
|
||||
->setParameter('customer', $query->getCustomers());
|
||||
}
|
||||
|
||||
$tags = $query->getTags();
|
||||
|
||||
@@ -150,7 +150,7 @@ final class SamlProvider
|
||||
}
|
||||
|
||||
if (!empty($results)) {
|
||||
return implode(' ', $results);
|
||||
return implode(' ', $results); // @phpstan-ignore argument.type
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -24,11 +24,38 @@ final class SamlAuthenticationSuccessHandler extends DefaultAuthenticationSucces
|
||||
|
||||
protected function determineTargetUrl(Request $request): string
|
||||
{
|
||||
// see https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf
|
||||
// if using the Deflate encoding, RelayState will be submitted using the query
|
||||
$relayState = $request->request->get('RelayState', $request->query->get('RelayState'));
|
||||
if (\is_scalar($relayState)) {
|
||||
$relayState = (string) $relayState;
|
||||
if ($relayState !== $this->httpUtils->generateUri($request, (string) $this->options['login_path'])) {
|
||||
return $relayState;
|
||||
|
||||
if (\is_string($relayState)) {
|
||||
$values = parse_url($relayState);
|
||||
|
||||
// we use only the path part of the URL to prevent external redirects
|
||||
$path = null;
|
||||
if (\is_array($values) && \array_key_exists('path', $values)) {
|
||||
$path = $values['path'];
|
||||
}
|
||||
|
||||
if (\is_string($path)
|
||||
&& $path !== ''
|
||||
&& str_starts_with($path, '/')
|
||||
&& !str_starts_with($path, '//')
|
||||
&& !str_contains($path, '\\')
|
||||
) {
|
||||
$target = $this->httpUtils->generateUri($request, $path);
|
||||
$loginUrl = $this->httpUtils->generateUri($request, (string) $this->options['login_path']);
|
||||
|
||||
if (\array_key_exists('scheme', $values) && str_starts_with($values['scheme'], 'http')) {
|
||||
if (\array_key_exists('host', $values) && !str_starts_with($target, $values['scheme'] . '://' . $values['host'])) {
|
||||
$target = null;
|
||||
}
|
||||
}
|
||||
|
||||
// make sure that the login URL is not the target, which would be an endless loop for the user
|
||||
if ($target !== null && $target !== $loginUrl) {
|
||||
return $target;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,15 +10,19 @@
|
||||
namespace App\Timesheet;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Event\TimesheetStatisticsQueryEvent;
|
||||
use App\Model\DailyStatistic;
|
||||
use App\Model\MonthlyStatistic;
|
||||
use App\Repository\Query\TimesheetStatisticQuery;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use DateTimeInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
final class TimesheetStatisticService
|
||||
{
|
||||
public function __construct(private readonly TimesheetRepository $repository)
|
||||
public function __construct(
|
||||
private readonly TimesheetRepository $repository,
|
||||
private readonly EventDispatcherInterface $eventDispatcher
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -72,6 +76,8 @@ final class TimesheetStatisticService
|
||||
;
|
||||
}
|
||||
|
||||
$this->eventDispatcher->dispatch(new TimesheetStatisticsQueryEvent($qb));
|
||||
|
||||
$results = $qb->getQuery()->getResult();
|
||||
|
||||
foreach ($results as $row) {
|
||||
@@ -96,13 +102,14 @@ final class TimesheetStatisticService
|
||||
|
||||
/**
|
||||
* @internal only for core development
|
||||
* @param DateTimeInterface $begin
|
||||
* @param DateTimeInterface $end
|
||||
* @param User[] $users
|
||||
* @return array
|
||||
*/
|
||||
public function getDailyStatisticsGrouped(DateTimeInterface $begin, DateTimeInterface $end, array $users): array
|
||||
public function getDailyStatisticsGrouped(TimesheetStatisticQuery $query): array
|
||||
{
|
||||
$begin = $query->getBegin();
|
||||
$end = $query->getEnd();
|
||||
$users = $query->getUsers();
|
||||
|
||||
$stats = [];
|
||||
$usersById = [];
|
||||
|
||||
@@ -137,6 +144,8 @@ final class TimesheetStatisticService
|
||||
->addGroupBy('billable')
|
||||
;
|
||||
|
||||
$this->eventDispatcher->dispatch(new TimesheetStatisticsQueryEvent($qb));
|
||||
|
||||
$results = $qb->getQuery()->getResult();
|
||||
|
||||
foreach ($results as $row) {
|
||||
@@ -173,11 +182,14 @@ final class TimesheetStatisticService
|
||||
|
||||
/**
|
||||
* @internal only for core development
|
||||
* @param User[] $users
|
||||
* @return array
|
||||
*/
|
||||
public function getMonthlyStatisticsGrouped(DateTimeInterface $begin, DateTimeInterface $end, array $users): array
|
||||
public function getMonthlyStatisticsGrouped(TimesheetStatisticQuery $query): array
|
||||
{
|
||||
$begin = $query->getBegin();
|
||||
$end = $query->getEnd();
|
||||
$users = $query->getUsers();
|
||||
|
||||
$stats = [];
|
||||
$usersById = [];
|
||||
|
||||
@@ -214,6 +226,8 @@ final class TimesheetStatisticService
|
||||
->addGroupBy('billable')
|
||||
;
|
||||
|
||||
$this->eventDispatcher->dispatch(new TimesheetStatisticsQueryEvent($qb));
|
||||
|
||||
$results = $qb->getQuery()->getResult();
|
||||
|
||||
foreach ($results as $row) {
|
||||
@@ -311,6 +325,8 @@ final class TimesheetStatisticService
|
||||
;
|
||||
}
|
||||
|
||||
$this->eventDispatcher->dispatch(new TimesheetStatisticsQueryEvent($qb));
|
||||
|
||||
$results = $qb->getQuery()->getResult();
|
||||
|
||||
foreach ($results as $row) {
|
||||
|
||||
58
src/Twig/ContractExtensions.php
Normal file
58
src/Twig/ContractExtensions.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?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\Twig;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\WorkingTime\Calculator\WorkingTimeCalculator;
|
||||
use App\WorkingTime\WorkingTimeService;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigTest;
|
||||
|
||||
final class ContractExtensions extends AbstractExtension
|
||||
{
|
||||
/** @var array<string, WorkingTimeCalculator> */
|
||||
private array $calculators = [];
|
||||
|
||||
public function __construct(private readonly WorkingTimeService $workingTimeService)
|
||||
{
|
||||
}
|
||||
|
||||
public function getTests(): array
|
||||
{
|
||||
return [
|
||||
/* @var array{user: User, date: \DateTimeInterface} $values */
|
||||
new TwigTest('work_day', function (array $values): bool {
|
||||
// TODO remove me in 3.0, deprecate with 2.55
|
||||
if (!\array_key_exists('user', $values) || !\array_key_exists('date', $values)) {
|
||||
throw new \Exception('Missing variable "user" or "date" to check for "is work_day');
|
||||
}
|
||||
|
||||
return $this->isWorkingDay($values['date'], $values['user']);
|
||||
}),
|
||||
new TwigTest('working_day', function (\DateTimeInterface $date, User $user): bool {
|
||||
return $this->isWorkingDay($date, $user);
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
private function isWorkingDay(\DateTimeInterface $date, User $user): bool
|
||||
{
|
||||
if ($user->getId() === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$id = 'user_' . $user->getId();
|
||||
if (!\array_key_exists($id, $this->calculators)) {
|
||||
$this->calculators[$id] = $this->workingTimeService->getContractMode($user)->getCalculator($user);
|
||||
}
|
||||
|
||||
return $this->calculators[$id]->isWorkDay($date);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
|
||||
// cannot be deleted right now, needs to be kept for invoice and export templates
|
||||
new TwigFilter('date_full', [$this, 'dateTime'], ['deprecation_info' => new DeprecatedCallableInfo('Kimai', '2.0', 'date_time')]),
|
||||
new TwigFilter('date_format', [$this, 'dateFormat']),
|
||||
new TwigFilter('date_weekday', [$this, 'dateWeekday']),
|
||||
new TwigFilter('date_weekday', [$this, 'dateWeekday'], ['is_safe' => ['html']]),
|
||||
new TwigFilter('time', [$this, 'time']),
|
||||
new TwigFilter('duration', [$this, 'duration']),
|
||||
new TwigFilter('chart_duration', [$this, 'durationChart']),
|
||||
@@ -144,9 +144,9 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
|
||||
return (string) $this->getFormatter()->dateFormat($date, $format);
|
||||
}
|
||||
|
||||
public function dateWeekday(\DateTimeInterface $date): string
|
||||
public function dateWeekday(\DateTimeInterface $date, string $spacer = ' '): string
|
||||
{
|
||||
return $this->dayName($date, true) . ' ' . $this->getFormatter()->dateFormat($date, 'd');
|
||||
return $this->dayName($date, true) . $spacer . $this->getFormatter()->dateFormat($date, 'd');
|
||||
}
|
||||
|
||||
public function time(\DateTimeInterface|string|null $date): string
|
||||
|
||||
42
src/Twig/SecurityPolicy/DefaultPolicy.php
Normal file
42
src/Twig/SecurityPolicy/DefaultPolicy.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?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\Twig\SecurityPolicy;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
|
||||
/**
|
||||
* @deprecated since 2.47.0 - use StrictPolicy instead
|
||||
*/
|
||||
#[Exclude]
|
||||
final class DefaultPolicy implements SecurityPolicyInterface
|
||||
{
|
||||
private SecurityPolicyInterface $securityPolicy;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->securityPolicy = new StrictPolicy();
|
||||
}
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
$this->securityPolicy->checkSecurity($tags, $filters, $functions);
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
$this->securityPolicy->checkMethodAllowed($obj, $method);
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
$this->securityPolicy->checkPropertyAllowed($obj, $property);
|
||||
}
|
||||
}
|
||||
42
src/Twig/SecurityPolicy/ExportPolicy.php
Normal file
42
src/Twig/SecurityPolicy/ExportPolicy.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?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\Twig\SecurityPolicy;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
|
||||
/**
|
||||
* @deprecated since 2.47.0 - use StrictPolicy instead
|
||||
*/
|
||||
#[Exclude]
|
||||
final class ExportPolicy implements SecurityPolicyInterface
|
||||
{
|
||||
private SecurityPolicyInterface $securityPolicy;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->securityPolicy = new StrictPolicy();
|
||||
}
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
$this->securityPolicy->checkSecurity($tags, $filters, $functions);
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
$this->securityPolicy->checkMethodAllowed($obj, $method);
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
$this->securityPolicy->checkPropertyAllowed($obj, $property);
|
||||
}
|
||||
}
|
||||
42
src/Twig/SecurityPolicy/InvoicePolicy.php
Normal file
42
src/Twig/SecurityPolicy/InvoicePolicy.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?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\Twig\SecurityPolicy;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
|
||||
/**
|
||||
* @deprecated since 2.47.0 - use StrictPolicy instead
|
||||
*/
|
||||
#[Exclude]
|
||||
final class InvoicePolicy implements SecurityPolicyInterface
|
||||
{
|
||||
private SecurityPolicyInterface $securityPolicy;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->securityPolicy = new StrictPolicy();
|
||||
}
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
$this->securityPolicy->checkSecurity($tags, $filters, $functions);
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
$this->securityPolicy->checkMethodAllowed($obj, $method);
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
$this->securityPolicy->checkPropertyAllowed($obj, $property);
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,12 @@ final class StrictPolicy implements SecurityPolicyInterface
|
||||
}
|
||||
|
||||
if ($obj instanceof User) {
|
||||
if (\in_array($lcm, ['getpassword', 'gettotpsecret', 'getplainpassword', 'getconfirmationtoken', 'gettotpauthenticationconfiguration'], true)) {
|
||||
if (str_contains($lcm, 'password')
|
||||
|| str_contains($lcm, 'totp')
|
||||
|| str_contains($lcm, 'api')
|
||||
|| str_contains($lcm, 'secret')
|
||||
|| str_contains($lcm, 'token')
|
||||
) {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access user secrets', User::class, $method);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,15 +21,17 @@ class Parsedown extends \Parsedown
|
||||
{
|
||||
$block = parent::blockHeader($Line);
|
||||
|
||||
$text = $block['element']['text'];
|
||||
if (isset($block['element']['handler']['argument'])) {
|
||||
$text = $block['element']['handler']['argument'];
|
||||
|
||||
if (\is_string($text) && $text !== '') {
|
||||
$id = $this->getIDfromText($text);
|
||||
if (\is_string($text) && $text !== '') {
|
||||
$id = $this->getIDfromText($text);
|
||||
|
||||
// add id-attribute
|
||||
$block['element']['attributes'] = [
|
||||
'id' => $id
|
||||
];
|
||||
// add id-attribute
|
||||
$block['element']['attributes'] = [
|
||||
'id' => $id
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $block;
|
||||
|
||||
@@ -44,55 +44,17 @@ final class ParsedownExtension extends Parsedown
|
||||
];
|
||||
|
||||
/**
|
||||
* Overwritten to add support for file:///
|
||||
*/
|
||||
protected $safeLinksWhitelist = [
|
||||
'file:///',
|
||||
'http://',
|
||||
'https://',
|
||||
'ftp://',
|
||||
'ftps://',
|
||||
'mailto:',
|
||||
'data:image/png;base64,',
|
||||
'data:image/gif;base64,',
|
||||
'data:image/jpeg;base64,',
|
||||
'irc:',
|
||||
'ircs:',
|
||||
'git:',
|
||||
'ssh:',
|
||||
'news:',
|
||||
'steam:',
|
||||
];
|
||||
|
||||
/**
|
||||
* Overwritten:
|
||||
* - added support for file:///
|
||||
* - open links in new windows
|
||||
* Overwritten to open links in new windows
|
||||
*/
|
||||
protected function inlineUrl($Excerpt): ?array
|
||||
{
|
||||
if ($this->urlsLinked !== true or !isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') {
|
||||
return null;
|
||||
$block = parent::inlineUrl($Excerpt);
|
||||
|
||||
if (isset($block['element']['attributes']) && \is_array($block['element']['attributes'])) {
|
||||
$block['element']['attributes']['target'] = '_blank';
|
||||
}
|
||||
|
||||
if (preg_match('/\b(https?:[\/]{2}|file:[\/]{3})[^\s<]+\b\/*/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE)) {
|
||||
$url = $matches[0][0];
|
||||
|
||||
return [
|
||||
'extent' => \strlen($matches[0][0]),
|
||||
'position' => $matches[0][1],
|
||||
'element' => [
|
||||
'name' => 'a',
|
||||
'text' => $url,
|
||||
'attributes' => [
|
||||
'href' => $url,
|
||||
'target' => '_blank'
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
return $block;
|
||||
}
|
||||
|
||||
protected function blockTable($Line, ?array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter
|
||||
|
||||
53
src/Validator/Constraints/NoSpecialCharacters.php
Normal file
53
src/Validator/Constraints/NoSpecialCharacters.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?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\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
#[\Attribute(\Attribute::TARGET_PROPERTY)]
|
||||
final class NoSpecialCharacters extends Constraint
|
||||
{
|
||||
public const SPECIAL_CHARACTERS_FOUND = 'kimai-html-character-001';
|
||||
|
||||
protected const ERROR_NAMES = [
|
||||
self::SPECIAL_CHARACTERS_FOUND => 'These characters are not allowed: {{ chars }}',
|
||||
];
|
||||
|
||||
/** @var string[] */
|
||||
public array $characters = [
|
||||
'<', // XSS
|
||||
'>', // XSS
|
||||
'"', // XSS
|
||||
'=', // DDE
|
||||
];
|
||||
|
||||
public string $message = 'These characters are not allowed: {{ chars }}';
|
||||
|
||||
/**
|
||||
* @param string[]|null $character
|
||||
*/
|
||||
public function __construct(
|
||||
mixed $options = null,
|
||||
?string $message = null,
|
||||
?array $character = null,
|
||||
?array $groups = null,
|
||||
mixed $payload = null
|
||||
)
|
||||
{
|
||||
parent::__construct($options, $groups, $payload);
|
||||
$this->message = $message ?? $this->message;
|
||||
$this->characters = $character ?? $this->characters;
|
||||
}
|
||||
|
||||
public function getTargets(): string
|
||||
{
|
||||
return self::PROPERTY_CONSTRAINT;
|
||||
}
|
||||
}
|
||||
43
src/Validator/Constraints/NoSpecialCharactersValidator.php
Normal file
43
src/Validator/Constraints/NoSpecialCharactersValidator.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?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\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
final class NoSpecialCharactersValidator extends ConstraintValidator
|
||||
{
|
||||
public function validate(mixed $value, Constraint $constraint): void
|
||||
{
|
||||
if (!($constraint instanceof NoSpecialCharacters)) {
|
||||
throw new UnexpectedTypeException($constraint, NoSpecialCharacters::class);
|
||||
}
|
||||
|
||||
if (!\is_string($value) || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$found = [];
|
||||
foreach ($constraint->characters as $character) {
|
||||
if (str_contains($value, $character)) {
|
||||
$found[] = $character;
|
||||
}
|
||||
}
|
||||
|
||||
if (\count($found) > 0) {
|
||||
$this->context->buildViolation(NoSpecialCharacters::getErrorName(NoSpecialCharacters::SPECIAL_CHARACTERS_FOUND))
|
||||
->setTranslationDomain('validators')
|
||||
->setParameter('{{ chars }}', implode(' ', $constraint->characters))
|
||||
->setCode(NoSpecialCharacters::SPECIAL_CHARACTERS_FOUND)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
namespace App\Voter;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\User;
|
||||
use App\Security\RolePermissionManager;
|
||||
@@ -33,6 +35,7 @@ final class ActivityVoter extends Voter
|
||||
'time',
|
||||
'delete',
|
||||
'permissions',
|
||||
'access',
|
||||
];
|
||||
|
||||
public function __construct(private readonly RolePermissionManager $permissionManager)
|
||||
@@ -54,6 +57,37 @@ final class ActivityVoter extends Voter
|
||||
return $subject instanceof Activity && $this->supportsAttribute($attribute);
|
||||
}
|
||||
|
||||
private function checkTeamPermission(Activity|Project|Customer $subject, User $user): bool
|
||||
{
|
||||
if ($user->canSeeAllData()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($subject instanceof Activity && $subject->getProject() !== null) {
|
||||
if (!$this->checkTeamPermission($subject->getProject(), $user)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($subject instanceof Project && $subject->getCustomer() !== null) {
|
||||
if (!$this->checkTeamPermission($subject->getCustomer(), $user)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($subject->getTeams()->count() === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($subject->getTeams() as $team) {
|
||||
if ($user->isInTeam($team)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
@@ -62,6 +96,12 @@ final class ActivityVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
// this is a virtual permission, only meant to be used by developer
|
||||
// it checks if access to the given activity is potentially possible
|
||||
if ($attribute === 'access') {
|
||||
return $this->checkTeamPermission($subject, $user);
|
||||
}
|
||||
|
||||
if ($this->permissionManager->hasRolePermission($user, $attribute . '_activity')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Voter;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\User;
|
||||
@@ -35,6 +36,7 @@ final class ProjectVoter extends Voter
|
||||
'permissions',
|
||||
'comments',
|
||||
'details',
|
||||
'access',
|
||||
];
|
||||
|
||||
public function __construct(private readonly RolePermissionManager $permissionManager)
|
||||
@@ -56,6 +58,31 @@ final class ProjectVoter extends Voter
|
||||
return $subject instanceof Project && $this->supportsAttribute($attribute);
|
||||
}
|
||||
|
||||
private function checkTeamPermission(Project|Customer $subject, User $user): bool
|
||||
{
|
||||
if ($user->canSeeAllData()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($subject instanceof Project && $subject->getCustomer() !== null) {
|
||||
if (!$this->checkTeamPermission($subject->getCustomer(), $user)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($subject->getTeams()->count() === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($subject->getTeams() as $team) {
|
||||
if ($user->isInTeam($team)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
@@ -64,6 +91,12 @@ final class ProjectVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
// this is a virtual permission, only meant to be used by developer
|
||||
// it checks if access to the given project is potentially possible
|
||||
if ($attribute === 'access') {
|
||||
return $this->checkTeamPermission($subject, $user);
|
||||
}
|
||||
|
||||
if ($this->permissionManager->hasRolePermission($user, $attribute . '_project')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user