Added basic API endpoints (#258)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,6 +31,7 @@
|
||||
|
||||
###> phpunit/phpunit ###
|
||||
/phpunit.xml
|
||||
/.phpunit.result.cache
|
||||
###< phpunit/phpunit ###
|
||||
|
||||
###> friendsofphp/php-cs-fixer ###
|
||||
|
||||
@@ -10,8 +10,11 @@
|
||||
"ext-pdo_sqlite": "*",
|
||||
"beberlei/DoctrineExtensions": "^1.0",
|
||||
"erusev/parsedown": "^1.6",
|
||||
"friendsofsymfony/rest-bundle": "^2.3",
|
||||
"friendsofsymfony/user-bundle": "~2.0",
|
||||
"jms/serializer-bundle": "^2.4",
|
||||
"kevinpapst/adminlte-bundle": "^1.2",
|
||||
"nelmio/api-doc-bundle": "^3.2",
|
||||
"ocramius/proxy-manager": "2.1.1",
|
||||
"sensio/framework-extra-bundle": "^5.1",
|
||||
"symfony/asset": "^4.0",
|
||||
@@ -92,7 +95,7 @@
|
||||
"extra": {
|
||||
"symfony": {
|
||||
"id": "01C3FWRDJJEX9K6Y3A4XDFXPBR",
|
||||
"allow-contrib": false
|
||||
"allow-contrib": true
|
||||
}
|
||||
},
|
||||
"repositories": [
|
||||
|
||||
1211
composer.lock
generated
1211
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -19,4 +19,7 @@ return [
|
||||
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
|
||||
KevinPapst\AdminLTEBundle\AdminLTEBundle::class => ['all' => true],
|
||||
FOS\UserBundle\FOSUserBundle::class => ['all' => true],
|
||||
FOS\RestBundle\FOSRestBundle::class => ['all' => true],
|
||||
Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
|
||||
JMS\SerializerBundle\JMSSerializerBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
7
config/packages/dev/jms_serializer.yaml
Normal file
7
config/packages/dev/jms_serializer.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
jms_serializer:
|
||||
visitors:
|
||||
json:
|
||||
options:
|
||||
- JSON_PRETTY_PRINT
|
||||
- JSON_UNESCAPED_SLASHES
|
||||
- JSON_PRESERVE_ZERO_FRACTION
|
||||
107
config/packages/fos_rest.yaml
Normal file
107
config/packages/fos_rest.yaml
Normal file
@@ -0,0 +1,107 @@
|
||||
fos_rest:
|
||||
# disable_csrf_role: ROLE_API
|
||||
# access_denied_listener:
|
||||
# enabled: false
|
||||
# service: null
|
||||
# formats:
|
||||
# name: ~
|
||||
# unauthorized_challenge: null
|
||||
# param_fetcher_listener:
|
||||
# enabled: false
|
||||
# force: false
|
||||
# service: null
|
||||
# cache_dir: '%kernel.cache_dir%/fos_rest'
|
||||
# allowed_methods_listener:
|
||||
# enabled: false
|
||||
# service: null
|
||||
routing_loader:
|
||||
default_format: json
|
||||
include_format: true
|
||||
# prefix_methods: true
|
||||
# body_converter:
|
||||
# enabled: false
|
||||
# validate: false
|
||||
# validation_errors_argument: validationErrors
|
||||
# service:
|
||||
# router: router
|
||||
# templating: templating
|
||||
# serializer: null
|
||||
# view_handler: fos_rest.view_handler.default
|
||||
# inflector: fos_rest.inflector.doctrine
|
||||
# validator: validator
|
||||
# serializer:
|
||||
# version: null
|
||||
# groups: []
|
||||
# serialize_null: false
|
||||
view:
|
||||
default_engine: twig
|
||||
# force_redirects:
|
||||
# name: ~
|
||||
# mime_types:
|
||||
# enabled: false
|
||||
# service: null
|
||||
# formats:
|
||||
# name: ~
|
||||
# formats:
|
||||
# name: ~
|
||||
# templating_formats:
|
||||
# name: ~
|
||||
# view_response_listener:
|
||||
# enabled: true
|
||||
# force: true
|
||||
# service: null
|
||||
failed_validation: 400
|
||||
# empty_content: 204
|
||||
# serialize_null: false
|
||||
# jsonp_handler:
|
||||
# callback_param: callback
|
||||
# mime_type: application/javascript+jsonp
|
||||
exception:
|
||||
enabled: true
|
||||
exception_controller: 'fos_rest.exception.controller:showAction'
|
||||
# service: null
|
||||
codes:
|
||||
# 'Symfony\Component\Routing\Exception\ResourceNotFoundException': 404
|
||||
# 'Doctrine\ORM\OptimisticLockException': HTTP_CONFLICT
|
||||
# 'Symfony\Component\HttpKernel\Exception\NotFoundHttpException': 400
|
||||
messages:
|
||||
'Symfony\Component\Routing\Exception\ResourceNotFoundException': true
|
||||
'Symfony\Component\HttpKernel\Exception\NotFoundHttpException': true
|
||||
# body_listener:
|
||||
# enabled: true
|
||||
# service: null
|
||||
# default_format: null
|
||||
# throw_exception_on_unsupported_content_type: false
|
||||
# decoders:
|
||||
# name: ~
|
||||
# array_normalizer:
|
||||
# service: null
|
||||
# forms: false
|
||||
format_listener:
|
||||
enabled: true
|
||||
# service: null
|
||||
rules:
|
||||
- { path: ^/api, prefer_extension: true, fallback_format: json, priorities: [ json, xml ] }
|
||||
# host: null # URL host name
|
||||
# methods: null # Method for URL
|
||||
# stop: false
|
||||
# attributes: []
|
||||
# versioning:
|
||||
# enabled: false
|
||||
# default_version: ~
|
||||
# resolvers:
|
||||
# query:
|
||||
# enabled: true
|
||||
# parameter_name: version
|
||||
# custom_header:
|
||||
# enabled: true
|
||||
# header_name: X-Accept-Version
|
||||
# media_type:
|
||||
# enabled: true
|
||||
# regex: /(v|version)=(?P<version>[0-9\.]+)/
|
||||
# guessing_order:
|
||||
# - query
|
||||
# - custom_header
|
||||
# - media_type
|
||||
zone:
|
||||
- { path: ^/api/* }
|
||||
12
config/packages/jms_serializer.yaml
Normal file
12
config/packages/jms_serializer.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
jms_serializer:
|
||||
visitors:
|
||||
xml:
|
||||
format_output: '%kernel.debug%'
|
||||
metadata:
|
||||
directories:
|
||||
FOSUB:
|
||||
namespace_prefix: "FOS\\UserBundle"
|
||||
path: "%kernel.root_dir%/../config/serializer/FOS/UserBundle"
|
||||
App:
|
||||
namespace_prefix: "App"
|
||||
path: "%kernel.root_dir%/../config/serializer/App"
|
||||
27
config/packages/nelmio_api_doc.yaml
Normal file
27
config/packages/nelmio_api_doc.yaml
Normal file
@@ -0,0 +1,27 @@
|
||||
nelmio_api_doc:
|
||||
models:
|
||||
use_jms: true
|
||||
areas:
|
||||
path_patterns:
|
||||
- ^/api(?!/doc)
|
||||
documentation:
|
||||
#host: '{{hostname}}'
|
||||
schemes: [http, https]
|
||||
info:
|
||||
title: Kimai 2 - API Docs
|
||||
description: REST API description for the Kimai 2 time-tracking software
|
||||
version: 0.1
|
||||
securityDefinitions:
|
||||
X-AUTH-USER:
|
||||
type: apiKey
|
||||
description: 'Value: {Username}'
|
||||
name: X-AUTH-USER
|
||||
in: header
|
||||
X-AUTH-TOKEN:
|
||||
type: apiKey
|
||||
description: 'Value: {API Token}'
|
||||
name: X-AUTH-TOKEN
|
||||
in: header
|
||||
security:
|
||||
- X-AUTH-USER: []
|
||||
- X-AUTH-TOKEN: []
|
||||
6
config/packages/prod/jms_serializer.yaml
Normal file
6
config/packages/prod/jms_serializer.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
jms_serializer:
|
||||
visitors:
|
||||
json:
|
||||
options:
|
||||
- JSON_UNESCAPED_SLASHES
|
||||
- JSON_PRESERVE_ZERO_FRACTION
|
||||
@@ -2,7 +2,6 @@ security:
|
||||
encoders:
|
||||
App\Entity\User: bcrypt
|
||||
|
||||
# https://symfony.com/doc/current/book/security.html#where-do-users-come-from-user-providers
|
||||
providers:
|
||||
fos_userbundle:
|
||||
id: fos_user.user_provider.username_email
|
||||
@@ -13,39 +12,28 @@ security:
|
||||
security: false
|
||||
|
||||
secured_area:
|
||||
# this firewall applies to all URLs
|
||||
pattern: ^/
|
||||
|
||||
# make sure only allowed users have access to their time-tracking
|
||||
user_checker: App\Security\UserChecker
|
||||
anonymous: true
|
||||
stateless: false
|
||||
|
||||
# but the firewall does not require login on every page
|
||||
# denying access is done in access_control or in your controllers
|
||||
anonymous: ~
|
||||
guard:
|
||||
authenticators:
|
||||
- App\Security\TokenAuthenticator
|
||||
|
||||
remember_me:
|
||||
secret: '%kernel.secret%'
|
||||
lifetime: 604800
|
||||
path: /
|
||||
|
||||
# This allows the user to login by submitting a username and password
|
||||
# Reference: http://symfony.com/doc/current/cookbook/security/form_login_setup.html
|
||||
form_login:
|
||||
# fos user bundle handles the form login
|
||||
provider: fos_userbundle
|
||||
# The route name that the login form submits to
|
||||
check_path: fos_user_security_check
|
||||
# The name of the route where the login form lives
|
||||
# When the user tries to access a protected page, they are redirected here
|
||||
login_path: fos_user_security_login
|
||||
# Secure the login form against CSRF
|
||||
# Reference: http://symfony.com/doc/current/cookbook/security/csrf_in_login_form.html
|
||||
csrf_token_generator: security.csrf.token_manager
|
||||
|
||||
logout:
|
||||
# The route name the user can go to in order to logout
|
||||
path: fos_user_security_logout
|
||||
# The name of the route to redirect to after logging out
|
||||
target: homepage
|
||||
|
||||
role_hierarchy:
|
||||
@@ -60,4 +48,4 @@ security:
|
||||
- { path: '^/(%app_locales%)/register', role: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||
- { path: '^/(%app_locales%)/resetting', role: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||
- { path: '^/(%app_locales%)/admin', roles: ROLE_ADMIN }
|
||||
|
||||
- { path: '^/api', roles: IS_AUTHENTICATED_REMEMBERED }
|
||||
|
||||
@@ -11,15 +11,3 @@ homepage:
|
||||
_controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::redirectAction
|
||||
route: timesheet
|
||||
permanent: true
|
||||
|
||||
fos_user_security:
|
||||
prefix: /{_locale}
|
||||
resource: "@FOSUserBundle/Resources/config/routing/security.xml"
|
||||
|
||||
fos_user_registration:
|
||||
prefix: /{_locale}/register
|
||||
resource: "@FOSUserBundle/Resources/config/routing/registration.xml"
|
||||
|
||||
fos_user_resetting:
|
||||
prefix: /{_locale}/resetting
|
||||
resource: "@FOSUserBundle/Resources/config/routing/resetting.xml"
|
||||
|
||||
@@ -5,4 +5,9 @@ controllers:
|
||||
requirements:
|
||||
_locale: '%app_locales%'
|
||||
defaults:
|
||||
_locale: '%locale%'
|
||||
_locale: '%locale%'
|
||||
|
||||
app.api:
|
||||
resource: '../src/API/'
|
||||
type: rest
|
||||
prefix: /api
|
||||
|
||||
14
config/routes/fos_user.yaml
Normal file
14
config/routes/fos_user.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
# Expose security related features like login and logout
|
||||
fos_user_security:
|
||||
prefix: /{_locale}
|
||||
resource: "@FOSUserBundle/Resources/config/routing/security.xml"
|
||||
|
||||
# Expose the user registration feature
|
||||
fos_user_registration:
|
||||
prefix: /{_locale}/register
|
||||
resource: "@FOSUserBundle/Resources/config/routing/registration.xml"
|
||||
|
||||
# Expose the users password-reset feature
|
||||
fos_user_resetting:
|
||||
prefix: /{_locale}/resetting
|
||||
resource: "@FOSUserBundle/Resources/config/routing/resetting.xml"
|
||||
11
config/routes/nelmio_api_doc.yaml
Normal file
11
config/routes/nelmio_api_doc.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
# Expose the Kimai API documentation as HTML
|
||||
app.swagger_ui:
|
||||
path: /api/doc
|
||||
methods: GET
|
||||
defaults: { _controller: nelmio_api_doc.controller.swagger_ui }
|
||||
|
||||
# Expose the Kimai API documentation as swagger compliant JSON
|
||||
app.swagger:
|
||||
path: /api/doc.json
|
||||
methods: GET
|
||||
defaults: { _controller: nelmio_api_doc.controller.swagger }
|
||||
18
config/serializer/App/Entity.Activity.yml
Normal file
18
config/serializer/App/Entity.Activity.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
App\Entity\Activity:
|
||||
exclusion_policy: All
|
||||
custom_accessor_order: [id, name, comment, visible, project_id]
|
||||
properties:
|
||||
id:
|
||||
include: true
|
||||
name:
|
||||
include: true
|
||||
comment:
|
||||
include: true
|
||||
visible:
|
||||
include: true
|
||||
virtual_properties:
|
||||
getProjectId:
|
||||
name: project_id
|
||||
serialized_name: project_id
|
||||
exp: "object.getProject() === null ? null : object.getProject().getId()"
|
||||
type: integer
|
||||
35
config/serializer/App/Entity.Customer.yml
Normal file
35
config/serializer/App/Entity.Customer.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
App\Entity\Customer:
|
||||
exclusion_policy: All
|
||||
properties:
|
||||
id:
|
||||
include: true
|
||||
name:
|
||||
include: true
|
||||
number:
|
||||
include: true
|
||||
comment:
|
||||
include: true
|
||||
visible:
|
||||
include: true
|
||||
company:
|
||||
include: true
|
||||
contact:
|
||||
include: true
|
||||
address:
|
||||
include: true
|
||||
country:
|
||||
include: true
|
||||
currency:
|
||||
include: true
|
||||
phone:
|
||||
include: true
|
||||
fax:
|
||||
include: true
|
||||
mobile:
|
||||
include: true
|
||||
mail:
|
||||
include: true
|
||||
homepage:
|
||||
include: true
|
||||
timezone:
|
||||
include: true
|
||||
22
config/serializer/App/Entity.Project.yml
Normal file
22
config/serializer/App/Entity.Project.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
App\Entity\Project:
|
||||
exclusion_policy: All
|
||||
custom_accessor_order: [id, name, comment, visible, budget, orderNumber, customer_id]
|
||||
properties:
|
||||
id:
|
||||
include: true
|
||||
name:
|
||||
include: true
|
||||
comment:
|
||||
include: true
|
||||
visible:
|
||||
include: true
|
||||
budget:
|
||||
include: true
|
||||
orderNumber:
|
||||
include: true
|
||||
virtual_properties:
|
||||
getCustomerId:
|
||||
name: customer_id
|
||||
serialized_name: customer_id
|
||||
exp: "object.getCustomer() === null ? null : object.getCustomer().getId()"
|
||||
type: integer
|
||||
11
config/serializer/App/Entity.User.yml
Normal file
11
config/serializer/App/Entity.User.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
App\Entity\User:
|
||||
exclusion_policy: All
|
||||
properties:
|
||||
id:
|
||||
include: true
|
||||
alias:
|
||||
include: true
|
||||
title:
|
||||
include: true
|
||||
avatar:
|
||||
include: true
|
||||
23
config/serializer/FOS/UserBundle/Model.User.yml
Normal file
23
config/serializer/FOS/UserBundle/Model.User.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
FOS\UserBundle\Model\User:
|
||||
exclusion_policy: NONE
|
||||
properties:
|
||||
email:
|
||||
exclude: true
|
||||
emailCanonical:
|
||||
exclude: true
|
||||
usernameCanonical:
|
||||
exclude: true
|
||||
password:
|
||||
exclude: true
|
||||
plainPassword:
|
||||
exclude: true
|
||||
registeredAt:
|
||||
exclude: true
|
||||
lastLogin:
|
||||
exclude: true
|
||||
confirmationToken:
|
||||
exclude: true
|
||||
passwordRequestedAt:
|
||||
exclude: true
|
||||
salt:
|
||||
exclude: true
|
||||
@@ -104,3 +104,27 @@ services:
|
||||
|
||||
App\Timesheet\Calculator\RateCalculator:
|
||||
arguments: ["%kimai.timesheet.rates%"]
|
||||
|
||||
# ================================================================================
|
||||
# REPOSITORIES
|
||||
# ================================================================================
|
||||
|
||||
App\Repository\UserRepository:
|
||||
class: Doctrine\ORM\EntityRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\User']
|
||||
|
||||
App\Repository\ActivityRepository:
|
||||
class: Doctrine\ORM\EntityRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\Activity']
|
||||
|
||||
App\Repository\ProjectRepository:
|
||||
class: Doctrine\ORM\EntityRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\Project']
|
||||
|
||||
App\Repository\CustomerRepository:
|
||||
class: Doctrine\ORM\EntityRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\Customer']
|
||||
|
||||
@@ -2,8 +2,4 @@ services:
|
||||
_defaults:
|
||||
public: true
|
||||
|
||||
# If you need to access services in a test, create an alias
|
||||
# and then fetch that alias from the container. As a convention,
|
||||
# aliases are prefixed with test. For example:
|
||||
#
|
||||
# test.App\Service\MyService: '@App\Service\MyService'
|
||||
test.PasswordEncoder: "@security.encoder_factory"
|
||||
89
src/API/ActivityController.php
Normal file
89
src/API/ActivityController.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* 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;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Repository\ActivityRepository;
|
||||
use FOS\RestBundle\Controller\Annotations\RouteResource;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation as API;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* @RouteResource("Activity")
|
||||
*
|
||||
* @Security("is_granted('ROLE_USER')")
|
||||
*/
|
||||
class ActivityController extends Controller
|
||||
{
|
||||
/**
|
||||
* @var ActivityRepository
|
||||
*/
|
||||
protected $repository;
|
||||
|
||||
/**
|
||||
* @var ViewHandlerInterface
|
||||
*/
|
||||
protected $viewHandler;
|
||||
|
||||
/**
|
||||
* @param ViewHandlerInterface $viewHandler
|
||||
* @param ActivityRepository $repository
|
||||
*/
|
||||
public function __construct(ViewHandlerInterface $viewHandler, ActivityRepository $repository)
|
||||
{
|
||||
$this->viewHandler = $viewHandler;
|
||||
$this->repository = $repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the collection of all existing activities",
|
||||
* @SWG\Schema(ref=@API\Model(type=Activity::class)),
|
||||
* )
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function cgetAction()
|
||||
{
|
||||
$data = $this->repository->findAll();
|
||||
$view = new View($data, 200);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns one activity entity",
|
||||
* @SWG\Schema(ref=@API\Model(type=Activity::class)),
|
||||
* )
|
||||
*
|
||||
* @param int $id
|
||||
* @return Response
|
||||
*/
|
||||
public function getAction($id)
|
||||
{
|
||||
$data = $this->repository->find($id);
|
||||
if (null === $data) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
$view = new View($data, 200);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
89
src/API/CustomerController.php
Normal file
89
src/API/CustomerController.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* 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;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Repository\CustomerRepository;
|
||||
use FOS\RestBundle\Controller\Annotations\RouteResource;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* @RouteResource("Customer")
|
||||
*
|
||||
* @Security("is_granted('ROLE_USER')")
|
||||
*/
|
||||
class CustomerController extends Controller
|
||||
{
|
||||
/**
|
||||
* @var CustomerRepository
|
||||
*/
|
||||
protected $repository;
|
||||
|
||||
/**
|
||||
* @var ViewHandlerInterface
|
||||
*/
|
||||
protected $viewHandler;
|
||||
|
||||
/**
|
||||
* @param ViewHandlerInterface $viewHandler
|
||||
* @param CustomerRepository $repository
|
||||
*/
|
||||
public function __construct(ViewHandlerInterface $viewHandler, CustomerRepository $repository)
|
||||
{
|
||||
$this->viewHandler = $viewHandler;
|
||||
$this->repository = $repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the collection of all existing customer",
|
||||
* @SWG\Schema(ref=@Model(type=Customer::class)),
|
||||
* )
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function cgetAction()
|
||||
{
|
||||
$data = $this->repository->findAll();
|
||||
$view = new View($data, 200);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns one customer entity",
|
||||
* @SWG\Schema(ref=@Model(type=Customer::class)),
|
||||
* )
|
||||
*
|
||||
* @param int $id
|
||||
* @return Response
|
||||
*/
|
||||
public function getAction($id)
|
||||
{
|
||||
$data = $this->repository->find($id);
|
||||
if (null === $data) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
$view = new View($data, 200);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
49
src/API/HealthcheckController.php
Normal file
49
src/API/HealthcheckController.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* 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;
|
||||
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
|
||||
|
||||
class HealthcheckController extends Controller
|
||||
{
|
||||
/**
|
||||
* @var ViewHandlerInterface
|
||||
*/
|
||||
protected $viewHandler;
|
||||
|
||||
/**
|
||||
* @param ViewHandlerInterface $viewHandler
|
||||
*/
|
||||
public function __construct(ViewHandlerInterface $viewHandler)
|
||||
{
|
||||
$this->viewHandler = $viewHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="A simple route that returns a 'pong', which you can use for testing the API",
|
||||
* )
|
||||
*
|
||||
* @Rest\Get(path="/ping")
|
||||
*/
|
||||
public function pingAction()
|
||||
{
|
||||
$view = new View(['message' => 'pong'], 200);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
22
src/API/NotFoundException.php
Normal file
22
src/API/NotFoundException.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* 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;
|
||||
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class NotFoundException extends NotFoundHttpException
|
||||
{
|
||||
public function __construct(string $message = 'Not found', \Exception $previous = null, int $code = 0, array $headers = [])
|
||||
{
|
||||
parent::__construct($message, $previous, $code, $headers);
|
||||
}
|
||||
}
|
||||
89
src/API/ProjectController.php
Normal file
89
src/API/ProjectController.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* 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;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Repository\ProjectRepository;
|
||||
use FOS\RestBundle\Controller\Annotations\RouteResource;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* @RouteResource("Project")
|
||||
*
|
||||
* @Security("is_granted('ROLE_USER')")
|
||||
*/
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
/**
|
||||
* @var ProjectRepository
|
||||
*/
|
||||
protected $repository;
|
||||
|
||||
/**
|
||||
* @var ViewHandlerInterface
|
||||
*/
|
||||
protected $viewHandler;
|
||||
|
||||
/**
|
||||
* @param ViewHandlerInterface $viewHandler
|
||||
* @param ProjectRepository $repository
|
||||
*/
|
||||
public function __construct(ViewHandlerInterface $viewHandler, ProjectRepository $repository)
|
||||
{
|
||||
$this->viewHandler = $viewHandler;
|
||||
$this->repository = $repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the collection of all existing projects",
|
||||
* @SWG\Schema(ref=@Model(type=Project::class)),
|
||||
* )
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function cgetAction()
|
||||
{
|
||||
$data = $this->repository->findAll();
|
||||
$view = new View($data, 200);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns one project entity",
|
||||
* @SWG\Schema(ref=@Model(type=Project::class)),
|
||||
* )
|
||||
*
|
||||
* @param int $id
|
||||
* @return Response
|
||||
*/
|
||||
public function getAction($id)
|
||||
{
|
||||
$data = $this->repository->find($id);
|
||||
if (null === $data) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
$view = new View($data, 200);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
90
src/API/UserController.php
Normal file
90
src/API/UserController.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* 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;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use FOS\RestBundle\Controller\Annotations\RouteResource;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* @RouteResource("User")
|
||||
*
|
||||
* @Security("is_granted('ROLE_SUPER_ADMIN')")
|
||||
* @Security("is_granted('IS_AUTHENTICATED_FULLY')")
|
||||
*/
|
||||
class UserController extends Controller
|
||||
{
|
||||
/**
|
||||
* @var UserRepository
|
||||
*/
|
||||
protected $repository;
|
||||
|
||||
/**
|
||||
* @var ViewHandlerInterface
|
||||
*/
|
||||
protected $viewHandler;
|
||||
|
||||
/**
|
||||
* @param ViewHandlerInterface $viewHandler
|
||||
* @param UserRepository $repository
|
||||
*/
|
||||
public function __construct(ViewHandlerInterface $viewHandler, UserRepository $repository)
|
||||
{
|
||||
$this->viewHandler = $viewHandler;
|
||||
$this->repository = $repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the collection of all registered users",
|
||||
* @SWG\Schema(ref=@Model(type=User::class)),
|
||||
* )
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function cgetAction()
|
||||
{
|
||||
$data = $this->repository->findAll();
|
||||
$view = new View($data, 200);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Return one user entity",
|
||||
* @SWG\Schema(ref=@Model(type=User::class)),
|
||||
* )
|
||||
*
|
||||
* @param int $id
|
||||
* @return Response
|
||||
*/
|
||||
public function getAction($id)
|
||||
{
|
||||
$data = $this->repository->find($id);
|
||||
if (null === $data) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
$view = new View($data, 200);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
268
src/Calendar/TimesheetEntity.php
Normal file
268
src/Calendar/TimesheetEntity.php
Normal file
@@ -0,0 +1,268 @@
|
||||
<?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\Calendar;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
|
||||
class TimesheetEntity
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
protected $id;
|
||||
/**
|
||||
* @var \DateTime
|
||||
*/
|
||||
protected $start;
|
||||
/**
|
||||
* @var \DateTime|null
|
||||
*/
|
||||
protected $end;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $title;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $customer;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $project;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $activity;
|
||||
/**
|
||||
* @var string|null
|
||||
*/
|
||||
protected $borderColor;
|
||||
/**
|
||||
* @var string|null
|
||||
*/
|
||||
protected $backgroundColor;
|
||||
|
||||
/**
|
||||
* @param Timesheet $entry
|
||||
*/
|
||||
public function __construct(Timesheet $entry)
|
||||
{
|
||||
$this->id = $entry->getId();
|
||||
$this->start = $entry->getBegin();
|
||||
$this->title = $entry->getActivity()->getName();
|
||||
$this->description = $entry->getDescription();
|
||||
$this->customer = $entry->getActivity()->getProject()->getCustomer()->getName();
|
||||
$this->project = $entry->getActivity()->getProject()->getName();
|
||||
$this->activity = $entry->getActivity()->getName();
|
||||
|
||||
if (null === $entry->getEnd()) {
|
||||
// TODO move these colors to the controller
|
||||
$this->borderColor = '#f39c12';
|
||||
$this->backgroundColor = '#f39c12';
|
||||
} else {
|
||||
$this->end = $entry->getEnd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getId(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $id
|
||||
* @return TimesheetEntity
|
||||
*/
|
||||
public function setId(int $id)
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTime
|
||||
*/
|
||||
public function getStart(): \DateTime
|
||||
{
|
||||
return $this->start;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateTime $start
|
||||
* @return TimesheetEntity
|
||||
*/
|
||||
public function setStart(\DateTime $start)
|
||||
{
|
||||
$this->start = $start;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTime|null
|
||||
*/
|
||||
public function getEnd(): ?\DateTime
|
||||
{
|
||||
return $this->end;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateTime|null $end
|
||||
* @return TimesheetEntity
|
||||
*/
|
||||
public function setEnd(?\DateTime $end)
|
||||
{
|
||||
$this->end = $end;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $title
|
||||
* @return TimesheetEntity
|
||||
*/
|
||||
public function setTitle(string $title)
|
||||
{
|
||||
$this->title = $title;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $description
|
||||
* @return TimesheetEntity
|
||||
*/
|
||||
public function setDescription(string $description)
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getCustomer(): string
|
||||
{
|
||||
return $this->customer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $customer
|
||||
* @return TimesheetEntity
|
||||
*/
|
||||
public function setCustomer(string $customer)
|
||||
{
|
||||
$this->customer = $customer;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getProject(): string
|
||||
{
|
||||
return $this->project;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $project
|
||||
* @return TimesheetEntity
|
||||
*/
|
||||
public function setProject(string $project)
|
||||
{
|
||||
$this->project = $project;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getActivity(): string
|
||||
{
|
||||
return $this->activity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $activity
|
||||
* @return TimesheetEntity
|
||||
*/
|
||||
public function setActivity(string $activity)
|
||||
{
|
||||
$this->activity = $activity;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|string
|
||||
*/
|
||||
public function getBorderColor(): ?string
|
||||
{
|
||||
return $this->borderColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|string $borderColor
|
||||
* @return TimesheetEntity
|
||||
*/
|
||||
public function setBorderColor(?string $borderColor)
|
||||
{
|
||||
$this->borderColor = $borderColor;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|string
|
||||
*/
|
||||
public function getBackgroundColor(): ?string
|
||||
{
|
||||
return $this->backgroundColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|string $backgroundColor
|
||||
* @return TimesheetEntity
|
||||
*/
|
||||
public function setBackgroundColor(?string $backgroundColor)
|
||||
{
|
||||
$this->backgroundColor = $backgroundColor;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Calendar\Service;
|
||||
use App\Calendar\TimesheetEntity;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
|
||||
@@ -96,35 +97,9 @@ class CalendarController extends AbstractController
|
||||
$result = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$result[] = $this->getTimesheetEntryForCalendar($entry);
|
||||
$result[] = new TimesheetEntity($entry);
|
||||
}
|
||||
|
||||
return $this->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Timesheet $entry
|
||||
* @return array
|
||||
*/
|
||||
protected function getTimesheetEntryForCalendar(Timesheet $entry)
|
||||
{
|
||||
$result = [
|
||||
'id' => $entry->getId(),
|
||||
'start' => $entry->getBegin(),
|
||||
'title' => $entry->getActivity()->getName(),
|
||||
'description' => $entry->getDescription(),
|
||||
'customer' => $entry->getActivity()->getProject()->getCustomer()->getName(),
|
||||
'project' => $entry->getActivity()->getProject()->getName(),
|
||||
'activity' => $entry->getActivity()->getName(),
|
||||
];
|
||||
|
||||
if (null === $entry->getEnd()) {
|
||||
$result['borderColor'] = '#f39c12';
|
||||
$result['backgroundColor'] = '#f39c12';
|
||||
} else {
|
||||
$result['end'] = $entry->getEnd() ?? new \DateTime();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Form\UserApiTokenType;
|
||||
use App\Form\UserEditType;
|
||||
use App\Form\UserPasswordType;
|
||||
use App\Form\UserPreferencesForm;
|
||||
@@ -104,6 +105,32 @@ class ProfileController extends AbstractController
|
||||
return $this->getProfileView($profile, 'password', null, $form);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route("/{username}/api-token", name="user_profile_api_token")
|
||||
* @Method({"GET", "POST"})
|
||||
* @Security("is_granted('api-token', profile)")
|
||||
*/
|
||||
public function apiTokenAction(User $profile, Request $request)
|
||||
{
|
||||
$form = $this->createApiTokenForm($profile);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$password = $this->encoder->encodePassword($profile, $profile->getPlainApiToken());
|
||||
$profile->setApiToken($password);
|
||||
|
||||
$entityManager = $this->getDoctrine()->getManager();
|
||||
$entityManager->persist($profile);
|
||||
$entityManager->flush();
|
||||
|
||||
$this->flashSuccess('action.updated_successfully');
|
||||
|
||||
return $this->redirectToRoute('user_profile', ['username' => $profile->getUsername()]);
|
||||
}
|
||||
|
||||
return $this->getProfileView($profile, 'api-token', null, null, null, null, $form);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route("/{username}/roles", name="user_profile_roles")
|
||||
* @Method({"GET", "POST"})
|
||||
@@ -179,6 +206,7 @@ class ProfileController extends AbstractController
|
||||
* @param Form|null $pwdForm
|
||||
* @param Form|null $rolesForm
|
||||
* @param Form|null $prefsForm
|
||||
* @param Form|null $apiTokenForm
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
* @throws \Doctrine\ORM\NonUniqueResultException
|
||||
*/
|
||||
@@ -188,7 +216,8 @@ class ProfileController extends AbstractController
|
||||
Form $editForm = null,
|
||||
Form $pwdForm = null,
|
||||
Form $rolesForm = null,
|
||||
Form $prefsForm = null
|
||||
Form $prefsForm = null,
|
||||
Form $apiTokenForm = null
|
||||
) {
|
||||
/* @var $timesheetRepo TimesheetRepository */
|
||||
$timesheetRepo = $this->getDoctrine()->getRepository(Timesheet::class);
|
||||
@@ -211,6 +240,10 @@ class ProfileController extends AbstractController
|
||||
$pwdForm = $pwdForm ?: $this->createPasswordForm($user);
|
||||
$viewVars['forms']['password'] = $pwdForm->createView();
|
||||
}
|
||||
if ($this->isGranted(UserVoter::API_TOKEN, $user)) {
|
||||
$apiTokenForm = $apiTokenForm ?: $this->createApiTokenForm($user);
|
||||
$viewVars['forms']['api-token'] = $apiTokenForm->createView();
|
||||
}
|
||||
if ($this->isGranted(UserVoter::ROLES, $user)) {
|
||||
$rolesForm = $rolesForm ?: $this->createRolesForm($user);
|
||||
$viewVars['forms']['roles'] = $rolesForm->createView();
|
||||
@@ -287,4 +320,21 @@ class ProfileController extends AbstractController
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param User $user
|
||||
* @return \Symfony\Component\Form\FormInterface
|
||||
*/
|
||||
private function createApiTokenForm(User $user)
|
||||
{
|
||||
return $this->createForm(
|
||||
UserApiTokenType::class,
|
||||
$user,
|
||||
[
|
||||
'validation_groups' => ['apiTokenUpdate'],
|
||||
'action' => $this->generateUrl('user_profile_api_token', ['username' => $user->getUsername()]),
|
||||
'method' => 'POST'
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
|
||||
class UserFixtures extends Fixture
|
||||
{
|
||||
public const DEFAULT_PASSWORD = 'kitten';
|
||||
public const DEFAULT_API_TOKEN = 'api_kitten';
|
||||
public const DEFAULT_AVATAR = 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y';
|
||||
|
||||
public const USERNAME_USER = 'john_user';
|
||||
@@ -84,6 +85,7 @@ class UserFixtures extends Fixture
|
||||
->setAvatar($userData[5])
|
||||
->setEnabled($userData[6])
|
||||
->setPassword($passwordEncoder->encodePassword($user, self::DEFAULT_PASSWORD))
|
||||
->setApiToken($passwordEncoder->encodePassword($user, self::DEFAULT_API_TOKEN))
|
||||
->setPreferences([$this->getUserPreference($user)])
|
||||
;
|
||||
|
||||
@@ -109,7 +111,7 @@ class UserFixtures extends Fixture
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate randomized test users
|
||||
* Generate randomized test users, which don't have API access.
|
||||
*
|
||||
* @param ObjectManager $manager
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace App\Doctrine;
|
||||
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
use Doctrine\ORM\Event\LoadClassMetadataEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
|
||||
/**
|
||||
* Adds a prefix to every doctrine entity AKA database table
|
||||
@@ -19,16 +20,27 @@ class TablePrefixSubscriber implements EventSubscriber
|
||||
{
|
||||
protected $prefix = '';
|
||||
|
||||
/**
|
||||
* @param string $prefix
|
||||
*/
|
||||
public function __construct($prefix)
|
||||
{
|
||||
$this->prefix = (string) $prefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|string[]
|
||||
*/
|
||||
public function getSubscribedEvents()
|
||||
{
|
||||
return ['loadClassMetadata'];
|
||||
return [
|
||||
Events::loadClassMetadata,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param LoadClassMetadataEventArgs $args
|
||||
*/
|
||||
public function loadClassMetadata(LoadClassMetadataEventArgs $args)
|
||||
{
|
||||
$classMetadata = $args->getClassMetadata();
|
||||
|
||||
@@ -78,6 +78,18 @@ class User extends BaseUser implements UserInterface
|
||||
*/
|
||||
private $avatar;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="api_token", type="string", length=255, nullable=true)
|
||||
*/
|
||||
protected $apiToken;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $plainApiToken;
|
||||
|
||||
/**
|
||||
* @var UserPreference[]|Collection
|
||||
*
|
||||
@@ -179,6 +191,44 @@ class User extends BaseUser implements UserInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getApiToken()
|
||||
{
|
||||
return $this->apiToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $apiToken
|
||||
* @return User
|
||||
*/
|
||||
public function setApiToken($apiToken)
|
||||
{
|
||||
$this->apiToken = $apiToken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getPlainApiToken(): ?string
|
||||
{
|
||||
return $this->plainApiToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $plainApiToken
|
||||
* @return User
|
||||
*/
|
||||
public function setPlainApiToken(string $plainApiToken)
|
||||
{
|
||||
$this->plainApiToken = $plainApiToken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return UserPreference[]|Collection
|
||||
*/
|
||||
|
||||
50
src/Form/UserApiTokenType.php
Normal file
50
src/Form/UserApiTokenType.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?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\User;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Defines the form used to set the users API token.
|
||||
*/
|
||||
class UserApiTokenType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$builder
|
||||
->add('plainApiToken', RepeatedType::class, [
|
||||
'type' => PasswordType::class,
|
||||
'first_options' => ['label' => 'label.api_token'],
|
||||
'second_options' => ['label' => 'label.api_token_repeat'],
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => User::class,
|
||||
'csrf_protection' => true,
|
||||
'csrf_field_name' => '_token',
|
||||
'csrf_token_id' => 'edit_user_api_token',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Defines the form used to create and manipulate Users.
|
||||
* Defines the form used to set the users password.
|
||||
*/
|
||||
class UserPasswordType extends AbstractType
|
||||
{
|
||||
|
||||
@@ -16,7 +16,7 @@ use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
/**
|
||||
* Initial database structure of Kimai 2.
|
||||
* This file is only required for testing the migrations!
|
||||
* This file is mainly required for testing the migrations.
|
||||
*/
|
||||
final class Version20180701120000 extends AbstractMigration
|
||||
{
|
||||
|
||||
@@ -16,7 +16,9 @@ use Doctrine\DBAL\Schema\Index;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
/**
|
||||
* Add constraints for the "delete user" feature.
|
||||
* Migrations fot the "delete user" feature.
|
||||
*
|
||||
* Adds constraints to the timesheet table, so all timesheet entries. will be deleted when a user is deleted.
|
||||
*/
|
||||
final class Version20180730044139 extends AbstractMigration
|
||||
{
|
||||
|
||||
79
src/Migrations/Version20180805183527.php
Normal file
79
src/Migrations/Version20180805183527.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Added "API-token" to users table.
|
||||
*/
|
||||
final class Version20180805183527 extends AbstractMigration
|
||||
{
|
||||
/**
|
||||
* @param Schema $schema
|
||||
* @throws \Doctrine\DBAL\DBALException
|
||||
* @throws \Doctrine\DBAL\Migrations\AbortMigrationException
|
||||
*/
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$platform = $this->getPlatform();
|
||||
|
||||
if (!in_array($platform, ['sqlite', 'mysql'])) {
|
||||
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
|
||||
}
|
||||
|
||||
$user = $this->getTableName('users');
|
||||
|
||||
if ($platform === 'sqlite') {
|
||||
$this->addSql('ALTER TABLE ' . $user . ' ADD COLUMN api_token VARCHAR(255) DEFAULT NULL');
|
||||
} else {
|
||||
$this->addSql('ALTER TABLE ' . $user . ' ADD api_token VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Schema $schema
|
||||
* @throws \Doctrine\DBAL\DBALException
|
||||
* @throws \Doctrine\DBAL\Migrations\AbortMigrationException
|
||||
*/
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$platform = $this->getPlatform();
|
||||
|
||||
if (!in_array($platform, ['sqlite', 'mysql'])) {
|
||||
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
|
||||
}
|
||||
|
||||
$user = $this->getTableName('user');
|
||||
|
||||
if ($platform === 'sqlite') {
|
||||
$this->addSql('DROP INDEX UNIQ_B9AC5BCE92FC23A8');
|
||||
$this->addSql('DROP INDEX UNIQ_B9AC5BCEA0D96FBF');
|
||||
$this->addSql('DROP INDEX UNIQ_B9AC5BCEC05FB297');
|
||||
$this->addSql('DROP INDEX UNIQ_B9AC5BCEF85E0677');
|
||||
$this->addSql('DROP INDEX UNIQ_B9AC5BCEE7927C74');
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $user . ' AS SELECT id, username, username_canonical, email, email_canonical, enabled, salt, password, last_login, confirmation_token, password_requested_at, roles, alias, registration_date, title, avatar FROM ' . $user);
|
||||
$this->addSql('DROP TABLE ' . $user);
|
||||
$this->addSql('CREATE TABLE ' . $user . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, username VARCHAR(180) NOT NULL, username_canonical VARCHAR(180) NOT NULL, email VARCHAR(180) NOT NULL, email_canonical VARCHAR(180) NOT NULL, enabled BOOLEAN NOT NULL, salt VARCHAR(255) DEFAULT NULL, password VARCHAR(255) NOT NULL, last_login DATETIME DEFAULT NULL, confirmation_token VARCHAR(180) DEFAULT NULL, password_requested_at DATETIME DEFAULT NULL, roles CLOB NOT NULL, alias VARCHAR(60) DEFAULT NULL, registration_date DATETIME DEFAULT NULL, title VARCHAR(50) DEFAULT NULL, avatar VARCHAR(255) DEFAULT NULL)');
|
||||
$this->addSql('INSERT INTO ' . $user . ' (id, username, username_canonical, email, email_canonical, enabled, salt, password, last_login, confirmation_token, password_requested_at, roles, alias, registration_date, title, avatar) SELECT id, username, username_canonical, email, email_canonical, enabled, salt, password, last_login, confirmation_token, password_requested_at, roles, alias, registration_date, title, avatar FROM __temp__' . $user);
|
||||
$this->addSql('DROP TABLE __temp__' . $user);
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCE92FC23A8 ON ' . $user . ' (username_canonical)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCEA0D96FBF ON ' . $user . ' (email_canonical)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCEC05FB297 ON ' . $user . ' (confirmation_token)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCEF85E0677 ON ' . $user . ' (username)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCEE7927C74 ON ' . $user . ' (email)');
|
||||
} else {
|
||||
$this->addSql('ALTER TABLE ' . $user . ' DROP api_token');
|
||||
}
|
||||
}
|
||||
}
|
||||
164
src/Security/TokenAuthenticator.php
Normal file
164
src/Security/TokenAuthenticator.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?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\Security;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserProviderInterface;
|
||||
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;
|
||||
|
||||
class TokenAuthenticator extends AbstractGuardAuthenticator
|
||||
{
|
||||
public const HEADER_USERNAME = 'X-AUTH-USER';
|
||||
public const HEADER_TOKEN = 'X-AUTH-TOKEN';
|
||||
public const HEADER_JAVASCRIPT = 'X-AUTH-SESSION';
|
||||
|
||||
/**
|
||||
* @var EncoderFactoryInterface
|
||||
*/
|
||||
protected $encoderFactory;
|
||||
|
||||
/**
|
||||
* @param EncoderFactoryInterface $encoderFactory
|
||||
*/
|
||||
public function __construct(EncoderFactoryInterface $encoderFactory)
|
||||
{
|
||||
$this->encoderFactory = $encoderFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Request $request
|
||||
* @return bool
|
||||
*/
|
||||
public function supports(Request $request)
|
||||
{
|
||||
if (strpos($request->getRequestUri(), '/api/doc') === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (strpos($request->getRequestUri(), '/api/') === 0) {
|
||||
// javascript requests can set a header to disable this authenticator and use the existing session
|
||||
return !$request->headers->has(self::HEADER_JAVASCRIPT);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Request $request
|
||||
* @return array|bool
|
||||
*/
|
||||
public function getCredentials(Request $request)
|
||||
{
|
||||
return [
|
||||
'user' => $request->headers->get(self::HEADER_USERNAME),
|
||||
'token' => $request->headers->get(self::HEADER_TOKEN),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $credentials
|
||||
* @param UserProviderInterface $userProvider
|
||||
* @return null|UserInterface
|
||||
*/
|
||||
public function getUser($credentials, UserProviderInterface $userProvider)
|
||||
{
|
||||
$token = $credentials['token'] ?? null;
|
||||
$user = $credentials['user'] ?? null;
|
||||
|
||||
if (empty($token) || empty($user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $userProvider->loadUserByUsername($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $credentials
|
||||
* @param UserInterface $user
|
||||
* @return bool
|
||||
*/
|
||||
public function checkCredentials($credentials, UserInterface $user)
|
||||
{
|
||||
$token = $credentials['token'];
|
||||
|
||||
if (!empty($token) && $user instanceof User && !empty($user->getApiToken())) {
|
||||
$encoder = $this->encoderFactory->getEncoder($user);
|
||||
|
||||
return $encoder->isPasswordValid($user->getApiToken(), $token, $user->getSalt());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Request $request
|
||||
* @param TokenInterface $token
|
||||
* @param string $providerKey
|
||||
* @return null|Response
|
||||
*/
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Request $request
|
||||
* @param AuthenticationException $exception
|
||||
* @return null|JsonResponse|Response
|
||||
*/
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
|
||||
{
|
||||
if (!$request->headers->has(self::HEADER_USERNAME) || !$request->headers->has(self::HEADER_TOKEN)) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Authentication required, missing headers: ' . self::HEADER_USERNAME . ', ' . self::HEADER_TOKEN],
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'message' => 'Invalid credentials'
|
||||
|
||||
// security measure: do not leak real reason (unknown user, invalid credentials ...)
|
||||
// you can uncomment this for debugging
|
||||
// 'message' => strtr($exception->getMessageKey(), $exception->getMessageData())
|
||||
];
|
||||
|
||||
return new JsonResponse($data, Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Request $request
|
||||
* @param AuthenticationException|null $authException
|
||||
* @return JsonResponse|Response
|
||||
*/
|
||||
public function start(Request $request, AuthenticationException $authException = null)
|
||||
{
|
||||
$data = [
|
||||
'message' => 'Authentication required, missing headers: ' . self::HEADER_USERNAME . ', ' . self::HEADER_TOKEN
|
||||
];
|
||||
|
||||
return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function supportsRememberMe()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ class UserVoter extends AbstractVoter
|
||||
public const PASSWORD = 'password';
|
||||
public const ROLES = 'roles';
|
||||
public const PREFERENCES = 'preferences';
|
||||
public const API_TOKEN = 'api-token';
|
||||
|
||||
public const ALLOWED_ATTRIBUTES = [
|
||||
self::VIEW,
|
||||
@@ -32,7 +33,8 @@ class UserVoter extends AbstractVoter
|
||||
self::ROLES,
|
||||
self::PASSWORD,
|
||||
self::DELETE,
|
||||
self::PREFERENCES
|
||||
self::PREFERENCES,
|
||||
self::API_TOKEN,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -71,6 +73,7 @@ class UserVoter extends AbstractVoter
|
||||
case self::VIEW:
|
||||
return $this->canView($subject, $user, $token);
|
||||
case self::EDIT:
|
||||
case self::API_TOKEN:
|
||||
case self::PASSWORD:
|
||||
return $this->canEdit($subject, $user, $token);
|
||||
case self::DELETE:
|
||||
|
||||
57
symfony.lock
57
symfony.lock
@@ -110,6 +110,9 @@
|
||||
"erusev/parsedown": {
|
||||
"version": "1.6.4"
|
||||
},
|
||||
"exsyst/swagger": {
|
||||
"version": "v0.4.1"
|
||||
},
|
||||
"friendsofphp/php-cs-fixer": {
|
||||
"version": "2.2",
|
||||
"recipe": {
|
||||
@@ -119,6 +122,15 @@
|
||||
"ref": "bb31a3bbec00a8fc8aa1c9fbf9b0ef9fc492f93d"
|
||||
}
|
||||
},
|
||||
"friendsofsymfony/rest-bundle": {
|
||||
"version": "2.2",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes-contrib",
|
||||
"branch": "master",
|
||||
"version": "2.2",
|
||||
"ref": "258300d52be6ad59b32a888d5ddafbf9638540ff"
|
||||
}
|
||||
},
|
||||
"friendsofsymfony/user-bundle": {
|
||||
"version": "v2.1.2"
|
||||
},
|
||||
@@ -128,6 +140,24 @@
|
||||
"jdorn/sql-formatter": {
|
||||
"version": "v1.2.17"
|
||||
},
|
||||
"jms/metadata": {
|
||||
"version": "1.6.0"
|
||||
},
|
||||
"jms/parser-lib": {
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"jms/serializer": {
|
||||
"version": "1.13.0"
|
||||
},
|
||||
"jms/serializer-bundle": {
|
||||
"version": "2.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes-contrib",
|
||||
"branch": "master",
|
||||
"version": "2.0",
|
||||
"ref": "fe60ce509ef04a3f40da96e3979bc8d9b13b2372"
|
||||
}
|
||||
},
|
||||
"kevinpapst/adminlte-bundle": {
|
||||
"version": "0.3"
|
||||
},
|
||||
@@ -137,6 +167,15 @@
|
||||
"myclabs/deep-copy": {
|
||||
"version": "1.7.0"
|
||||
},
|
||||
"nelmio/api-doc-bundle": {
|
||||
"version": "3.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes-contrib",
|
||||
"branch": "master",
|
||||
"version": "3.0",
|
||||
"ref": "c8e0c38e1a280ab9e37587a8fa32b251d5bc1c94"
|
||||
}
|
||||
},
|
||||
"nikic/php-parser": {
|
||||
"version": "v4.0.2"
|
||||
},
|
||||
@@ -161,6 +200,9 @@
|
||||
"php-cs-fixer/diff": {
|
||||
"version": "v1.2.0"
|
||||
},
|
||||
"phpcollection/phpcollection": {
|
||||
"version": "0.5.0"
|
||||
},
|
||||
"phpdocumentor/reflection-common": {
|
||||
"version": "1.0.1"
|
||||
},
|
||||
@@ -170,6 +212,9 @@
|
||||
"phpdocumentor/type-resolver": {
|
||||
"version": "0.4.0"
|
||||
},
|
||||
"phpoption/phpoption": {
|
||||
"version": "1.5.0"
|
||||
},
|
||||
"phpspec/prophecy": {
|
||||
"version": "1.7.3"
|
||||
},
|
||||
@@ -413,6 +458,9 @@
|
||||
"symfony/property-access": {
|
||||
"version": "v4.0.3"
|
||||
},
|
||||
"symfony/property-info": {
|
||||
"version": "v4.1.3"
|
||||
},
|
||||
"symfony/routing": {
|
||||
"version": "4.0",
|
||||
"recipe": {
|
||||
@@ -536,10 +584,19 @@
|
||||
"white-october/pagerfanta-bundle": {
|
||||
"version": "v1.1.2"
|
||||
},
|
||||
"willdurand/jsonp-callback-validator": {
|
||||
"version": "v1.1.0"
|
||||
},
|
||||
"willdurand/negotiation": {
|
||||
"version": "v2.3.1"
|
||||
},
|
||||
"zendframework/zend-code": {
|
||||
"version": "3.3.0"
|
||||
},
|
||||
"zendframework/zend-eventmanager": {
|
||||
"version": "3.2.0"
|
||||
},
|
||||
"zircote/swagger-php": {
|
||||
"version": "2.0.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ swagger_data.spec.info.title }}</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans:400,700|Source+Code+Pro:300,600|Titillium+Web:400,600,700">
|
||||
<link rel="stylesheet" href="{{ asset('bundles/nelmioapidoc/swagger-ui/swagger-ui.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('bundles/nelmioapidoc/style.css') }}">
|
||||
{# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #}
|
||||
<script id="swagger-data" type="application/json">{{ swagger_data|json_encode(65)|raw }}</script>
|
||||
</head>
|
||||
<body style="margin-top:0;">
|
||||
<div id="swagger-ui" class="api-platform"></div>
|
||||
<script src="{{ asset('bundles/nelmioapidoc/swagger-ui/swagger-ui-bundle.js') }}"></script>
|
||||
<script src="{{ asset('bundles/nelmioapidoc/swagger-ui/swagger-ui-standalone-preset.js') }}"></script>
|
||||
<script src="{{ asset('bundles/nelmioapidoc/init-swagger-ui.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
169
tests/API/APIControllerBaseTest.php
Normal file
169
tests/API/APIControllerBaseTest.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?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;
|
||||
|
||||
use App\DataFixtures\UserFixtures;
|
||||
use App\Entity\User;
|
||||
use App\Tests\Controller\ControllerBaseTest;
|
||||
use Symfony\Bundle\FrameworkBundle\Client;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Adds some useful functions for writing API integration tests.
|
||||
*/
|
||||
abstract class APIControllerBaseTest extends ControllerBaseTest
|
||||
{
|
||||
/**
|
||||
* @param string $role
|
||||
* @return Client
|
||||
*/
|
||||
protected function getClientForAuthenticatedUser(string $role = User::ROLE_USER)
|
||||
{
|
||||
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:
|
||||
$client = null;
|
||||
break;
|
||||
}
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $url
|
||||
* @param bool $json
|
||||
* @return string
|
||||
*/
|
||||
protected function createUrl($url, $json = true)
|
||||
{
|
||||
return '/' . ltrim($url, '/') . ($json ? '.json' : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Client $client
|
||||
* @param string $url
|
||||
* @param string $method
|
||||
*/
|
||||
protected function assertRequestIsSecured(Client $client, string $url, $method = 'GET')
|
||||
{
|
||||
$this->request($client, $url, $method);
|
||||
$this->assertResponseIsSecured($client->getResponse(), $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Response $response
|
||||
* @param string $url
|
||||
*/
|
||||
protected function assertResponseIsSecured(Response $response, string $url)
|
||||
{
|
||||
$data = ['message' => 'Authentication required, missing headers: X-AUTH-USER, X-AUTH-TOKEN'];
|
||||
|
||||
$this->assertEquals(
|
||||
$data,
|
||||
json_decode($response->getContent(), true),
|
||||
sprintf('The secure URL %s is not protected.', $url)
|
||||
);
|
||||
|
||||
$this->assertEquals(
|
||||
Response::HTTP_FORBIDDEN, // TODO that should actually be 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')
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser($role);
|
||||
$client->request($method, $this->createUrl($url));
|
||||
|
||||
$this->assertFalse(
|
||||
$client->getResponse()->isSuccessful(),
|
||||
sprintf('The secure URL %s is not protected for role %s', $url, $role)
|
||||
);
|
||||
|
||||
$expected = [
|
||||
'code' => 403,
|
||||
'message' => 'Access denied.'
|
||||
];
|
||||
|
||||
$this->assertEquals(403, $client->getResponse()->getStatusCode());
|
||||
|
||||
$this->assertEquals(
|
||||
$expected,
|
||||
json_decode($client->getResponse()->getContent(), true)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Client $client
|
||||
* @param string $url
|
||||
* @param string $method
|
||||
* @return Crawler
|
||||
*/
|
||||
protected function request(Client $client, string $url, $method = 'GET')
|
||||
{
|
||||
return $client->request($method, $this->createUrl($url), [], [], ['HTTP_CONTENT_TYPE' => 'application/json']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $role
|
||||
* @param string $url
|
||||
*/
|
||||
protected function assertEntityNotFound(string $role, string $url)
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser($role);
|
||||
$this->request($client, $url);
|
||||
|
||||
$expected = [
|
||||
'code' => 404,
|
||||
'message' => 'Not found'
|
||||
];
|
||||
|
||||
$this->assertEquals(404, $client->getResponse()->getStatusCode());
|
||||
|
||||
$this->assertEquals(
|
||||
$expected,
|
||||
json_decode($client->getResponse()->getContent(), true)
|
||||
);
|
||||
}
|
||||
}
|
||||
64
tests/API/ActivityControllerTest.php
Normal file
64
tests/API/ActivityControllerTest.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?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;
|
||||
|
||||
use App\Entity\User;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \App\API\ActivityController
|
||||
* @group integration
|
||||
* @group legacy
|
||||
*/
|
||||
class ActivityControllerTest extends APIControllerBaseTest
|
||||
{
|
||||
public function testIsSecure()
|
||||
{
|
||||
$this->assertUrlIsSecured('/api/activities');
|
||||
}
|
||||
|
||||
public function testGetCollection()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->assertAccessIsGranted($client, '/api/activities');
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
|
||||
$this->assertInternalType('array', $result);
|
||||
$this->assertNotEmpty($result);
|
||||
$this->assertEquals(1, count($result));
|
||||
$this->assertStructure($result[0]);
|
||||
}
|
||||
|
||||
public function testGetEntity()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->assertAccessIsGranted($client, '/api/activities/1');
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
|
||||
$this->assertInternalType('array', $result);
|
||||
$this->assertStructure($result);
|
||||
}
|
||||
|
||||
public function testNotFound()
|
||||
{
|
||||
$this->assertEntityNotFound(User::ROLE_USER, '/api/activities/2');
|
||||
}
|
||||
|
||||
protected function assertStructure(array $result)
|
||||
{
|
||||
$expectedKeys = [
|
||||
'id', 'name', 'comment', 'visible', 'project_id'
|
||||
];
|
||||
|
||||
$actual = array_keys($result);
|
||||
|
||||
$this->assertEquals(count($expectedKeys), count($actual), 'Activity entity has different amount of keys');
|
||||
$this->assertEquals($expectedKeys, $actual, 'Activity structure does not match');
|
||||
}
|
||||
}
|
||||
51
tests/API/ApiDocControllerTest.php
Normal file
51
tests/API/ApiDocControllerTest.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?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;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Tests\Controller\ControllerBaseTest;
|
||||
|
||||
/**
|
||||
* @group integration
|
||||
* @group legacy
|
||||
*/
|
||||
class ApiDocControllerTest extends ControllerBaseTest
|
||||
{
|
||||
public function testIsSecure()
|
||||
{
|
||||
$this->assertUrlIsSecured('/api/doc');
|
||||
}
|
||||
|
||||
public function testGetDocs()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->assertAccessIsGranted($client, '/api/doc');
|
||||
$this->assertContains('<title>Kimai 2 - API Docs</title>', $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testGetJsonDocs()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->assertAccessIsGranted($client, '/api/doc.json');
|
||||
$this->assertContains('"title":"Kimai 2 - API Docs"', $client->getResponse()->getContent());
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertInternalType('array', $result);
|
||||
$this->assertNotEmpty($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $url
|
||||
* @return string
|
||||
*/
|
||||
protected function createUrl($url)
|
||||
{
|
||||
return '/' . ltrim($url, '/');
|
||||
}
|
||||
}
|
||||
65
tests/API/CustomerControllerTest.php
Normal file
65
tests/API/CustomerControllerTest.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 App\Tests\API;
|
||||
|
||||
use App\Entity\User;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \App\API\CustomerController
|
||||
* @group integration
|
||||
* @group legacy
|
||||
*/
|
||||
class CustomerControllerTest extends APIControllerBaseTest
|
||||
{
|
||||
public function testIsSecure()
|
||||
{
|
||||
$this->assertUrlIsSecured('/api/customers');
|
||||
}
|
||||
|
||||
public function testGetCollection()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->assertAccessIsGranted($client, '/api/customers');
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
|
||||
$this->assertInternalType('array', $result);
|
||||
$this->assertNotEmpty($result);
|
||||
$this->assertEquals(1, count($result));
|
||||
$this->assertStructure($result[0]);
|
||||
}
|
||||
|
||||
public function testGetEntity()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->assertAccessIsGranted($client, '/api/customers/1');
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
|
||||
$this->assertInternalType('array', $result);
|
||||
$this->assertStructure($result);
|
||||
}
|
||||
|
||||
public function testNotFound()
|
||||
{
|
||||
$this->assertEntityNotFound(User::ROLE_USER, '/api/customers/2');
|
||||
}
|
||||
|
||||
protected function assertStructure(array $result)
|
||||
{
|
||||
$expectedKeys = [
|
||||
'id', 'name', 'number', 'comment', 'visible', 'company', 'contact', 'address', 'country', 'currency',
|
||||
'phone', 'fax', 'mobile', 'mail', 'timezone'
|
||||
];
|
||||
|
||||
$actual = array_keys($result);
|
||||
|
||||
$this->assertEquals(count($expectedKeys), count($actual), 'Customer entity has different amount of keys');
|
||||
$this->assertEquals($expectedKeys, $actual, 'Customer structure does not match');
|
||||
}
|
||||
}
|
||||
35
tests/API/HealthcheckControllerTest.php
Normal file
35
tests/API/HealthcheckControllerTest.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?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;
|
||||
|
||||
use App\Entity\User;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \App\API\HealthcheckController
|
||||
* @group integration
|
||||
* @group legacy
|
||||
*/
|
||||
class HealthcheckControllerTest extends APIControllerBaseTest
|
||||
{
|
||||
public function testIsSecure()
|
||||
{
|
||||
$this->assertUrlIsSecured('/api/ping');
|
||||
}
|
||||
|
||||
public function testPing()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->assertAccessIsGranted($client, '/api/ping');
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
|
||||
$this->assertInternalType('array', $result);
|
||||
$this->assertEquals(['message' => 'pong'], $result);
|
||||
}
|
||||
}
|
||||
64
tests/API/ProjectControllerTest.php
Normal file
64
tests/API/ProjectControllerTest.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?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;
|
||||
|
||||
use App\Entity\User;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \App\API\ProjectController
|
||||
* @group integration
|
||||
* @group legacy
|
||||
*/
|
||||
class ProjectControllerTest extends APIControllerBaseTest
|
||||
{
|
||||
public function testIsSecure()
|
||||
{
|
||||
$this->assertUrlIsSecured('/api/projects');
|
||||
}
|
||||
|
||||
public function testGetCollection()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->assertAccessIsGranted($client, '/api/projects');
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
|
||||
$this->assertInternalType('array', $result);
|
||||
$this->assertNotEmpty($result);
|
||||
$this->assertEquals(1, count($result));
|
||||
$this->assertStructure($result[0]);
|
||||
}
|
||||
|
||||
public function testGetEntity()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->assertAccessIsGranted($client, '/api/projects/1');
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
|
||||
$this->assertInternalType('array', $result);
|
||||
$this->assertStructure($result);
|
||||
}
|
||||
|
||||
public function testNotFound()
|
||||
{
|
||||
$this->assertEntityNotFound(User::ROLE_USER, '/api/projects/2');
|
||||
}
|
||||
|
||||
protected function assertStructure(array $result)
|
||||
{
|
||||
$expectedKeys = [
|
||||
'id', 'name', 'comment', 'visible', 'budget', 'order_number', 'customer_id'
|
||||
];
|
||||
|
||||
$actual = array_keys($result);
|
||||
|
||||
$this->assertEquals(count($expectedKeys), count($actual), 'Project entity has different amount of keys');
|
||||
$this->assertEquals($expectedKeys, $actual, 'Project structure does not match');
|
||||
}
|
||||
}
|
||||
65
tests/API/UserControllerTest.php
Normal file
65
tests/API/UserControllerTest.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 App\Tests\API;
|
||||
|
||||
use App\Entity\User;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \App\API\UserController
|
||||
* @group integration
|
||||
* @group legacy
|
||||
*/
|
||||
class UserControllerTest extends APIControllerBaseTest
|
||||
{
|
||||
public function testIsSecure()
|
||||
{
|
||||
$this->assertUrlIsSecured('/api/users');
|
||||
$this->assertUrlIsSecuredForRole(User::ROLE_ADMIN, '/api/users');
|
||||
}
|
||||
|
||||
public function testGetCollection()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||
$this->assertAccessIsGranted($client, '/api/users');
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
|
||||
$this->assertInternalType('array', $result);
|
||||
$this->assertNotEmpty($result);
|
||||
$this->assertEquals(6, count($result));
|
||||
$this->assertStructure($result[0]);
|
||||
}
|
||||
|
||||
public function testGetEntity()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||
$this->assertAccessIsGranted($client, '/api/users/1');
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
|
||||
$this->assertInternalType('array', $result);
|
||||
$this->assertStructure($result);
|
||||
}
|
||||
|
||||
public function testNotFound()
|
||||
{
|
||||
$this->assertEntityNotFound(User::ROLE_SUPER_ADMIN, '/api/users/99');
|
||||
}
|
||||
|
||||
protected function assertStructure(array $result)
|
||||
{
|
||||
$expectedKeys = [
|
||||
'id', 'username', 'enabled', 'roles', 'alias', 'title', 'avatar'
|
||||
];
|
||||
|
||||
$actual = array_keys($result);
|
||||
|
||||
$this->assertEquals(count($expectedKeys), count($actual), 'User entity has different amount of keys');
|
||||
$this->assertEquals($expectedKeys, $actual, 'User structure does not match');
|
||||
}
|
||||
}
|
||||
@@ -52,15 +52,13 @@ class UserControllerTest extends ControllerBaseTest
|
||||
$this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode($username) . '/edit'));
|
||||
$client->followRedirect();
|
||||
|
||||
$expectedTabs = ['#charts', '#settings', '#password', '#api-token', '#roles'];
|
||||
|
||||
$tabs = $client->getCrawler()->filter('div.nav-tabs-custom ul.nav-tabs li');
|
||||
$this->assertEquals(4, $tabs->count());
|
||||
$expectedTabs = ['#charts', '#settings', '#password', '#roles'];
|
||||
$this->assertEquals(count($expectedTabs), $tabs->count());
|
||||
$foundTabs = [];
|
||||
foreach ($tabs->filter('a') as $tab) {
|
||||
$name = $tab->getAttribute('href');
|
||||
if (in_array($name, $expectedTabs)) {
|
||||
$foundTabs[] = $name;
|
||||
}
|
||||
$foundTabs[] = $tab->getAttribute('href');
|
||||
}
|
||||
$this->assertEmpty(array_diff($expectedTabs, $foundTabs));
|
||||
|
||||
|
||||
@@ -72,5 +72,27 @@ class CalendarControllerTest extends ControllerBaseTest
|
||||
$this->assertInternalType('array', $json);
|
||||
$this->assertNotEmpty($json);
|
||||
$this->assertEquals(10, count($json));
|
||||
foreach ($json as $result) {
|
||||
$this->assertInternalType('array', $result);
|
||||
$this->assertCalendarStructure($result);
|
||||
}
|
||||
}
|
||||
|
||||
protected function assertCalendarStructure(array $result)
|
||||
{
|
||||
$this->assertArrayHasKey('id', $result);
|
||||
$this->assertArrayHasKey('start', $result);
|
||||
$this->assertArrayHasKey('title', $result);
|
||||
$this->assertArrayHasKey('description', $result);
|
||||
$this->assertArrayHasKey('customer', $result);
|
||||
$this->assertArrayHasKey('project', $result);
|
||||
$this->assertArrayHasKey('activity', $result);
|
||||
$this->assertArrayHasKey('borderColor', $result);
|
||||
$this->assertArrayHasKey('backgroundColor', $result);
|
||||
|
||||
if (isset($result['end'])) {
|
||||
$this->assertNull($result['borderColor']);
|
||||
$this->assertNull($result['backgroundColor']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,18 +96,18 @@ abstract class ControllerBaseTest extends WebTestCase
|
||||
*/
|
||||
protected function assertRequestIsSecured(Client $client, string $url, $method = 'GET')
|
||||
{
|
||||
$client->request($method, $this->createUrl($url));
|
||||
$this->request($client, $url, $method);
|
||||
|
||||
/* @var RedirectResponse $response */
|
||||
$response = $client->getResponse();
|
||||
|
||||
$this->assertTrue(
|
||||
$response->isRedirect(),
|
||||
sprintf('The secure URL %s is not protected.', $url . $response->getContent())
|
||||
sprintf('The secure URL %s is not protected.', $url)
|
||||
);
|
||||
|
||||
$this->assertEquals(
|
||||
'http://localhost' . $this->createUrl('/login'),
|
||||
$this->assertStringEndsWith(
|
||||
'/login',
|
||||
$response->getTargetUrl(),
|
||||
sprintf('The secure URL %s does not redirect to the login form.', $url)
|
||||
);
|
||||
|
||||
@@ -10,10 +10,13 @@
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\DataFixtures\UserFixtures;
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \App\Controller\InvoiceController
|
||||
* @group integration
|
||||
* @group legacy
|
||||
*/
|
||||
class ProfileControllerTest extends ControllerBaseTest
|
||||
{
|
||||
@@ -28,15 +31,13 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER);
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$expectedTabs = ['#charts', '#settings', '#password', '#api-token', '#preferences'];
|
||||
|
||||
$tabs = $client->getCrawler()->filter('div.nav-tabs-custom ul.nav-tabs li');
|
||||
$this->assertEquals(4, $tabs->count());
|
||||
$expectedTabs = ['#charts', '#settings', '#password', '#preferences'];
|
||||
$this->assertEquals(count($expectedTabs), $tabs->count());
|
||||
$foundTabs = [];
|
||||
foreach ($tabs->filter('a') as $tab) {
|
||||
$name = $tab->getAttribute('href');
|
||||
if (in_array($name, $expectedTabs)) {
|
||||
$foundTabs[] = $name;
|
||||
}
|
||||
$foundTabs[] = $tab->getAttribute('href');
|
||||
}
|
||||
$this->assertEmpty(array_diff($expectedTabs, $foundTabs));
|
||||
}
|
||||
@@ -47,4 +48,74 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
$this->request($client, '/profile/' . UserFixtures::USERNAME_TEAMLEAD);
|
||||
$this->assertFalse($client->getResponse()->isSuccessful());
|
||||
}
|
||||
|
||||
public function testUpdateApiToken()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $client->getContainer()->get('security.token_storage')->getToken()->getUser();
|
||||
/** @var EncoderFactoryInterface $passwordEncoder */
|
||||
$passwordEncoder = $client->getContainer()->get('test.PasswordEncoder');
|
||||
|
||||
$this->assertTrue($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), UserFixtures::DEFAULT_API_TOKEN, $user->getSalt()));
|
||||
$this->assertFalse($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), 'test123', $user->getSalt()));
|
||||
$this->assertEquals(UserFixtures::USERNAME_USER, $user->getUsername());
|
||||
|
||||
$form = $client->getCrawler()->filter('form[name=user_api_token]')->form();
|
||||
$client->submit($form, [
|
||||
'user_api_token' => [
|
||||
'plainApiToken' => [
|
||||
'first' => 'test123',
|
||||
'second' => 'test123',
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode(UserFixtures::USERNAME_USER)));
|
||||
$client->followRedirect();
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$this->assertHasFlashSuccess($client);
|
||||
|
||||
$user = $client->getContainer()->get('security.token_storage')->getToken()->getUser();
|
||||
$this->assertFalse($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), UserFixtures::DEFAULT_API_TOKEN, $user->getSalt()));
|
||||
$this->assertTrue($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), 'test123', $user->getSalt()));
|
||||
}
|
||||
|
||||
public function testUpdatePassword()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $client->getContainer()->get('security.token_storage')->getToken()->getUser();
|
||||
/** @var EncoderFactoryInterface $passwordEncoder */
|
||||
$passwordEncoder = $client->getContainer()->get('test.PasswordEncoder');
|
||||
|
||||
$this->assertTrue($passwordEncoder->getEncoder($user)->isPasswordValid($user->getPassword(), UserFixtures::DEFAULT_PASSWORD, $user->getSalt()));
|
||||
$this->assertFalse($passwordEncoder->getEncoder($user)->isPasswordValid($user->getPassword(), 'test123', $user->getSalt()));
|
||||
$this->assertEquals(UserFixtures::USERNAME_USER, $user->getUsername());
|
||||
|
||||
$form = $client->getCrawler()->filter('form[name=user_password]')->form();
|
||||
$client->submit($form, [
|
||||
'user_password' => [
|
||||
'plainPassword' => [
|
||||
'first' => 'test123',
|
||||
'second' => 'test123',
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode(UserFixtures::USERNAME_USER)));
|
||||
$client->followRedirect();
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$this->assertHasFlashSuccess($client);
|
||||
|
||||
$user = $client->getContainer()->get('security.token_storage')->getToken()->getUser();
|
||||
$this->assertFalse($passwordEncoder->getEncoder($user)->isPasswordValid($user->getPassword(), UserFixtures::DEFAULT_PASSWORD, $user->getSalt()));
|
||||
$this->assertTrue($passwordEncoder->getEncoder($user)->isPasswordValid($user->getPassword(), 'test123', $user->getSalt()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,14 @@
|
||||
<source>label.user_profile</source>
|
||||
<target>Mein Profil</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.api_token">
|
||||
<source>label.api_token</source>
|
||||
<target>API Passwort</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.api_token_repeat">
|
||||
<source>label.api_token_repeat</source>
|
||||
<target>API Passwort wiederholen</target>
|
||||
</trans-unit>
|
||||
|
||||
<!--
|
||||
Menu / Navbar items
|
||||
@@ -343,6 +351,10 @@
|
||||
<source>profile.password</source>
|
||||
<target>Passwort</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="profile.api-token">
|
||||
<source>profile.api-token</source>
|
||||
<target>API</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="profile.roles">
|
||||
<source>profile.roles</source>
|
||||
<target>Rollen</target>
|
||||
|
||||
@@ -63,6 +63,14 @@
|
||||
<source>label.user_profile</source>
|
||||
<target>My profile</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.api_token">
|
||||
<source>label.api_token</source>
|
||||
<target>API password</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.api_token_repeat">
|
||||
<source>label.api_token_repeat</source>
|
||||
<target>Repeat API password</target>
|
||||
</trans-unit>
|
||||
|
||||
<!--
|
||||
Menu / Navbar items
|
||||
@@ -351,6 +359,10 @@
|
||||
<source>profile.password</source>
|
||||
<target>Password</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="profile.api-token">
|
||||
<source>profile.api-token</source>
|
||||
<target>API</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="profile.roles">
|
||||
<source>profile.roles</source>
|
||||
<target>Roles</target>
|
||||
|
||||
Binary file not shown.
@@ -13,12 +13,16 @@ For the most part Kimai usage should be self-explanatory, so we will only cover
|
||||
|
||||
- [Timesheets](timesheet.md) - information about timesheets
|
||||
|
||||
## Developers and Administrators
|
||||
## Developer & Administrator
|
||||
|
||||
- [Kimai configurations](configurations.md) - application configs, which can only be changed in config files
|
||||
- [Developer docu](developers.md) - how to extend Kimai's feature set
|
||||
- [User and Security](users.md) - docu for user and security topics, like authentication, registration and roles
|
||||
- [FAQ](faq.md) - some answers to frequently asked questions
|
||||
- [Emails](emails.md) - transport configuration and handling of emails
|
||||
- [API](developers_api.md) - how to use the JSON API
|
||||
|
||||
## Installation
|
||||
|
||||
- [Installation](installation.md) - instructions about several installation methods
|
||||
- [Importing data](migration_v1.md) - how to import your timesheets from Kimai v1
|
||||
- [Emails](emails.md) - transport configuration and handling of emails
|
||||
|
||||
23
var/docs/developers_api.md
Normal file
23
var/docs/developers_api.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# REST API
|
||||
|
||||
Read the Swagger documentation of the Kimai 2 API in your Kimai installation at `/api/doc`.
|
||||
|
||||
Or you can export the JSON collection by visiting `/api/doc.json`. Store the result in a file, which can be imported with Postman.
|
||||
|
||||
## Authentication
|
||||
|
||||
When calling the API you have to submit two additional header with every call for authentication:
|
||||
|
||||
- `X-AUTH-USER` - holds the username or email
|
||||
- `X-AUTH-TOKEN` - holds the users API password, which he can set in his profile
|
||||
|
||||
Please make sure to ONLY call the Kimai 2 API via `https` to protect the users data!
|
||||
|
||||
## Calling the API with Javascript
|
||||
|
||||
If you develop your own extension and need to use the API for logged-in user, then you have to set the header `X-AUTH-SESSION`
|
||||
which will allow Kimai to use the current user session and not look for the default token based API authentication.
|
||||
|
||||
### Next step
|
||||
|
||||
Back to the [developer documentation](developers.md).
|
||||
@@ -10,3 +10,4 @@ Internal documentation for project maintainers
|
||||
- Push a release branch and add it as last PR merge into master
|
||||
- Edit the release-draft and add the "Full changelog" link + everything from the "Merged pull requests" section from CHANGELOG.md
|
||||
- Create the release
|
||||
- Post a new issue at [YunoHost tracker for Kimai 2](https://github.com/YunoHost-Apps/kimai2_ynh)
|
||||
Reference in New Issue
Block a user