added API tokens, deprecate API passwords (#4637)
This commit is contained in:
@@ -18,7 +18,6 @@ use App\Event\PageActionsEvent;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
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: '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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getTimesheetActions(Timesheet $timesheet, string $view, string $locale): Response
|
||||
{
|
||||
$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: '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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getActivityActions(Activity $activity, string $view, string $locale): Response
|
||||
{
|
||||
$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: '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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getProjectActions(Project $project, string $view, string $locale): Response
|
||||
{
|
||||
$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: '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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getCustomerActions(Customer $customer, string $view, string $locale): Response
|
||||
{
|
||||
$event = new PageActionsEvent($this->getUser(), ['customer' => $customer], 'customer', $view);
|
||||
|
||||
@@ -24,7 +24,6 @@ use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
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')))]
|
||||
#[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: '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')]
|
||||
@@ -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\Parameter(name: 'id', in: 'path', description: 'Activity ID to fetch', required: true)]
|
||||
#[Route(methods: ['GET'], path: '/{id}', name: 'get_activity', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[IsGranted('view', 'activity')]
|
||||
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\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ActivityEditForm'))]
|
||||
#[Route(methods: ['POST'], path: '', name: 'post_activity')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postAction(Request $request): Response
|
||||
{
|
||||
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\Parameter(name: 'id', in: 'path', description: 'Activity ID to update', required: true)]
|
||||
#[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
|
||||
{
|
||||
$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\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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[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')]
|
||||
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\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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getRatesAction(Activity $activity): Response
|
||||
{
|
||||
$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\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)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[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
|
||||
{
|
||||
@@ -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\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ActivityRateForm'))]
|
||||
#[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
|
||||
{
|
||||
$rate = new ActivityRate();
|
||||
|
||||
46
src/API/Authentication/AccessTokenHandler.php
Normal file
46
src/API/Authentication/AccessTokenHandler.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -16,16 +16,29 @@ final class ApiRequestMatcher implements RequestMatcherInterface
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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 !$request->headers->has(SessionAuthenticator::HEADER_JAVASCRIPT) &&
|
||||
$request->headers->has(TokenAuthenticator::HEADER_USERNAME) &&
|
||||
$request->headers->has(TokenAuthenticator::HEADER_TOKEN);
|
||||
// let's use this firewall if a Bearer token is set in the header
|
||||
if ($request->headers->has('Authorization')) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -29,14 +29,25 @@ final class TokenAuthenticator extends AbstractAuthenticator
|
||||
public const HEADER_USERNAME = 'X-AUTH-USER';
|
||||
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
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -14,7 +14,6 @@ use App\Configuration\SystemConfiguration;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
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)))]
|
||||
#[Route(methods: ['GET'], path: '/config/timesheet')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function timesheetConfigAction(SystemConfiguration $configuration): Response
|
||||
{
|
||||
$model = new TimesheetConfig();
|
||||
|
||||
@@ -24,7 +24,6 @@ use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
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')))]
|
||||
#[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: '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)')]
|
||||
@@ -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'))]
|
||||
#[Route(methods: ['GET'], path: '/{id}', name: 'get_customer', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[IsGranted('view', 'customer')]
|
||||
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\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CustomerEditForm'))]
|
||||
#[Route(methods: ['POST'], path: '', name: 'post_customer')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postAction(Request $request, CustomerService $customerService): Response
|
||||
{
|
||||
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\Parameter(name: 'id', in: 'path', description: 'Customer ID to update', required: true)]
|
||||
#[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
|
||||
{
|
||||
$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\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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[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')]
|
||||
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\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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getRatesAction(Customer $customer): Response
|
||||
{
|
||||
$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\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)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[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
|
||||
{
|
||||
@@ -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\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CustomerRateForm'))]
|
||||
#[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
|
||||
{
|
||||
$rate = new CustomerRate();
|
||||
|
||||
@@ -25,7 +25,6 @@ use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
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')))]
|
||||
#[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: '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')]
|
||||
@@ -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'))]
|
||||
#[Route(methods: ['GET'], path: '/{id}', name: 'get_project', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[IsGranted('view', 'project')]
|
||||
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\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ProjectEditForm'))]
|
||||
#[Route(methods: ['POST'], path: '', name: 'post_project')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postAction(Request $request): Response
|
||||
{
|
||||
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\Parameter(name: 'id', in: 'path', description: 'Project ID to update', required: true)]
|
||||
#[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
|
||||
{
|
||||
$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\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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[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')]
|
||||
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\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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getRatesAction(Project $project): Response
|
||||
{
|
||||
$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\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)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[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
|
||||
{
|
||||
@@ -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\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ProjectRateForm'))]
|
||||
#[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
|
||||
{
|
||||
$rate = new ProjectRate();
|
||||
|
||||
@@ -15,7 +15,6 @@ use App\Plugin\PluginManager;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
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'}"))]
|
||||
#[Route(methods: ['GET'], path: '/ping')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function pingAction(): Response
|
||||
{
|
||||
$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)))]
|
||||
#[Route(methods: ['GET'], path: '/version')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function versionAction(): Response
|
||||
{
|
||||
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))))]
|
||||
#[Route(methods: ['GET'], path: '/plugins')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function pluginAction(PluginManager $pluginManager): Response
|
||||
{
|
||||
$plugins = [];
|
||||
|
||||
@@ -16,7 +16,6 @@ use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
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')))]
|
||||
#[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')]
|
||||
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\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TagEditForm'))]
|
||||
#[Route(methods: ['POST'], name: 'post_tag')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postAction(Request $request): Response
|
||||
{
|
||||
if (!$this->isGranted('manage_tag') && !$this->isGranted('create_tag')) {
|
||||
@@ -97,8 +92,6 @@ final class TagController extends BaseApiController
|
||||
#[IsGranted('delete_tag')]
|
||||
#[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)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_tag')]
|
||||
public function deleteAction(Tag $tag): Response
|
||||
{
|
||||
|
||||
@@ -21,7 +21,6 @@ use App\Repository\ProjectRepository;
|
||||
use App\Repository\TeamRepository;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -49,8 +48,6 @@ final class TeamController extends BaseApiController
|
||||
#[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')))]
|
||||
#[Route(methods: ['GET'], path: '', name: 'get_teams')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function cgetAction(): Response
|
||||
{
|
||||
$data = $this->repository->findAll();
|
||||
@@ -67,8 +64,6 @@ final class TeamController extends BaseApiController
|
||||
#[IsGranted('view_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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getAction(Team $team): Response
|
||||
{
|
||||
$view = new View($team, 200);
|
||||
@@ -83,8 +78,6 @@ final class TeamController extends BaseApiController
|
||||
#[IsGranted('delete_team')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one team')])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Team ID to delete', required: true)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_team', requirements: ['id' => '\d+'])]
|
||||
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\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TeamEditForm'))]
|
||||
#[Route(methods: ['POST'], path: '', name: 'post_team')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postAction(Request $request): Response
|
||||
{
|
||||
$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\Parameter(name: 'id', in: 'path', description: 'Team ID to update', required: true)]
|
||||
#[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
|
||||
{
|
||||
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: '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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postMemberAction(Team $team, #[MapEntity(mapping: ['userId' => 'id'])] User $member): Response
|
||||
{
|
||||
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\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)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[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
|
||||
{
|
||||
@@ -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: '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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postCustomerAction(Team $team, #[MapEntity(mapping: ['customerId' => 'id'])] Customer $customer, CustomerRepository $customerRepository): Response
|
||||
{
|
||||
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\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)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[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
|
||||
{
|
||||
@@ -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: '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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postProjectAction(Team $team, #[MapEntity(mapping: ['projectId' => 'id'])] Project $project, ProjectRepository $projectRepository): Response
|
||||
{
|
||||
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\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)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[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
|
||||
{
|
||||
@@ -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: '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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postActivityAction(Team $team, #[MapEntity(mapping: ['activityId' => 'id'])] Activity $activity, ActivityRepository $activityRepository): Response
|
||||
{
|
||||
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\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)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[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
|
||||
{
|
||||
|
||||
@@ -31,7 +31,6 @@ use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
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')"))]
|
||||
#[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')]
|
||||
#[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: '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')]
|
||||
@@ -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\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to fetch', required: true)]
|
||||
#[Route(methods: ['GET'], path: '/{id}', name: 'get_timesheet', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getAction(Timesheet $timesheet): Response
|
||||
{
|
||||
$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\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEditForm'))]
|
||||
#[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)')]
|
||||
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\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEditForm'))]
|
||||
#[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
|
||||
{
|
||||
$event = new TimesheetMetaDefinitionEvent($timesheet);
|
||||
@@ -391,8 +382,6 @@ final class TimesheetController extends BaseApiController
|
||||
#[IsGranted('delete', 'timesheet')]
|
||||
#[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)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_timesheet', requirements: ['id' => '\d+'])]
|
||||
public function deleteAction(Timesheet $timesheet): Response
|
||||
{
|
||||
@@ -409,8 +398,6 @@ final class TimesheetController extends BaseApiController
|
||||
#[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')))]
|
||||
#[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: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries (default: 10)')]
|
||||
public function recentAction(ParamFetcherInterface $paramFetcher): Response
|
||||
@@ -445,8 +432,6 @@ final class TimesheetController extends BaseApiController
|
||||
#[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')))]
|
||||
#[Route(methods: ['GET'], path: '/active', name: 'active_timesheet')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function activeAction(): Response
|
||||
{
|
||||
/** @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)]
|
||||
#[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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function stopAction(Timesheet $timesheet): Response
|
||||
{
|
||||
$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)]
|
||||
#[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+'])]
|
||||
#[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: '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
|
||||
@@ -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\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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function duplicateAction(Timesheet $timesheet): Response
|
||||
{
|
||||
$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\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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function exportAction(Timesheet $timesheet): Response
|
||||
{
|
||||
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\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+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[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')]
|
||||
public function metaAction(Timesheet $timesheet, ParamFetcherInterface $paramFetcher): Response
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
namespace App\API;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\AccessToken;
|
||||
use App\Entity\User;
|
||||
use App\Event\PrepareUserEvent;
|
||||
use App\Form\API\UserApiCreateForm;
|
||||
use App\Form\API\UserApiEditForm;
|
||||
use App\Repository\AccessTokenRepository;
|
||||
use App\Repository\Query\UserQuery;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Utils\SearchTerm;
|
||||
@@ -21,7 +23,6 @@ use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
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 function __construct(
|
||||
private ViewHandlerInterface $viewHandler,
|
||||
private UserRepository $repository,
|
||||
private UserPasswordHasherInterface $passwordHasher,
|
||||
private SystemConfiguration $configuration
|
||||
private readonly ViewHandlerInterface $viewHandler,
|
||||
private readonly UserRepository $repository,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly SystemConfiguration $configuration
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -55,8 +56,6 @@ final class UserController extends BaseApiController
|
||||
#[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')))]
|
||||
#[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: '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)')]
|
||||
@@ -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\Parameter(name: 'id', in: 'path', description: 'User ID to fetch', required: true)]
|
||||
#[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
|
||||
{
|
||||
// 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'))]
|
||||
#[Route(methods: ['GET'], path: '/me', name: 'me_user')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function meAction(): Response
|
||||
{
|
||||
$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\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/UserCreateForm'))]
|
||||
#[Route(methods: ['POST'], path: '', name: 'post_user')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postAction(Request $request): Response
|
||||
{
|
||||
$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\Parameter(name: 'id', in: 'path', description: 'User ID to update', required: true)]
|
||||
#[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
|
||||
{
|
||||
$form = $this->createForm(UserApiEditForm::class, $profile, [
|
||||
@@ -227,4 +218,29 @@ final class UserController extends BaseApiController
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\DataFixtures\UserFixtures;
|
||||
use App\Entity\AccessToken;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
@@ -34,7 +36,10 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
#[AsCommand(name: 'kimai:reset:test', description: 'Resets the "test" environment')]
|
||||
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);
|
||||
}
|
||||
@@ -95,7 +100,8 @@ final class ResetTestCommand extends AbstractResetCommand
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
|
||||
UserFixtures::DEFAULT_API_TOKEN . '_customer',
|
||||
],
|
||||
[
|
||||
2,
|
||||
@@ -115,7 +121,8 @@ final class ResetTestCommand extends AbstractResetCommand
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
|
||||
UserFixtures::DEFAULT_API_TOKEN . '_user',
|
||||
],
|
||||
[
|
||||
3,
|
||||
@@ -135,7 +142,8 @@ final class ResetTestCommand extends AbstractResetCommand
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
|
||||
UserFixtures::DEFAULT_API_TOKEN . '_inactive',
|
||||
],
|
||||
[
|
||||
4,
|
||||
@@ -155,7 +163,8 @@ final class ResetTestCommand extends AbstractResetCommand
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
|
||||
UserFixtures::DEFAULT_API_TOKEN . '_teamlead',
|
||||
],
|
||||
[
|
||||
5,
|
||||
@@ -175,7 +184,8 @@ final class ResetTestCommand extends AbstractResetCommand
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
|
||||
UserFixtures::DEFAULT_API_TOKEN . '_admin',
|
||||
],
|
||||
[
|
||||
6,
|
||||
@@ -195,7 +205,8 @@ final class ResetTestCommand extends AbstractResetCommand
|
||||
'2020-04-14 09:50:38',
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
|
||||
UserFixtures::DEFAULT_API_TOKEN . '_super',
|
||||
],
|
||||
[
|
||||
7,
|
||||
@@ -215,7 +226,8 @@ final class ResetTestCommand extends AbstractResetCommand
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
|
||||
UserFixtures::DEFAULT_API_TOKEN . '_qa1',
|
||||
],
|
||||
[
|
||||
8,
|
||||
@@ -235,7 +247,8 @@ final class ResetTestCommand extends AbstractResetCommand
|
||||
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]);
|
||||
}
|
||||
|
||||
$accessToken = new AccessToken($user, $userConf[18]);
|
||||
$accessToken->setName('Test fixture');
|
||||
$this->entityManager->persist($accessToken);
|
||||
|
||||
$this->entityManager->persist($user);
|
||||
$userEntities[] = $user;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\AccessToken;
|
||||
use App\Entity\User;
|
||||
use App\Entity\UserPreference;
|
||||
use App\Event\PrepareUserEvent;
|
||||
use App\Form\AccessTokenForm;
|
||||
use App\Form\Model\TotpActivation;
|
||||
use App\Form\UserApiTokenType;
|
||||
use App\Form\UserApiPasswordType;
|
||||
use App\Form\UserContractType;
|
||||
use App\Form\UserEditType;
|
||||
use App\Form\UserPasswordType;
|
||||
@@ -21,6 +23,7 @@ use App\Form\UserPreferencesForm;
|
||||
use App\Form\UserRolesType;
|
||||
use App\Form\UserTeamsType;
|
||||
use App\Form\UserTwoFactorType;
|
||||
use App\Repository\AccessTokenRepository;
|
||||
use App\Repository\Query\TimesheetStatisticQuery;
|
||||
use App\Repository\TeamRepository;
|
||||
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('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);
|
||||
|
||||
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');
|
||||
|
||||
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', [
|
||||
'tab' => 'api-token',
|
||||
'created_token' => $createdToken,
|
||||
'access_tokens' => $accessTokens,
|
||||
'page_setup' => $this->getPageSetup($profile, 'api-token'),
|
||||
'user' => $profile,
|
||||
'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'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[IsGranted('2fa', 'profile')]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use App\Entity\AccessToken;
|
||||
use App\Entity\User;
|
||||
use App\Entity\UserPreference;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
@@ -86,6 +87,10 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
*/
|
||||
$manager->persist($prefs[0]);
|
||||
$manager->persist($prefs[1]);
|
||||
|
||||
$accessToken = new AccessToken($user, $userData[10]);
|
||||
$accessToken->setName('Test fixture');
|
||||
$manager->persist($accessToken);
|
||||
}
|
||||
|
||||
$manager->flush();
|
||||
@@ -168,7 +173,8 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
// enabled = $userData[6]
|
||||
// timezone = $userData[7]
|
||||
// password = $userData[8]
|
||||
// api = $userData[9]
|
||||
// api old = $userData[9]
|
||||
// api new = $userData[10]
|
||||
|
||||
return [
|
||||
[
|
||||
@@ -182,6 +188,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
'America/Vancouver',
|
||||
self::DEFAULT_PASSWORD,
|
||||
self::DEFAULT_API_TOKEN,
|
||||
self::DEFAULT_API_TOKEN . '_john',
|
||||
],
|
||||
[
|
||||
'John Doe',
|
||||
@@ -194,6 +201,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
'America/Vancouver',
|
||||
'password',
|
||||
'password',
|
||||
self::DEFAULT_API_TOKEN . '_user',
|
||||
],
|
||||
// inactive user to test login
|
||||
[
|
||||
@@ -207,6 +215,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
'Australia/Sydney',
|
||||
self::DEFAULT_PASSWORD,
|
||||
self::DEFAULT_API_TOKEN,
|
||||
self::DEFAULT_API_TOKEN . '_inactive',
|
||||
],
|
||||
[
|
||||
'Tony Maier',
|
||||
@@ -219,6 +228,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
'Asia/Bangkok',
|
||||
self::DEFAULT_PASSWORD,
|
||||
self::DEFAULT_API_TOKEN,
|
||||
self::DEFAULT_API_TOKEN . '_teamlead',
|
||||
],
|
||||
[
|
||||
'Tony Maier',
|
||||
@@ -231,6 +241,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
'Asia/Bangkok',
|
||||
'password',
|
||||
'password',
|
||||
self::DEFAULT_API_TOKEN . '_tony',
|
||||
],
|
||||
// no avatar to test default image macro
|
||||
[
|
||||
@@ -244,6 +255,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
'Europe/London',
|
||||
self::DEFAULT_PASSWORD,
|
||||
self::DEFAULT_API_TOKEN,
|
||||
self::DEFAULT_API_TOKEN . '_anna',
|
||||
],
|
||||
[
|
||||
'Anna Smith',
|
||||
@@ -256,6 +268,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
'Europe/London',
|
||||
'password',
|
||||
'password',
|
||||
self::DEFAULT_API_TOKEN . '_admin',
|
||||
],
|
||||
// no alias to test twig username macro
|
||||
[
|
||||
@@ -269,6 +282,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
'Europe/Berlin',
|
||||
self::DEFAULT_PASSWORD,
|
||||
self::DEFAULT_API_TOKEN,
|
||||
self::DEFAULT_API_TOKEN . '_susan',
|
||||
],
|
||||
[
|
||||
null,
|
||||
@@ -281,6 +295,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
'Europe/Berlin',
|
||||
'password',
|
||||
'password',
|
||||
self::DEFAULT_API_TOKEN . '_super',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
106
src/Entity/AccessToken.php
Normal file
106
src/Entity/AccessToken.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,11 @@ final class ProfileSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
$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);
|
||||
if ($this->profileManager->isValidProfile($profile)) {
|
||||
$this->profileManager->setProfile($request->getSession(), $profile);
|
||||
|
||||
46
src/Form/AccessTokenForm.php
Normal file
46
src/Form/AccessTokenForm.php
Normal 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'
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,10 @@ use Symfony\Component\Form\FormBuilderInterface;
|
||||
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>
|
||||
*/
|
||||
final class UserApiTokenType extends AbstractType
|
||||
final class UserApiPasswordType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
@@ -40,7 +40,7 @@ final class UserApiTokenType extends AbstractType
|
||||
'data_class' => User::class,
|
||||
'csrf_protection' => true,
|
||||
'csrf_field_name' => '_token',
|
||||
'csrf_token_id' => 'edit_user_api_token',
|
||||
'csrf_token_id' => 'edit_user_password_token',
|
||||
]);
|
||||
}
|
||||
}
|
||||
47
src/Repository/AccessTokenRepository.php
Normal file
47
src/Repository/AccessTokenRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user