added API tokens, deprecate API passwords (#4637)
This commit is contained in:
@@ -48,19 +48,13 @@ nelmio_api_doc:
|
||||
title: Kimai - API Docs
|
||||
description: |
|
||||
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:
|
||||
securitySchemes:
|
||||
apiUser:
|
||||
type: apiKey
|
||||
description: 'Value: {Username}'
|
||||
name: X-AUTH-USER
|
||||
in: header
|
||||
apiToken:
|
||||
type: apiKey
|
||||
description: 'Value: {API Token}'
|
||||
name: X-AUTH-TOKEN
|
||||
in: header
|
||||
bearer:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: KIMAI
|
||||
description: API Token
|
||||
security:
|
||||
- X-AUTH-USER: []
|
||||
X-AUTH-TOKEN: []
|
||||
- bearer: []
|
||||
|
||||
@@ -18,6 +18,8 @@ security:
|
||||
security: false
|
||||
|
||||
api:
|
||||
access_token:
|
||||
token_handler: App\API\Authentication\AccessTokenHandler
|
||||
request_matcher: App\API\Authentication\ApiRequestMatcher
|
||||
user_checker: App\Security\UserChecker
|
||||
stateless: true
|
||||
@@ -35,7 +37,6 @@ security:
|
||||
entry_point: form_login
|
||||
|
||||
custom_authenticators:
|
||||
- App\API\Authentication\SessionAuthenticator
|
||||
- App\Saml\SamlAuthenticator
|
||||
|
||||
remember_me:
|
||||
|
||||
@@ -197,6 +197,11 @@ services:
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\WorkingTime']
|
||||
|
||||
App\Repository\AccessTokenRepository:
|
||||
class: App\Repository\AccessTokenRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\AccessToken']
|
||||
|
||||
monolog.formatter.kimai:
|
||||
class: Monolog\Formatter\LineFormatter
|
||||
arguments:
|
||||
|
||||
56
migrations/Version20240214061246.php
Normal file
56
migrations/Version20240214061246.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,18 @@
|
||||
<style type="text/css">
|
||||
body { margin-top: 0; }
|
||||
header { display:none; }
|
||||
#swagger-ui .information-container pre.base-url { display:none; }
|
||||
#swagger-ui .scheme-container { margin-top: -120px; box-shadow: none; height: 120px; }
|
||||
#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.api-platform .information-container.wrapper {
|
||||
margin-bottoM: 5px;
|
||||
}
|
||||
.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>
|
||||
{% endblock %}
|
||||
19
templates/user/access-token.html.twig
Normal file
19
templates/user/access-token.html.twig
Normal 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 %}
|
||||
@@ -1,31 +1,103 @@
|
||||
{% extends 'user/form.html.twig' %}
|
||||
{% 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="">
|
||||
{{ widgets.alert('warning', 'api_password.missing_description'|trans, 'api_password.missing_title'|trans, 'warning', false) }}
|
||||
<div class="col-12 col-md-6">
|
||||
<p>
|
||||
{{ 'api_password.intro'|trans }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>URL</strong>: {{ url('api.swagger_ui', {}, false)|replace({'/doc': ''}) }}</li>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 text-md-end">
|
||||
<a class="btn action-create modal-ajax-form" href="{{ path('user_profile_access_token', {'username': user.userIdentifier}) }}">{{ icon('create', true) }} {{ 'create'|trans }}</a>
|
||||
<a class="btn" target="_blank" href="{{ path('api.swagger_ui') }}" data-toggle="tooltip" title="Swagger API Docs">{{ icon('documentation', true) }} Swagger Docs</a>
|
||||
<a class="btn" target="_blank" href="{{ 'rest-api.html'|docu_link }}" data-toggle="tooltip" title="{{ 'help'|trans }}">{{ icon('help', true) }} {{ 'help'|trans }}</a>
|
||||
</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 %}
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="d-flex">
|
||||
<div class="me-auto">
|
||||
<p>{{ 'api_password.intro'|trans }}</p>
|
||||
<ul>
|
||||
<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 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>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
{{ parent() }}
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('kimai.initialized', function() {
|
||||
KimaiReloadPageWidget.create('kimai.accessToken');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -22,42 +22,25 @@ use Symfony\Component\HttpKernel\HttpKernelBrowser;
|
||||
*/
|
||||
abstract class APIControllerBaseTest extends ControllerBaseTest
|
||||
{
|
||||
protected function getClientForAuthenticatedUser(string $role = User::ROLE_USER): HttpKernelBrowser
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function getAuthHeader(string $username, string $password): array
|
||||
{
|
||||
switch ($role) {
|
||||
case User::ROLE_SUPER_ADMIN:
|
||||
$client = self::createClient([], [
|
||||
'HTTP_X_AUTH_USER' => UserFixtures::USERNAME_SUPER_ADMIN,
|
||||
'HTTP_X_AUTH_TOKEN' => UserFixtures::DEFAULT_API_TOKEN,
|
||||
]);
|
||||
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 [
|
||||
'HTTP_AUTHORIZATION' => 'Bearer ' . $password,
|
||||
];
|
||||
}
|
||||
|
||||
return $client;
|
||||
protected function getClientForAuthenticatedUser(string $role = User::ROLE_USER): HttpKernelBrowser
|
||||
{
|
||||
return match ($role) {
|
||||
User::ROLE_SUPER_ADMIN => self::createClient([], $this->getAuthHeader(UserFixtures::USERNAME_SUPER_ADMIN, UserFixtures::DEFAULT_API_TOKEN . '_super')),
|
||||
User::ROLE_ADMIN => self::createClient([], $this->getAuthHeader(UserFixtures::USERNAME_ADMIN, UserFixtures::DEFAULT_API_TOKEN . '_admin')),
|
||||
User::ROLE_TEAMLEAD => self::createClient([], $this->getAuthHeader(UserFixtures::USERNAME_TEAMLEAD, UserFixtures::DEFAULT_API_TOKEN . '_teamlead')),
|
||||
User::ROLE_USER => self::createClient([], $this->getAuthHeader(UserFixtures::USERNAME_USER, UserFixtures::DEFAULT_API_TOKEN . '_user')),
|
||||
default => throw new \Exception(sprintf('Unknown role "%s"', $role)),
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$this->request($client, $url, $method);
|
||||
$this->assertResponseIsSecured($client->getResponse(), $url);
|
||||
}
|
||||
$response = $client->getResponse();
|
||||
|
||||
/**
|
||||
* @param Response $response
|
||||
* @param string $url
|
||||
*/
|
||||
protected function assertResponseIsSecured(Response $response, string $url): void
|
||||
{
|
||||
$data = ['message' => 'Authentication required, missing user header: X-AUTH-USER'];
|
||||
$data = [
|
||||
'message' => 'Unauthorized',
|
||||
'code' => 401
|
||||
];
|
||||
|
||||
self::assertEquals(
|
||||
$data,
|
||||
@@ -99,17 +78,12 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
||||
);
|
||||
|
||||
self::assertEquals(
|
||||
Response::HTTP_FORBIDDEN,
|
||||
Response::HTTP_UNAUTHORIZED,
|
||||
$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
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser($role);
|
||||
|
||||
@@ -97,20 +97,20 @@ class ApiDocControllerTest extends ControllerBaseTest
|
||||
'/api/users',
|
||||
'/api/users/{id}',
|
||||
'/api/users/me',
|
||||
'/api/users/api-token/{id}',
|
||||
];
|
||||
|
||||
$this->assertArrayHasKey('openapi', $json);
|
||||
$this->assertEquals('3.0.0', $json['openapi']);
|
||||
$this->assertArrayHasKey('info', $json);
|
||||
$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->assertEquals($paths, array_keys($json['paths']));
|
||||
|
||||
$this->assertArrayHasKey('security', $json);
|
||||
$this->assertArrayHasKey('X-AUTH-USER', $json['security'][0]);
|
||||
$this->assertArrayHasKey('X-AUTH-TOKEN', $json['security'][0]);
|
||||
$this->assertEquals(['bearer' => []], $json['security'][0]);
|
||||
|
||||
$this->assertArrayHasKey('components', $json);
|
||||
$this->assertArrayHasKey('schemas', $json['components']);
|
||||
|
||||
65
tests/API/Authentication/AccessTokenHandlerTest.php
Normal file
65
tests/API/Authentication/AccessTokenHandlerTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
|
||||
/**
|
||||
* @covers \App\API\Authentication\TokenAuthenticator
|
||||
* @group legacy
|
||||
*/
|
||||
class TokenAuthenticatorTest extends TestCase
|
||||
{
|
||||
@@ -47,19 +48,13 @@ class TokenAuthenticatorTest extends TestCase
|
||||
self::assertFalse($sut->supports($request));
|
||||
|
||||
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo']);
|
||||
self::assertTrue($sut->supports($request));
|
||||
self::assertFalse($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::assertTrue($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::assertTrue($sut->supports($request));
|
||||
}
|
||||
|
||||
public function testAuthenticateWithMissingAuthHeader(): void
|
||||
|
||||
93
tests/API/AuthenticationTest.php
Normal file
93
tests/API/AuthenticationTest.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,6 @@ class InvoiceCreateCommandTest extends KernelTestCase
|
||||
* Allowed option: exported
|
||||
* Allowed option: by-customer
|
||||
* Allowed option: by-project
|
||||
* Allowed option: set-exported
|
||||
* Allowed option: template-meta
|
||||
*
|
||||
* @param array $options
|
||||
@@ -169,7 +168,7 @@ class InvoiceCreateCommandTest extends KernelTestCase
|
||||
$fixture = new InvoiceTemplateFixtures();
|
||||
$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();
|
||||
$this->assertStringContainsString('Created 1 invoice(s)', $output);
|
||||
|
||||
@@ -265,9 +265,9 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
$this->assertFalse($passwordEncoder->getPasswordHasher($user)->verify($user->getApiToken(), 'test1234'));
|
||||
$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, [
|
||||
'user_api_token' => [
|
||||
'user_api_password' => [
|
||||
'plainApiToken' => [
|
||||
'first' => 'test1234',
|
||||
'second' => 'test1234',
|
||||
@@ -292,16 +292,16 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
$this->assertFormHasValidationError(
|
||||
User::ROLE_USER,
|
||||
'/profile/' . UserFixtures::USERNAME_USER . '/api-token',
|
||||
'form[name=user_api_token]',
|
||||
'form[name=user_api_password]',
|
||||
[
|
||||
'user_api_token' => [
|
||||
'user_api_password' => [
|
||||
'plainApiToken' => [
|
||||
'first' => 'abcdef1',
|
||||
'second' => 'abcdef1',
|
||||
]
|
||||
]
|
||||
],
|
||||
['#user_api_token_plainApiToken_first']
|
||||
['#user_api_password_plainApiToken_first']
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
52
tests/Entity/AccessTokenTest.php
Normal file
52
tests/Entity/AccessTokenTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -1381,10 +1381,6 @@
|
||||
<source>help.invoiceTemplate_customer</source>
|
||||
<target state="translated">يتم إنشاء فواتير هذا الزبون بشكل افتراضي باستخدام هذا النموذج. يمكن تغييره أثناء إنشاء الفاتورة إذا لزم الأمر.</target>
|
||||
</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">
|
||||
<source>remove_filter</source>
|
||||
<target state="translated">إعادة تعيين عوامل تصفية البحث</target>
|
||||
|
||||
@@ -1459,10 +1459,6 @@
|
||||
<source>Deactivated</source>
|
||||
<target state="translated">Deaktivováno</target>
|
||||
</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">
|
||||
<source>Activated</source>
|
||||
<target state="translated">Aktivováno</target>
|
||||
|
||||
@@ -1514,16 +1514,12 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="JSBReoa" resname="api_password.intro">
|
||||
<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 id="QT8.BCN" resname="api_password.missing_title">
|
||||
<source>api_password.missing_title</source>
|
||||
<target>API kann nicht genutzt werden</target>
|
||||
</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">
|
||||
<source>search.no_results</source>
|
||||
<target>Kein Ergebnis gefunden für "%input%"</target>
|
||||
@@ -1752,6 +1748,22 @@
|
||||
<source>absence_comment_mandatory</source>
|
||||
<target>Abwesenheit: Kommentar ist Pflichtfeld</target>
|
||||
</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>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -1514,16 +1514,12 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="JSBReoa" resname="api_password.intro">
|
||||
<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 id="QT8.BCN" resname="api_password.missing_title">
|
||||
<source>api_password.missing_title</source>
|
||||
<target>API cannot be used</target>
|
||||
</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">
|
||||
<source>search.no_results</source>
|
||||
<target>No results found for "%input%"</target>
|
||||
@@ -1752,6 +1748,22 @@
|
||||
<source>absence_comment_mandatory</source>
|
||||
<target>Absence: Comment is a mandatory field</target>
|
||||
</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>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -1407,10 +1407,6 @@
|
||||
<source>api_password.missing_title</source>
|
||||
<target state="translated">No se puede usar la aplicación</target>
|
||||
</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">
|
||||
<source>search.no_results</source>
|
||||
<target state="translated">No se han encontrado resultados para "%input%"</target>
|
||||
|
||||
@@ -1184,10 +1184,6 @@
|
||||
<source>help.globalActivity</source>
|
||||
<target state="translated">اگر پروژه ای را انتخاب نکنید، این فعالیت جهانی می شود و می توان آن را با هر پروژه ای ترکیب کرد. اگر پروژه ای را انتخاب کنید، فعالیت فقط با آن پروژه قابل استفاده است. تنظیم را نمی توان بعداً تغییر داد.</target>
|
||||
</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">
|
||||
<source>remove_filter</source>
|
||||
<target state="translated">فیلتر جستجو را بازنشانی کنید</target>
|
||||
|
||||
@@ -1355,10 +1355,6 @@
|
||||
<source>skin</source>
|
||||
<target state="translated">Design</target>
|
||||
</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">
|
||||
<source>modal.columns.profile</source>
|
||||
<target state="translated">Profiili</target>
|
||||
|
||||
@@ -1371,10 +1371,6 @@
|
||||
<source>api_password.missing_title</source>
|
||||
<target state="translated">L'API ne peut pas être utilisée</target>
|
||||
</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">
|
||||
<source>search.no_results</source>
|
||||
<target state="translated">Aucun résultat trouvé pour "%input%"</target>
|
||||
|
||||
@@ -1298,10 +1298,6 @@
|
||||
<source>skin.dark</source>
|
||||
<target state="translated">כהה</target>
|
||||
</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">
|
||||
<source>please_choose</source>
|
||||
<target state="translated">נא לבחור</target>
|
||||
|
||||
@@ -1343,10 +1343,6 @@
|
||||
<source>skin.dark</source>
|
||||
<target state="translated">Tamno</target>
|
||||
</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">
|
||||
<source>api_password.missing_title</source>
|
||||
<target state="translated">API se ne može koristiti</target>
|
||||
|
||||
@@ -1422,10 +1422,6 @@
|
||||
<source>api_password.missing_title</source>
|
||||
<target state="final">Non è possibile usare l'API</target>
|
||||
</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">
|
||||
<source>please_choose</source>
|
||||
<target state="final">Fai la tua scelta</target>
|
||||
|
||||
@@ -1393,10 +1393,6 @@
|
||||
<source>Activated</source>
|
||||
<target state="translated">Aktivert</target>
|
||||
</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">
|
||||
<source>search.no_results</source>
|
||||
<target state="translated">Fant ingen resultater for «%input%»</target>
|
||||
|
||||
@@ -1415,10 +1415,6 @@
|
||||
<source>api_password.intro</source>
|
||||
<target state="translated">Het API-wachtwoord wordt gebruikt voor communicatie tussen Kimai en uw applicaties</target>
|
||||
</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">
|
||||
<source>search.no_results</source>
|
||||
<target state="translated">Geen resultaten gevonden voor "%input%"</target>
|
||||
|
||||
@@ -1447,10 +1447,6 @@
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -1383,10 +1383,6 @@
|
||||
<source>api_password.missing_title</source>
|
||||
<target state="translated">A API não pode ser usada</target>
|
||||
</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">
|
||||
<source>search.no_results</source>
|
||||
<target state="translated">Nenhum resultado foi encontrado para "%input%"</target>
|
||||
|
||||
@@ -1403,10 +1403,6 @@
|
||||
<source>api_password.missing_title</source>
|
||||
<target state="translated">A API não pode ser usada</target>
|
||||
</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">
|
||||
<source>search.no_results</source>
|
||||
<target state="translated">Nenhum resultado foi encontrado para "%input%"</target>
|
||||
|
||||
@@ -1194,10 +1194,6 @@
|
||||
<source>api_password.missing_title</source>
|
||||
<target state="translated">API sa nedá použiť</target>
|
||||
</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">
|
||||
<source>send_to</source>
|
||||
<target state="translated">Poslať %name%</target>
|
||||
|
||||
@@ -1255,10 +1255,6 @@
|
||||
<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>
|
||||
</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">
|
||||
<source>stats.userDurationWeek</source>
|
||||
<target state="translated">Mina arbetstider denna vecka</target>
|
||||
|
||||
@@ -1423,10 +1423,6 @@
|
||||
<source>extended_settings</source>
|
||||
<target state="translated">Genişletilmiş ayarlar</target>
|
||||
</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">
|
||||
<source>please_choose</source>
|
||||
<target state="translated">Lütfen seçin</target>
|
||||
|
||||
@@ -1362,10 +1362,6 @@
|
||||
<source>Activated</source>
|
||||
<target state="translated">Активовано</target>
|
||||
</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">
|
||||
<source>about.title</source>
|
||||
<target state="translated">Про Kimai</target>
|
||||
|
||||
@@ -1403,10 +1403,6 @@
|
||||
<source>api_password.intro</source>
|
||||
<target state="translated">API 密码用于 Kimai 和您的应用程序之间的通信</target>
|
||||
</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">
|
||||
<source>search.no_results</source>
|
||||
<target state="translated">找不到 "%input%" 的结果</target>
|
||||
|
||||
@@ -1130,10 +1130,6 @@
|
||||
<source>skin</source>
|
||||
<target state="translated">設計</target>
|
||||
</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">
|
||||
<source>modal.columns.profile</source>
|
||||
<target state="translated">資料</target>
|
||||
|
||||
Reference in New Issue
Block a user