Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef170a8ca7 |
@@ -21,3 +21,7 @@ indent_size = 4
|
||||
[*.yml]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.xml]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
@@ -45,3 +45,7 @@ APP_SECRET=change_this_to_something_unique
|
||||
#================================================================================
|
||||
# unlikely, that you need to change this one
|
||||
CORS_ALLOW_ORIGIN=^https?://localhost(:[0-9]+)?$
|
||||
|
||||
###> symfony/messenger ###
|
||||
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
||||
###< symfony/messenger ###
|
||||
|
||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -5,10 +5,8 @@ tests export-ignore
|
||||
.codecov.yml export-ignore
|
||||
.editorconfig export-ignore
|
||||
eslint.config.js export-ignore
|
||||
eslint.config.mjs export-ignore
|
||||
.gitattributes export-ignore
|
||||
.gitignore export-ignore
|
||||
.php-cs-fixer.dist.php export-ignore
|
||||
php-cs-fixer.dist.php export-ignore
|
||||
babel.config.js export-ignore
|
||||
package.json export-ignore
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -53,7 +53,6 @@ body:
|
||||
attributes:
|
||||
label: Which PHP version are you using?
|
||||
options:
|
||||
- "8.5"
|
||||
- "8.4"
|
||||
- "8.2"
|
||||
- "8.3"
|
||||
|
||||
2
.github/release-drafter.yml
vendored
2
.github/release-drafter.yml
vendored
@@ -22,7 +22,7 @@ version-resolver:
|
||||
- 'translation'
|
||||
default: patch
|
||||
template: |
|
||||
**Compatible with PHP 8.1 to 8.5**
|
||||
**Compatible with PHP 8.1 to 8.4**
|
||||
|
||||
$CHANGES
|
||||
|
||||
|
||||
14
.github/workflows/docker.yaml
vendored
14
.github/workflows/docker.yaml
vendored
@@ -16,13 +16,13 @@ jobs:
|
||||
steps:
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
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@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -60,10 +60,11 @@ jobs:
|
||||
tags: |
|
||||
kimai/kimai2:latest
|
||||
kimai/kimai2:fpm
|
||||
kimai/kimai2:fpm-${{ env.kimai_version }}
|
||||
push: true
|
||||
|
||||
- name: Apache image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -73,13 +74,12 @@ 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@v6
|
||||
uses: docker/build-push-action@v5
|
||||
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@v6
|
||||
- uses: dessant/lock-threads@v5
|
||||
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: read
|
||||
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
|
||||
needs: correct_repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@v7
|
||||
- uses: release-drafter/release-drafter@v6
|
||||
env:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
14
.github/workflows/testing.yaml
vendored
14
.github/workflows/testing.yaml
vendored
@@ -19,13 +19,13 @@ jobs:
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.1', '8.2', '8.3', '8.4', '8.5']
|
||||
php: ['8.1', '8.2', '8.3', '8.4']
|
||||
|
||||
name: Integration (${{ matrix.php }})
|
||||
steps:
|
||||
|
||||
- name: Clone Kimai
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
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@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "${{ env.composer_cache_directory }}"
|
||||
key: ${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
MAILER_URL: null://localhost
|
||||
|
||||
- name: Full test-suite
|
||||
if: matrix.php != '8.5'
|
||||
if: matrix.php != '8.2'
|
||||
run: vendor/bin/phpunit tests/
|
||||
env:
|
||||
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?charset=utf8mb4&serverVersion=8.0.35
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
MAILER_URL: null://localhost
|
||||
|
||||
- name: Full test-suite with coverage
|
||||
if: matrix.php == '8.5'
|
||||
if: matrix.php == '8.2'
|
||||
run: vendor/bin/phpunit tests/ --coverage-clover=coverage.xml
|
||||
env:
|
||||
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?charset=utf8mb4&serverVersion=8.0.35
|
||||
@@ -103,8 +103,8 @@ jobs:
|
||||
MAILER_URL: null://localhost
|
||||
|
||||
- name: Upload code coverage
|
||||
if: matrix.php == '8.5'
|
||||
uses: codecov/codecov-action@v6
|
||||
if: matrix.php == '8.2'
|
||||
uses: codecov/codecov-action@v5
|
||||
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@v4
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.WEBSITE_ACCESS_TOKEN }}
|
||||
repository: kimai/www.kimai.org
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,8 +3,6 @@
|
||||
/.env-*
|
||||
/.idea/
|
||||
.DS_Store
|
||||
var/templates/
|
||||
|
||||
|
||||
# custom apache rules e.g. to deactivate ioncube loader
|
||||
/public/.user.ini
|
||||
@@ -16,7 +14,6 @@ var/templates/
|
||||
# YARN 2
|
||||
/.yarnrc.yml
|
||||
/.yarn
|
||||
/.pnp.*
|
||||
|
||||
# for keeping empty directories
|
||||
/config/packages/local.yaml
|
||||
|
||||
@@ -10,7 +10,7 @@ Send your ideas, code reviews, pull requests and feature requests to help to imp
|
||||
- Make your changes in a new git branch, based on the latest code in `main`
|
||||
- Apply our code-style by running `composer codestyle-fix`
|
||||
- Run the static code analysis with `composer phpstan`
|
||||
- Verify everything still works with `composer tests`
|
||||
- Verify everything still works with `composer tests-unit`
|
||||
- Add tests for your changes
|
||||
|
||||
Further documentation can be found in the [developer documentation](https://www.kimai.org/documentation/developers.html).
|
||||
|
||||
17
README.md
17
README.md
@@ -6,9 +6,11 @@
|
||||
<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<br>#1 Open-Source Time-Tracker</h1>
|
||||
<h1 align="center">Kimai - 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.
|
||||
@@ -27,8 +29,8 @@ and so much more.
|
||||
|
||||
### Requirements
|
||||
|
||||
- PHP 8.1.3 minimum with support for 8.2, 8.3, 8.4, 8.5
|
||||
- MariaDB / MySQL: oldest maintained LTS release (MariaDB >= [10.6](https://endoflife.date/mariadb) or MySQL >= [8.4](https://endoflife.date/mysql)) or newer
|
||||
- PHP 8.1.3 minimum (support for PHP 8.2, 8.3, 8.4)
|
||||
- MariaDB or MySQL
|
||||
- A webserver and subdomain (subdirectory is not supported)
|
||||
- PHP extensions: `gd`, `intl`, `json`, `mbstring`, `pdo`, `tokenizer`, `xml`, `xsl`, `zip`
|
||||
|
||||
@@ -42,7 +44,7 @@ and so much more.
|
||||
|
||||
There are more documented ways for [on-premise hosting](https://www.kimai.org/documentation/chapter-on-premise.html).
|
||||
|
||||
And if you don't want to host Kimai, you can use the [Cloud version](https://www.kimai.cloud/) of it.
|
||||
And if you don't want to host Kimai, you can use [the Cloud version](https://www.kimai.cloud/) of it.
|
||||
|
||||
### Updating Kimai
|
||||
|
||||
@@ -69,7 +71,6 @@ 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.
|
||||
@@ -78,12 +79,6 @@ 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/),
|
||||
|
||||
@@ -13,8 +13,6 @@ Perform EACH version specific task between your version and the new one, otherwi
|
||||
|
||||
### Developer
|
||||
|
||||
Do not use method chaining: all fluent interface, especially in Entities, are no longer supported.
|
||||
|
||||
Removed translations:
|
||||
- `action.edit`: use `edit` instead
|
||||
- `my.profile`: use `user_profile` instead
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
/**
|
||||
* @deprecated use invoice-pdf instead
|
||||
*/
|
||||
|
||||
require('./sass/_invoice.scss');
|
||||
|
||||
@@ -96,7 +96,6 @@ export default class KimaiPlugin {
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use the plugin directly
|
||||
* @param {string} title
|
||||
* @returns {string}
|
||||
*/
|
||||
|
||||
@@ -24,7 +24,7 @@ export default class KimaiAutocompleteTags extends KimaiAutocomplete {
|
||||
API.get(apiUrl, {'name': query}, (data) => {
|
||||
let results = [];
|
||||
for (let item of data) {
|
||||
results.push({text: item.name, value: item.name, color: item['color-safe']});
|
||||
results.push({text: item.name, value: item.name, color: item.color});
|
||||
}
|
||||
callback(results);
|
||||
}, () => {
|
||||
|
||||
@@ -83,7 +83,7 @@ export default class KimaiTeamForm extends KimaiFormPlugin {
|
||||
prototype.dataset['widgetCounter'] = (++counter).toString();
|
||||
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = ESCAPER.sanitize(newWidget);
|
||||
temp.innerHTML = newWidget;
|
||||
temp.querySelector('input[type=hidden]').value = option.value;
|
||||
|
||||
const newNode = temp.firstElementChild;
|
||||
|
||||
@@ -73,14 +73,15 @@ export default class KimaiAPILink extends KimaiPlugin {
|
||||
const ALERT = this.getContainer().getPlugin('alert');
|
||||
const successHandle = () => {
|
||||
EVENTS.trigger(eventName);
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
if (attributes['msgSuccess'] !== undefined) {
|
||||
ALERT.success(attributes['msgSuccess']);
|
||||
}
|
||||
};
|
||||
const errorHandle = (error) => {
|
||||
let message = 'action.update.error';
|
||||
if (attributes['msgError'] !== undefined) {
|
||||
message = attributes['msgError'];
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
API.handleError(message, error);
|
||||
};
|
||||
|
||||
@@ -89,8 +90,6 @@ export default class KimaiAPILink extends KimaiPlugin {
|
||||
data = attributes['payload'];
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadContent'));
|
||||
|
||||
if (method === 'PATCH') {
|
||||
API.patch(url, data, successHandle, errorHandle);
|
||||
} else if (method === 'POST') {
|
||||
|
||||
@@ -217,6 +217,8 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
|
||||
const eventName = form.dataset['formEvent'];
|
||||
/** @type {KimaiEvent} alert */
|
||||
const events = this.getContainer().getPlugin('event');
|
||||
/** @type {KimaiAlert} alert */
|
||||
const alert = this.getContainer().getPlugin('alert');
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -257,8 +259,15 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
|
||||
} else {
|
||||
events.trigger(eventName);
|
||||
|
||||
// try to find form defined message first, but
|
||||
let msg = form.dataset['msgSuccess'];
|
||||
// if that is not available: use a generic fallback message
|
||||
if (msg === null || msg === undefined || msg === '') {
|
||||
msg = 'action.update.success';
|
||||
}
|
||||
this._isDirty = false;
|
||||
this._getModal().hide();
|
||||
alert.success(msg);
|
||||
}
|
||||
});
|
||||
})
|
||||
@@ -268,8 +277,6 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
|
||||
message = 'action.update.error';
|
||||
}
|
||||
|
||||
/** @type {KimaiAlert} alert */
|
||||
const alert = this.getContainer().getPlugin('alert');
|
||||
alert.error(message, error.message);
|
||||
|
||||
// this is useful for changing form fields and retrying to save (and in development to test form changes)
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
*/
|
||||
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export default class KimaiEscape extends KimaiPlugin {
|
||||
|
||||
@@ -27,23 +26,14 @@ export default class KimaiEscape extends KimaiPlugin {
|
||||
return '';
|
||||
}
|
||||
|
||||
const charToReplace = {
|
||||
const tagsToReplace = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
};
|
||||
|
||||
return title.replace(/[&<>"]/g, function(tag) {
|
||||
return charToReplace[tag] || tag;
|
||||
return title.replace(/[&<>]/g, function(tag) {
|
||||
return tagsToReplace[tag] || tag;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} html
|
||||
* @returns {string}
|
||||
*/
|
||||
sanitize(html) {
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export default class KimaiThemeInitializer extends KimaiPlugin {
|
||||
}
|
||||
|
||||
// at which element we append the loading screen
|
||||
let container = 'div.page-wrapper';
|
||||
let container = 'body';
|
||||
if (event.detail !== undefined && event.detail !== null) {
|
||||
container = event.detail;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ export default class KimaiCalendar {
|
||||
const DATES = this.kimai.getPlugin('date');
|
||||
/** @type {KimaiAjaxModalForm} MODAL */
|
||||
const MODAL = this.kimai.getPlugin('modal');
|
||||
/** @type {KimaiAlert} ALERT */
|
||||
const ALERT = this.kimai.getPlugin('alert');
|
||||
|
||||
// Instead of using "buttonIcons" the theme needs to be adjusted directly
|
||||
// https://fullcalendar.io/docs/buttonIcons
|
||||
@@ -195,7 +197,7 @@ export default class KimaiCalendar {
|
||||
if (!this.isKimaiSource(unmountInfo.event)) {
|
||||
return;
|
||||
}
|
||||
const popover = Popover.getInstance(unmountInfo.el);
|
||||
const popover = Popover.getInstance(unmountInfo.element);
|
||||
if (popover !== null) {
|
||||
popover.dispose();
|
||||
}
|
||||
@@ -280,7 +282,6 @@ export default class KimaiCalendar {
|
||||
droppable: true,
|
||||
// drop function handles external draggable events
|
||||
drop: (dropInfo) => {
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadContent'));
|
||||
const entry = dropInfo.draggedEl;
|
||||
const source = entry.parentElement;
|
||||
let data = JSON.parse(entry.dataset.entry);
|
||||
@@ -323,7 +324,7 @@ export default class KimaiCalendar {
|
||||
(result) => {
|
||||
const newItem = this.convertSourceForCalendar(result);
|
||||
this.getCalendar().addEvent(newItem, true);
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
ALERT.success('action.update.success');
|
||||
}
|
||||
);
|
||||
} else {
|
||||
@@ -333,7 +334,7 @@ export default class KimaiCalendar {
|
||||
(result) => {
|
||||
const newItem = this.convertSourceForCalendar(result);
|
||||
this.getCalendar().addEvent(newItem, true);
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
ALERT.success('action.update.success');
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -663,7 +664,7 @@ export default class KimaiCalendar {
|
||||
}
|
||||
}
|
||||
|
||||
return escaper.sanitize(`
|
||||
return `
|
||||
<div class="calendar-entry">
|
||||
<ul>
|
||||
<li>` + this.options['translations']['customer'] + `: ` + escaper.escapeForHtml(eventObj.customer) + `</li>
|
||||
@@ -672,7 +673,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>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -702,6 +703,8 @@ export default class KimaiCalendar {
|
||||
|
||||
/** @type {KimaiAPI} API */
|
||||
const API = this.kimai.getPlugin('api');
|
||||
/** @type {KimaiAlert} ALERT */
|
||||
const ALERT = this.kimai.getPlugin('alert');
|
||||
/** @type {KimaiDateUtils} DATE */
|
||||
const DATES = this.kimai.getPlugin('date');
|
||||
|
||||
@@ -713,14 +716,11 @@ export default class KimaiCalendar {
|
||||
payload.end = null;
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadContent'));
|
||||
|
||||
const updateUrl = this.options.url.update(event.id);
|
||||
API.patch(updateUrl, JSON.stringify(payload), () => {
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
ALERT.success('action.update.success');
|
||||
}, (error) => {
|
||||
eventArg.revert();
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
API.handleError('action.update.error', error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
@import "variables";
|
||||
@import "layout";
|
||||
@import "error-page";
|
||||
@import "print";
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1021;
|
||||
/*background-color: var(--tblr-backdrop-bg);*/
|
||||
background-color: var(--tblr-backdrop-bg);
|
||||
opacity: $modal-backdrop-opacity;
|
||||
}
|
||||
|
||||
@@ -48,12 +48,4 @@ fieldset.form-fieldset > legend {
|
||||
.page-title {
|
||||
color: var(--tblr-body-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Highlighted text is not visible - https://github.com/tabler/tabler/issues/2603 */
|
||||
[data-bs-theme=dark] {
|
||||
::selection,
|
||||
.text-selected {
|
||||
background-color: var(--#{$prefix}primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
: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); };
|
||||
@@ -14,7 +14,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "8.1.*||8.2.*||8.3.*||8.4.*||8.5.*",
|
||||
"php": "8.1.*||8.2.*||8.3.*||8.4.*",
|
||||
"ext-gd": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-json": "*",
|
||||
@@ -74,6 +74,7 @@
|
||||
"symfony/translation": "^6.0",
|
||||
"symfony/twig-bundle": "^6.0",
|
||||
"symfony/validator": "^6.0",
|
||||
"symfony/webhook": "^6.0",
|
||||
"symfony/webpack-encore-bundle": "^2.0",
|
||||
"symfony/yaml": "^6.0",
|
||||
"twig/cssinliner-extra": "^3.0",
|
||||
|
||||
1630
composer.lock
generated
1630
composer.lock
generated
File diff suppressed because it is too large
Load Diff
22
config/packages/messenger.yaml
Normal file
22
config/packages/messenger.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
framework:
|
||||
messenger:
|
||||
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
|
||||
# failure_transport: failed
|
||||
|
||||
transports:
|
||||
# https://symfony.com/doc/current/messenger.html#transport-configuration
|
||||
# async: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
# failed: 'doctrine://default?queue_name=failed'
|
||||
sync: 'sync://'
|
||||
|
||||
routing:
|
||||
# Route your messages to the transports
|
||||
# 'App\Message\YourMessage': async
|
||||
|
||||
# when@test:
|
||||
# framework:
|
||||
# messenger:
|
||||
# transports:
|
||||
# # replace with your transport name here (e.g., my_transport: 'in-memory://')
|
||||
# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
|
||||
# async: 'in-memory://'
|
||||
@@ -1,10 +1,5 @@
|
||||
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,7 +75,6 @@ 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
|
||||
@@ -96,7 +95,6 @@ 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
|
||||
@@ -110,7 +108,6 @@ 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
|
||||
@@ -122,7 +119,6 @@ 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
|
||||
@@ -134,7 +130,6 @@ 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
|
||||
|
||||
3
config/routes/webhook.yaml
Normal file
3
config/routes/webhook.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
#webhook:
|
||||
# resource: '@FrameworkBundle/Resources/config/routing/webhook.xml'
|
||||
# prefix: /webhook
|
||||
@@ -42,7 +42,6 @@
|
||||
"bootstrap": "^5.3",
|
||||
"chart.js": "^4",
|
||||
"core-js": "^3",
|
||||
"dompurify": "^3",
|
||||
"eslint": "^9",
|
||||
"globals": "^15",
|
||||
"gridstack": "^7",
|
||||
|
||||
83
phpstan.neon
83
phpstan.neon
@@ -27,7 +27,6 @@ parameters:
|
||||
numericOperandsInArithmeticOperators: true
|
||||
switchConditionsMatchingType: true
|
||||
noVariableVariables: false
|
||||
reportNonIntStringArrayKey: false
|
||||
paths:
|
||||
- src
|
||||
tmpDir: %rootDir%/../../../var/cache/phpstan
|
||||
@@ -1665,6 +1664,11 @@ 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
|
||||
@@ -2640,6 +2644,16 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/Type/UserPreferenceType.php
|
||||
|
||||
-
|
||||
message: "#^PHPDoc tag @var for variable \\$collection contains generic class Doctrine\\\\Common\\\\Collections\\\\ArrayCollection but does not specify its types\\: TKey, T$#"
|
||||
count: 1
|
||||
path: src/Form/Type/UserPreferencesCollectionType.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$key of method Doctrine\\\\Common\\\\Collections\\\\ArrayCollection\\<\\(int\\|string\\),mixed\\>\\:\\:set\\(\\) expects \\(int\\|string\\), string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Type/UserPreferencesCollectionType.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset mixed on mixed\\.$#"
|
||||
count: 1
|
||||
@@ -2782,7 +2796,7 @@ parameters:
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, mixed given\\.$#"
|
||||
count: 4
|
||||
count: 5
|
||||
path: src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
@@ -3795,11 +3809,71 @@ parameters:
|
||||
count: 1
|
||||
path: src/Utils/ParsedownExtension.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\Utils\\\\ParsedownExtension\\:\\:\\$safeLinksWhitelist has no type specified\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ParsedownExtension.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$profile of method App\\\\Utils\\\\ProfileManager\\:\\:getProfile\\(\\) expects string, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ProfileManager.php
|
||||
|
||||
-
|
||||
message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 0 on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$body on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$draft on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$html_url on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$prerelease on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$published_at on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$tag_name on mixed\\.$#"
|
||||
count: 2
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$zipball_url on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Utils\\\\ReleaseVersion\\:\\:getReleasesFromGithub\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$json of function json_decode expects string, string\\|false given\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'pattern' on mixed\\.$#"
|
||||
count: 1
|
||||
@@ -3934,3 +4008,8 @@ parameters:
|
||||
message: "#^Method App\\\\Form\\\\MultiUpdate\\\\MultiUpdateTableDTO\\:\\:setEntities\\(\\) has parameter \\$entities with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection but does not specify its types\\: TKey, T$#"
|
||||
count: 1
|
||||
path: src/Form/MultiUpdate/MultiUpdateTableDTO.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Entity\\\\InvoiceTemplate\\:\\:getMetaFields\\(\\) return type with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection does not specify its types\\: TKey, T$#"
|
||||
count: 1
|
||||
path: src/Entity/InvoiceTemplate.php
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<ini name="error_reporting" value="-1"/>
|
||||
<ini name="max_execution_time" value="-1"/>
|
||||
<ini name="date.timezone" value="UTC"/>
|
||||
<ini name="intl.default_locale" value="en"/>
|
||||
<ini name="intl.default_locale" value="en_US"/>
|
||||
<ini name="date.timezone" value="Europe/Vienna"/>
|
||||
<env name="KERNEL_CLASS" value="App\Kernel" force="true"/>
|
||||
<env name="SYMFONY_DEPRECATIONS_HELPER" value="weak"/>
|
||||
@@ -38,6 +38,13 @@
|
||||
GRANT execute,select,insert,update,delete,create,alter,drop,index,references ON `kimai2_test`.* TO kimai2_test@127.0.0.1;
|
||||
-->
|
||||
<env name="BOOTSTRAP_RESET_DATABASE" value="true"/>
|
||||
|
||||
<!-- ###+ symfony/messenger ### -->
|
||||
<!-- Choose one of the transports below -->
|
||||
<!-- MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages -->
|
||||
<!-- MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages -->
|
||||
<env name="MESSENGER_TRANSPORT_DSN" value="doctrine://default?auto_setup=0"/>
|
||||
<!-- ###- symfony/messenger ### -->
|
||||
</php>
|
||||
<testsuites>
|
||||
<testsuite name="Kimai">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<tile>
|
||||
<square150x150logo src="favicon/mstile-150x150.png"/>
|
||||
<square310x310logo src="favicon/mstile-large.jpg"/>
|
||||
<TileColor>#ffffff</TileColor>
|
||||
<TileColor>#00a300</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
File diff suppressed because one or more lines are too long
2
public/build/app.1899dc33.js
Normal file
2
public/build/app.1899dc33.js
Normal file
File diff suppressed because one or more lines are too long
2
public/build/app.9662939e.js
Normal file
2
public/build/app.9662939e.js
Normal file
File diff suppressed because one or more lines are too long
@@ -175,5 +175,3 @@
|
||||
/*!
|
||||
* [KIMAI] Wrapper class for loading Kimai app in browser script scope
|
||||
*/
|
||||
|
||||
/*! @license DOMPurify 3.3.3 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.3.3/LICENSE */
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
public/build/calendar.b2d70caa.js
Normal file
2
public/build/calendar.b2d70caa.js
Normal file
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/chart.bafa38e7.js
Normal file
2
public/build/chart.bafa38e7.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
/*!
|
||||
* @kurkle/color v0.3.4
|
||||
* @kurkle/color v0.3.2
|
||||
* https://github.com/kurkle/color#readme
|
||||
* (c) 2024 Jukka Kurkela
|
||||
* (c) 2023 Jukka Kurkela
|
||||
* Released under the MIT License
|
||||
*/
|
||||
|
||||
2
public/build/dashboard.6ce7ac9c.js
Normal file
2
public/build/dashboard.6ce7ac9c.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,25 +2,25 @@
|
||||
"entrypoints": {
|
||||
"app": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/app.f0f8091d.js"
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/app.9662939e.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app.99ea4166.css"
|
||||
"/build/app.c18ba3c6.css"
|
||||
]
|
||||
},
|
||||
"app-rtl": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/app-rtl.15853b82.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app-rtl.16262d9a.css"
|
||||
"/build/app-rtl.2003dce5.css"
|
||||
]
|
||||
},
|
||||
"export-pdf": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/export-pdf.395749ab.js"
|
||||
],
|
||||
"css": [
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"invoice": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/invoice.42b319e4.js"
|
||||
],
|
||||
"css": [
|
||||
@@ -38,7 +38,7 @@
|
||||
},
|
||||
"invoice-pdf": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/invoice-pdf.26d98626.js"
|
||||
],
|
||||
"css": [
|
||||
@@ -47,14 +47,14 @@
|
||||
},
|
||||
"chart": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/chart.56f16a68.js"
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/chart.bafa38e7.js"
|
||||
]
|
||||
},
|
||||
"calendar": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/calendar.13247e65.js"
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/calendar.b2d70caa.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/calendar.d757753e.css"
|
||||
@@ -62,8 +62,8 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/dashboard.9708ae5e.js"
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/dashboard.6ce7ac9c.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/dashboard.b7129fa1.css"
|
||||
@@ -71,8 +71,8 @@
|
||||
},
|
||||
"highlight": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/highlight.0296a734.js"
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/highlight.13d5d50e.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/highlight.98bf3927.css"
|
||||
@@ -80,23 +80,23 @@
|
||||
}
|
||||
},
|
||||
"integrity": {
|
||||
"/build/runtime.684e9f6d.js": "sha384-suKiEX2de4fdNqQzdYbUd6osp4AepD9FiMXl+1QdvgMW9dcQqUWQNQasf3KWzwLr",
|
||||
"/build/app.f0f8091d.js": "sha384-F6UUWeiIwbFffkZMRmbczLyw5tuOvtUNLuX1/iY6ZfncN1vYGSgalEumAp4HXFvX",
|
||||
"/build/app.99ea4166.css": "sha384-OF0ozOygdShRhEacN7Tb8YtDSXABczVqjmXRYQjX1YGTmuRQMNCipFTqzHd9IyCH",
|
||||
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
|
||||
"/build/app.9662939e.js": "sha384-bRL78SVI1wjTweqKeb+ZSSbA78i6By89S8/xx+55zmbOVtF1TCu3lf3pvdki5ZVJ",
|
||||
"/build/app.c18ba3c6.css": "sha384-qkIgqLzngG2NchdFVImbGMU49lWlZg6Y9D0z2P0u1j2NQstDicA4KqIkkNYcpErm",
|
||||
"/build/app-rtl.15853b82.js": "sha384-UnKKgLMu9FnRT+CFE0no/+UiUks012bYriQdUWa6f02mo6Lswl947mPybjvKL503",
|
||||
"/build/app-rtl.16262d9a.css": "sha384-CjN7UFkBszmM9k6xfN8LWH79IOsgpVTwwHoffvAOc04j9+y904uDw/Y+LnSQmrDj",
|
||||
"/build/app-rtl.2003dce5.css": "sha384-pl8GyGo8sRRw1zLh9D42ZAvo450onfxeszZWocUUuzq70jAOwOlaoPYh/yyc20U7",
|
||||
"/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",
|
||||
"/build/invoice.36018785.css": "sha384-jukM9uZ6pexDxXKgZThSxiqXimzsxzniBMHz08N9x8ryXZYkM5r/ZgaainCV0+J6",
|
||||
"/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.13247e65.js": "sha384-8b3wBuxn8m2FsxLMtbIpZTg1SGBX9b+0dYRTnF63mGQfcXhRGHKtUs5REwVyhYiP",
|
||||
"/build/chart.bafa38e7.js": "sha384-Ays2qGKvOqs4NSeN/zJOPcrnzIEC/uYSCIkC3kN0KQye+mA5Lq7UkfjZDjulvFUX",
|
||||
"/build/calendar.b2d70caa.js": "sha384-FS7Q9iCWHpo2Nzk0tmNvnuTbxJKLdbXFba9F6WcXfHbKFAS2Q+lW29H9Mv+ap5kA",
|
||||
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ",
|
||||
"/build/dashboard.9708ae5e.js": "sha384-QN7XIQuxFZu76sHVrgdpZL81+Q2VTwcgF3aI2CnpwYZiMoPbzrcTGfPL3RDy696t",
|
||||
"/build/dashboard.6ce7ac9c.js": "sha384-5BOoyjZx/ZKr9IbogWfT/aBRHx7XA1fePADdUpUNlSF/rqVo3taXLLcrIoaDZ+8b",
|
||||
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS",
|
||||
"/build/highlight.0296a734.js": "sha384-vGFKI/KM+uyGIlzKsKhQh4w41hQteB4u14oRYqObMT03/YTZXq6Re5nUN6g3sGQH",
|
||||
"/build/highlight.13d5d50e.js": "sha384-+RqBBPIzp5YQpbBeHgVNXzHiewK1PZtizPh4in1PlBU6RcT1bDleW3QSJMDeBnPO",
|
||||
"/build/highlight.98bf3927.css": "sha384-YgweSwDwN0dI4DEmh478xYVw/TewJYvCO1QTbWHpaHFDPuLrZvYMR0Tc+QfFxVPE"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
1
public/build/highlight.13d5d50e.js
Normal file
1
public/build/highlight.13d5d50e.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"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.css": "/build/app.c18ba3c6.css",
|
||||
"build/app.js": "/build/app.9662939e.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.2003dce5.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",
|
||||
@@ -9,14 +9,14 @@
|
||||
"build/invoice.js": "/build/invoice.42b319e4.js",
|
||||
"build/invoice-pdf.css": "/build/invoice-pdf.2b749265.css",
|
||||
"build/invoice-pdf.js": "/build/invoice-pdf.26d98626.js",
|
||||
"build/chart.js": "/build/chart.56f16a68.js",
|
||||
"build/chart.js": "/build/chart.bafa38e7.js",
|
||||
"build/calendar.css": "/build/calendar.d757753e.css",
|
||||
"build/calendar.js": "/build/calendar.13247e65.js",
|
||||
"build/calendar.js": "/build/calendar.b2d70caa.js",
|
||||
"build/dashboard.css": "/build/dashboard.b7129fa1.css",
|
||||
"build/dashboard.js": "/build/dashboard.9708ae5e.js",
|
||||
"build/dashboard.js": "/build/dashboard.6ce7ac9c.js",
|
||||
"build/highlight.css": "/build/highlight.98bf3927.css",
|
||||
"build/highlight.js": "/build/highlight.0296a734.js",
|
||||
"build/runtime.js": "/build/runtime.684e9f6d.js",
|
||||
"build/highlight.js": "/build/highlight.13d5d50e.js",
|
||||
"build/runtime.js": "/build/runtime.6c399d29.js",
|
||||
"build/fonts/fa-solid-900.ttf": "/build/fonts/fa-solid-900.2582b0e4.ttf",
|
||||
"build/fonts/fa-brands-400.ttf": "/build/fonts/fa-brands-400.1815e004.ttf",
|
||||
"build/fonts/fa-solid-900.woff2": "/build/fonts/fa-solid-900.2463b90d.woff2",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
!function(){"use strict";var e,r={},n={};function t(e){var o=n[e];if(void 0!==o)return o.exports;var i=n[e]={id:e,loaded:!1,exports:{}};return r[e].call(i.exports,i,i.exports,t),i.loaded=!0,i.exports}t.m=r,t.amdO={},e=[],t.O=function(r,n,o,i){if(!n){var u=1/0;for(l=0;l<e.length;l++){n=e[l][0],o=e[l][1],i=e[l][2];for(var f=!0,a=0;a<n.length;a++)(!1&i||u>=i)&&Object.keys(t.O).every(function(e){return t.O[e](n[a])})?n.splice(a--,1):(f=!1,i<u&&(u=i));if(f){e.splice(l--,1);var c=o();void 0!==c&&(r=c)}}return r}i=i||0;for(var l=e.length;l>0&&e[l-1][2]>i;l--)e[l]=e[l-1];e[l]=[n,o,i]},t.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(r,{a:r}),r},t.d=function(e,r){for(var n in r)t.o(r,n)&&!t.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:r[n]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),t.hmd=function(e){return(e=Object.create(e)).children||(e.children=[]),Object.defineProperty(e,"exports",{enumerable:!0,set:function(){throw new Error("ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: "+e.id)}}),e},t.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},function(){var e={121:0};t.O.j=function(r){return 0===e[r]};var r=function(r,n){var o,i,u=n[0],f=n[1],a=n[2],c=0;if(u.some(function(r){return 0!==e[r]})){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(a)var l=a(t)}for(r&&r(n);c<u.length;c++)i=u[c],t.o(e,i)&&e[i]&&e[i][0](),e[i]=0;return t.O(l)},n=self.webpackChunkkimai=self.webpackChunkkimai||[];n.forEach(r.bind(null,0)),n.push=r.bind(null,n.push.bind(n))}()}();
|
||||
1
public/build/runtime.6c399d29.js
Normal file
1
public/build/runtime.6c399d29.js
Normal file
@@ -0,0 +1 @@
|
||||
!function(){"use strict";var e,r={},n={};function t(e){var o=n[e];if(void 0!==o)return o.exports;var i=n[e]={id:e,loaded:!1,exports:{}};return r[e].call(i.exports,i,i.exports,t),i.loaded=!0,i.exports}t.m=r,t.amdO={},e=[],t.O=function(r,n,o,i){if(!n){var u=1/0;for(l=0;l<e.length;l++){n=e[l][0],o=e[l][1],i=e[l][2];for(var f=!0,a=0;a<n.length;a++)(!1&i||u>=i)&&Object.keys(t.O).every((function(e){return t.O[e](n[a])}))?n.splice(a--,1):(f=!1,i<u&&(u=i));if(f){e.splice(l--,1);var c=o();void 0!==c&&(r=c)}}return r}i=i||0;for(var l=e.length;l>0&&e[l-1][2]>i;l--)e[l]=e[l-1];e[l]=[n,o,i]},t.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(r,{a:r}),r},t.d=function(e,r){for(var n in r)t.o(r,n)&&!t.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:r[n]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),t.hmd=function(e){return(e=Object.create(e)).children||(e.children=[]),Object.defineProperty(e,"exports",{enumerable:!0,set:function(){throw new Error("ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: "+e.id)}}),e},t.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},function(){var e={121:0};t.O.j=function(r){return 0===e[r]};var r=function(r,n){var o,i,u=n[0],f=n[1],a=n[2],c=0;if(u.some((function(r){return 0!==e[r]}))){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(a)var l=a(t)}for(r&&r(n);c<u.length;c++)i=u[c],t.o(e,i)&&e[i]&&e[i][0](),e[i]=0;return t.O(l)},n=self.webpackChunkkimai=self.webpackChunkkimai||[];n.forEach(r.bind(null,0)),n.push=r.bind(null,n.push.bind(n))}()}();
|
||||
@@ -25,7 +25,7 @@
|
||||
],
|
||||
"scope": "./",
|
||||
"start_url": "./",
|
||||
"theme-color": "#262626",
|
||||
"theme-color": "#1d273b",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -71,7 +71,6 @@ 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);
|
||||
@@ -91,7 +90,6 @@ 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);
|
||||
@@ -111,7 +109,6 @@ 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);
|
||||
@@ -131,7 +128,6 @@ 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,14 +72,15 @@ final class ActivityController extends BaseApiController
|
||||
|
||||
/** @var array<int> $projects */
|
||||
$projects = $paramFetcher->get('projects');
|
||||
$pr = $paramFetcher->get('project');
|
||||
if (\is_string($pr) && $pr !== '') {
|
||||
$projects[] = $pr;
|
||||
$project = $paramFetcher->get('project');
|
||||
if (\is_string($project) && $project !== '') {
|
||||
$projects[] = $project;
|
||||
}
|
||||
|
||||
foreach ($projectRepository->findByIds(array_unique($projects)) as $project) {
|
||||
if (!$this->isGranted('access', $project)) {
|
||||
throw $this->createAccessDeniedException('Cannot access Project: ' . $project->getId());
|
||||
foreach (array_unique($projects) as $projectId) {
|
||||
$project = $projectRepository->find($projectId);
|
||||
if ($project === null) {
|
||||
throw $this->createNotFoundException('Unknown project: ' . $projectId);
|
||||
}
|
||||
$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) && // @phpstan-ignore classConstant.deprecatedClass
|
||||
$request->headers->has(TokenAuthenticator::HEADER_TOKEN)) { // @phpstan-ignore classConstant.deprecatedClass
|
||||
if ($request->headers->has(TokenAuthenticator::HEADER_USERNAME) &&
|
||||
$request->headers->has(TokenAuthenticator::HEADER_TOKEN)) {
|
||||
return true;
|
||||
}
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
@@ -14,9 +14,6 @@ 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,9 +13,6 @@ 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,24 +13,17 @@ 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';
|
||||
@@ -38,9 +31,7 @@ final class TokenAuthenticator extends AbstractAuthenticator
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiUserRepository $userProvider,
|
||||
private readonly PasswordHasherFactoryInterface $passwordHasherFactory,
|
||||
private readonly RateLimiterFactory $oldApiTokensLimiter,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly PasswordHasherFactoryInterface $passwordHasherFactory
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -53,6 +44,8 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -84,12 +77,10 @@ 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.');
|
||||
}
|
||||
|
||||
@@ -97,17 +88,11 @@ 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, 'loadUserByIdentifier']),
|
||||
new UserBadge($credentials['username'], [$this->userProvider, 'loadUserByIdentifier']),
|
||||
new CustomCredentials($checkCredentials, $credentials['password'])
|
||||
);
|
||||
|
||||
@@ -116,30 +101,6 @@ 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,7 +18,6 @@ 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;
|
||||
@@ -44,8 +43,8 @@ final class InvoiceController extends BaseApiController
|
||||
#[IsGranted('view_invoice')]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of invoices', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/InvoiceCollection')))]
|
||||
#[Route(methods: ['GET'], path: '', name: 'get_invoices')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only invoices created at or after this date-time will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only invoices created before or at this date-time will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records before this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'customers', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of customer IDs to filter, e.g.: customers[]=1&customers[]=2')]
|
||||
#[Rest\QueryParam(name: 'status', map: true, requirements: 'pending|paid|canceled|new', strict: true, nullable: true, default: [], description: 'Invoice status: pending, paid, canceled, new. Default: all')]
|
||||
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
|
||||
@@ -77,9 +76,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -94,7 +90,6 @@ 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,14 +85,15 @@ final class ProjectController extends BaseApiController
|
||||
|
||||
/** @var array<int> $customers */
|
||||
$customers = $paramFetcher->get('customers');
|
||||
$cu = $paramFetcher->get('customer');
|
||||
if (\is_string($cu) && $cu !== '') {
|
||||
$customers[] = $cu;
|
||||
$customer = $paramFetcher->get('customer');
|
||||
if (\is_string($customer) && $customer !== '') {
|
||||
$customers[] = $customer;
|
||||
}
|
||||
|
||||
foreach ($customerRepository->findByIds(array_unique($customers)) as $customer) {
|
||||
if (!$this->isGranted('access', $customer)) {
|
||||
throw $this->createAccessDeniedException('Cannot access Customer: ' . $customer->getId());
|
||||
foreach (array_unique($customers) as $customerId) {
|
||||
$customer = $customerRepository->find($customerId);
|
||||
if ($customer === null) {
|
||||
throw $this->createNotFoundException('Unknown customer: ' . $customerId);
|
||||
}
|
||||
$query->addCustomer($customer);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ final class TeamController extends BaseApiController
|
||||
* Fetch team
|
||||
*/
|
||||
#[IsGranted('view_team')]
|
||||
#[OA\Response(response: 200, description: 'Returns the team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))]
|
||||
#[OA\Response(response: 200, description: 'Returns one team entity', 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: 'Empty')])]
|
||||
#[IsGranted('delete_team')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one team')])]
|
||||
#[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,12 +219,10 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant customer access
|
||||
*
|
||||
* The team is granted access to the customer.
|
||||
* Grant team access to customer
|
||||
*/
|
||||
#[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'))])]
|
||||
#[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'))])]
|
||||
#[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+'])]
|
||||
@@ -244,12 +242,10 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke customer access
|
||||
*
|
||||
* This removes access to the customer from the team.
|
||||
* Revoke customer access from 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'))])]
|
||||
#[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'))])]
|
||||
#[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+'])]
|
||||
@@ -269,12 +265,10 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant project access
|
||||
*
|
||||
* The team is granted access to the project.
|
||||
* Grant team access to project
|
||||
*/
|
||||
#[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'))])]
|
||||
#[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'))])]
|
||||
#[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+'])]
|
||||
@@ -294,12 +288,10 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke project access
|
||||
*
|
||||
* This removes access to the project from the team.
|
||||
* Revoke project access from 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'))])]
|
||||
#[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'))])]
|
||||
#[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+'])]
|
||||
@@ -319,12 +311,10 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant activity access
|
||||
*
|
||||
* The team is granted access to the activity.
|
||||
* Grant team access to activity
|
||||
*/
|
||||
#[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'))])]
|
||||
#[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'))])]
|
||||
#[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+'])]
|
||||
@@ -344,12 +334,10 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke activity access
|
||||
*
|
||||
* This removes access to the activity from the team.
|
||||
* Revoke activity access from 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'))])]
|
||||
#[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'))])]
|
||||
#[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+'])]
|
||||
|
||||
@@ -86,8 +86,8 @@ final class TimesheetController extends BaseApiController
|
||||
#[Rest\QueryParam(name: 'tags', map: true, strict: true, nullable: true, default: [], description: 'List of tag names, e.g. tags[]=bar&tags[]=foo')]
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|begin|end|rate', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, begin, end, rate (default: begin)')]
|
||||
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: DESC)')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records started at or after this date-time will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records started at or before this date-time will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records before this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'exported', requirements: '0|1', strict: true, nullable: true, description: 'Use this flag if you want to filter for export state. Allowed values: 0=not exported, 1=exported (default: all)')]
|
||||
#[Rest\QueryParam(name: 'active', requirements: '0|1', strict: true, nullable: true, description: 'Filter for running/active records. Allowed values: 0=stopped, 1=active (default: all)')]
|
||||
#[Rest\QueryParam(name: 'billable', requirements: '0|1', strict: true, nullable: true, description: 'Filter for non-/billable records. Allowed values: 0=non-billable, 1=billable (default: all)')]
|
||||
@@ -126,42 +126,45 @@ final class TimesheetController extends BaseApiController
|
||||
|
||||
/** @var array<int> $customers */
|
||||
$customers = $paramFetcher->get('customers');
|
||||
$cu = $paramFetcher->get('customer');
|
||||
if (\is_string($cu) && $cu !== '') {
|
||||
$customers[] = $cu;
|
||||
$customer = $paramFetcher->get('customer');
|
||||
if (\is_string($customer) && $customer !== '') {
|
||||
$customers[] = $customer;
|
||||
}
|
||||
|
||||
foreach ($customerRepository->findByIds(array_unique($customers)) as $customer) {
|
||||
if (!$this->isGranted('access', $customer)) {
|
||||
throw $this->createAccessDeniedException('Cannot access Customer: ' . $customer->getId());
|
||||
foreach (array_unique($customers) as $customerId) {
|
||||
$customer = $customerRepository->find($customerId);
|
||||
if ($customer === null) {
|
||||
throw $this->createNotFoundException('Unknown customer: ' . $customerId);
|
||||
}
|
||||
$query->addCustomer($customer);
|
||||
}
|
||||
|
||||
/** @var array<int> $projects */
|
||||
$projects = $paramFetcher->get('projects');
|
||||
$pr = $paramFetcher->get('project');
|
||||
if (\is_string($pr) && $pr !== '') {
|
||||
$projects[] = $pr;
|
||||
$project = $paramFetcher->get('project');
|
||||
if (\is_string($project) && $project !== '') {
|
||||
$projects[] = $project;
|
||||
}
|
||||
|
||||
foreach ($projectRepository->findByIds(array_unique($projects)) as $project) {
|
||||
if (!$this->isGranted('access', $project)) {
|
||||
throw $this->createAccessDeniedException('Cannot access Project: ' . $project->getId());
|
||||
foreach (array_unique($projects) as $projectId) {
|
||||
$project = $projectRepository->find($projectId);
|
||||
if ($project === null) {
|
||||
throw $this->createNotFoundException('Unknown project: ' . $project);
|
||||
}
|
||||
$query->addProject($project);
|
||||
}
|
||||
|
||||
/** @var array<int> $activities */
|
||||
$activities = $paramFetcher->get('activities');
|
||||
$ac = $paramFetcher->get('activity');
|
||||
if (\is_string($ac) && $ac !== '') {
|
||||
$activities[] = $ac;
|
||||
$activity = $paramFetcher->get('activity');
|
||||
if (\is_string($activity) && $activity !== '') {
|
||||
$activities[] = $activity;
|
||||
}
|
||||
|
||||
foreach ($activityRepository->findByIds(array_unique($activities)) as $activity) {
|
||||
if (!$this->isGranted('access', $activity)) {
|
||||
throw $this->createAccessDeniedException('Cannot access Activity: ' . $activity->getId());
|
||||
foreach (array_unique($activities) as $activityId) {
|
||||
$activity = $activityRepository->find($activityId);
|
||||
if ($activity === null) {
|
||||
throw $this->createNotFoundException('Unknown activity: ' . $activity);
|
||||
}
|
||||
$query->addActivity($activity);
|
||||
}
|
||||
@@ -231,6 +234,7 @@ final class TimesheetController extends BaseApiController
|
||||
}
|
||||
|
||||
$data = $this->repository->getPagerfantaForQuery($query);
|
||||
|
||||
$view = new View($data, 200);
|
||||
|
||||
$full = $paramFetcher->get('full');
|
||||
@@ -376,7 +380,7 @@ final class TimesheetController extends BaseApiController
|
||||
#[IsGranted('view_own_timesheet')]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of recent user activities (always the latest entry of a unique working set grouped by customer, project and activity)', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollectionExpanded')))]
|
||||
#[Route(methods: ['GET'], path: '/recent', name: 'recent_timesheet')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records started at or after this date will be included. Default: today - 1 year (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included. Default: today - 1 year (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries (default: 10)')]
|
||||
public function recentAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
@@ -482,7 +486,8 @@ final class TimesheetController extends BaseApiController
|
||||
|
||||
$copy = $paramFetcher->get('copy');
|
||||
if ($copy === 'all') {
|
||||
// we do NOT copy rates, as those should always be calculated from the configured settings
|
||||
$copyTimesheet->setHourlyRate($timesheet->getHourlyRate());
|
||||
$copyTimesheet->setFixedRate($timesheet->getFixedRate());
|
||||
$copyTimesheet->setDescription($timesheet->getDescription());
|
||||
$copyTimesheet->setBillable($timesheet->isBillable());
|
||||
|
||||
|
||||
@@ -236,18 +236,16 @@ 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, UserService $userService): Response
|
||||
public function updateUserPreference(User $profile, Request $request, EventDispatcherInterface $dispatcher): 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"');
|
||||
}
|
||||
@@ -255,23 +253,14 @@ 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;
|
||||
}
|
||||
|
||||
if ($dirty) {
|
||||
$userService->saveUser($profile);
|
||||
}
|
||||
$this->repository->saveUser($profile);
|
||||
|
||||
$view = new View($profile, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
@@ -147,6 +147,7 @@ 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);
|
||||
|
||||
@@ -47,8 +47,6 @@ final class TranslationCommand extends Command
|
||||
->addOption('fill-empty', null, InputOption::VALUE_NONE, 'Pre-fills empty translations with the english version')
|
||||
->addOption('delete-empty', null, InputOption::VALUE_NONE, 'Delete all empty keys and files which have no translated key at all')
|
||||
->addOption('move-resname', null, InputOption::VALUE_REQUIRED, 'Move a resname from one file to another (needs "source" and "target" options)')
|
||||
->addOption('copy-resname', null, InputOption::VALUE_REQUIRED, 'Copy a resname within one file (needs "source" option)')
|
||||
->addOption('target-resname', null, InputOption::VALUE_REQUIRED, 'Target resname when copying (needs "source" option)')
|
||||
->addOption('move-all', null, InputOption::VALUE_NONE, 'Move all keys from one file to another (needs "source" and "target" options)')
|
||||
->addOption('source', null, InputOption::VALUE_REQUIRED, 'Single source file to use')
|
||||
->addOption('only-core', null, InputOption::VALUE_NONE, 'Do not include plugin and theme directories')
|
||||
@@ -152,21 +150,6 @@ final class TranslationCommand extends Command
|
||||
return $this->moveResname($io, $moveResname, $sources, $targets);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Move resname from source to target
|
||||
// ==========================================================================
|
||||
$copyResname = $input->getOption('copy-resname');
|
||||
$targetResname = $input->getOption('target-resname');
|
||||
if (\is_string($copyResname)) {
|
||||
if (!\is_string($targetResname)) {
|
||||
$io->error('To copy a resname, we need a target-resname');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return $this->copyResname($io, $copyResname, $targetResname, $sources);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Move all keys from source to target
|
||||
// ==========================================================================
|
||||
@@ -508,7 +491,7 @@ final class TranslationCommand extends Command
|
||||
\sprintf('Missing english translation for key: %s in file %s', $key, $file)
|
||||
);
|
||||
}
|
||||
$unit->target[0] = $translations[$key]; // @phpstan-ignore-line
|
||||
$unit->target[0] = $translations[$key];
|
||||
$unit->target['state'] = 'needs-translation';
|
||||
$foundEmpty = true;
|
||||
}
|
||||
@@ -671,62 +654,6 @@ final class TranslationCommand extends Command
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $sources
|
||||
*/
|
||||
private function copyResname(SymfonyStyle $io, string $resname, string $target, array $sources): int
|
||||
{
|
||||
foreach ($sources as $source) {
|
||||
$tmp = basename($source);
|
||||
$pos = strpos($tmp, '.');
|
||||
if ($pos === false) {
|
||||
$io->error('Unexpected filename: ' . $source);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$sourceDocument = new \DOMDocument('1.0');
|
||||
$sourceDocument->load($source);
|
||||
|
||||
$copiedNode = false;
|
||||
|
||||
/** @var \DOMElement $element */
|
||||
foreach ($sourceDocument->getElementsByTagName('trans-unit') as $element) {
|
||||
if (!$element->hasAttribute('resname')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $element->getAttribute('resname');
|
||||
|
||||
if ($key === $resname) {
|
||||
$newElement = clone $element;
|
||||
$newElement->setAttribute('resname', $target);
|
||||
foreach ($newElement->childNodes->getIterator() as $child) {
|
||||
if ($child->nodeName === 'source') {
|
||||
$child->textContent = $target;
|
||||
}
|
||||
}
|
||||
$newElement->setAttribute('id', $this->generateId($target));
|
||||
|
||||
$newNode = $sourceDocument->importNode($newElement, true);
|
||||
$sourceDocument->documentElement->firstElementChild->firstElementChild->appendChild($newNode); // @phpstan-ignore-line
|
||||
$copiedNode = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($copiedNode) {
|
||||
$xmlDocument = new \DOMDocument('1.0');
|
||||
$xmlDocument->preserveWhiteSpace = false;
|
||||
$xmlDocument->formatOutput = true;
|
||||
$xmlDocument->loadXML($sourceDocument->saveXML()); // @phpstan-ignore-line
|
||||
file_put_contents($source, $xmlDocument->saveXML());
|
||||
}
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $sources
|
||||
* @param array<string> $targets
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace App\Configuration;
|
||||
|
||||
final class SamlConfiguration implements SamlConfigurationInterface
|
||||
{
|
||||
public function __construct(private readonly SystemConfiguration $configuration)
|
||||
public function __construct(private SystemConfiguration $configuration)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -54,9 +54,4 @@ final class SamlConfiguration implements SamlConfigurationInterface
|
||||
{
|
||||
return $this->configuration->getSamlConnection();
|
||||
}
|
||||
|
||||
public function cleanupLongRelayState(): bool
|
||||
{
|
||||
return (bool) $this->configuration->find('saml.connection.cleanupLongRelayState');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,4 @@ interface SamlConfigurationInterface
|
||||
public function isRolesResetOnLogin(): bool;
|
||||
|
||||
public function getConnection(): array;
|
||||
|
||||
// TODO 3.0 activate me
|
||||
//public function cleanupLongRelayState(): bool;
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ 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.54.0';
|
||||
public const VERSION = '2.45.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 25400;
|
||||
public const VERSION_ID = 24500;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -66,20 +66,13 @@ 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);
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ final class CustomerController extends AbstractController
|
||||
$table->addColumn('company', ['class' => 'd-none']);
|
||||
$table->addColumn('vat_id', ['class' => 'd-none w-min']);
|
||||
$table->addColumn('contact', ['class' => 'd-none']);
|
||||
$table->addColumn('city', ['class' => 'd-none']);
|
||||
$table->addColumn('address', ['class' => 'd-none']);
|
||||
$table->addColumn('country', ['class' => 'd-none w-min']);
|
||||
$table->addColumn('currency', ['class' => 'd-none w-min']);
|
||||
$table->addColumn('phone', ['class' => 'd-none']);
|
||||
|
||||
@@ -26,6 +26,19 @@ 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.
|
||||
*/
|
||||
@@ -95,32 +108,10 @@ final class DoctorController extends AbstractController
|
||||
'logLines' => $logLines,
|
||||
'logSize' => $this->getLogSize(),
|
||||
'composer' => $this->getComposerPackages(),
|
||||
'release' => $latestRelease,
|
||||
'opcache' => $this->getOpcacheConfiguration()
|
||||
'release' => $latestRelease
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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>
|
||||
*/
|
||||
@@ -157,27 +148,9 @@ 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 ($composer['require'] as $name => $version) {
|
||||
if (!str_starts_with($name, 'ext-')) {
|
||||
continue;
|
||||
}
|
||||
$extName = str_replace('ext-', '', $name);
|
||||
foreach (self::REQUIRED_EXTENSIONS as $extName) {
|
||||
$results[$extName] = false;
|
||||
if (\extension_loaded($extName)) {
|
||||
$results[$extName] = true;
|
||||
@@ -291,12 +264,7 @@ final class DoctorController extends AbstractController
|
||||
'sys_temp_dir',
|
||||
'date.timezone',
|
||||
'session.gc_maxlifetime',
|
||||
'disable_functions',
|
||||
'opcache.enable',
|
||||
'opcache.memory_consumption',
|
||||
'opcache.interned_strings_buffer',
|
||||
'opcache.max_accelerated_files',
|
||||
'opcache.validate_timestamps',
|
||||
'disable_functions'
|
||||
];
|
||||
|
||||
$settings = [];
|
||||
|
||||
@@ -61,15 +61,18 @@ use Twig\Environment;
|
||||
final class InvoiceController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ServiceInvoice $service,
|
||||
private readonly InvoiceTemplateRepository $templateRepository,
|
||||
private readonly InvoiceRepository $invoiceRepository,
|
||||
private readonly EventDispatcherInterface $dispatcher
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/', name: 'invoice', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_invoice')]
|
||||
public function indexAction(Request $request, ServiceInvoice $service, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function indexAction(Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$templateRepository->hasTemplate()) {
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
if ($this->isGranted('manage_invoice_template')) {
|
||||
return $this->redirectToRoute('admin_invoice_template_create');
|
||||
}
|
||||
@@ -92,7 +95,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
if ($form->isValid() && $query->getTemplate() !== null) {
|
||||
try {
|
||||
$models = $service->createModels($query);
|
||||
$models = $this->service->createModels($query);
|
||||
$searched = true;
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
@@ -136,8 +139,12 @@ final class InvoiceController extends AbstractController
|
||||
#[Route(path: '/preview/{customer}/{token}', name: 'invoice_preview', methods: ['GET'])]
|
||||
#[IsGranted('create_invoice')]
|
||||
#[IsGranted('access', 'customer')]
|
||||
public function previewAction(Customer $customer, string $token, Request $request, ServiceInvoice $service): Response
|
||||
public function previewAction(Customer $customer, string $token, Request $request): Response
|
||||
{
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
if (!$this->isCsrfTokenValid('invoice.preview', $token)) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
@@ -157,10 +164,10 @@ final class InvoiceController extends AbstractController
|
||||
if ($form->isValid()) {
|
||||
try {
|
||||
$query->setCustomers([$customer]);
|
||||
$model = $service->createModel($query);
|
||||
$model = $this->service->createModel($query);
|
||||
$model->setPreview(true);
|
||||
|
||||
return $service->renderInvoice($model, $this->dispatcher, true);
|
||||
return $this->service->renderInvoice($model, $this->dispatcher, true);
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
@@ -174,8 +181,12 @@ final class InvoiceController extends AbstractController
|
||||
#[Route(path: '/save-invoice/{customer}/{token}', name: 'invoice_create', methods: ['GET'])]
|
||||
#[IsGranted('create_invoice')]
|
||||
#[IsGranted('access', 'customer')]
|
||||
public function createInvoiceAction(Customer $customer, string $token, Request $request, CustomerRepository $customerRepository, ServiceInvoice $service): Response
|
||||
public function createInvoiceAction(Customer $customer, string $token, Request $request, CustomerRepository $customerRepository): Response
|
||||
{
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
if (!$this->isCsrfTokenValid('invoice.create', $token)) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
@@ -192,7 +203,7 @@ final class InvoiceController extends AbstractController
|
||||
if ($form->isValid()) {
|
||||
try {
|
||||
$query->setCustomers([$customer]);
|
||||
$model = $service->createModel($query);
|
||||
$model = $this->service->createModel($query);
|
||||
|
||||
// save default template for customer if not yet set
|
||||
if ($customer->getInvoiceTemplate() === null) {
|
||||
@@ -200,7 +211,7 @@ final class InvoiceController extends AbstractController
|
||||
$customerRepository->saveCustomer($customer);
|
||||
}
|
||||
|
||||
$invoice = $service->createInvoice($model, $this->dispatcher);
|
||||
$invoice = $this->service->createInvoice($model, $this->dispatcher);
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
@@ -218,7 +229,7 @@ final class InvoiceController extends AbstractController
|
||||
#[Route(path: '/change-status/{id}/{status}/{token}', name: 'admin_invoice_status', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_invoice')]
|
||||
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
|
||||
public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager, ServiceInvoice $service): Response
|
||||
public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
@@ -243,7 +254,7 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
|
||||
try {
|
||||
$service->changeInvoiceStatus($invoice, $status);
|
||||
$this->service->changeInvoiceStatus($invoice, $status);
|
||||
$this->flashSuccess('action.update.success');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
@@ -255,14 +266,14 @@ final class InvoiceController extends AbstractController
|
||||
#[Route(path: '/edit/{id}', name: 'admin_invoice_edit', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_invoice')]
|
||||
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
|
||||
public function editAction(Invoice $invoice, Request $request, InvoiceRepository $invoiceRepository): Response
|
||||
public function editAction(Invoice $invoice, Request $request): Response
|
||||
{
|
||||
$form = $this->createInvoiceEditForm($invoice);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$invoiceRepository->saveInvoice($invoice);
|
||||
$this->invoiceRepository->saveInvoice($invoice);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
@@ -281,7 +292,7 @@ final class InvoiceController extends AbstractController
|
||||
#[Route(path: '/delete/{id}/{token}', name: 'admin_invoice_delete', methods: ['GET'])]
|
||||
#[IsGranted('delete_invoice')]
|
||||
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
|
||||
public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager, ServiceInvoice $service): Response
|
||||
public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
@@ -292,7 +303,7 @@ final class InvoiceController extends AbstractController
|
||||
$csrfTokenManager->refreshToken('invoice.status');
|
||||
|
||||
try {
|
||||
$service->deleteInvoice($invoice, $this->dispatcher);
|
||||
$this->service->deleteInvoice($invoice, $this->dispatcher);
|
||||
$this->flashSuccess('action.delete.success');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashDeleteException($ex);
|
||||
@@ -304,9 +315,9 @@ final class InvoiceController extends AbstractController
|
||||
#[Route(path: '/download/{id}', name: 'admin_invoice_download', methods: ['GET'])]
|
||||
#[IsGranted('view_invoice')]
|
||||
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
|
||||
public function downloadAction(Invoice $invoice, ServiceInvoice $service): Response
|
||||
public function downloadAction(Invoice $invoice): Response
|
||||
{
|
||||
$file = $service->getInvoiceFile($invoice);
|
||||
$file = $this->service->getInvoiceFile($invoice);
|
||||
|
||||
if (null === $file) {
|
||||
throw $this->createNotFoundException(
|
||||
@@ -319,12 +330,12 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/show/{page}', defaults: ['page' => 1], requirements: ['page' => '[1-9]\d*'], name: 'admin_invoice_list', methods: ['GET'])]
|
||||
#[IsGranted('view_invoice')]
|
||||
public function showInvoicesAction(Request $request, int $page, InvoiceRepository $invoiceRepository): Response
|
||||
public function showInvoicesAction(Request $request, int $page): Response
|
||||
{
|
||||
$invoice = null;
|
||||
|
||||
if (null !== ($id = $request->query->get('id'))) {
|
||||
$invoice = $invoiceRepository->find($id);
|
||||
$invoice = $this->invoiceRepository->find($id);
|
||||
}
|
||||
|
||||
$query = new InvoiceArchiveQuery();
|
||||
@@ -336,7 +347,7 @@ final class InvoiceController extends AbstractController
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
}
|
||||
|
||||
$entries = $invoiceRepository->getPagerfantaForQuery($query);
|
||||
$entries = $this->invoiceRepository->getPagerfantaForQuery($query);
|
||||
$metaColumns = $this->findMetaColumns($query);
|
||||
|
||||
$table = new DataTable('invoices', $query);
|
||||
@@ -378,7 +389,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/export', name: 'invoice_export', methods: ['GET'])]
|
||||
#[IsGranted('view_invoice')]
|
||||
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter, InvoiceRepository $invoiceRepository, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter): Response
|
||||
{
|
||||
$query = new InvoiceArchiveQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
@@ -387,7 +398,7 @@ final class InvoiceController extends AbstractController
|
||||
$form->setData($query);
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
$entries = $invoiceRepository->getInvoicesForQuery($query);
|
||||
$entries = $this->invoiceRepository->getInvoicesForQuery($query);
|
||||
|
||||
$spreadsheet = $exporter->export(
|
||||
Invoice::class,
|
||||
@@ -401,12 +412,12 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/template/{page}', requirements: ['page' => '[1-9]\d*'], defaults: ['page' => 1], name: 'admin_invoice_template', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('manage_invoice_template')]
|
||||
public function listTemplateAction(int $page, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function listTemplateAction(int $page): Response
|
||||
{
|
||||
$query = new BaseQuery();
|
||||
$query->setPage($page);
|
||||
|
||||
$entries = $templateRepository->getPagerfantaForQuery($query);
|
||||
$entries = $this->templateRepository->getPagerfantaForQuery($query);
|
||||
|
||||
$table = new DataTable('invoice_template', $query);
|
||||
$table->setPagination($entries);
|
||||
@@ -438,16 +449,16 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/template/{id}/edit', name: 'admin_invoice_template_edit', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('manage_invoice_template')]
|
||||
public function editTemplateAction(InvoiceTemplate $template, Request $request, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function editTemplateAction(InvoiceTemplate $template, Request $request): Response
|
||||
{
|
||||
return $this->renderTemplateForm($template, $request, $templateRepository);
|
||||
return $this->renderTemplateForm($template, $request);
|
||||
}
|
||||
|
||||
#[Route(path: '/document_download/{document}', name: 'admin_invoice_document_download', methods: ['GET'])]
|
||||
#[IsGranted('upload_invoice_template')]
|
||||
public function downloadDocument(string $document, ServiceInvoice $service): Response
|
||||
public function downloadDocument(string $document, Environment $twig): Response
|
||||
{
|
||||
$event = new InvoiceDocumentsEvent($service->getDocuments(true));
|
||||
$event = new InvoiceDocumentsEvent($this->service->getDocuments(true));
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
foreach ($event->getInvoiceDocuments() as $doc) {
|
||||
@@ -461,7 +472,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/document_upload', name: 'admin_invoice_document_upload', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('upload_invoice_template')]
|
||||
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration, ServiceInvoice $service, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration): Response
|
||||
{
|
||||
$dir = $documentRepository->getUploadDirectory();
|
||||
$invoiceDir = $dir;
|
||||
@@ -473,11 +484,11 @@ final class InvoiceController extends AbstractController
|
||||
$invoiceDir = rtrim($invoiceDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
|
||||
$used = [];
|
||||
foreach ($templateRepository->findAll() as $template) {
|
||||
foreach ($this->templateRepository->findAll() as $template) {
|
||||
$used[$template->getRenderer()] = $template;
|
||||
}
|
||||
|
||||
$event = new InvoiceDocumentsEvent($service->getDocuments(true));
|
||||
$event = new InvoiceDocumentsEvent($this->service->getDocuments(true));
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
$documents = [];
|
||||
@@ -595,7 +606,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/document/{id}/delete/{token}', name: 'invoice_document_delete', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('manage_invoice_template')]
|
||||
public function deleteDocument(string $id, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceDocumentRepository $documentRepository, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function deleteDocument(string $id, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceDocumentRepository $documentRepository): Response
|
||||
{
|
||||
$document = $documentRepository->findByName($id);
|
||||
if ($document === null) {
|
||||
@@ -618,7 +629,7 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($templateRepository->findAll() as $template) {
|
||||
foreach ($this->templateRepository->findAll() as $template) {
|
||||
if ($template->getRenderer() === $id) {
|
||||
$this->flashError('Document is used and cannot be deleted.');
|
||||
|
||||
@@ -638,19 +649,19 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/template/create/{id}', name: 'admin_invoice_template_copy', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('manage_invoice_template')]
|
||||
public function copyTemplateAction(Request $request, InvoiceTemplate $copyFrom, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function copyTemplateAction(Request $request, InvoiceTemplate $copyFrom): Response
|
||||
{
|
||||
return $this->createTemplate($request, $templateRepository, $copyFrom);
|
||||
return $this->createTemplate($request, $copyFrom);
|
||||
}
|
||||
|
||||
#[Route(path: '/template/create', name: 'admin_invoice_template_create', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('manage_invoice_template')]
|
||||
public function createTemplateAction(Request $request, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function createTemplateAction(Request $request): Response
|
||||
{
|
||||
return $this->createTemplate($request, $templateRepository, null);
|
||||
return $this->createTemplate($request, null);
|
||||
}
|
||||
|
||||
private function createTemplate(Request $request, InvoiceTemplateRepository $templateRepository, ?InvoiceTemplate $copyFrom = null): Response
|
||||
private function createTemplate(Request $request, ?InvoiceTemplate $copyFrom = null): Response
|
||||
{
|
||||
$template = new InvoiceTemplate();
|
||||
$template->setLanguage($request->getLocale());
|
||||
@@ -660,12 +671,12 @@ final class InvoiceController extends AbstractController
|
||||
$template->setName($copyFrom->getName() . ' (1)');
|
||||
}
|
||||
|
||||
return $this->renderTemplateForm($template, $request, $templateRepository);
|
||||
return $this->renderTemplateForm($template, $request);
|
||||
}
|
||||
|
||||
#[Route(path: '/template/{id}/delete/{csrfToken}', name: 'admin_invoice_template_delete', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('manage_invoice_template')]
|
||||
public function deleteTemplate(InvoiceTemplate $template, string $csrfToken, CsrfTokenManagerInterface $csrfTokenManager, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function deleteTemplate(InvoiceTemplate $template, string $csrfToken, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.delete_template', $csrfToken))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
@@ -676,7 +687,7 @@ final class InvoiceController extends AbstractController
|
||||
$csrfTokenManager->refreshToken('invoice.delete_template');
|
||||
|
||||
try {
|
||||
$templateRepository->removeTemplate($template);
|
||||
$this->templateRepository->removeTemplate($template);
|
||||
$this->flashSuccess('action.delete.success');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashDeleteException($ex);
|
||||
@@ -716,7 +727,7 @@ final class InvoiceController extends AbstractController
|
||||
$this->flashError('action.update.error', $err);
|
||||
}
|
||||
|
||||
private function renderTemplateForm(InvoiceTemplate $template, Request $request, InvoiceTemplateRepository $templateRepository): Response
|
||||
private function renderTemplateForm(InvoiceTemplate $template, Request $request): Response
|
||||
{
|
||||
$event = new InvoiceTemplateMetaDefinitionEvent($template);
|
||||
$this->dispatcher->dispatch($event);
|
||||
@@ -727,7 +738,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
$templateRepository->saveTemplate($template);
|
||||
$this->templateRepository->saveTemplate($template);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_template');
|
||||
|
||||
@@ -16,7 +16,6 @@ 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;
|
||||
|
||||
@@ -38,7 +37,7 @@ abstract class AbstractUserReportController extends AbstractController
|
||||
|
||||
protected function getStatisticDataRaw(DateTimeInterface $begin, DateTimeInterface $end, User $user): array
|
||||
{
|
||||
return $this->statisticService->getDailyStatisticsGrouped(new TimesheetStatisticQuery($begin, $end, [$user]));
|
||||
return $this->statisticService->getDailyStatisticsGrouped($begin, $end, [$user]);
|
||||
}
|
||||
|
||||
protected function createStatisticModel(DateTimeInterface $begin, DateTimeInterface $end, User $user): DateStatisticInterface
|
||||
|
||||
@@ -17,7 +17,6 @@ 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;
|
||||
@@ -126,7 +125,7 @@ final class UserYearController extends AbstractUserReportController
|
||||
|
||||
protected function getStatisticDataRaw(DateTimeInterface $begin, DateTimeInterface $end, User $user): array
|
||||
{
|
||||
return $this->statisticService->getMonthlyStatisticsGrouped(new TimesheetStatisticQuery($begin, $end, [$user]));
|
||||
return $this->statisticService->getMonthlyStatisticsGrouped($begin, $end, [$user]);
|
||||
}
|
||||
|
||||
protected function createStatisticModel(DateTimeInterface $begin, DateTimeInterface $end, User $user): DateStatisticInterface
|
||||
|
||||
@@ -38,9 +38,15 @@ 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(int $page, TeamRepository $repository, Request $request): Response
|
||||
public function listTeams(TeamRepository $repository, Request $request, $page): Response
|
||||
{
|
||||
$query = new TeamQuery();
|
||||
$query->setPage($page);
|
||||
@@ -75,6 +81,10 @@ 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; // @phpstan-ignore argument.type
|
||||
$newConfig[implode('.', $keys)] = $value;
|
||||
}
|
||||
|
||||
$container->setParameter('kimai.config', $newConfig);
|
||||
|
||||
@@ -862,9 +862,6 @@ 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,7 +60,6 @@ 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]
|
||||
@@ -118,7 +117,6 @@ 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'])]
|
||||
|
||||
@@ -23,8 +23,7 @@ trait ColorTrait
|
||||
* The assigned color in HTML hex format, e.g. #dd1d00
|
||||
*/
|
||||
#[ORM\Column(name: 'color', type: Types::STRING, length: 7, nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Exclude]
|
||||
#[Exporter\Expose(label: 'color')]
|
||||
#[Constraints\HexColor]
|
||||
private ?string $color = null;
|
||||
@@ -51,10 +50,10 @@ trait ColorTrait
|
||||
abstract public function getName(): ?string;
|
||||
|
||||
/**
|
||||
* Color will never be empty and is generated from the entity tag name if not set explicit.
|
||||
* Internal value: this color will never be empty and is generated by the tag name if not set explicit.
|
||||
*/
|
||||
#[Serializer\VirtualProperty]
|
||||
#[Serializer\SerializedName('color-safe')]
|
||||
#[Serializer\SerializedName('color')]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
public function getColorSafe(): string
|
||||
{
|
||||
|
||||
@@ -45,7 +45,6 @@ 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]
|
||||
@@ -53,7 +52,6 @@ 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'])]
|
||||
@@ -143,7 +141,6 @@ 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'])]
|
||||
@@ -151,8 +148,6 @@ 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')]
|
||||
@@ -236,7 +231,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[Assert\Length(max: 50)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'buyerReference')]
|
||||
#[Exporter\Expose(label: 'buyer_reference')]
|
||||
private ?string $buyerReference = null;
|
||||
|
||||
public function __construct(string $name)
|
||||
|
||||
@@ -256,18 +256,6 @@ 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';
|
||||
|
||||
@@ -12,8 +12,8 @@ namespace App\Entity;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
||||
/**
|
||||
* @method array<Tag> getTags()
|
||||
* @method int getBreak()
|
||||
* @method getTags() array
|
||||
* @method getBreak() int
|
||||
*/
|
||||
interface ExportableItem
|
||||
{
|
||||
|
||||
@@ -88,10 +88,6 @@ class InvoiceTemplate implements EntityWithMetaFields
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'template', targetEntity: InvoiceTemplateMeta::class, cascade: ['persist'])]
|
||||
private Collection $meta;
|
||||
/**
|
||||
* @var array<Tax>
|
||||
*/
|
||||
private array $taxRates = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -272,36 +268,23 @@ class InvoiceTemplate implements EntityWithMetaFields
|
||||
$this->language = $language;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<Tax> $taxRates
|
||||
*/
|
||||
public function setTaxRates(array $taxRates): void
|
||||
{
|
||||
$this->taxRates = $taxRates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Tax[]
|
||||
*/
|
||||
public function getTaxRates(): array
|
||||
{
|
||||
if (\count($this->taxRates) > 0) {
|
||||
return $this->taxRates;
|
||||
}
|
||||
|
||||
// TODO make me configurable via UI
|
||||
$tax = new Tax(
|
||||
TaxType::STANDARD,
|
||||
$this->vat ?? 0.00,
|
||||
'vat',
|
||||
true,
|
||||
null
|
||||
'VAT',
|
||||
$this->vat ?? 0.00
|
||||
);
|
||||
|
||||
return [$tax];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, InvoiceTemplateMeta>
|
||||
* @return Collection|MetaTableTypeInterface[]
|
||||
*/
|
||||
public function getMetaFields(): Collection
|
||||
{
|
||||
|
||||
@@ -11,10 +11,6 @@ namespace App\Entity;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
/**
|
||||
* @method null|string getSection()
|
||||
* @method void setSection(?string $name)
|
||||
*/
|
||||
interface MetaTableTypeInterface
|
||||
{
|
||||
/**
|
||||
@@ -47,8 +43,6 @@ interface MetaTableTypeInterface
|
||||
/**
|
||||
* This will merge the current object with the values from the given $meta instance.
|
||||
* It should NOT update the name or value, but only the form settings.
|
||||
*
|
||||
* Settings from the given $meta object will win over the current ones.
|
||||
*/
|
||||
public function merge(MetaTableTypeInterface $meta): MetaTableTypeInterface;
|
||||
|
||||
@@ -138,16 +132,6 @@ interface MetaTableTypeInterface
|
||||
*/
|
||||
public function getOrder(): int;
|
||||
|
||||
/**
|
||||
* FIXME activate with 3.0
|
||||
*/
|
||||
//public function setSection(?string $section): void;
|
||||
|
||||
/**
|
||||
* FIXME activate with 3.0
|
||||
*/
|
||||
//public function getSection(): ?string;
|
||||
|
||||
/**
|
||||
* Whether true if this field is defined by a plugin, or false if it is a value stored in the database.
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
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;
|
||||
@@ -31,7 +30,6 @@ 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]
|
||||
@@ -69,7 +67,6 @@ trait MetaTableTypeTrait
|
||||
*/
|
||||
private mixed $data = null;
|
||||
private bool $updated = false;
|
||||
private ?string $section = null;
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
@@ -196,11 +193,13 @@ trait MetaTableTypeTrait
|
||||
|
||||
public function merge(MetaTableTypeInterface $meta): MetaTableTypeInterface
|
||||
{
|
||||
$this->setConstraints($meta->getConstraints());
|
||||
$this->setIsRequired($meta->isRequired());
|
||||
$this->setIsVisible($meta->isVisible());
|
||||
$this->setOptions($meta->getOptions());
|
||||
$this->setOrder($meta->getOrder());
|
||||
$this
|
||||
->setConstraints($meta->getConstraints())
|
||||
->setIsRequired($meta->isRequired())
|
||||
->setIsVisible($meta->isVisible())
|
||||
->setOptions($meta->getOptions())
|
||||
->setOrder($meta->getOrder())
|
||||
;
|
||||
|
||||
if ($meta->getLabel() !== null) {
|
||||
$this->setLabel($meta->getLabel());
|
||||
@@ -210,10 +209,6 @@ trait MetaTableTypeTrait
|
||||
$this->setType($meta->getType());
|
||||
}
|
||||
|
||||
if ($meta->getSection() !== null) {
|
||||
$this->setSection($meta->getSection());
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -271,16 +266,6 @@ trait MetaTableTypeTrait
|
||||
}
|
||||
}
|
||||
|
||||
public function setSection(?string $section): void
|
||||
{
|
||||
$this->section = $section;
|
||||
}
|
||||
|
||||
public function getSection(): ?string
|
||||
{
|
||||
return $this->section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this field is defined by a plugin or just a value stored in the database.
|
||||
*/
|
||||
|
||||
@@ -63,7 +63,6 @@ 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]
|
||||
@@ -171,7 +170,6 @@ 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,7 +10,6 @@
|
||||
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;
|
||||
@@ -27,7 +26,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
class Tag
|
||||
{
|
||||
/**
|
||||
* Tag ID
|
||||
* Internal Tag ID
|
||||
*/
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
@@ -36,10 +35,9 @@ class Tag
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?int $id = null;
|
||||
/**
|
||||
* Tag name cannot contain the character: " < > = ,
|
||||
* The tag name
|
||||
*/
|
||||
#[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)]
|
||||
@@ -54,6 +52,10 @@ class Tag
|
||||
|
||||
use ColorTrait;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
|
||||
@@ -13,10 +13,8 @@ final class Tax
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaxType $type,
|
||||
private readonly float $rate,
|
||||
private readonly string $name,
|
||||
private readonly bool $show,
|
||||
private readonly ?string $note,
|
||||
private readonly string $name = 'VAT',
|
||||
private readonly float $rate = 0.0,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -35,14 +33,4 @@ final class Tax
|
||||
{
|
||||
return $this->rate;
|
||||
}
|
||||
|
||||
public function isShow(): bool
|
||||
{
|
||||
return $this->rate > 0.0 || $this->show;
|
||||
}
|
||||
|
||||
public function getNote(): ?string
|
||||
{
|
||||
return $this->note;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ 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,7 +81,6 @@ 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')]
|
||||
@@ -97,16 +96,14 @@ 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
|
||||
* URL to the user avatar, will be auto-generated if empty
|
||||
*/
|
||||
#[ORM\Column(name: 'avatar', type: Types::STRING, length: 255, nullable: true)]
|
||||
#[Assert\Url]
|
||||
#[Assert\Length(max: 255, groups: ['Profile'])]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
@@ -165,7 +162,6 @@ 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;
|
||||
@@ -177,7 +173,6 @@ 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'])]
|
||||
@@ -381,24 +376,14 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called from the "edit user preferences" form.
|
||||
* Therefor it just merges the values for existing preferences and adds new ones.
|
||||
* But it will NOT remove existing preferences or replace the underlying collection.
|
||||
*
|
||||
* @param iterable<UserPreference> $preferences
|
||||
*/
|
||||
public function setPreferences(iterable $preferences): User
|
||||
{
|
||||
$this->preferences = new ArrayCollection();
|
||||
|
||||
foreach ($preferences as $preference) {
|
||||
if (($name = $preference->getName()) === null) {
|
||||
continue;
|
||||
}
|
||||
$p = $this->getPreference($name);
|
||||
if ($p === null) {
|
||||
$this->addPreference($preference);
|
||||
} else {
|
||||
$p->setValue($preference->getValue());
|
||||
}
|
||||
$this->addPreference($preference);
|
||||
}
|
||||
|
||||
return $this;
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
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;
|
||||
@@ -62,7 +61,6 @@ 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]
|
||||
|
||||
60
src/Entity/WebhookConfiguration.php
Normal file
60
src/Entity/WebhookConfiguration.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?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\Entity;
|
||||
|
||||
class WebhookConfiguration
|
||||
{
|
||||
private string $name;
|
||||
private string $url;
|
||||
private string $transport;
|
||||
private string $secret;
|
||||
private string $authentication;
|
||||
|
||||
public function __construct(
|
||||
string $name,
|
||||
string $url,
|
||||
string $transport,
|
||||
#[\SensitiveParameter]
|
||||
string $secret,
|
||||
string $authentication
|
||||
)
|
||||
{
|
||||
$this->name = $name;
|
||||
$this->url = $url;
|
||||
$this->transport = $transport;
|
||||
$this->secret = $secret;
|
||||
$this->authentication = $authentication;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getUrl(): string
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
public function getTransport(): string
|
||||
{
|
||||
return $this->transport;
|
||||
}
|
||||
|
||||
public function getSecret(): string
|
||||
{
|
||||
return $this->secret;
|
||||
}
|
||||
|
||||
public function getAuthentication(): string
|
||||
{
|
||||
return $this->authentication;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user