added API tokens, deprecate API passwords (#4637)

This commit is contained in:
Kevin Papst
2024-04-05 23:51:16 +02:00
committed by GitHub
parent dd51c8dfba
commit afe0656502
60 changed files with 889 additions and 624 deletions

View File

@@ -48,19 +48,13 @@ nelmio_api_doc:
title: Kimai - API Docs title: Kimai - API Docs
description: | description: |
JSON API for the Kimai time-tracking software: [API documentation](https://www.kimai.org/documentation/rest-api.html), [Swagger definition file](doc.json) JSON API for the Kimai time-tracking software: [API documentation](https://www.kimai.org/documentation/rest-api.html), [Swagger definition file](doc.json)
version: '0.7' version: '1.0'
components: components:
securitySchemes: securitySchemes:
apiUser: bearer:
type: apiKey type: http
description: 'Value: {Username}' scheme: bearer
name: X-AUTH-USER bearerFormat: KIMAI
in: header description: API Token
apiToken:
type: apiKey
description: 'Value: {API Token}'
name: X-AUTH-TOKEN
in: header
security: security:
- X-AUTH-USER: [] - bearer: []
X-AUTH-TOKEN: []

View File

@@ -18,6 +18,8 @@ security:
security: false security: false
api: api:
access_token:
token_handler: App\API\Authentication\AccessTokenHandler
request_matcher: App\API\Authentication\ApiRequestMatcher request_matcher: App\API\Authentication\ApiRequestMatcher
user_checker: App\Security\UserChecker user_checker: App\Security\UserChecker
stateless: true stateless: true
@@ -35,7 +37,6 @@ security:
entry_point: form_login entry_point: form_login
custom_authenticators: custom_authenticators:
- App\API\Authentication\SessionAuthenticator
- App\Saml\SamlAuthenticator - App\Saml\SamlAuthenticator
remember_me: remember_me:

View File

@@ -197,6 +197,11 @@ services:
factory: ['@doctrine.orm.entity_manager', getRepository] factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\WorkingTime'] arguments: ['App\Entity\WorkingTime']
App\Repository\AccessTokenRepository:
class: App\Repository\AccessTokenRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\AccessToken']
monolog.formatter.kimai: monolog.formatter.kimai:
class: Monolog\Formatter\LineFormatter class: Monolog\Formatter\LineFormatter
arguments: arguments:

View File

@@ -0,0 +1,56 @@
<?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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* @version 2.14
*/
final class Version20240214061246 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds the table for API access tokens';
}
public function up(Schema $schema): void
{
$accessTokens = $schema->createTable('kimai2_access_token');
$accessTokens->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$accessTokens->addColumn('user_id', 'integer', ['notnull' => true]);
$accessTokens->addColumn('token', 'string', ['notnull' => true, 'length' => 100]);
$accessTokens->addColumn('name', 'string', ['notnull' => true, 'length' => 50]);
$accessTokens->addColumn('last_usage', 'datetime_immutable', ['notnull' => false, 'default' => null]);
$accessTokens->addColumn('expires_at', 'datetime_immutable', ['notnull' => false, 'default' => null]);
$accessTokens->setPrimaryKey(['id']);
$accessTokens->addIndex(['user_id'], 'IDX_6FB0DB1EA76ED395');
$accessTokens->addUniqueIndex(['token'], 'UNIQ_6FB0DB1E5F37A13B');
$accessTokens->addForeignKeyConstraint('kimai2_users', ['user_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_6FB0DB1EA76ED395');
}
public function down(Schema $schema): void
{
$table = $schema->getTable('kimai2_access_token');
$table->removeForeignKey('FK_6FB0DB1EA76ED395');
$schema->dropTable('kimai2_access_token');
}
public function isTransactional(): bool
{
return false;
}
}

View File

@@ -18,7 +18,6 @@ use App\Event\PageActionsEvent;
use FOS\RestBundle\View\View; use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model; use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -73,8 +72,6 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)] #[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)]
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)] #[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[Route(methods: ['GET'], path: '/timesheet/{id}/{view}/{locale}', name: 'get_timesheet_actions', requirements: ['id' => '\d+'])] #[Route(methods: ['GET'], path: '/timesheet/{id}/{view}/{locale}', name: 'get_timesheet_actions', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getTimesheetActions(Timesheet $timesheet, string $view, string $locale): Response public function getTimesheetActions(Timesheet $timesheet, string $view, string $locale): Response
{ {
$event = new PageActionsEvent($this->getUser(), ['timesheet' => $timesheet], 'timesheet', $view); $event = new PageActionsEvent($this->getUser(), ['timesheet' => $timesheet], 'timesheet', $view);
@@ -93,8 +90,6 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)] #[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)]
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)] #[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[Route(methods: ['GET'], path: '/activity/{id}/{view}/{locale}', name: 'get_activity_actions', requirements: ['id' => '\d+'])] #[Route(methods: ['GET'], path: '/activity/{id}/{view}/{locale}', name: 'get_activity_actions', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getActivityActions(Activity $activity, string $view, string $locale): Response public function getActivityActions(Activity $activity, string $view, string $locale): Response
{ {
$event = new PageActionsEvent($this->getUser(), ['activity' => $activity], 'activity', $view); $event = new PageActionsEvent($this->getUser(), ['activity' => $activity], 'activity', $view);
@@ -113,8 +108,6 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)] #[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)]
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)] #[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[Route(methods: ['GET'], path: '/project/{id}/{view}/{locale}', name: 'get_project_actions', requirements: ['id' => '\d+'])] #[Route(methods: ['GET'], path: '/project/{id}/{view}/{locale}', name: 'get_project_actions', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getProjectActions(Project $project, string $view, string $locale): Response public function getProjectActions(Project $project, string $view, string $locale): Response
{ {
$event = new PageActionsEvent($this->getUser(), ['project' => $project], 'project', $view); $event = new PageActionsEvent($this->getUser(), ['project' => $project], 'project', $view);
@@ -133,8 +126,6 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)] #[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)]
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)] #[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[Route(methods: ['GET'], path: '/customer/{id}/{view}/{locale}', name: 'get_customer_actions', requirements: ['id' => '\d+'])] #[Route(methods: ['GET'], path: '/customer/{id}/{view}/{locale}', name: 'get_customer_actions', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getCustomerActions(Customer $customer, string $view, string $locale): Response public function getCustomerActions(Customer $customer, string $view, string $locale): Response
{ {
$event = new PageActionsEvent($this->getUser(), ['customer' => $customer], 'customer', $view); $event = new PageActionsEvent($this->getUser(), ['customer' => $customer], 'customer', $view);

View File

@@ -24,7 +24,6 @@ use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface; use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View; use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bridge\Doctrine\Attribute\MapEntity;
@@ -56,8 +55,6 @@ final class ActivityController extends BaseApiController
*/ */
#[OA\Response(response: 200, description: 'Returns a collection of activities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ActivityCollection')))] #[OA\Response(response: 200, description: 'Returns a collection of activities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ActivityCollection')))]
#[Route(methods: ['GET'], path: '', name: 'get_activities')] #[Route(methods: ['GET'], path: '', name: 'get_activities')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\QueryParam(name: 'project', requirements: '\d+', strict: true, nullable: true, description: 'Project ID to filter activities')] #[Rest\QueryParam(name: 'project', requirements: '\d+', strict: true, nullable: true, description: 'Project ID to filter activities')]
#[Rest\QueryParam(name: 'projects', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of project IDs to filter activities, e.g.: projects[]=1&projects[]=2')] #[Rest\QueryParam(name: 'projects', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of project IDs to filter activities, e.g.: projects[]=1&projects[]=2')]
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter activities: 1=visible, 2=hidden, 3=all')] #[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter activities: 1=visible, 2=hidden, 3=all')]
@@ -126,8 +123,6 @@ final class ActivityController extends BaseApiController
#[OA\Response(response: 200, description: 'Returns one activity entity', content: new OA\JsonContent(ref: '#/components/schemas/ActivityEntity'))] #[OA\Response(response: 200, description: 'Returns one activity entity', content: new OA\JsonContent(ref: '#/components/schemas/ActivityEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Activity ID to fetch', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Activity ID to fetch', required: true)]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_activity', requirements: ['id' => '\d+'])] #[Route(methods: ['GET'], path: '/{id}', name: 'get_activity', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[IsGranted('view', 'activity')] #[IsGranted('view', 'activity')]
public function getAction(Activity $activity): Response public function getAction(Activity $activity): Response
{ {
@@ -143,8 +138,6 @@ final class ActivityController extends BaseApiController
#[OA\Post(description: 'Creates a new activity and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created activity', content: new OA\JsonContent(ref: '#/components/schemas/ActivityEntity'))])] #[OA\Post(description: 'Creates a new activity and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created activity', content: new OA\JsonContent(ref: '#/components/schemas/ActivityEntity'))])]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ActivityEditForm'))] #[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ActivityEditForm'))]
#[Route(methods: ['POST'], path: '', name: 'post_activity')] #[Route(methods: ['POST'], path: '', name: 'post_activity')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postAction(Request $request): Response public function postAction(Request $request): Response
{ {
if (!$this->isGranted('create_activity')) { if (!$this->isGranted('create_activity')) {
@@ -186,8 +179,6 @@ final class ActivityController extends BaseApiController
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ActivityEditForm'))] #[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ActivityEditForm'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Activity ID to update', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Activity ID to update', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_activity', requirements: ['id' => '\d+'])] #[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_activity', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function patchAction(Request $request, Activity $activity): Response public function patchAction(Request $request, Activity $activity): Response
{ {
$event = new ActivityMetaDefinitionEvent($activity); $event = new ActivityMetaDefinitionEvent($activity);
@@ -223,8 +214,6 @@ final class ActivityController extends BaseApiController
#[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/ActivityEntity'))] #[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/ActivityEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Activity record ID to set the meta-field value for', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Activity record ID to set the meta-field value for', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}/meta', requirements: ['id' => '\d+'])] #[Route(methods: ['PATCH'], path: '/{id}/meta', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\RequestParam(name: 'name', strict: true, nullable: false, description: 'The meta-field name')] #[Rest\RequestParam(name: 'name', strict: true, nullable: false, description: 'The meta-field name')]
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')] #[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
public function metaAction(Activity $activity, ParamFetcherInterface $paramFetcher): Response public function metaAction(Activity $activity, ParamFetcherInterface $paramFetcher): Response
@@ -256,8 +245,6 @@ final class ActivityController extends BaseApiController
#[OA\Response(response: 200, description: 'Returns a collection of activity rate entities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ActivityRate')))] #[OA\Response(response: 200, description: 'Returns a collection of activity rate entities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ActivityRate')))]
#[OA\Parameter(name: 'id', in: 'path', description: 'The activity whose rates will be returned', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'The activity whose rates will be returned', required: true)]
#[Route(methods: ['GET'], path: '/{id}/rates', name: 'get_activity_rates', requirements: ['id' => '\d+'])] #[Route(methods: ['GET'], path: '/{id}/rates', name: 'get_activity_rates', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getRatesAction(Activity $activity): Response public function getRatesAction(Activity $activity): Response
{ {
$rates = $this->activityRateRepository->getRatesForActivity($activity); $rates = $this->activityRateRepository->getRatesForActivity($activity);
@@ -275,8 +262,6 @@ final class ActivityController extends BaseApiController
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])] #[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The activity whose rate will be removed', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'The activity whose rate will be removed', required: true)]
#[OA\Parameter(name: 'rateId', in: 'path', description: 'The rate to remove', required: true)] #[OA\Parameter(name: 'rateId', in: 'path', description: 'The rate to remove', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}/rates/{rateId}', name: 'delete_activity_rate', requirements: ['id' => '\d+', 'rateId' => '\d+'])] #[Route(methods: ['DELETE'], path: '/{id}/rates/{rateId}', name: 'delete_activity_rate', requirements: ['id' => '\d+', 'rateId' => '\d+'])]
public function deleteRateAction(Activity $activity, #[MapEntity(mapping: ['rateId' => 'id'])] ActivityRate $rate): Response public function deleteRateAction(Activity $activity, #[MapEntity(mapping: ['rateId' => 'id'])] ActivityRate $rate): Response
{ {
@@ -299,8 +284,6 @@ final class ActivityController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'The activity to add the rate for', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'The activity to add the rate for', required: true)]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ActivityRateForm'))] #[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ActivityRateForm'))]
#[Route(methods: ['POST'], path: '/{id}/rates', name: 'post_activity_rate', requirements: ['id' => '\d+'])] #[Route(methods: ['POST'], path: '/{id}/rates', name: 'post_activity_rate', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postRateAction(Activity $activity, Request $request): Response public function postRateAction(Activity $activity, Request $request): Response
{ {
$rate = new ActivityRate(); $rate = new ActivityRate();

View File

@@ -0,0 +1,46 @@
<?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\API\Authentication;
use App\Repository\AccessTokenRepository;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
final class AccessTokenHandler implements AccessTokenHandlerInterface
{
public function __construct(
private readonly AccessTokenRepository $accessTokenRepository
)
{
}
public function getUserBadgeFrom(string $accessToken): UserBadge
{
$accessToken = $this->accessTokenRepository->findByToken($accessToken);
if (null === $accessToken) {
throw new BadCredentialsException('Invalid credentials.');
}
if (!$accessToken->isValid()) {
throw new BadCredentialsException('Invalid token.');
}
$now = new \DateTimeImmutable();
// record last usage only if this is the first time OR once every minute
if ($accessToken->getLastUsage() === null || $now->getTimestamp() > $accessToken->getLastUsage()->getTimestamp() + 60) {
$accessToken->setLastUsage($now);
$this->accessTokenRepository->saveAccessToken($accessToken);
}
return new UserBadge($accessToken->getUser()->getUserIdentifier(), fn (string $userIdentifier) => $accessToken->getUser());
}
}

View File

@@ -16,16 +16,29 @@ final class ApiRequestMatcher implements RequestMatcherInterface
{ {
public function matches(Request $request): bool public function matches(Request $request): bool
{ {
if (str_contains($request->getRequestUri(), '/api/doc')) { // we do not want to handle URLs that
if (!str_starts_with($request->getRequestUri(), '/api/')) {
return false; return false;
} }
if (str_contains($request->getRequestUri(), '/api/')) { // API documentation is only available to registered users
if (str_starts_with($request->getRequestUri(), '/api/doc')) {
return false; return false;
} }
return !$request->headers->has(SessionAuthenticator::HEADER_JAVASCRIPT) && // let's use this firewall if a Bearer token is set in the header
$request->headers->has(TokenAuthenticator::HEADER_USERNAME) && if ($request->headers->has('Authorization')) {
$request->headers->has(TokenAuthenticator::HEADER_TOKEN); return true;
}
// let's use this firewall if the deprecated username & token combination is available
if ($request->headers->has(TokenAuthenticator::HEADER_USERNAME) &&
$request->headers->has(TokenAuthenticator::HEADER_TOKEN)) {
return true;
}
// checking for a previous session allows us to skip the API firewall and token access handler
// we simply re-use the existing session when doing API calls from the frontend
return !$request->hasPreviousSession();
} }
} }

View File

@@ -1,69 +0,0 @@
<?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\API\Authentication;
use Scheb\TwoFactorBundle\Security\Http\Authenticator\TwoFactorAuthenticator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
final class SessionAuthenticator extends AbstractAuthenticator
{
public const HEADER_JAVASCRIPT = 'X-AUTH-SESSION';
public function __construct(private TokenAuthenticator $authenticator)
{
}
public function createToken(Passport $passport, string $firewallName): TokenInterface
{
$token = parent::createToken($passport, $firewallName);
// this should not be necessary, as /api/ is excluded from 2FA process, but just to make sure this
// authenticator never triggers 2FA, we add the attribute to the token
// https://symfony.com/bundles/SchebTwoFactorBundle/6.x/custom_conditions.html
$token->setAttribute(TwoFactorAuthenticator::FLAG_2FA_COMPLETE, true);
return $token;
}
public function supports(Request $request): bool
{
if (str_contains($request->getRequestUri(), '/api/')) {
// API docs can only be access, when the user is logged in
if (str_contains($request->getRequestUri(), '/api/doc')) {
return false;
}
return !$request->headers->has(self::HEADER_JAVASCRIPT);
}
return false;
}
public function authenticate(Request $request): Passport
{
return $this->authenticator->authenticate($request);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return $this->authenticator->onAuthenticationSuccess($request, $token, $firewallName);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
return $this->authenticator->onAuthenticationFailure($request, $exception);
}
}

View File

@@ -29,14 +29,25 @@ final class TokenAuthenticator extends AbstractAuthenticator
public const HEADER_USERNAME = 'X-AUTH-USER'; public const HEADER_USERNAME = 'X-AUTH-USER';
public const HEADER_TOKEN = 'X-AUTH-TOKEN'; public const HEADER_TOKEN = 'X-AUTH-TOKEN';
public function __construct(private ApiUserRepository $userProvider, private PasswordHasherFactoryInterface $passwordHasherFactory) public function __construct(
private readonly ApiUserRepository $userProvider,
private readonly PasswordHasherFactoryInterface $passwordHasherFactory
)
{ {
} }
public function supports(Request $request): bool public function supports(Request $request): bool
{ {
if (str_contains($request->getRequestUri(), '/api/')) { if (str_contains($request->getRequestUri(), '/api/')) {
return !str_contains($request->getRequestUri(), '/api/doc'); if (str_contains($request->getRequestUri(), '/api/doc')) {
return false;
}
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;
}
} }
return false; return false;

View File

@@ -14,7 +14,6 @@ use App\Configuration\SystemConfiguration;
use FOS\RestBundle\View\View; use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model; use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -33,8 +32,6 @@ final class ConfigurationController extends BaseApiController
*/ */
#[OA\Response(response: 200, description: 'Returns the instance specific timesheet configuration', content: new OA\JsonContent(ref: new Model(type: TimesheetConfig::class)))] #[OA\Response(response: 200, description: 'Returns the instance specific timesheet configuration', content: new OA\JsonContent(ref: new Model(type: TimesheetConfig::class)))]
#[Route(methods: ['GET'], path: '/config/timesheet')] #[Route(methods: ['GET'], path: '/config/timesheet')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function timesheetConfigAction(SystemConfiguration $configuration): Response public function timesheetConfigAction(SystemConfiguration $configuration): Response
{ {
$model = new TimesheetConfig(); $model = new TimesheetConfig();

View File

@@ -24,7 +24,6 @@ use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface; use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View; use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bridge\Doctrine\Attribute\MapEntity;
@@ -56,8 +55,6 @@ final class CustomerController extends BaseApiController
*/ */
#[OA\Response(response: 200, description: 'Returns a collection of customers', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/CustomerCollection')))] #[OA\Response(response: 200, description: 'Returns a collection of customers', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/CustomerCollection')))]
#[Route(methods: ['GET'], path: '', name: 'get_customers')] #[Route(methods: ['GET'], path: '', name: 'get_customers')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter customers: 1=visible, 2=hidden, 3=both')] #[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter customers: 1=visible, 2=hidden, 3=both')]
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')] #[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|name', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name (default: name)')] #[Rest\QueryParam(name: 'orderBy', requirements: 'id|name', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name (default: name)')]
@@ -103,8 +100,6 @@ final class CustomerController extends BaseApiController
*/ */
#[OA\Response(response: 200, description: 'Returns one customer entity', content: new OA\JsonContent(ref: '#/components/schemas/CustomerEntity'))] #[OA\Response(response: 200, description: 'Returns one customer entity', content: new OA\JsonContent(ref: '#/components/schemas/CustomerEntity'))]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_customer', requirements: ['id' => '\d+'])] #[Route(methods: ['GET'], path: '/{id}', name: 'get_customer', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[IsGranted('view', 'customer')] #[IsGranted('view', 'customer')]
public function getAction(Customer $customer): Response public function getAction(Customer $customer): Response
{ {
@@ -120,8 +115,6 @@ final class CustomerController extends BaseApiController
#[OA\Post(description: 'Creates a new customer and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created customer', content: new OA\JsonContent(ref: '#/components/schemas/CustomerEntity'))])] #[OA\Post(description: 'Creates a new customer and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created customer', content: new OA\JsonContent(ref: '#/components/schemas/CustomerEntity'))])]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CustomerEditForm'))] #[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CustomerEditForm'))]
#[Route(methods: ['POST'], path: '', name: 'post_customer')] #[Route(methods: ['POST'], path: '', name: 'post_customer')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postAction(Request $request, CustomerService $customerService): Response public function postAction(Request $request, CustomerService $customerService): Response
{ {
if (!$this->isGranted('create_customer')) { if (!$this->isGranted('create_customer')) {
@@ -163,8 +156,6 @@ final class CustomerController extends BaseApiController
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CustomerEditForm'))] #[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CustomerEditForm'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Customer ID to update', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Customer ID to update', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_customer', requirements: ['id' => '\d+'])] #[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_customer', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function patchAction(Request $request, Customer $customer): Response public function patchAction(Request $request, Customer $customer): Response
{ {
$event = new CustomerMetaDefinitionEvent($customer); $event = new CustomerMetaDefinitionEvent($customer);
@@ -200,8 +191,6 @@ final class CustomerController extends BaseApiController
#[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/CustomerEntity'))] #[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/CustomerEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Customer record ID to set the meta-field value for', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Customer record ID to set the meta-field value for', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}/meta', requirements: ['id' => '\d+'])] #[Route(methods: ['PATCH'], path: '/{id}/meta', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\RequestParam(name: 'name', strict: true, nullable: false, description: 'The meta-field name')] #[Rest\RequestParam(name: 'name', strict: true, nullable: false, description: 'The meta-field name')]
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')] #[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
public function metaAction(Customer $customer, ParamFetcherInterface $paramFetcher): Response public function metaAction(Customer $customer, ParamFetcherInterface $paramFetcher): Response
@@ -233,8 +222,6 @@ final class CustomerController extends BaseApiController
#[OA\Response(response: 200, description: 'Returns a collection of customer rate entities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/CustomerRate')))] #[OA\Response(response: 200, description: 'Returns a collection of customer rate entities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/CustomerRate')))]
#[OA\Parameter(name: 'id', in: 'path', description: 'The customer whose rates will be returned', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'The customer whose rates will be returned', required: true)]
#[Route(methods: ['GET'], path: '/{id}/rates', name: 'get_customer_rates', requirements: ['id' => '\d+'])] #[Route(methods: ['GET'], path: '/{id}/rates', name: 'get_customer_rates', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getRatesAction(Customer $customer): Response public function getRatesAction(Customer $customer): Response
{ {
$rates = $this->customerRateRepository->getRatesForCustomer($customer); $rates = $this->customerRateRepository->getRatesForCustomer($customer);
@@ -252,8 +239,6 @@ final class CustomerController extends BaseApiController
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])] #[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The customer whose rate will be removed', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'The customer whose rate will be removed', required: true)]
#[OA\Parameter(name: 'rateId', in: 'path', description: 'The rate to remove', required: true)] #[OA\Parameter(name: 'rateId', in: 'path', description: 'The rate to remove', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}/rates/{rateId}', name: 'delete_customer_rate', requirements: ['id' => '\d+', 'rateId' => '\d+'])] #[Route(methods: ['DELETE'], path: '/{id}/rates/{rateId}', name: 'delete_customer_rate', requirements: ['id' => '\d+', 'rateId' => '\d+'])]
public function deleteRateAction(Customer $customer, #[MapEntity(mapping: ['rateId' => 'id'])] CustomerRate $rate): Response public function deleteRateAction(Customer $customer, #[MapEntity(mapping: ['rateId' => 'id'])] CustomerRate $rate): Response
{ {
@@ -276,8 +261,6 @@ final class CustomerController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'The customer to add the rate for', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'The customer to add the rate for', required: true)]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CustomerRateForm'))] #[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CustomerRateForm'))]
#[Route(methods: ['POST'], path: '/{id}/rates', name: 'post_customer_rate', requirements: ['id' => '\d+'])] #[Route(methods: ['POST'], path: '/{id}/rates', name: 'post_customer_rate', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postRateAction(Customer $customer, Request $request): Response public function postRateAction(Customer $customer, Request $request): Response
{ {
$rate = new CustomerRate(); $rate = new CustomerRate();

View File

@@ -25,7 +25,6 @@ use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface; use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View; use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bridge\Doctrine\Attribute\MapEntity;
@@ -59,8 +58,6 @@ final class ProjectController extends BaseApiController
*/ */
#[OA\Response(response: 200, description: 'Returns a collection of projects', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ProjectCollection')))] #[OA\Response(response: 200, description: 'Returns a collection of projects', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ProjectCollection')))]
#[Route(methods: ['GET'], path: '', name: 'get_projects')] #[Route(methods: ['GET'], path: '', name: 'get_projects')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\QueryParam(name: 'customer', requirements: '\d+', strict: true, nullable: true, description: 'Customer ID to filter projects')] #[Rest\QueryParam(name: 'customer', requirements: '\d+', strict: true, nullable: true, description: 'Customer ID to filter projects')]
#[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: '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: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter projects: 1=visible, 2=hidden, 3=both')] #[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter projects: 1=visible, 2=hidden, 3=both')]
@@ -157,8 +154,6 @@ final class ProjectController extends BaseApiController
*/ */
#[OA\Response(response: 200, description: 'Returns one project entity', content: new OA\JsonContent(ref: '#/components/schemas/ProjectEntity'))] #[OA\Response(response: 200, description: 'Returns one project entity', content: new OA\JsonContent(ref: '#/components/schemas/ProjectEntity'))]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_project', requirements: ['id' => '\d+'])] #[Route(methods: ['GET'], path: '/{id}', name: 'get_project', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[IsGranted('view', 'project')] #[IsGranted('view', 'project')]
public function getAction(Project $project): Response public function getAction(Project $project): Response
{ {
@@ -174,8 +169,6 @@ final class ProjectController extends BaseApiController
#[OA\Post(description: 'Creates a new project and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created project', content: new OA\JsonContent(ref: '#/components/schemas/ProjectEntity'))])] #[OA\Post(description: 'Creates a new project and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created project', content: new OA\JsonContent(ref: '#/components/schemas/ProjectEntity'))])]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ProjectEditForm'))] #[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ProjectEditForm'))]
#[Route(methods: ['POST'], path: '', name: 'post_project')] #[Route(methods: ['POST'], path: '', name: 'post_project')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postAction(Request $request): Response public function postAction(Request $request): Response
{ {
if (!$this->isGranted('create_project')) { if (!$this->isGranted('create_project')) {
@@ -216,8 +209,6 @@ final class ProjectController extends BaseApiController
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ProjectEditForm'))] #[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ProjectEditForm'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Project ID to update', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Project ID to update', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_project', requirements: ['id' => '\d+'])] #[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_project', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function patchAction(Request $request, Project $project): Response public function patchAction(Request $request, Project $project): Response
{ {
$event = new ProjectMetaDefinitionEvent($project); $event = new ProjectMetaDefinitionEvent($project);
@@ -255,8 +246,6 @@ final class ProjectController extends BaseApiController
#[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/ProjectEntity'))] #[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/ProjectEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Project record ID to set the meta-field value for', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Project record ID to set the meta-field value for', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}/meta', requirements: ['id' => '\d+'])] #[Route(methods: ['PATCH'], path: '/{id}/meta', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\RequestParam(name: 'name', strict: true, nullable: false, description: 'The meta-field name')] #[Rest\RequestParam(name: 'name', strict: true, nullable: false, description: 'The meta-field name')]
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')] #[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
public function metaAction(Project $project, ParamFetcherInterface $paramFetcher): Response public function metaAction(Project $project, ParamFetcherInterface $paramFetcher): Response
@@ -288,8 +277,6 @@ final class ProjectController extends BaseApiController
#[OA\Response(response: 200, description: 'Returns a collection of project rate entities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ProjectRate')))] #[OA\Response(response: 200, description: 'Returns a collection of project rate entities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ProjectRate')))]
#[OA\Parameter(name: 'id', in: 'path', description: 'The project whose rates will be returned', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'The project whose rates will be returned', required: true)]
#[Route(methods: ['GET'], path: '/{id}/rates', name: 'get_project_rates', requirements: ['id' => '\d+'])] #[Route(methods: ['GET'], path: '/{id}/rates', name: 'get_project_rates', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getRatesAction(Project $project): Response public function getRatesAction(Project $project): Response
{ {
$rates = $this->projectRateRepository->getRatesForProject($project); $rates = $this->projectRateRepository->getRatesForProject($project);
@@ -307,8 +294,6 @@ final class ProjectController extends BaseApiController
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])] #[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The project whose rate will be removed', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'The project whose rate will be removed', required: true)]
#[OA\Parameter(name: 'rateId', in: 'path', description: 'The rate to remove', required: true)] #[OA\Parameter(name: 'rateId', in: 'path', description: 'The rate to remove', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}/rates/{rateId}', name: 'delete_project_rate', requirements: ['id' => '\d+', 'rateId' => '\d+'])] #[Route(methods: ['DELETE'], path: '/{id}/rates/{rateId}', name: 'delete_project_rate', requirements: ['id' => '\d+', 'rateId' => '\d+'])]
public function deleteRateAction(Project $project, #[MapEntity(mapping: ['rateId' => 'id'])] ProjectRate $rate): Response public function deleteRateAction(Project $project, #[MapEntity(mapping: ['rateId' => 'id'])] ProjectRate $rate): Response
{ {
@@ -331,8 +316,6 @@ final class ProjectController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'The project to add the rate for', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'The project to add the rate for', required: true)]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ProjectRateForm'))] #[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ProjectRateForm'))]
#[Route(methods: ['POST'], path: '/{id}/rates', name: 'post_project_rate', requirements: ['id' => '\d+'])] #[Route(methods: ['POST'], path: '/{id}/rates', name: 'post_project_rate', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postRateAction(Project $project, Request $request): Response public function postRateAction(Project $project, Request $request): Response
{ {
$rate = new ProjectRate(); $rate = new ProjectRate();

View File

@@ -15,7 +15,6 @@ use App\Plugin\PluginManager;
use FOS\RestBundle\View\View; use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model; use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -34,8 +33,6 @@ final class StatusController extends BaseApiController
*/ */
#[OA\Response(response: 200, description: "A simple route that returns a 'pong', which you can use for testing the API", content: new OA\JsonContent(example: "{'message': 'pong'}"))] #[OA\Response(response: 200, description: "A simple route that returns a 'pong', which you can use for testing the API", content: new OA\JsonContent(example: "{'message': 'pong'}"))]
#[Route(methods: ['GET'], path: '/ping')] #[Route(methods: ['GET'], path: '/ping')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function pingAction(): Response public function pingAction(): Response
{ {
$view = new View(['message' => 'pong'], 200); $view = new View(['message' => 'pong'], 200);
@@ -48,8 +45,6 @@ final class StatusController extends BaseApiController
*/ */
#[OA\Response(response: 200, description: 'Returns version information about the current release', content: new OA\JsonContent(ref: new Model(type: Version::class)))] #[OA\Response(response: 200, description: 'Returns version information about the current release', content: new OA\JsonContent(ref: new Model(type: Version::class)))]
#[Route(methods: ['GET'], path: '/version')] #[Route(methods: ['GET'], path: '/version')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function versionAction(): Response public function versionAction(): Response
{ {
return $this->viewHandler->handle(new View(new Version(), 200)); return $this->viewHandler->handle(new View(new Version(), 200));
@@ -60,8 +55,6 @@ final class StatusController extends BaseApiController
*/ */
#[OA\Response(response: 200, description: 'Returns a list of plugin names and versions', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: new Model(type: Plugin::class))))] #[OA\Response(response: 200, description: 'Returns a list of plugin names and versions', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: new Model(type: Plugin::class))))]
#[Route(methods: ['GET'], path: '/plugins')] #[Route(methods: ['GET'], path: '/plugins')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function pluginAction(PluginManager $pluginManager): Response public function pluginAction(PluginManager $pluginManager): Response
{ {
$plugins = []; $plugins = [];

View File

@@ -16,7 +16,6 @@ use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface; use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View; use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -41,8 +40,6 @@ final class TagController extends BaseApiController
*/ */
#[OA\Response(response: 200, description: 'Returns the collection of all existing tags as string array', content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'string')))] #[OA\Response(response: 200, description: 'Returns the collection of all existing tags as string array', content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'string')))]
#[Route(methods: ['GET'], name: 'get_tags')] #[Route(methods: ['GET'], name: 'get_tags')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\QueryParam(name: 'name', strict: true, nullable: true, description: 'Search term to filter tag list')] #[Rest\QueryParam(name: 'name', strict: true, nullable: true, description: 'Search term to filter tag list')]
public function cgetAction(ParamFetcherInterface $paramFetcher): Response public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{ {
@@ -62,8 +59,6 @@ final class TagController extends BaseApiController
#[OA\Post(description: 'Creates a new tag and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created tag', content: new OA\JsonContent(ref: '#/components/schemas/TagEntity'))])] #[OA\Post(description: 'Creates a new tag and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created tag', content: new OA\JsonContent(ref: '#/components/schemas/TagEntity'))])]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TagEditForm'))] #[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TagEditForm'))]
#[Route(methods: ['POST'], name: 'post_tag')] #[Route(methods: ['POST'], name: 'post_tag')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postAction(Request $request): Response public function postAction(Request $request): Response
{ {
if (!$this->isGranted('manage_tag') && !$this->isGranted('create_tag')) { if (!$this->isGranted('manage_tag') && !$this->isGranted('create_tag')) {
@@ -97,8 +92,6 @@ final class TagController extends BaseApiController
#[IsGranted('delete_tag')] #[IsGranted('delete_tag')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'HTTP code 204 for a successful delete')])] #[OA\Delete(responses: [new OA\Response(response: 204, description: 'HTTP code 204 for a successful delete')])]
#[OA\Parameter(name: 'id', in: 'path', description: 'Tag ID to delete', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Tag ID to delete', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_tag')] #[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_tag')]
public function deleteAction(Tag $tag): Response public function deleteAction(Tag $tag): Response
{ {

View File

@@ -21,7 +21,6 @@ use App\Repository\ProjectRepository;
use App\Repository\TeamRepository; use App\Repository\TeamRepository;
use FOS\RestBundle\View\View; use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -49,8 +48,6 @@ final class TeamController extends BaseApiController
#[IsGranted('view_team')] #[IsGranted('view_team')]
#[OA\Response(response: 200, description: 'Returns the collection of teams', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TeamCollection')))] #[OA\Response(response: 200, description: 'Returns the collection of teams', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TeamCollection')))]
#[Route(methods: ['GET'], path: '', name: 'get_teams')] #[Route(methods: ['GET'], path: '', name: 'get_teams')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function cgetAction(): Response public function cgetAction(): Response
{ {
$data = $this->repository->findAll(); $data = $this->repository->findAll();
@@ -67,8 +64,6 @@ final class TeamController extends BaseApiController
#[IsGranted('view_team')] #[IsGranted('view_team')]
#[OA\Response(response: 200, description: 'Returns one team entity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))] #[OA\Response(response: 200, description: 'Returns one team entity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_team', requirements: ['id' => '\d+'])] #[Route(methods: ['GET'], path: '/{id}', name: 'get_team', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getAction(Team $team): Response public function getAction(Team $team): Response
{ {
$view = new View($team, 200); $view = new View($team, 200);
@@ -83,8 +78,6 @@ final class TeamController extends BaseApiController
#[IsGranted('delete_team')] #[IsGranted('delete_team')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one 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)] #[OA\Parameter(name: 'id', in: 'path', description: 'Team ID to delete', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_team', requirements: ['id' => '\d+'])] #[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_team', requirements: ['id' => '\d+'])]
public function deleteAction(Team $team): Response public function deleteAction(Team $team): Response
{ {
@@ -102,8 +95,6 @@ final class TeamController extends BaseApiController
#[OA\Post(description: 'Creates a new team and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])] #[OA\Post(description: 'Creates a new team and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TeamEditForm'))] #[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TeamEditForm'))]
#[Route(methods: ['POST'], path: '', name: 'post_team')] #[Route(methods: ['POST'], path: '', name: 'post_team')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postAction(Request $request): Response public function postAction(Request $request): Response
{ {
$team = new Team(''); $team = new Team('');
@@ -134,8 +125,6 @@ final class TeamController extends BaseApiController
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TeamEditForm'))] #[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)] #[OA\Parameter(name: 'id', in: 'path', description: 'Team ID to update', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_team', requirements: ['id' => '\d+'])] #[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_team', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function patchAction(Request $request, Team $team): Response public function patchAction(Request $request, Team $team): Response
{ {
if ($request->request->has('members')) { if ($request->request->has('members')) {
@@ -174,8 +163,6 @@ final class TeamController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'The team which will receive the new member', required: true)] #[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)] #[OA\Parameter(name: 'userId', in: 'path', description: 'The team member to add (User ID)', required: true)]
#[Route(methods: ['POST'], path: '/{id}/members/{userId}', name: 'post_team_member', requirements: ['id' => '\d+', 'userId' => '\d+'])] #[Route(methods: ['POST'], path: '/{id}/members/{userId}', name: 'post_team_member', requirements: ['id' => '\d+', 'userId' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postMemberAction(Team $team, #[MapEntity(mapping: ['userId' => 'id'])] User $member): Response public function postMemberAction(Team $team, #[MapEntity(mapping: ['userId' => 'id'])] User $member): Response
{ {
if ($member->isInTeam($team)) { if ($member->isInTeam($team)) {
@@ -199,8 +186,6 @@ final class TeamController extends BaseApiController
#[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\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: '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)] #[OA\Parameter(name: 'userId', in: 'path', description: 'The team member to remove (User ID)', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}/members/{userId}', name: 'delete_team_member', requirements: ['id' => '\d+', 'userId' => '\d+'])] #[Route(methods: ['DELETE'], path: '/{id}/members/{userId}', name: 'delete_team_member', requirements: ['id' => '\d+', 'userId' => '\d+'])]
public function deleteMemberAction(Team $team, #[MapEntity(mapping: ['userId' => 'id'])] User $member): Response public function deleteMemberAction(Team $team, #[MapEntity(mapping: ['userId' => 'id'])] User $member): Response
{ {
@@ -230,8 +215,6 @@ final class TeamController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)] #[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)] #[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+'])] #[Route(methods: ['POST'], path: '/{id}/customers/{customerId}', name: 'post_team_customer', requirements: ['id' => '\d+', 'customerId' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postCustomerAction(Team $team, #[MapEntity(mapping: ['customerId' => 'id'])] Customer $customer, CustomerRepository $customerRepository): Response public function postCustomerAction(Team $team, #[MapEntity(mapping: ['customerId' => 'id'])] Customer $customer, CustomerRepository $customerRepository): Response
{ {
if ($team->hasCustomer($customer)) { if ($team->hasCustomer($customer)) {
@@ -254,8 +237,6 @@ final class TeamController extends BaseApiController
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a customer from the team.', content: new OA\JsonContent(ref: '#/components/schemas/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: '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)] #[OA\Parameter(name: 'customerId', in: 'path', description: 'The customer to remove (Customer ID)', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}/customers/{customerId}', name: 'delete_team_customer', requirements: ['id' => '\d+', 'customerId' => '\d+'])] #[Route(methods: ['DELETE'], path: '/{id}/customers/{customerId}', name: 'delete_team_customer', requirements: ['id' => '\d+', 'customerId' => '\d+'])]
public function deleteCustomerAction(Team $team, #[MapEntity(mapping: ['customerId' => 'id'])] Customer $customer, CustomerRepository $customerRepository): Response public function deleteCustomerAction(Team $team, #[MapEntity(mapping: ['customerId' => 'id'])] Customer $customer, CustomerRepository $customerRepository): Response
{ {
@@ -280,8 +261,6 @@ final class TeamController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)] #[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)] #[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+'])] #[Route(methods: ['POST'], path: '/{id}/projects/{projectId}', name: 'post_team_project', requirements: ['id' => '\d+', 'projectId' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postProjectAction(Team $team, #[MapEntity(mapping: ['projectId' => 'id'])] Project $project, ProjectRepository $projectRepository): Response public function postProjectAction(Team $team, #[MapEntity(mapping: ['projectId' => 'id'])] Project $project, ProjectRepository $projectRepository): Response
{ {
if ($team->hasProject($project)) { if ($team->hasProject($project)) {
@@ -304,8 +283,6 @@ final class TeamController extends BaseApiController
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a project from the team.', content: new OA\JsonContent(ref: '#/components/schemas/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: '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)] #[OA\Parameter(name: 'projectId', in: 'path', description: 'The project to remove (Project ID)', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}/projects/{projectId}', name: 'delete_team_project', requirements: ['id' => '\d+', 'projectId' => '\d+'])] #[Route(methods: ['DELETE'], path: '/{id}/projects/{projectId}', name: 'delete_team_project', requirements: ['id' => '\d+', 'projectId' => '\d+'])]
public function deleteProjectAction(Team $team, #[MapEntity(mapping: ['projectId' => 'id'])] Project $project, ProjectRepository $projectRepository): Response public function deleteProjectAction(Team $team, #[MapEntity(mapping: ['projectId' => 'id'])] Project $project, ProjectRepository $projectRepository): Response
{ {
@@ -330,8 +307,6 @@ final class TeamController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)] #[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)] #[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+'])] #[Route(methods: ['POST'], path: '/{id}/activities/{activityId}', name: 'post_team_activity', requirements: ['id' => '\d+', 'activityId' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postActivityAction(Team $team, #[MapEntity(mapping: ['activityId' => 'id'])] Activity $activity, ActivityRepository $activityRepository): Response public function postActivityAction(Team $team, #[MapEntity(mapping: ['activityId' => 'id'])] Activity $activity, ActivityRepository $activityRepository): Response
{ {
if ($team->hasActivity($activity)) { if ($team->hasActivity($activity)) {
@@ -354,8 +329,6 @@ final class TeamController extends BaseApiController
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a activity from the team.', content: new OA\JsonContent(ref: '#/components/schemas/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: '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)] #[OA\Parameter(name: 'activityId', in: 'path', description: 'The activity to remove (Activity ID)', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}/activities/{activityId}', name: 'delete_team_activity', requirements: ['id' => '\d+', 'activityId' => '\d+'])] #[Route(methods: ['DELETE'], path: '/{id}/activities/{activityId}', name: 'delete_team_activity', requirements: ['id' => '\d+', 'activityId' => '\d+'])]
public function deleteActivityAction(Team $team, #[MapEntity(mapping: ['activityId' => 'id'])] Activity $activity, ActivityRepository $activityRepository): Response public function deleteActivityAction(Team $team, #[MapEntity(mapping: ['activityId' => 'id'])] Activity $activity, ActivityRepository $activityRepository): Response
{ {

View File

@@ -31,7 +31,6 @@ use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface; use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View; use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\ExpressionLanguage\Expression;
@@ -73,8 +72,6 @@ final class TimesheetController extends BaseApiController
#[IsGranted(new Expression("is_granted('view_own_timesheet') or is_granted('view_other_timesheet')"))] #[IsGranted(new Expression("is_granted('view_own_timesheet') or is_granted('view_other_timesheet')"))]
#[OA\Response(response: 200, description: 'Returns a collection of timesheet records. The datetime fields are given in the users local time including the timezone offset (ISO-8601).', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollection')))] #[OA\Response(response: 200, description: 'Returns a collection of timesheet records. The datetime fields are given in the users local time including the timezone offset (ISO-8601).', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollection')))]
#[Route(methods: ['GET'], path: '', name: 'get_timesheets')] #[Route(methods: ['GET'], path: '', name: 'get_timesheets')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\QueryParam(name: 'user', requirements: '\d+|all', strict: true, nullable: true, description: "User ID to filter timesheets. Needs permission 'view_other_timesheet', pass 'all' to fetch data for all user (default: current user)")] #[Rest\QueryParam(name: 'user', requirements: '\d+|all', strict: true, nullable: true, description: "User ID to filter timesheets. Needs permission 'view_other_timesheet', pass 'all' to fetch data for all user (default: current user)")]
#[Rest\QueryParam(name: 'users', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of user IDs to filter, e.g.: users[]=1&users[]=2 (ignored if user=all)')] #[Rest\QueryParam(name: 'users', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of user IDs to filter, e.g.: users[]=1&users[]=2 (ignored if user=all)')]
#[Rest\QueryParam(name: 'customer', requirements: '\d+', strict: true, nullable: true, description: 'Customer ID to filter timesheets')] #[Rest\QueryParam(name: 'customer', requirements: '\d+', strict: true, nullable: true, description: 'Customer ID to filter timesheets')]
@@ -275,8 +272,6 @@ final class TimesheetController extends BaseApiController
#[OA\Response(response: 200, description: 'Returns one timesheet record. Be aware that the datetime fields are given in the users local time including the timezone offset via ISO 8601.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))] #[OA\Response(response: 200, description: 'Returns one timesheet record. Be aware that the datetime fields are given in the users local time including the timezone offset via ISO 8601.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to fetch', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to fetch', required: true)]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_timesheet', requirements: ['id' => '\d+'])] #[Route(methods: ['GET'], path: '/{id}', name: 'get_timesheet', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getAction(Timesheet $timesheet): Response public function getAction(Timesheet $timesheet): Response
{ {
$view = new View($timesheet, 200); $view = new View($timesheet, 200);
@@ -292,8 +287,6 @@ final class TimesheetController extends BaseApiController
#[OA\Post(description: 'Creates a new timesheet record for the current user and returns it afterwards.', responses: [new OA\Response(response: 200, description: 'Returns the new created timesheet', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))])] #[OA\Post(description: 'Creates a new timesheet record for the current user and returns it afterwards.', responses: [new OA\Response(response: 200, description: 'Returns the new created timesheet', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))])]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEditForm'))] #[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEditForm'))]
#[Route(methods: ['POST'], path: '', name: 'post_timesheet')] #[Route(methods: ['POST'], path: '', name: 'post_timesheet')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\QueryParam(name: 'full', strict: true, nullable: true, description: 'Allows to fetch fully serialized objects including subresources (TimesheetExpanded). Allowed values: true (default: false)')] #[Rest\QueryParam(name: 'full', strict: true, nullable: true, description: 'Allows to fetch fully serialized objects including subresources (TimesheetExpanded). Allowed values: true (default: false)')]
public function postAction(Request $request, ParamFetcherInterface $paramFetcher): Response public function postAction(Request $request, ParamFetcherInterface $paramFetcher): Response
{ {
@@ -348,8 +341,6 @@ final class TimesheetController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to update', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to update', required: true)]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEditForm'))] #[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEditForm'))]
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_timesheet', requirements: ['id' => '\d+'])] #[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_timesheet', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function patchAction(Request $request, Timesheet $timesheet): Response public function patchAction(Request $request, Timesheet $timesheet): Response
{ {
$event = new TimesheetMetaDefinitionEvent($timesheet); $event = new TimesheetMetaDefinitionEvent($timesheet);
@@ -391,8 +382,6 @@ final class TimesheetController extends BaseApiController
#[IsGranted('delete', 'timesheet')] #[IsGranted('delete', 'timesheet')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one timesheet record')])] #[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one timesheet record')])]
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to delete', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to delete', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_timesheet', requirements: ['id' => '\d+'])] #[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_timesheet', requirements: ['id' => '\d+'])]
public function deleteAction(Timesheet $timesheet): Response public function deleteAction(Timesheet $timesheet): Response
{ {
@@ -409,8 +398,6 @@ final class TimesheetController extends BaseApiController
#[IsGranted('view_own_timesheet')] #[IsGranted('view_own_timesheet')]
#[OA\Response(response: 200, description: 'Returns the 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')))] #[OA\Response(response: 200, description: 'Returns the 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')] #[Route(methods: ['GET'], path: '/recent', name: 'recent_timesheet')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[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)')] #[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)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries (default: 10)')] #[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries (default: 10)')]
public function recentAction(ParamFetcherInterface $paramFetcher): Response public function recentAction(ParamFetcherInterface $paramFetcher): Response
@@ -445,8 +432,6 @@ final class TimesheetController extends BaseApiController
#[IsGranted('view_own_timesheet')] #[IsGranted('view_own_timesheet')]
#[OA\Response(response: 200, description: 'Returns the collection of active timesheet records for the current user', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollectionExpanded')))] #[OA\Response(response: 200, description: 'Returns the collection of active timesheet records for the current user', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollectionExpanded')))]
#[Route(methods: ['GET'], path: '/active', name: 'active_timesheet')] #[Route(methods: ['GET'], path: '/active', name: 'active_timesheet')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function activeAction(): Response public function activeAction(): Response
{ {
/** @var User $user */ /** @var User $user */
@@ -471,8 +456,6 @@ final class TimesheetController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to stop', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to stop', required: true)]
#[Route(methods: ['GET'], path: '/{id}/stop', name: 'stop_timesheet_get', requirements: ['id' => '\d+'])] #[Route(methods: ['GET'], path: '/{id}/stop', name: 'stop_timesheet_get', requirements: ['id' => '\d+'])]
#[Route(methods: ['PATCH'], path: '/{id}/stop', name: 'stop_timesheet', requirements: ['id' => '\d+'])] #[Route(methods: ['PATCH'], path: '/{id}/stop', name: 'stop_timesheet', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function stopAction(Timesheet $timesheet): Response public function stopAction(Timesheet $timesheet): Response
{ {
$this->service->stopTimesheet($timesheet); $this->service->stopTimesheet($timesheet);
@@ -491,8 +474,6 @@ final class TimesheetController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to restart', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to restart', required: true)]
#[Route(methods: ['GET'], path: '/{id}/restart', name: 'restart_timesheet_get', requirements: ['id' => '\d+'])] #[Route(methods: ['GET'], path: '/{id}/restart', name: 'restart_timesheet_get', requirements: ['id' => '\d+'])]
#[Route(methods: ['PATCH'], path: '/{id}/restart', name: 'restart_timesheet', requirements: ['id' => '\d+'])] #[Route(methods: ['PATCH'], path: '/{id}/restart', name: 'restart_timesheet', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\RequestParam(name: 'copy', requirements: 'all', strict: true, nullable: true, description: 'Whether data should be copied to the new entry. Allowed values: all (default: nothing is copied)')] #[Rest\RequestParam(name: 'copy', requirements: 'all', strict: true, nullable: true, description: 'Whether data should be copied to the new entry. Allowed values: all (default: nothing is copied)')]
#[Rest\RequestParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Changes the restart date to the given one (default: now)')] #[Rest\RequestParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Changes the restart date to the given one (default: now)')]
public function restartAction(Timesheet $timesheet, ParamFetcherInterface $paramFetcher): Response public function restartAction(Timesheet $timesheet, ParamFetcherInterface $paramFetcher): Response
@@ -553,8 +534,6 @@ final class TimesheetController extends BaseApiController
#[OA\Response(response: 200, description: 'Duplicates a timesheet record, resetting the export state only.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))] #[OA\Response(response: 200, description: 'Duplicates a timesheet record, resetting the export state only.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to duplicate', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to duplicate', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}/duplicate', name: 'duplicate_timesheet', requirements: ['id' => '\d+'])] #[Route(methods: ['PATCH'], path: '/{id}/duplicate', name: 'duplicate_timesheet', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function duplicateAction(Timesheet $timesheet): Response public function duplicateAction(Timesheet $timesheet): Response
{ {
$copyTimesheet = clone $timesheet; $copyTimesheet = clone $timesheet;
@@ -576,8 +555,6 @@ final class TimesheetController extends BaseApiController
#[OA\Response(response: 200, description: 'Switches the exported state on the record and therefor locks / unlocks it for further updates. Needs edit_export_*_timesheet permission.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))] #[OA\Response(response: 200, description: 'Switches the exported state on the record and therefor locks / unlocks it for further updates. Needs edit_export_*_timesheet permission.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to switch export state', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to switch export state', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}/export', name: 'export_timesheet', requirements: ['id' => '\d+'])] #[Route(methods: ['PATCH'], path: '/{id}/export', name: 'export_timesheet', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function exportAction(Timesheet $timesheet): Response public function exportAction(Timesheet $timesheet): Response
{ {
if ($timesheet->isExported() && !$this->isGranted('edit_exported_timesheet')) { if ($timesheet->isExported() && !$this->isGranted('edit_exported_timesheet')) {
@@ -601,8 +578,6 @@ final class TimesheetController extends BaseApiController
#[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))] #[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to set the meta-field value for', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to set the meta-field value for', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}/meta', requirements: ['id' => '\d+'])] #[Route(methods: ['PATCH'], path: '/{id}/meta', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\RequestParam(name: 'name', strict: true, nullable: false, description: 'The meta-field name')] #[Rest\RequestParam(name: 'name', strict: true, nullable: false, description: 'The meta-field name')]
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')] #[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
public function metaAction(Timesheet $timesheet, ParamFetcherInterface $paramFetcher): Response public function metaAction(Timesheet $timesheet, ParamFetcherInterface $paramFetcher): Response

View File

@@ -10,10 +10,12 @@
namespace App\API; namespace App\API;
use App\Configuration\SystemConfiguration; use App\Configuration\SystemConfiguration;
use App\Entity\AccessToken;
use App\Entity\User; use App\Entity\User;
use App\Event\PrepareUserEvent; use App\Event\PrepareUserEvent;
use App\Form\API\UserApiCreateForm; use App\Form\API\UserApiCreateForm;
use App\Form\API\UserApiEditForm; use App\Form\API\UserApiEditForm;
use App\Repository\AccessTokenRepository;
use App\Repository\Query\UserQuery; use App\Repository\Query\UserQuery;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use App\Utils\SearchTerm; use App\Utils\SearchTerm;
@@ -21,7 +23,6 @@ use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface; use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View; use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -42,10 +43,10 @@ final class UserController extends BaseApiController
public const GROUPS_COLLECTION_FULL = ['Default', 'Collection', 'User', 'User_Entity']; public const GROUPS_COLLECTION_FULL = ['Default', 'Collection', 'User', 'User_Entity'];
public function __construct( public function __construct(
private ViewHandlerInterface $viewHandler, private readonly ViewHandlerInterface $viewHandler,
private UserRepository $repository, private readonly UserRepository $repository,
private UserPasswordHasherInterface $passwordHasher, private readonly UserPasswordHasherInterface $passwordHasher,
private SystemConfiguration $configuration private readonly SystemConfiguration $configuration
) { ) {
} }
@@ -55,8 +56,6 @@ final class UserController extends BaseApiController
#[IsGranted('view_user')] #[IsGranted('view_user')]
#[OA\Response(response: 200, description: 'Returns the collection of users. Required permission: view_user', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/UserCollection')))] #[OA\Response(response: 200, description: 'Returns the collection of users. Required permission: view_user', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/UserCollection')))]
#[Route(methods: ['GET'], path: '', name: 'get_users')] #[Route(methods: ['GET'], path: '', name: 'get_users')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter users: 1=visible, 2=hidden, 3=all')] #[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter users: 1=visible, 2=hidden, 3=all')]
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|username|alias|email', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, username, alias, email (default: username)')] #[Rest\QueryParam(name: 'orderBy', requirements: 'id|username|alias|email', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, username, alias, email (default: username)')]
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')] #[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
@@ -108,8 +107,6 @@ final class UserController extends BaseApiController
#[OA\Response(response: 200, description: 'Return one user entity.', content: new OA\JsonContent(ref: '#/components/schemas/UserEntity'))] #[OA\Response(response: 200, description: 'Return one user entity.', content: new OA\JsonContent(ref: '#/components/schemas/UserEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'User ID to fetch', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'User ID to fetch', required: true)]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_user', requirements: ['id' => '\d+'])] #[Route(methods: ['GET'], path: '/{id}', name: 'get_user', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getAction(User $profile, EventDispatcherInterface $dispatcher): Response public function getAction(User $profile, EventDispatcherInterface $dispatcher): Response
{ {
// we need to prepare the user preferences, which is done via an EventSubscriber // we need to prepare the user preferences, which is done via an EventSubscriber
@@ -127,8 +124,6 @@ final class UserController extends BaseApiController
*/ */
#[OA\Response(response: 200, description: 'Return the current user entity.', content: new OA\JsonContent(ref: '#/components/schemas/UserEntity'))] #[OA\Response(response: 200, description: 'Return the current user entity.', content: new OA\JsonContent(ref: '#/components/schemas/UserEntity'))]
#[Route(methods: ['GET'], path: '/me', name: 'me_user')] #[Route(methods: ['GET'], path: '/me', name: 'me_user')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function meAction(): Response public function meAction(): Response
{ {
$view = new View($this->getUser(), 200); $view = new View($this->getUser(), 200);
@@ -144,8 +139,6 @@ final class UserController extends BaseApiController
#[OA\Post(description: 'Creates a new user and returns it afterwards')] #[OA\Post(description: 'Creates a new user and returns it afterwards')]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/UserCreateForm'))] #[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/UserCreateForm'))]
#[Route(methods: ['POST'], path: '', name: 'post_user')] #[Route(methods: ['POST'], path: '', name: 'post_user')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postAction(Request $request): Response public function postAction(Request $request): Response
{ {
$user = new User(); $user = new User();
@@ -198,8 +191,6 @@ final class UserController extends BaseApiController
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/UserEditForm'))] #[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/UserEditForm'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'User ID to update', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'User ID to update', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_user', requirements: ['id' => '\d+'])] #[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_user', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function patchAction(Request $request, User $profile): Response public function patchAction(Request $request, User $profile): Response
{ {
$form = $this->createForm(UserApiEditForm::class, $profile, [ $form = $this->createForm(UserApiEditForm::class, $profile, [
@@ -227,4 +218,29 @@ final class UserController extends BaseApiController
return $this->viewHandler->handle($view); return $this->viewHandler->handle($view);
} }
/**
* Delete an API token for the current user
*/
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Success if the token could be deleted.')])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The API token ID to remove', required: true)]
#[Route(methods: ['DELETE'], path: '/api-token/{id}', name: 'delete_api_token', requirements: ['id' => '\d+'])]
public function deleteApiToken(AccessToken $accessToken, AccessTokenRepository $accessTokenRepository): Response
{
$user = $this->getUser();
if (!$this->isGranted('api-token', $user)) {
throw $this->createAccessDeniedException('User has no access to API tokens');
}
if ($accessToken->getUser() !== $user) {
throw $this->createAccessDeniedException('You are not allowed to delete this access token');
}
$accessTokenRepository->deleteAccessToken($accessToken);
$view = new View(null, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
return $this->viewHandler->handle($view);
}
} }

View File

@@ -9,6 +9,8 @@
namespace App\Command; namespace App\Command;
use App\DataFixtures\UserFixtures;
use App\Entity\AccessToken;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
@@ -34,7 +36,10 @@ use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:reset:test', description: 'Resets the "test" environment')] #[AsCommand(name: 'kimai:reset:test', description: 'Resets the "test" environment')]
final class ResetTestCommand extends AbstractResetCommand final class ResetTestCommand extends AbstractResetCommand
{ {
public function __construct(private EntityManagerInterface $entityManager, string $kernelEnvironment) public function __construct(
private readonly EntityManagerInterface $entityManager,
string $kernelEnvironment
)
{ {
parent::__construct($kernelEnvironment); parent::__construct($kernelEnvironment);
} }
@@ -95,7 +100,8 @@ final class ResetTestCommand extends AbstractResetCommand
null, null,
null, null,
null, null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO' '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_customer',
], ],
[ [
2, 2,
@@ -115,7 +121,8 @@ final class ResetTestCommand extends AbstractResetCommand
null, null,
null, null,
null, null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO' '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_user',
], ],
[ [
3, 3,
@@ -135,7 +142,8 @@ final class ResetTestCommand extends AbstractResetCommand
null, null,
null, null,
null, null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO' '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_inactive',
], ],
[ [
4, 4,
@@ -155,7 +163,8 @@ final class ResetTestCommand extends AbstractResetCommand
null, null,
null, null,
null, null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO' '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_teamlead',
], ],
[ [
5, 5,
@@ -175,7 +184,8 @@ final class ResetTestCommand extends AbstractResetCommand
null, null,
null, null,
null, null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO' '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_admin',
], ],
[ [
6, 6,
@@ -195,7 +205,8 @@ final class ResetTestCommand extends AbstractResetCommand
'2020-04-14 09:50:38', '2020-04-14 09:50:38',
null, null,
null, null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO' '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_super',
], ],
[ [
7, 7,
@@ -215,7 +226,8 @@ final class ResetTestCommand extends AbstractResetCommand
null, null,
null, null,
null, null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO' '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_qa1',
], ],
[ [
8, 8,
@@ -235,7 +247,8 @@ final class ResetTestCommand extends AbstractResetCommand
null, null,
null, null,
null, null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO' '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_qa2',
], ],
]; ];
@@ -283,6 +296,10 @@ final class ResetTestCommand extends AbstractResetCommand
$user->setApiToken($userConf[17]); $user->setApiToken($userConf[17]);
} }
$accessToken = new AccessToken($user, $userConf[18]);
$accessToken->setName('Test fixture');
$this->entityManager->persist($accessToken);
$this->entityManager->persist($user); $this->entityManager->persist($user);
$userEntities[] = $user; $userEntities[] = $user;
} }

View File

@@ -9,11 +9,13 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\AccessToken;
use App\Entity\User; use App\Entity\User;
use App\Entity\UserPreference; use App\Entity\UserPreference;
use App\Event\PrepareUserEvent; use App\Event\PrepareUserEvent;
use App\Form\AccessTokenForm;
use App\Form\Model\TotpActivation; use App\Form\Model\TotpActivation;
use App\Form\UserApiTokenType; use App\Form\UserApiPasswordType;
use App\Form\UserContractType; use App\Form\UserContractType;
use App\Form\UserEditType; use App\Form\UserEditType;
use App\Form\UserPasswordType; use App\Form\UserPasswordType;
@@ -21,6 +23,7 @@ use App\Form\UserPreferencesForm;
use App\Form\UserRolesType; use App\Form\UserRolesType;
use App\Form\UserTeamsType; use App\Form\UserTeamsType;
use App\Form\UserTwoFactorType; use App\Form\UserTwoFactorType;
use App\Repository\AccessTokenRepository;
use App\Repository\Query\TimesheetStatisticQuery; use App\Repository\Query\TimesheetStatisticQuery;
use App\Repository\TeamRepository; use App\Repository\TeamRepository;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
@@ -151,24 +154,74 @@ final class ProfileController extends AbstractController
]); ]);
} }
#[Route(path: '/{username}/api-token', name: 'user_profile_api_token', methods: ['GET', 'POST'])] #[Route(path: '/{username}/create-access-token', name: 'user_profile_access_token', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')] #[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('api-token', 'profile')] #[IsGranted('api-token', 'profile')]
public function apiTokenAction(User $profile, Request $request, UserService $userService): Response public function createAccessToken(User $profile, Request $request, AccessTokenRepository $accessTokenRepository): Response
{ {
$form = $this->createApiTokenForm($profile); $accessToken = new AccessToken($profile, substr(bin2hex(random_bytes(100)), 0, 25));
$form = $this->createForm(AccessTokenForm::class, $accessToken, [
'action' => $this->generateUrl('user_profile_access_token', ['username' => $profile->getUserIdentifier()]),
'method' => 'POST'
]);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$userService->updateUser($profile); $accessTokenRepository->saveAccessToken($accessToken);
$this->flashSuccess('action.update.success');
$request->getSession()->set('_show_access_token', $accessToken->getId());
return new Response();
}
return $this->render('user/access-token.html.twig', [
'access_token' => $accessToken,
'user' => $profile,
'form' => $form->createView(),
]);
}
#[Route(path: '/{username}/api-token', name: 'user_profile_api_token', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('api-token', 'profile')]
public function apiTokenAction(User $profile, Request $request, UserService $userService, AccessTokenRepository $accessTokenRepository): Response
{
$form = $this->createForm(UserApiPasswordType::class, $profile, [
'action' => $this->generateUrl('user_profile_api_token', ['username' => $profile->getUserIdentifier()]),
'method' => 'POST'
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
@trigger_error('User ' . $profile->getUsername() . ' created deprecated API password.', E_USER_DEPRECATED);
$userService->saveUser($profile);
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile_api_token', ['username' => $profile->getUserIdentifier()]); return $this->redirectToRoute('user_profile_api_token', ['username' => $profile->getUserIdentifier()]);
} }
$accessTokens = $accessTokenRepository->findForUser($profile);
$createdToken = null;
$createdId = $request->getSession()->get('_show_access_token');
$request->getSession()->remove('_show_access_token');
if ($createdId !== null) {
foreach ($accessTokens as $accessToken) {
if ($accessToken->getId() === $createdId) {
$createdToken = $accessToken;
}
}
}
return $this->render('user/api-token.html.twig', [ return $this->render('user/api-token.html.twig', [
'tab' => 'api-token', 'tab' => 'api-token',
'created_token' => $createdToken,
'access_tokens' => $accessTokens,
'page_setup' => $this->getPageSetup($profile, 'api-token'), 'page_setup' => $this->getPageSetup($profile, 'api-token'),
'user' => $profile, 'user' => $profile,
'form' => $form->createView(), 'form' => $form->createView(),
@@ -390,18 +443,6 @@ final class ProfileController extends AbstractController
); );
} }
private function createApiTokenForm(User $user): FormInterface
{
return $this->createForm(
UserApiTokenType::class,
$user,
[
'action' => $this->generateUrl('user_profile_api_token', ['username' => $user->getUserIdentifier()]),
'method' => 'POST'
]
);
}
#[Route(path: '/{username}/2fa', name: 'user_profile_2fa', methods: ['GET', 'POST'])] #[Route(path: '/{username}/2fa', name: 'user_profile_2fa', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')] #[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('2fa', 'profile')] #[IsGranted('2fa', 'profile')]

View File

@@ -9,6 +9,7 @@
namespace App\DataFixtures; namespace App\DataFixtures;
use App\Entity\AccessToken;
use App\Entity\User; use App\Entity\User;
use App\Entity\UserPreference; use App\Entity\UserPreference;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
@@ -86,6 +87,10 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
*/ */
$manager->persist($prefs[0]); $manager->persist($prefs[0]);
$manager->persist($prefs[1]); $manager->persist($prefs[1]);
$accessToken = new AccessToken($user, $userData[10]);
$accessToken->setName('Test fixture');
$manager->persist($accessToken);
} }
$manager->flush(); $manager->flush();
@@ -168,7 +173,8 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
// enabled = $userData[6] // enabled = $userData[6]
// timezone = $userData[7] // timezone = $userData[7]
// password = $userData[8] // password = $userData[8]
// api = $userData[9] // api old = $userData[9]
// api new = $userData[10]
return [ return [
[ [
@@ -182,6 +188,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
'America/Vancouver', 'America/Vancouver',
self::DEFAULT_PASSWORD, self::DEFAULT_PASSWORD,
self::DEFAULT_API_TOKEN, self::DEFAULT_API_TOKEN,
self::DEFAULT_API_TOKEN . '_john',
], ],
[ [
'John Doe', 'John Doe',
@@ -194,6 +201,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
'America/Vancouver', 'America/Vancouver',
'password', 'password',
'password', 'password',
self::DEFAULT_API_TOKEN . '_user',
], ],
// inactive user to test login // inactive user to test login
[ [
@@ -207,6 +215,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
'Australia/Sydney', 'Australia/Sydney',
self::DEFAULT_PASSWORD, self::DEFAULT_PASSWORD,
self::DEFAULT_API_TOKEN, self::DEFAULT_API_TOKEN,
self::DEFAULT_API_TOKEN . '_inactive',
], ],
[ [
'Tony Maier', 'Tony Maier',
@@ -219,6 +228,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
'Asia/Bangkok', 'Asia/Bangkok',
self::DEFAULT_PASSWORD, self::DEFAULT_PASSWORD,
self::DEFAULT_API_TOKEN, self::DEFAULT_API_TOKEN,
self::DEFAULT_API_TOKEN . '_teamlead',
], ],
[ [
'Tony Maier', 'Tony Maier',
@@ -231,6 +241,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
'Asia/Bangkok', 'Asia/Bangkok',
'password', 'password',
'password', 'password',
self::DEFAULT_API_TOKEN . '_tony',
], ],
// no avatar to test default image macro // no avatar to test default image macro
[ [
@@ -244,6 +255,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
'Europe/London', 'Europe/London',
self::DEFAULT_PASSWORD, self::DEFAULT_PASSWORD,
self::DEFAULT_API_TOKEN, self::DEFAULT_API_TOKEN,
self::DEFAULT_API_TOKEN . '_anna',
], ],
[ [
'Anna Smith', 'Anna Smith',
@@ -256,6 +268,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
'Europe/London', 'Europe/London',
'password', 'password',
'password', 'password',
self::DEFAULT_API_TOKEN . '_admin',
], ],
// no alias to test twig username macro // no alias to test twig username macro
[ [
@@ -269,6 +282,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
'Europe/Berlin', 'Europe/Berlin',
self::DEFAULT_PASSWORD, self::DEFAULT_PASSWORD,
self::DEFAULT_API_TOKEN, self::DEFAULT_API_TOKEN,
self::DEFAULT_API_TOKEN . '_susan',
], ],
[ [
null, null,
@@ -281,6 +295,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
'Europe/Berlin', 'Europe/Berlin',
'password', 'password',
'password', 'password',
self::DEFAULT_API_TOKEN . '_super',
], ],
]; ];
} }

106
src/Entity/AccessToken.php Normal file
View File

@@ -0,0 +1,106 @@
<?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;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_access_token')]
#[ORM\Entity(repositoryClass: 'App\Repository\AccessTokenRepository')]
#[ORM\UniqueConstraint(columns: ['token'])]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(fields: ['token'])]
class AccessToken
{
#[ORM\Column(name: 'id', type: 'integer')]
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private User $user;
#[ORM\Column(name: 'token', type: 'string', length: 100, nullable: false)]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100)]
private string $token;
#[ORM\Column(name: 'name', type: 'string', length: 50, nullable: false)]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 50)]
private ?string $name = null;
#[ORM\Column(name: 'last_usage', type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $lastUsage = null;
#[ORM\Column(name: 'expires_at', type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $expiresAt = null;
public function __construct(User $user, string $token)
{
$this->user = $user;
$this->token = $token;
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): User
{
return $this->user;
}
public function setName(?string $name): void
{
$this->name = $name;
}
public function getName(): ?string
{
return $this->name;
}
public function setLastUsage(\DateTimeImmutable $lastUsage): void
{
$this->lastUsage = $lastUsage;
}
public function getLastUsage(): ?\DateTimeImmutable
{
return $this->lastUsage;
}
public function getToken(): string
{
return $this->token;
}
public function getExpiresAt(): ?\DateTimeImmutable
{
return $this->expiresAt;
}
public function setExpiresAt(?\DateTimeImmutable $expiresAt): void
{
$this->expiresAt = $expiresAt;
}
public function isValid(): bool
{
return $this->expiresAt === null || $this->expiresAt > new \DateTimeImmutable();
}
public function __clone()
{
if ($this->id) {
$this->id = null;
}
}
}

View File

@@ -31,6 +31,11 @@ final class ProfileSubscriber implements EventSubscriberInterface
{ {
$request = $event->getRequest(); $request = $event->getRequest();
// make sure that we do NOT access the session, if the request is stateless
if ($request->attributes->getBoolean('_stateless')) {
return;
}
$profile = $this->profileManager->getProfileFromCookie($request); $profile = $this->profileManager->getProfileFromCookie($request);
if ($this->profileManager->isValidProfile($profile)) { if ($this->profileManager->isValidProfile($profile)) {
$this->profileManager->setProfile($request->getSession(), $profile); $this->profileManager->setProfile($request->getSession(), $profile);

View File

@@ -0,0 +1,46 @@
<?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\Form;
use App\Entity\AccessToken;
use App\Form\Type\DatePickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class AccessTokenForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'required' => true,
])
->add('expiresAt', DatePickerType::class, [
'label' => 'expires',
'required' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AccessToken::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'access_token_form',
'attr' => [
'data-form-event' => 'kimai.accessToken'
],
]);
}
}

View File

@@ -17,10 +17,10 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
/** /**
* Defines the form used to set the users API token. * Defines the form used to set the users API password.
* @extends AbstractType<User> * @extends AbstractType<User>
*/ */
final class UserApiTokenType extends AbstractType final class UserApiPasswordType extends AbstractType
{ {
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
@@ -40,7 +40,7 @@ final class UserApiTokenType extends AbstractType
'data_class' => User::class, 'data_class' => User::class,
'csrf_protection' => true, 'csrf_protection' => true,
'csrf_field_name' => '_token', 'csrf_field_name' => '_token',
'csrf_token_id' => 'edit_user_api_token', 'csrf_token_id' => 'edit_user_password_token',
]); ]);
} }
} }

View File

@@ -0,0 +1,47 @@
<?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\Repository;
use App\Entity\AccessToken;
use App\Entity\User;
use Doctrine\ORM\EntityRepository;
/**
* @extends EntityRepository<AccessToken>
*/
class AccessTokenRepository extends EntityRepository
{
public function findByToken(string $token): ?AccessToken
{
return $this->findOneBy(['token' => $token]);
}
/**
* @return array<AccessToken>
*/
public function findForUser(User $user): array
{
return $this->findBy(['user' => $user]);
}
public function saveAccessToken(AccessToken $accessToken): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($accessToken);
$entityManager->flush();
}
public function deleteAccessToken(AccessToken $accessToken): void
{
$entityManager = $this->getEntityManager();
$entityManager->remove($accessToken);
$entityManager->flush();
}
}

View File

@@ -5,9 +5,18 @@
<style type="text/css"> <style type="text/css">
body { margin-top: 0; } body { margin-top: 0; }
header { display:none; } header { display:none; }
#swagger-ui .information-container pre.base-url { display:none; } #swagger-ui.api-platform .information-container.wrapper {
#swagger-ui .scheme-container { margin-top: -120px; box-shadow: none; height: 120px; } margin-bottoM: 5px;
#swagger-ui .information-container .info { padding-top: 5px; padding-bottom: 5px; } }
#swagger-ui.api-platform .information-container.wrapper { margin-bottom: -65px; border-bottom: none; } .swagger-ui .servers .computed-url {
margin: 0;
}
.swagger-ui .servers h4 {
margin: 10px 0;
}
.swagger-ui .servers table td {
padding-top: 0;
padding-bottom: 0;
}
</style> </style>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends kimai_context.modalRequest ? 'form.html.twig' : 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block main %}
{% set formEditTemplate = kimai_context.modalRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig' %}
{% set formOptions = {
'title': (access_token.id is null ? 'create'|trans : 'edit'|trans({}, 'actions')),
'form': form,
'back': path('user_profile_access_token', {'username': user.userIdentifier})
} %}
{% embed formEditTemplate with formOptions %}
{% form_theme form 'form/horizontal.html.twig' %}
{% block form_body %}
{{ form_row(form.name) }}
{{ form_row(form.expiresAt) }}
{{ form_rest(form) }}
{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -1,31 +1,103 @@
{% extends 'user/form.html.twig' %} {% extends 'user/form.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{% block form_pre_content %} {% block form_body %}
<div class="card-body">
{% if user.apiToken is empty %}
<div class="row mb-3"> <div class="row mb-3">
<div class=""> <div class="col-12 col-md-6">
{{ widgets.alert('warning', 'api_password.missing_description'|trans, 'api_password.missing_title'|trans, 'warning', false) }} <p>
</div> {{ 'api_password.intro'|trans }}
</div> </p>
{% endif %} <p>
<strong>URL</strong>: {{ url('api.swagger_ui', {}, false)|replace({'/doc': ''}) }}</li>
</p>
</div>
<div class="row mb-3"> <div class="col-12 col-md-6 text-md-end">
<div class="d-flex"> <a class="btn action-create modal-ajax-form" href="{{ path('user_profile_access_token', {'username': user.userIdentifier}) }}">{{ icon('create', true) }} {{ 'create'|trans }}</a>
<div class="me-auto"> <a class="btn" target="_blank" href="{{ path('api.swagger_ui') }}" data-toggle="tooltip" title="Swagger API Docs">{{ icon('documentation', true) }} Swagger Docs</a>
<p>{{ 'api_password.intro'|trans }}</p> <a class="btn" target="_blank" href="{{ 'rest-api.html'|docu_link }}" data-toggle="tooltip" title="{{ 'help'|trans }}">{{ icon('help', true) }} {{ 'help'|trans }}</a>
<ul> </div>
<li>{{ 'username'|trans }}: {{ user.userIdentifier }}</li>
<li>URL: {{ url('api.swagger_ui', {}, false)|replace({'/doc': ''}) }}</li>
</ul>
</div>
<div class="ms-auto">
<a class="btn btn-icon" target="_blank" href="{{ path('api.swagger_ui') }}" data-toggle="tooltip" title="Swagger API Docs">{{ icon('documentation', true) }}</a>
<a class="btn btn-icon" target="_blank" href="{{ 'rest-api.html'|docu_link }}" data-toggle="tooltip" title="{{ 'help'|trans }}">{{ icon('help', true) }}</a>
</div>
</div> </div>
{% if created_token is not null %}
<div class="card mb-4">
<div class="ribbon bg-red">{{ 'status.new'|trans }}</div>
<div class="card-body">
<h3 class="card-title">{{ created_token.name }}</h3>
<p class="text-secondary"></p>
<p>{{ 'api_token_hidden'|trans }}</p>
<div class="codeblock">
<div class="codeblock-copy" style="position: absolute; right: 30px; padding-top: 5px">
<button class="btn btn-icon btn-dark" onclick="navigator.clipboard.writeText('{{ created_token.token }}');"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-clipboard icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5h-2a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-12a2 2 0 0 0 -2 -2h-2"></path><path d="M9 3m0 2a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v0a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2z"></path></svg></button>
</div>
<pre><code>{{ created_token.token }}</code></pre>
</div>
</div>
</div>
{% endif %}
{% if access_tokens|length > 0 %}
<table class="table">
<thead>
<tr>
<th>{{ 'name'|trans }}</th>
<th>{{ 'last_usage'|trans }}</th>
<th>{{ 'expires'|trans }}</th>
<th class="w-min actions"></th>
</tr>
</thead>
<tbody>
{% for token in access_tokens %}
<tr>
<td>{{ token.name }}</td>
<td>
{% if token.lastUsage is not null %}
{{ token.lastUsage|date }}
{% endif %}
</td>
<td>
{% if token.expiresAt is not null %}
{{ token.expiresAt|date }}
{% endif %}
</td>
<td>
{{ widgets.action_button('trash', {'url': path('delete_api_token', {id: token.id}), class: 'api-link', 'attr': {
'data-question': 'confirm.delete',
'data-event': 'kimai.accessToken',
'data-method': 'DELETE',
'data-question': 'confirm.delete',
'data-msg-error': 'action.delete.error',
'data-msg-success': 'action.delete.success'
}}) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'activity_invoice_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% block title %}{{ 'password'|trans }}{% endblock %}
{% block body %}
{{ widgets.alert('danger', 'api_password_deprecated'|trans) }}
{{ form_start(form) }}
{{ form_widget(form) }}
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
{{ form_end(form) }}
{% endblock %}
{% endembed %}
</div> </div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
KimaiReloadPageWidget.create('kimai.accessToken');
});
</script>
{% endblock %} {% endblock %}

View File

@@ -22,42 +22,25 @@ use Symfony\Component\HttpKernel\HttpKernelBrowser;
*/ */
abstract class APIControllerBaseTest extends ControllerBaseTest abstract class APIControllerBaseTest extends ControllerBaseTest
{ {
/**
* @return array<string, string>
*/
private function getAuthHeader(string $username, string $password): array
{
return [
'HTTP_AUTHORIZATION' => 'Bearer ' . $password,
];
}
protected function getClientForAuthenticatedUser(string $role = User::ROLE_USER): HttpKernelBrowser protected function getClientForAuthenticatedUser(string $role = User::ROLE_USER): HttpKernelBrowser
{ {
switch ($role) { return match ($role) {
case User::ROLE_SUPER_ADMIN: User::ROLE_SUPER_ADMIN => self::createClient([], $this->getAuthHeader(UserFixtures::USERNAME_SUPER_ADMIN, UserFixtures::DEFAULT_API_TOKEN . '_super')),
$client = self::createClient([], [ User::ROLE_ADMIN => self::createClient([], $this->getAuthHeader(UserFixtures::USERNAME_ADMIN, UserFixtures::DEFAULT_API_TOKEN . '_admin')),
'HTTP_X_AUTH_USER' => UserFixtures::USERNAME_SUPER_ADMIN, User::ROLE_TEAMLEAD => self::createClient([], $this->getAuthHeader(UserFixtures::USERNAME_TEAMLEAD, UserFixtures::DEFAULT_API_TOKEN . '_teamlead')),
'HTTP_X_AUTH_TOKEN' => UserFixtures::DEFAULT_API_TOKEN, User::ROLE_USER => self::createClient([], $this->getAuthHeader(UserFixtures::USERNAME_USER, UserFixtures::DEFAULT_API_TOKEN . '_user')),
]); default => throw new \Exception(sprintf('Unknown role "%s"', $role)),
break; };
case User::ROLE_ADMIN:
$client = self::createClient([], [
'HTTP_X_AUTH_USER' => UserFixtures::USERNAME_ADMIN,
'HTTP_X_AUTH_TOKEN' => UserFixtures::DEFAULT_API_TOKEN,
]);
break;
case User::ROLE_TEAMLEAD:
$client = self::createClient([], [
'HTTP_X_AUTH_USER' => UserFixtures::USERNAME_TEAMLEAD,
'HTTP_X_AUTH_TOKEN' => UserFixtures::DEFAULT_API_TOKEN,
]);
break;
case User::ROLE_USER:
$client = self::createClient([], [
'HTTP_X_AUTH_USER' => UserFixtures::USERNAME_USER,
'HTTP_X_AUTH_TOKEN' => UserFixtures::DEFAULT_API_TOKEN,
]);
break;
default:
throw new \Exception(sprintf('Unknown role "%s"', $role));
}
return $client;
} }
protected function createUrl(string $url): string protected function createUrl(string $url): string
@@ -81,16 +64,12 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
protected function assertRequestIsSecured(HttpKernelBrowser $client, string $url, string $method = 'GET'): void protected function assertRequestIsSecured(HttpKernelBrowser $client, string $url, string $method = 'GET'): void
{ {
$this->request($client, $url, $method); $this->request($client, $url, $method);
$this->assertResponseIsSecured($client->getResponse(), $url); $response = $client->getResponse();
}
/** $data = [
* @param Response $response 'message' => 'Unauthorized',
* @param string $url 'code' => 401
*/ ];
protected function assertResponseIsSecured(Response $response, string $url): void
{
$data = ['message' => 'Authentication required, missing user header: X-AUTH-USER'];
self::assertEquals( self::assertEquals(
$data, $data,
@@ -99,17 +78,12 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
); );
self::assertEquals( self::assertEquals(
Response::HTTP_FORBIDDEN, Response::HTTP_UNAUTHORIZED,
$response->getStatusCode(), $response->getStatusCode(),
sprintf('The secure URL %s has the wrong status code %s.', $url, $response->getStatusCode()) sprintf('The secure URL %s has the wrong status code %s.', $url, $response->getStatusCode())
); );
} }
/**
* @param string $role
* @param string $url
* @param string $method
*/
protected function assertUrlIsSecuredForRole(string $role, string $url, string $method = 'GET'): void protected function assertUrlIsSecuredForRole(string $role, string $url, string $method = 'GET'): void
{ {
$client = $this->getClientForAuthenticatedUser($role); $client = $this->getClientForAuthenticatedUser($role);

View File

@@ -97,20 +97,20 @@ class ApiDocControllerTest extends ControllerBaseTest
'/api/users', '/api/users',
'/api/users/{id}', '/api/users/{id}',
'/api/users/me', '/api/users/me',
'/api/users/api-token/{id}',
]; ];
$this->assertArrayHasKey('openapi', $json); $this->assertArrayHasKey('openapi', $json);
$this->assertEquals('3.0.0', $json['openapi']); $this->assertEquals('3.0.0', $json['openapi']);
$this->assertArrayHasKey('info', $json); $this->assertArrayHasKey('info', $json);
$this->assertEquals('Kimai - API Docs', $json['info']['title']); $this->assertEquals('Kimai - API Docs', $json['info']['title']);
$this->assertEquals('0.7', $json['info']['version']); $this->assertEquals('1.0', $json['info']['version']);
$this->assertArrayHasKey('paths', $json); $this->assertArrayHasKey('paths', $json);
$this->assertEquals($paths, array_keys($json['paths'])); $this->assertEquals($paths, array_keys($json['paths']));
$this->assertArrayHasKey('security', $json); $this->assertArrayHasKey('security', $json);
$this->assertArrayHasKey('X-AUTH-USER', $json['security'][0]); $this->assertEquals(['bearer' => []], $json['security'][0]);
$this->assertArrayHasKey('X-AUTH-TOKEN', $json['security'][0]);
$this->assertArrayHasKey('components', $json); $this->assertArrayHasKey('components', $json);
$this->assertArrayHasKey('schemas', $json['components']); $this->assertArrayHasKey('schemas', $json['components']);

View File

@@ -0,0 +1,65 @@
<?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 API\Authentication;
use App\API\Authentication\AccessTokenHandler;
use App\Entity\AccessToken;
use App\Entity\User;
use App\Repository\AccessTokenRepository;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
/**
* @covers \App\API\Authentication\AccessTokenHandler
*/
class AccessTokenHandlerTest extends TestCase
{
private function getSut(?AccessToken $accessToken = null): AccessTokenHandler
{
$userProvider = $this->createMock(AccessTokenRepository::class);
$userProvider->method('findByToken')->willReturn($accessToken);
return new AccessTokenHandler($userProvider);
}
public function testUnknownToken(): void
{
$this->expectException(BadCredentialsException::class);
$this->expectExceptionMessage('Invalid credentials.');
$sut = $this->getSut();
$sut->getUserBadgeFrom('foo');
}
public function testInvalidToken(): void
{
$user = new User();
$user->setUserIdentifier('foo');
$accessToken = new AccessToken($user, 'Test');
$accessToken->setExpiresAt(new \DateTimeImmutable('-1 day'));
$this->expectException(BadCredentialsException::class);
$this->expectExceptionMessage('Invalid token.');
$sut = $this->getSut($accessToken);
$sut->getUserBadgeFrom('foo');
}
public function testValidTokenSetsLastUsage(): void
{
$user = new User();
$user->setUserIdentifier('foo-bar');
$accessToken = new AccessToken($user, 'Test');
$this->assertNull($accessToken->getLastUsage());
$sut = $this->getSut($accessToken);
$badge = $sut->getUserBadgeFrom('foo');
$this->assertNotNull($accessToken->getLastUsage());
$this->assertSame('foo-bar', $badge->getUserIdentifier());
}
}

View File

@@ -1,178 +0,0 @@
<?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\Tests\API\Authentication;
use App\API\Authentication\SessionAuthenticator;
use App\API\Authentication\TokenAuthenticator;
use App\Entity\User;
use App\Repository\ApiUserRepository;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\PasswordHasher\PasswordHasherInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
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;
/**
* @covers \App\API\Authentication\SessionAuthenticator
*/
class SessionAuthenticatorTest extends TestCase
{
private function getSut(bool $verify = true): SessionAuthenticator
{
$userProvider = $this->createMock(ApiUserRepository::class);
$passwordHasherFactory = $this->createMock(PasswordHasherFactoryInterface::class);
$passwordHasher = $this->createMock(PasswordHasherInterface::class);
$passwordHasher->method('verify')->willReturn($verify);
$passwordHasherFactory->method('getPasswordHasher')->willReturn($passwordHasher);
$token = new TokenAuthenticator($userProvider, $passwordHasherFactory);
return new SessionAuthenticator($token);
}
public function testSupports(): void
{
$sut = $this->getSut();
// not supporting because /api path is not the beginning of the URL
$request = new Request([], [], [], [], [], ['REQUEST_URI' => 'dfghj/api/doc/dfghj']);
self::assertFalse($sut->supports($request));
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo']);
self::assertTrue($sut->supports($request));
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/doc']);
self::assertFalse($sut->supports($request));
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-SESSION' => true]);
self::assertFalse($sut->supports($request));
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo', 'HTTP_X-AUTH-TOKEN' => 'bar']);
self::assertTrue($sut->supports($request));
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo', 'HTTP_X-AUTH-TOKEN' => 'bar', 'HTTP_X-AUTH-SESSION' => true]);
self::assertFalse($sut->supports($request));
}
public function testAuthenticateWithMissingAuthHeader(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->expectExceptionMessage('Authentication required, missing user header: X-AUTH-USER');
$sut = $this->getSut();
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo']);
$sut->authenticate($request);
}
public function testAuthenticateWithMissingToken(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->expectExceptionMessage('Authentication required, missing token header: X-AUTH-TOKEN');
$sut = $this->getSut();
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo']);
$sut->authenticate($request);
}
public function testAuthenticateWithEmptyToken(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->expectExceptionMessage('Authentication required, missing token header: X-AUTH-TOKEN');
$sut = $this->getSut();
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo', 'HTTP_X-AUTH-TOKEN' => '']);
$sut->authenticate($request);
}
public function testAuthenticateWithMissingUser(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->expectExceptionMessage('Authentication required, missing user header: X-AUTH-USER');
$sut = $this->getSut();
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-TOKEN' => 'bar']);
$sut->authenticate($request);
}
public function testAuthenticateWithEmptyUser(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->expectExceptionMessage('Authentication required, missing user header: X-AUTH-USER');
$sut = $this->getSut();
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => '', 'HTTP_X-AUTH-TOKEN' => 'bar']);
$sut->authenticate($request);
}
public function testAuthenticate(): void
{
$sut = $this->getSut();
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo2', 'HTTP_X-AUTH-TOKEN' => 'bar']);
$passport = $sut->authenticate($request);
self::assertInstanceOf(Passport::class, $passport);
$badge = $passport->getBadge(UserBadge::class);
self::assertInstanceOf(UserBadge::class, $badge);
self::assertEquals('foo2', $badge->getUserIdentifier());
$user = new User();
$user->setApiToken('bar2');
$badge = $passport->getBadge(CustomCredentials::class);
self::assertInstanceOf(CustomCredentials::class, $badge);
self::assertFalse($badge->isResolved());
$badge->executeCustomChecker($user);
self::assertTrue($badge->isResolved());
}
public function testAuthenticateFailsOnMissingApiTokenForUser(): void
{
$this->expectException(BadCredentialsException::class);
$this->expectExceptionMessage('The user has no activated API account.');
$sut = $this->getSut();
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo2', 'HTTP_X-AUTH-TOKEN' => 'bar']);
$passport = $sut->authenticate($request);
$user = new User();
/** @var CustomCredentials $badge */
$badge = $passport->getBadge(CustomCredentials::class);
$badge->executeCustomChecker($user);
}
public function testAuthenticateFailsOnWrongPassword(): void
{
$this->expectException(BadCredentialsException::class);
$this->expectExceptionMessage('The presented password is invalid.');
$sut = $this->getSut(false);
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo2', 'HTTP_X-AUTH-TOKEN' => 'bar']);
$passport = $sut->authenticate($request);
$user = new User();
$user->setApiToken('bar');
/** @var CustomCredentials $badge */
$badge = $passport->getBadge(CustomCredentials::class);
$badge->executeCustomChecker($user);
}
}

View File

@@ -24,6 +24,7 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
/** /**
* @covers \App\API\Authentication\TokenAuthenticator * @covers \App\API\Authentication\TokenAuthenticator
* @group legacy
*/ */
class TokenAuthenticatorTest extends TestCase class TokenAuthenticatorTest extends TestCase
{ {
@@ -47,19 +48,13 @@ class TokenAuthenticatorTest extends TestCase
self::assertFalse($sut->supports($request)); self::assertFalse($sut->supports($request));
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo']); $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo']);
self::assertTrue($sut->supports($request)); self::assertFalse($sut->supports($request));
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/doc']); $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/doc']);
self::assertFalse($sut->supports($request)); self::assertFalse($sut->supports($request));
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-SESSION' => true]);
self::assertTrue($sut->supports($request));
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo', 'HTTP_X-AUTH-TOKEN' => 'bar']); $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo', 'HTTP_X-AUTH-TOKEN' => 'bar']);
self::assertTrue($sut->supports($request)); self::assertTrue($sut->supports($request));
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo', 'HTTP_X-AUTH-TOKEN' => 'bar', 'HTTP_X-AUTH-SESSION' => true]);
self::assertTrue($sut->supports($request));
} }
public function testAuthenticateWithMissingAuthHeader(): void public function testAuthenticateWithMissingAuthHeader(): void

View File

@@ -0,0 +1,93 @@
<?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 API;
use App\DataFixtures\UserFixtures;
use App\Entity\User;
use App\Tests\API\APIControllerBaseTest;
use Symfony\Component\HttpFoundation\Response;
/**
* These tests make sure, that the deprecated API login with X-AUTH-USER and X-AUTH-TOKEN still works.
*
* @group legacy
* @group integration
*/
class AuthenticationTest extends APIControllerBaseTest
{
public function testPinIsSecure(): void
{
$this->assertUrlIsSecured('/api/ping');
}
public function testPingWithAccessToken(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/ping');
$response = $client->getResponse()->getContent();
$this->assertIsString($response);
$result = json_decode($response, true);
$this->assertIsArray($result);
$this->assertEquals(['message' => 'pong'], $result);
}
public function testPingWithAuthTokenAndUsername(): void
{
$client = self::createClient([], $this->getAuthHeader(UserFixtures::USERNAME_USER, UserFixtures::DEFAULT_API_TOKEN));
$this->assertAccessIsGranted($client, '/api/ping');
$response = $client->getResponse()->getContent();
$this->assertIsString($response);
$result = json_decode($response, true);
$this->assertIsArray($result);
$this->assertEquals(['message' => 'pong'], $result);
}
public function testPingWithInvalidAuthTokenAndUsername(): void
{
$client = self::createClient([], $this->getAuthHeader(UserFixtures::USERNAME_USER, 'xxxx'));
$url = '/api/ping';
$method = 'GET';
$this->request($client, $url, $method);
$response = $client->getResponse();
$data = [
'message' => 'Invalid credentials',
];
$this->assertIsString($response->getContent());
$this->assertEquals(
$data,
json_decode($response->getContent(), true),
sprintf('The secure URL %s is not protected.', $url)
);
$this->assertEquals(
Response::HTTP_FORBIDDEN,
$response->getStatusCode(),
sprintf('The secure URL %s has the wrong status code %s.', $url, $response->getStatusCode())
);
}
/**
* @return array<string, string>
*/
private function getAuthHeader(string $username, string $password): array
{
return [
'HTTP_X_AUTH_USER' => $username,
'HTTP_X_AUTH_TOKEN' => $password,
];
}
}

View File

@@ -84,7 +84,6 @@ class InvoiceCreateCommandTest extends KernelTestCase
* Allowed option: exported * Allowed option: exported
* Allowed option: by-customer * Allowed option: by-customer
* Allowed option: by-project * Allowed option: by-project
* Allowed option: set-exported
* Allowed option: template-meta * Allowed option: template-meta
* *
* @param array $options * @param array $options
@@ -169,7 +168,7 @@ class InvoiceCreateCommandTest extends KernelTestCase
$fixture = new InvoiceTemplateFixtures(); $fixture = new InvoiceTemplateFixtures();
$this->importFixture($fixture); $this->importFixture($fixture);
$commandTester = $this->createInvoice(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--set-exported' => null, '--customer' => 1, '--template' => 'Invoice', '--start' => '2020-01-01', '--end' => '2020-03-01']); $commandTester = $this->createInvoice(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--customer' => 1, '--template' => 'Invoice', '--start' => '2020-01-01', '--end' => '2020-03-01']);
$output = $commandTester->getDisplay(); $output = $commandTester->getDisplay();
$this->assertStringContainsString('Created 1 invoice(s)', $output); $this->assertStringContainsString('Created 1 invoice(s)', $output);

View File

@@ -265,9 +265,9 @@ class ProfileControllerTest extends ControllerBaseTest
$this->assertFalse($passwordEncoder->getPasswordHasher($user)->verify($user->getApiToken(), 'test1234')); $this->assertFalse($passwordEncoder->getPasswordHasher($user)->verify($user->getApiToken(), 'test1234'));
$this->assertEquals(UserFixtures::USERNAME_USER, $user->getUserIdentifier()); $this->assertEquals(UserFixtures::USERNAME_USER, $user->getUserIdentifier());
$form = $client->getCrawler()->filter('form[name=user_api_token]')->form(); $form = $client->getCrawler()->filter('form[name=user_api_password]')->form();
$client->submit($form, [ $client->submit($form, [
'user_api_token' => [ 'user_api_password' => [
'plainApiToken' => [ 'plainApiToken' => [
'first' => 'test1234', 'first' => 'test1234',
'second' => 'test1234', 'second' => 'test1234',
@@ -292,16 +292,16 @@ class ProfileControllerTest extends ControllerBaseTest
$this->assertFormHasValidationError( $this->assertFormHasValidationError(
User::ROLE_USER, User::ROLE_USER,
'/profile/' . UserFixtures::USERNAME_USER . '/api-token', '/profile/' . UserFixtures::USERNAME_USER . '/api-token',
'form[name=user_api_token]', 'form[name=user_api_password]',
[ [
'user_api_token' => [ 'user_api_password' => [
'plainApiToken' => [ 'plainApiToken' => [
'first' => 'abcdef1', 'first' => 'abcdef1',
'second' => 'abcdef1', 'second' => 'abcdef1',
] ]
] ]
], ],
['#user_api_token_plainApiToken_first'] ['#user_api_password_plainApiToken_first']
); );
} }

View File

@@ -0,0 +1,52 @@
<?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\Tests\Entity;
use App\Entity\AccessToken;
use App\Entity\User;
/**
* @covers \App\Entity\AccessToken
*/
class AccessTokenTest extends AbstractEntityTest
{
public function testDefaultValues(): void
{
$user = new User();
$sut = new AccessToken($user, 'foo');
$this->assertNull($sut->getId());
$this->assertNull($sut->getName());
$this->assertNull($sut->getExpiresAt());
$this->assertNull($sut->getLastUsage());
$this->assertSame('foo', $sut->getToken());
$this->assertSame($user, $sut->getUser());
$this->assertTrue($sut->isValid());
$sut->setName('bar');
$this->assertSame('bar', $sut->getName());
$dateTime = new \DateTimeImmutable('-1 year');
$sut->setLastUsage($dateTime);
$this->assertSame($dateTime, $sut->getLastUsage());
$dateTime = new \DateTimeImmutable('-1 month');
$sut->setExpiresAt($dateTime);
$this->assertSame($dateTime, $sut->getExpiresAt());
}
public function testIsValid(): void
{
$user = new User();
$sut = new AccessToken($user, 'foo');
$sut->setExpiresAt(new \DateTimeImmutable('-1 day'));
$this->assertFalse($sut->isValid());
}
}

View File

@@ -1381,10 +1381,6 @@
<source>help.invoiceTemplate_customer</source> <source>help.invoiceTemplate_customer</source>
<target state="translated">يتم إنشاء فواتير هذا الزبون بشكل افتراضي باستخدام هذا النموذج. يمكن تغييره أثناء إنشاء الفاتورة إذا لزم الأمر.</target> <target state="translated">يتم إنشاء فواتير هذا الزبون بشكل افتراضي باستخدام هذا النموذج. يمكن تغييره أثناء إنشاء الفاتورة إذا لزم الأمر.</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">لم تقم بعد بإدخال كلمة مرور API. لأسباب أمنية ، لا يمكنك استخدام واجهة برمجة التطبيقات حتى تقوم بحفظ كلمة مرور.</target>
</trans-unit>
<trans-unit id="1MfS6X5" resname="remove_filter"> <trans-unit id="1MfS6X5" resname="remove_filter">
<source>remove_filter</source> <source>remove_filter</source>
<target state="translated">إعادة تعيين عوامل تصفية البحث</target> <target state="translated">إعادة تعيين عوامل تصفية البحث</target>

View File

@@ -1459,10 +1459,6 @@
<source>Deactivated</source> <source>Deactivated</source>
<target state="translated">Deaktivováno</target> <target state="translated">Deaktivováno</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">Dosud jste nezadali heslo API. Z bezpečnostních důvodů nemůžete rozhraní API používat, dokud heslo neuložíte.</target>
</trans-unit>
<trans-unit id="3WHV.yf" resname="activated"> <trans-unit id="3WHV.yf" resname="activated">
<source>Activated</source> <source>Activated</source>
<target state="translated">Aktivováno</target> <target state="translated">Aktivováno</target>

View File

@@ -1514,16 +1514,12 @@
</trans-unit> </trans-unit>
<trans-unit id="JSBReoa" resname="api_password.intro"> <trans-unit id="JSBReoa" resname="api_password.intro">
<source>api_password.intro</source> <source>api_password.intro</source>
<target state="translated">Das API Passwort dient zur Kommunikation zwischen Kimai und von Ihnen genutzten Apps</target> <target state="translated">Hier verwalten Sie Ihre API-Tokens, welche zur Authentifizierung Ihrer Anwendung gegenüber Kimai dienen.</target>
</trans-unit> </trans-unit>
<trans-unit id="QT8.BCN" resname="api_password.missing_title"> <trans-unit id="QT8.BCN" resname="api_password.missing_title">
<source>api_password.missing_title</source> <source>api_password.missing_title</source>
<target>API kann nicht genutzt werden</target> <target>API kann nicht genutzt werden</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target>Sie haben noch kein API Passwort hinterlegt. Aus Sicherheitsgründen ist die Nutzung der API bis zur Hinterlegung eines Passworts für Sie nicht nutzbar.</target>
</trans-unit>
<trans-unit id="wYcx0So" resname="search.no_results"> <trans-unit id="wYcx0So" resname="search.no_results">
<source>search.no_results</source> <source>search.no_results</source>
<target>Kein Ergebnis gefunden für "%input%"</target> <target>Kein Ergebnis gefunden für "%input%"</target>
@@ -1752,6 +1748,22 @@
<source>absence_comment_mandatory</source> <source>absence_comment_mandatory</source>
<target>Abwesenheit: Kommentar ist Pflichtfeld</target> <target>Abwesenheit: Kommentar ist Pflichtfeld</target>
</trans-unit> </trans-unit>
<trans-unit id="q4ooRfF" resname="expires">
<source>Expiry date</source>
<target>Ablaufdatum</target>
</trans-unit>
<trans-unit id="rkCw7Ml" resname="last_usage">
<source>Last usage</source>
<target>Letzte Nutzung</target>
</trans-unit>
<trans-unit id="Z9IIn_b" resname="api_token_hidden">
<source>api_token_hidden</source>
<target>Dies ist das einzige Mal, dass der Token angezeigt wird! Bewahren Sie ihn gut auf und vergewissern Sie sich, dass Sie ihn notiert haben, bevor Sie dieses Fenster schließen.</target>
</trans-unit>
<trans-unit id="TCr2ArH" resname="api_password_deprecated">
<source>api_password_deprecated</source>
<target>API Passwörter sind veraltet: bitte nutzen Sie stattdessen API-Tokens.</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -1514,16 +1514,12 @@
</trans-unit> </trans-unit>
<trans-unit id="JSBReoa" resname="api_password.intro"> <trans-unit id="JSBReoa" resname="api_password.intro">
<source>api_password.intro</source> <source>api_password.intro</source>
<target>The API password is used for communication between Kimai and your apps</target> <target>Here you can manage your API tokens, which are used to authenticate your application to Kimai.</target>
</trans-unit> </trans-unit>
<trans-unit id="QT8.BCN" resname="api_password.missing_title"> <trans-unit id="QT8.BCN" resname="api_password.missing_title">
<source>api_password.missing_title</source> <source>api_password.missing_title</source>
<target>API cannot be used</target> <target>API cannot be used</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target>You have not yet entered an API password. For security reasons, you cannot use the API until you have saved a password.</target>
</trans-unit>
<trans-unit id="wYcx0So" resname="search.no_results"> <trans-unit id="wYcx0So" resname="search.no_results">
<source>search.no_results</source> <source>search.no_results</source>
<target>No results found for "%input%"</target> <target>No results found for "%input%"</target>
@@ -1752,6 +1748,22 @@
<source>absence_comment_mandatory</source> <source>absence_comment_mandatory</source>
<target>Absence: Comment is a mandatory field</target> <target>Absence: Comment is a mandatory field</target>
</trans-unit> </trans-unit>
<trans-unit id="q4ooRfF" resname="expires">
<source>Expiry date</source>
<target>Expiry date</target>
</trans-unit>
<trans-unit id="rkCw7Ml" resname="last_usage">
<source>Last usage</source>
<target>Last usage</target>
</trans-unit>
<trans-unit id="Z9IIn_b" resname="api_token_hidden">
<source>api_token_hidden</source>
<target>This is the only time this key will ever be displayed! So keep it safe and make sure you've copied it down before closing this window.</target>
</trans-unit>
<trans-unit id="TCr2ArH" resname="api_password_deprecated">
<source>api_password_deprecated</source>
<target>API passwords are outdated: please use API tokens instead.</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -1407,10 +1407,6 @@
<source>api_password.missing_title</source> <source>api_password.missing_title</source>
<target state="translated">No se puede usar la aplicación</target> <target state="translated">No se puede usar la aplicación</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">Aún no ha ingresado una contraseña para la aplicación. Por razones de seguridad, no puede usar la aplicación hasta que haya guardado una contraseña.</target>
</trans-unit>
<trans-unit id="wYcx0So" resname="search.no_results"> <trans-unit id="wYcx0So" resname="search.no_results">
<source>search.no_results</source> <source>search.no_results</source>
<target state="translated">No se han encontrado resultados para "%input%"</target> <target state="translated">No se han encontrado resultados para "%input%"</target>

View File

@@ -1184,10 +1184,6 @@
<source>help.globalActivity</source> <source>help.globalActivity</source>
<target state="translated">اگر پروژه ای را انتخاب نکنید، این فعالیت جهانی می شود و می توان آن را با هر پروژه ای ترکیب کرد. اگر پروژه ای را انتخاب کنید، فعالیت فقط با آن پروژه قابل استفاده است. تنظیم را نمی توان بعداً تغییر داد.</target> <target state="translated">اگر پروژه ای را انتخاب نکنید، این فعالیت جهانی می شود و می توان آن را با هر پروژه ای ترکیب کرد. اگر پروژه ای را انتخاب کنید، فعالیت فقط با آن پروژه قابل استفاده است. تنظیم را نمی توان بعداً تغییر داد.</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">شما هنوز رمز عبور API را وارد نکرده اید. به دلایل امنیتی، تا زمانی که رمز عبور را ذخیره نکرده باشید، نمی توانید از API استفاده کنید.</target>
</trans-unit>
<trans-unit id="1MfS6X5" resname="remove_filter"> <trans-unit id="1MfS6X5" resname="remove_filter">
<source>remove_filter</source> <source>remove_filter</source>
<target state="translated">فیلتر جستجو را بازنشانی کنید</target> <target state="translated">فیلتر جستجو را بازنشانی کنید</target>

View File

@@ -1355,10 +1355,6 @@
<source>skin</source> <source>skin</source>
<target state="translated">Design</target> <target state="translated">Design</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">Et ole vielä syöttänyt API salasanaa. Turvallisuussyistä et voi käyttää API:a ennen salasanan tallentamista.</target>
</trans-unit>
<trans-unit id="WOAx9yz" resname="modal.columns.profile"> <trans-unit id="WOAx9yz" resname="modal.columns.profile">
<source>modal.columns.profile</source> <source>modal.columns.profile</source>
<target state="translated">Profiili</target> <target state="translated">Profiili</target>

View File

@@ -1371,10 +1371,6 @@
<source>api_password.missing_title</source> <source>api_password.missing_title</source>
<target state="translated">L'API ne peut pas être utilisée</target> <target state="translated">L'API ne peut pas être utilisée</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">Vous n'avez pas encore saisi de mot de passe pour l'API. Pour des raisons de sécurité, vous ne pouvez pas utiliser l'API tant que vous n'avez pas enregistré un mot de passe.</target>
</trans-unit>
<trans-unit id="wYcx0So" resname="search.no_results"> <trans-unit id="wYcx0So" resname="search.no_results">
<source>search.no_results</source> <source>search.no_results</source>
<target state="translated">Aucun résultat trouvé pour "%input%"</target> <target state="translated">Aucun résultat trouvé pour "%input%"</target>

View File

@@ -1298,10 +1298,6 @@
<source>skin.dark</source> <source>skin.dark</source>
<target state="translated">כהה</target> <target state="translated">כהה</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">טרם מילאת סיסמה ל־API. מטעמי אבטחה, אי אפשר להשתמש ב־API עד לשמירת סיסמה.</target>
</trans-unit>
<trans-unit id="dru_Thv" resname="please_choose"> <trans-unit id="dru_Thv" resname="please_choose">
<source>please_choose</source> <source>please_choose</source>
<target state="translated">נא לבחור</target> <target state="translated">נא לבחור</target>

View File

@@ -1343,10 +1343,6 @@
<source>skin.dark</source> <source>skin.dark</source>
<target state="translated">Tamno</target> <target state="translated">Tamno</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">Još nisi upisao/la API lozinku. Iz sigurnosnih razloga ne možeš koristiti API dok ne spremiš lozinku.</target>
</trans-unit>
<trans-unit id="QT8.BCN" resname="api_password.missing_title"> <trans-unit id="QT8.BCN" resname="api_password.missing_title">
<source>api_password.missing_title</source> <source>api_password.missing_title</source>
<target state="translated">API se ne može koristiti</target> <target state="translated">API se ne može koristiti</target>

View File

@@ -1422,10 +1422,6 @@
<source>api_password.missing_title</source> <source>api_password.missing_title</source>
<target state="final">Non è possibile usare l'API</target> <target state="final">Non è possibile usare l'API</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description" approved="yes">
<source>api_password.missing_description</source>
<target state="final">Non hai ancora inserito una password API. Per motivi di sicurezza, non puoi usare l'API finché non hai salvato una password.</target>
</trans-unit>
<trans-unit id="dru_Thv" resname="please_choose" approved="yes"> <trans-unit id="dru_Thv" resname="please_choose" approved="yes">
<source>please_choose</source> <source>please_choose</source>
<target state="final">Fai la tua scelta</target> <target state="final">Fai la tua scelta</target>

View File

@@ -1393,10 +1393,6 @@
<source>Activated</source> <source>Activated</source>
<target state="translated">Aktivert</target> <target state="translated">Aktivert</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">Du har ikke skrevet inn et API-passord enda. Av sikkerhetshensyn kan du ikke bruke API-et til du har lagret et passord.</target>
</trans-unit>
<trans-unit id="wYcx0So" resname="search.no_results"> <trans-unit id="wYcx0So" resname="search.no_results">
<source>search.no_results</source> <source>search.no_results</source>
<target state="translated">Fant ingen resultater for «%input%»</target> <target state="translated">Fant ingen resultater for «%input%»</target>

View File

@@ -1415,10 +1415,6 @@
<source>api_password.intro</source> <source>api_password.intro</source>
<target state="translated">Het API-wachtwoord wordt gebruikt voor communicatie tussen Kimai en uw applicaties</target> <target state="translated">Het API-wachtwoord wordt gebruikt voor communicatie tussen Kimai en uw applicaties</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">U heeft nog geen API-wachtwoord ingevoerd. Vanwege veiligheidsredenen kunt u de API niet gebruiken tot u een wachtwoord heeft opgeslagen.</target>
</trans-unit>
<trans-unit id="wYcx0So" resname="search.no_results"> <trans-unit id="wYcx0So" resname="search.no_results">
<source>search.no_results</source> <source>search.no_results</source>
<target state="translated">Geen resultaten gevonden voor "%input%"</target> <target state="translated">Geen resultaten gevonden voor "%input%"</target>

View File

@@ -1447,10 +1447,6 @@
<source>help.globalActivities</source> <source>help.globalActivities</source>
<target state="translated">W przypadku wyłączenia tego ustawienia, czas może być rejestrowany jedynie przy działaniach specyficznych dla projektu.</target> <target state="translated">W przypadku wyłączenia tego ustawienia, czas może być rejestrowany jedynie przy działaniach specyficznych dla projektu.</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">Nie wprowadzono jeszcze hasła do API. Ze względów bezpieczeństwa nie będziesz mógł korzystać z API, dopóki nie wprowadzisz hasła.</target>
</trans-unit>
<trans-unit id="jLzX0Ae" resname="help.invoiceLabel"> <trans-unit id="jLzX0Ae" resname="help.invoiceLabel">
<source>help.invoiceLabel</source> <source>help.invoiceLabel</source>
<target state="translated">Ten tekst zastępuje nazwę przedmiotu, umożliwiając bardziej szczegółowe oznaczenie dla przedmiotów faktury.</target> <target state="translated">Ten tekst zastępuje nazwę przedmiotu, umożliwiając bardziej szczegółowe oznaczenie dla przedmiotów faktury.</target>

View File

@@ -1383,10 +1383,6 @@
<source>api_password.missing_title</source> <source>api_password.missing_title</source>
<target state="translated">A API não pode ser usada</target> <target state="translated">A API não pode ser usada</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">Ainda não inseriu uma palavra-passe para a API. Por questões de segurança,não pode usar a API até que uma palavra-passe seja salva.</target>
</trans-unit>
<trans-unit id="wYcx0So" resname="search.no_results"> <trans-unit id="wYcx0So" resname="search.no_results">
<source>search.no_results</source> <source>search.no_results</source>
<target state="translated">Nenhum resultado foi encontrado para "%input%"</target> <target state="translated">Nenhum resultado foi encontrado para "%input%"</target>

View File

@@ -1403,10 +1403,6 @@
<source>api_password.missing_title</source> <source>api_password.missing_title</source>
<target state="translated">A API não pode ser usada</target> <target state="translated">A API não pode ser usada</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">Você ainda não inseriu uma senha para a API. Por questões de segurança, você não pode usar a API até que uma senha seja salva.</target>
</trans-unit>
<trans-unit id="wYcx0So" resname="search.no_results"> <trans-unit id="wYcx0So" resname="search.no_results">
<source>search.no_results</source> <source>search.no_results</source>
<target state="translated">Nenhum resultado foi encontrado para "%input%"</target> <target state="translated">Nenhum resultado foi encontrado para "%input%"</target>

View File

@@ -1194,10 +1194,6 @@
<source>api_password.missing_title</source> <source>api_password.missing_title</source>
<target state="translated">API sa nedá použiť</target> <target state="translated">API sa nedá použiť</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">Nezadali ste API heslo. Kvôli bezpečnostným dôvodom nemôžete používať API kým nenastavíte heslo.</target>
</trans-unit>
<trans-unit id="95DUED0" resname="send_to"> <trans-unit id="95DUED0" resname="send_to">
<source>send_to</source> <source>send_to</source>
<target state="translated">Poslať %name%</target> <target state="translated">Poslať %name%</target>

View File

@@ -1255,10 +1255,6 @@
<source>help.globalActivity</source> <source>help.globalActivity</source>
<target state="translated">Om du inte väljer ett projekt blir denna aktivitet global och den kan kombineras med vilket projekt som helst. Om du väljer ett projekt kan aktiviteten endast användas med det projektet. Inställningen kan inte ändras senare.</target> <target state="translated">Om du inte väljer ett projekt blir denna aktivitet global och den kan kombineras med vilket projekt som helst. Om du väljer ett projekt kan aktiviteten endast användas med det projektet. Inställningen kan inte ändras senare.</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">Du har ännu inte angett ett API-lösenord. Av säkerhetsskäl kan du inte använda API förrän du har sparat ett lösenord.</target>
</trans-unit>
<trans-unit id="DXcvOMM" resname="stats.userDurationWeek"> <trans-unit id="DXcvOMM" resname="stats.userDurationWeek">
<source>stats.userDurationWeek</source> <source>stats.userDurationWeek</source>
<target state="translated">Mina arbetstider denna vecka</target> <target state="translated">Mina arbetstider denna vecka</target>

View File

@@ -1423,10 +1423,6 @@
<source>extended_settings</source> <source>extended_settings</source>
<target state="translated">Genişletilmiş ayarlar</target> <target state="translated">Genişletilmiş ayarlar</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">Henüz bir API parolası girmediniz. Güvenlik nedeniyle, bir parola kaydedinceye kadar API'yi kullanamazsınız.</target>
</trans-unit>
<trans-unit id="dru_Thv" resname="please_choose"> <trans-unit id="dru_Thv" resname="please_choose">
<source>please_choose</source> <source>please_choose</source>
<target state="translated">Lütfen seçin</target> <target state="translated">Lütfen seçin</target>

View File

@@ -1362,10 +1362,6 @@
<source>Activated</source> <source>Activated</source>
<target state="translated">Активовано</target> <target state="translated">Активовано</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">Ви ще не внесли пароль API. З огляду на безпеку, користуватися API без збереженого пароля Ви не можете.</target>
</trans-unit>
<trans-unit id="3Clo55j" resname="about.title"> <trans-unit id="3Clo55j" resname="about.title">
<source>about.title</source> <source>about.title</source>
<target state="translated">Про Kimai</target> <target state="translated">Про Kimai</target>

View File

@@ -1403,10 +1403,6 @@
<source>api_password.intro</source> <source>api_password.intro</source>
<target state="translated">API 密码用于 Kimai 和您的应用程序之间的通信</target> <target state="translated">API 密码用于 Kimai 和您的应用程序之间的通信</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">您尚未输入 API 密码。出于安全原因,您必须先保存密码才能使用 API。</target>
</trans-unit>
<trans-unit id="wYcx0So" resname="search.no_results"> <trans-unit id="wYcx0So" resname="search.no_results">
<source>search.no_results</source> <source>search.no_results</source>
<target state="translated">找不到 "%input%" 的结果</target> <target state="translated">找不到 "%input%" 的结果</target>

View File

@@ -1130,10 +1130,6 @@
<source>skin</source> <source>skin</source>
<target state="translated">設計</target> <target state="translated">設計</target>
</trans-unit> </trans-unit>
<trans-unit id="fxFzCHd" resname="api_password.missing_description">
<source>api_password.missing_description</source>
<target state="translated">您尚未輸入 API 密碼。基於安全考量,您必須先儲存密碼才能使用 API。</target>
</trans-unit>
<trans-unit id="WOAx9yz" resname="modal.columns.profile"> <trans-unit id="WOAx9yz" resname="modal.columns.profile">
<source>modal.columns.profile</source> <source>modal.columns.profile</source>
<target state="translated">資料</target> <target state="translated">資料</target>