Commit 988f27ed authored by Andrzej Prusinowski's avatar Andrzej Prusinowski

remove webhooks in favour of generic authenticators

parent 1c0157cb
showTooltip = (elem, msg) ->
$(elem).addClass('tooltipped tooltipped-s').attr('aria-label', msg)
fallbackMessage = (action) ->
actionKey = if action == 'cut' then 'X' else 'C'
switch
when /iPhone|iPad/i.test(navigator.userAgent) then 'No support :('
when /Mac/i.test(navigator.userAgent) then 'Press ⌘-' + actionKey + ' to ' + action
else 'Press Ctrl-' + actionKey + ' to ' + action
$('.btn-clipboard').on 'mouseleave', (e) ->
$(this).removeClass('tooltipped tooltipped-s').removeAttr('aria-label')
clipboard = new Clipboard('.btn-clipboard')
clipboard.on 'success', (e) ->
showTooltip(e.trigger, 'Copied!')
clipboard.on 'error', (e) ->
showTooltip(e.trigger, fallbackMessage(e.action))
This diff is collapsed.
.tooltipped {
position: relative
}
.tooltipped:after {
position: absolute;
z-index: 1000000;
display: none;
padding: 5px 8px;
font: normal normal 11px/1.5 Helvetica, arial, nimbussansl, liberationsans, freesans, clean, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol";
color: #fff;
text-align: center;
text-decoration: none;
text-shadow: none;
text-transform: none;
letter-spacing: normal;
word-wrap: break-word;
white-space: pre;
pointer-events: none;
content: attr(aria-label);
background: rgba(0, 0, 0, 0.8);
border-radius: 3px;
-webkit-font-smoothing: subpixel-antialiased
}
.tooltipped:before {
position: absolute;
z-index: 1000001;
display: none;
width: 0;
height: 0;
color: rgba(0, 0, 0, 0.8);
pointer-events: none;
content: "";
border: 5px solid transparent
}
.tooltipped:hover:before,
.tooltipped:hover:after,
.tooltipped:active:before,
.tooltipped:active:after,
.tooltipped:focus:before,
.tooltipped:focus:after {
display: inline-block;
text-decoration: none
}
.tooltipped-multiline:hover:after,
.tooltipped-multiline:active:after,
.tooltipped-multiline:focus:after {
display: table-cell
}
.tooltipped-s:after,
.tooltipped-se:after,
.tooltipped-sw:after {
top: 100%;
right: 50%;
margin-top: 5px
}
.tooltipped-s:before,
.tooltipped-se:before,
.tooltipped-sw:before {
top: auto;
right: 50%;
bottom: -5px;
margin-right: -5px;
border-bottom-color: rgba(0, 0, 0, 0.8)
}
.tooltipped-se:after {
right: auto;
left: 50%;
margin-left: -15px
}
.tooltipped-sw:after {
margin-right: -15px
}
.tooltipped-n:after,
.tooltipped-ne:after,
.tooltipped-nw:after {
right: 50%;
bottom: 100%;
margin-bottom: 5px
}
.tooltipped-n:before,
.tooltipped-ne:before,
.tooltipped-nw:before {
top: -5px;
right: 50%;
bottom: auto;
margin-right: -5px;
border-top-color: rgba(0, 0, 0, 0.8)
}
.tooltipped-ne:after {
right: auto;
left: 50%;
margin-left: -15px
}
.tooltipped-nw:after {
margin-right: -15px
}
.tooltipped-s:after,
.tooltipped-n:after {
-webkit-transform: translateX(50%);
-ms-transform: translateX(50%);
transform: translateX(50%)
}
.tooltipped-w:after {
right: 100%;
bottom: 50%;
margin-right: 5px;
-webkit-transform: translateY(50%);
-ms-transform: translateY(50%);
transform: translateY(50%)
}
.tooltipped-w:before {
top: 50%;
bottom: 50%;
left: -5px;
margin-top: -5px;
border-left-color: rgba(0, 0, 0, 0.8)
}
.tooltipped-e:after {
bottom: 50%;
left: 100%;
margin-left: 5px;
-webkit-transform: translateY(50%);
-ms-transform: translateY(50%);
transform: translateY(50%)
}
.tooltipped-e:before {
top: 50%;
right: -5px;
bottom: 50%;
margin-top: -5px;
border-right-color: rgba(0, 0, 0, 0.8)
}
.tooltipped-multiline:after {
width: -webkit-max-content;
width: -moz-max-content;
width: max-content;
max-width: 250px;
word-break: break-word;
word-wrap: normal;
white-space: pre-line;
border-collapse: separate
}
.tooltipped-multiline.tooltipped-s:after,
.tooltipped-multiline.tooltipped-n:after {
right: auto;
left: 50%;
-webkit-transform: translateX(-50%);
-ms-transform: translateX(-50%);
transform: translateX(-50%)
}
.tooltipped-multiline.tooltipped-w:after,
.tooltipped-multiline.tooltipped-e:after {
right: 100%
}
@media screen and (min-width: 0\0) {
.tooltipped-multiline:after {
width: 250px
}
}
.tooltipped-sticky:before,
.tooltipped-sticky:after {
display: inline-block
}
.tooltipped-sticky.tooltipped-multiline:after {
display: table-cell
}
......@@ -9,6 +9,7 @@ assets:
css:
inputs:
- [ scss/build.scss, scss ]
- [ scss/tooltips.scss, scss ]
- lib/font-awesome-4.7.0.min.css
- lib/select2/css/select2.css
- lib/select2-bootstrap.css
......@@ -21,6 +22,7 @@ assets:
- lib/select2/js/select2.full.js
- lib/moment-2.17.1.min.js
- lib/bootstrap-datetimepicker.min.js
- lib/clipboard.js
- [ ../../vendor/avris/time-diff/src/Asset/TimeDiff.coffee, coffee ]
- [ coffee/components/* , coffee ]
- [ coffee/modules/* , coffee ]
......
......@@ -42,7 +42,7 @@ class BaseApiController extends Controller
/** @var AuthenticatorRepository $authrepo */
$authrepo = $this->getEm()->getRepository(Authenticator::class);
$auth = $authrepo->findByToken(Project::AUTH_API_KEY, $token);
$auth = $authrepo->findByToken(Authenticator::TYPE_API_KEY, $token);
if (!$auth || !$auth->getProject()) {
throw new UnauthorisedException('Invalid token', $request, $token);
}
......
......@@ -68,45 +68,4 @@ class ApiController extends Controller
]);
}
}
/**
* @M\Route("/{name}/api-keys")
* @M\Secure(check="canManageProject")
*/
public function keysAction(Project $project)
{
if ($this->getRequest()->isPost() && $removeId = $this->getData('remove')) {
$auth = $project->removeApiKey($removeId);
$this->getEm()->persist($project);
$this->getEm()->flush();
$this->addFlash(FlashBag::SUCCESS, l('api.keys.remove.success', ['name' => $auth->getPayload()['name']]));
return $this->redirectToRoute('apiKeys', ['name' => $project->getName()]);
}
$form = new ApiKeyForm();
$form->bindRequest($this->getRequest());
if ($form->isValid()) {
$name = $form->getObject()->name;
$token = $this->get('crypt')->generateSecret();
$project->addApiKey($name, $token);
$this->getEm()->persist($project);
$this->getEm()->flush();
$this->addFlash(FlashBag::SUCCESS, l('api.keys.success', [
'name' => $name,
'token' => $token,
])->getLocalized());
return $this->redirectToRoute('apiKeys', ['name' => $project->getName()]);
}
return $this->render([
'project' => $project,
'keys' => $project->getApiKeys(),
'form' => $form,
]);
}
}
......@@ -203,7 +203,7 @@ class ProjectController extends Controller
public function acceptInvitationAction(Project $project, $token)
{
/** @var Authenticator $auth */
$auth = $this->getEm()->getRepository('Authenticator')->findByToken(Project::INVITATION_TOKEN, $token);
$auth = $this->getEm()->getRepository('Authenticator')->findByToken(Authenticator::TYPE_INVITATION, $token);
if (!$auth || $auth->getPayload()['project'] !== $project->getId()) {
$this->addFlash(FlashBag::DANGER, l('entity.Project.invite.invalidToken'));
return $this->redirectToRoute('home');
......
<?php
namespace App\Form;
use App\Form\Widget\Token;
use Avris\Micrus\Forms\Assert as Assert;
use Avris\Micrus\Forms\Widget as Widget;
use Avris\Micrus\Forms\Form;
......@@ -10,9 +11,14 @@ class ApiKeyForm extends Form
public function configure()
{
$this
->add('name', Widget\Text::class, [], [
->add('name', Widget\Text::class, [
l('api.keys.name')
], [
new Assert\NotBlank()
])
->add('token', Token::class, [
l('api.keys.token')
])
;
}
}
......@@ -2,10 +2,10 @@
namespace App\Form;
use App\Form\Assert\UrlTemplate;
use App\Model\Authenticator;
use App\Model\Project;
use App\Model\ProjectUser;
use App\Model\Server;
use App\Model\Webhook;
use App\Service\Github\GithubService;
use Avris\Micrus\Forms\Assert as Assert;
use Avris\Micrus\Forms\Widget as Widget;
......@@ -93,9 +93,20 @@ class ProjectForm extends Form
: l('entity.Project.revoke.cannotRevokeYourself');
}, $this->getObject())
], !$isNew)
->add('apiKeys', Widget\MultipleSubForm::class, [
'form' => ApiKeyForm::class,
'model' => Authenticator::class,
'container' => $this->container,
'add' => true,
'btnAddText' => '<span class="fa fa-plus-circle"></span>',
'remove' => true,
'btnRemoveText' => '<span class="fa fa-trash"></span>',
], [
new Assert\UniqueField('name'),
])
->add('webhooks', Widget\MultipleSubForm::class, [
'form' => WebhookForm::class,
'model' => Webhook::class,
'model' => Authenticator::class,
'container' => $this->container,
'add' => true,
'btnAddText' => '<span class="fa fa-plus-circle"></span>',
......
<?php
namespace App\Form;
use App\Model\Webhook;
use App\Form\Widget\Token;
use App\Model\Authenticator;
use Avris\Micrus\Forms\Form;
use Avris\Micrus\Forms\Assert as Assert;
use Avris\Micrus\Forms\Widget as Widget;
......@@ -11,15 +12,23 @@ class WebhookForm extends Form
public function configure()
{
$this
->add('url', Widget\Url::class, [], [ new Assert\NotBlank() ])
->add('url', Widget\Url::class, [
'label' => l('entity.Project.webhooks.fields.url'),
], [
new Assert\NotBlank()
])
->add('events', Widget\Choice::class, [
'choices' => array_combine(Webhook::getHandledEvents(), Webhook::getHandledEvents()),
'choiceTranslation' => 'entity.Webhook.event.',
'label' => l('entity.Project.webhooks.fields.events'),
'choices' => array_combine(Authenticator::getWebhookEvents(), Authenticator::getWebhookEvents()),
'choiceTranslation' => 'entity.Project.webhooks.events.',
'multiple' => true,
'expanded' => true,
], [
new Assert\NotBlank(),
])
->add('token', Token::class, [
'label' => l('entity.Project.webhooks.fields.token'),
])
;
}
}
<?php
namespace App\Form\Widget;
use Avris\Micrus\Forms\Assert as Assert;
use Avris\Micrus\Forms\Widget\Widget;
class Token extends Widget
{
protected function getTemplate($widgetValue = null)
{
if (!$widgetValue) {
return '';
}
return '<div class="input-group">
<input id="{id}" name="{name}" type="text" value="{value}"
class="{widget_class}" readonly {asserts} {attributes} {extra}/>
<div class="input-group-addon btn-clipboard" data-clipboard-target="#{id}">
<span class="fa fa-clipboard"></span>
</div>
</div>';
}
public function isReadonly()
{
return true;
}
}
......@@ -34,6 +34,7 @@ entity:
githubRepo: Github Repo
servers: Servers
projectUsers: Members
apiKeys: API keys
webhooks: Webhooks
metrics:
all: All
......@@ -87,6 +88,15 @@ entity:
remove:
confirm: Are you sure you want to remove the project <strong>%project%</strong>?
success: Project %project% has been removed.
webhooks:
fields:
url: URL
events: Events
token: X-Token header
events:
updateServer: Update server
updateProject: Update project
ProjectUser:
fields:
user: User
......@@ -143,14 +153,6 @@ entity:
all: All
lastWeek: Last week
Webhook:
fields:
url: URL
events: Events
event:
updateServer: Update server
updateProject: Update project
github:
hint: Hint
hintRepo: If you <a href="%link%" target="_blank">link this project</a> to a github repo, you'll see a list of available branches here.
......
......@@ -9,9 +9,18 @@ use Doctrine\ORM\Mapping as ORM;
*/
class Authenticator extends BaseAuthenticator
{
const TYPE_INVITATION = 'invitation';
const TYPE_API_KEY = 'api_key';
const TYPE_WEBHOOK = 'webhook';
protected static $webhookEvents = [
'updateServer',
'updateProject',
];
/**
* @var Project
* @ORM\ManyToOne(targetEntity="Project", inversedBy="apiKeys")
* @ORM\ManyToOne(targetEntity="Project", inversedBy="authenticators")
**/
protected $project;
......@@ -32,4 +41,12 @@ class Authenticator extends BaseAuthenticator
$this->project = $project;
return $this;
}
/**
* @return string[]
*/
public static function getWebhookEvents()
{
return self::$webhookEvents;
}
}
......@@ -15,9 +15,6 @@ use ICanBoogie\DateTime;
**/
class Project implements \JsonSerializable
{
const INVITATION_TOKEN = 'invitation';
const AUTH_API_KEY = 'api_key';
/**
* @var string
* @ORM\Id
......@@ -85,14 +82,7 @@ class Project implements \JsonSerializable
* @ORM\OneToMany(targetEntity="Authenticator", mappedBy="project", cascade={"persist","remove"}, orphanRemoval=true)
* @ORM\OrderBy({"createdAt"="ASC"})
**/
protected $apiKeys;
/**
* @var Webhook[]|ArrayCollection
* @ORM\OneToMany(targetEntity="Webhook", mappedBy="project", cascade={"persist","remove"}, orphanRemoval=true)
* @ORM\OrderBy({"createdAt"="ASC"})
**/
protected $webhooks;
protected $authenticators;
public function __construct($name)
{
......@@ -100,8 +90,7 @@ class Project implements \JsonSerializable
$this->servers = new ArrayCollection();
$this->users = new ArrayCollection();
$this->createdAt = new DateTime();
$this->apiKeys = new ArrayCollection();
$this->webhooks = new ArrayCollection();
$this->authenticators = new ArrayCollection();
}
/**
......@@ -389,87 +378,83 @@ class Project implements \JsonSerializable
*/
public function getApiKeys()
{
return $this->apiKeys->matching(Criteria::create()
->where(Criteria::expr()->eq('type', self::AUTH_API_KEY))
return $this->authenticators->matching(Criteria::create()
->where(Criteria::expr()->eq('type', Authenticator::TYPE_API_KEY))
);
}
/**
* @param string $name
* @param string $token
* @return Authenticator
* @param Authenticator $key
* @return $this
*/
public function addApiKey($name, $token)
public function addApiKeys(Authenticator $key)
{
$auth = new Authenticator();
$auth->setType(self::AUTH_API_KEY);
$auth->setProject($this);
$auth->setPayload([
'name' => $name,
'token' => $token,
]);
$this->apiKeys->add($auth);
$this->authenticators->add($key);
$key->setType(Authenticator::TYPE_API_KEY);
$key->setProject($this);
$key->set('token', $this->generateToken());
return $auth;
return $this;
}
/**
* @param string $id
* @return Authenticator
* @param Authenticator $key
* @return $this
* @throws NotFoundException
*/
public function removeApiKey($id)
public function removeApiKeys(Authenticator $key)
{
$auth = $this->apiKeys->filter(function (Authenticator $auth) use ($id) {
return $auth->getId() === $id;
})->first();
$this->authenticators->removeElement($key);
$key->setProject(null);
if (!$auth) {
throw new NotFoundException;
}
$this->apiKeys->removeElement($auth);
$auth->setProject(null);
return $auth;
return $this;
}
/**
* @return Webhook[]|ArrayCollection
* @return Authenticator[]|ArrayCollection
*/
public function getWebhooks(Event $event = null)
{
return $event
? $this->webhooks->filter(function (Webhook $webhook) use ($event) {
return in_array($event->getName(), $webhook->getEvents());
})
: $this->webhooks;
return $this->authenticators->filter(function (Authenticator $webhook) use ($event) {
return $webhook->getType() === Authenticator::TYPE_WEBHOOK
&& (!$event || in_array($event->getName(), $webhook->getPayload()['events']));
});
}
/**
* @param Webhook $webhook
* @param Authenticator $webhook
* @return $this
*/
public function addWebhooks(Webhook $webhook)
public function addWebhooks(Authenticator $webhook)
{
$this->webhooks->add($webhook);
$this->authenticators->add($webhook);
$webhook->setType(Authenticator::TYPE_WEBHOOK);
$webhook->setProject($this);
$webhook->set('token', $this->generateToken());
return $this;
}
/**
* @param Webhook $webhook
* @param Authenticator $webhook
* @return $this
*/
public function removeWebhooks(Webhook $webhook)
public function removeWebhooks(Authenticator $webhook)
{
$this->webhooks->removeElement($webhook);
$this->authenticators->removeElement($webhook);
$webhook->setProject(null);
return $this;
}
/**
* @return string
*/
protected function generateToken()
{
return hash('sha256', mt_rand());
}
public function jsonSerialize()
{
return [
......
<?php
namespace App\Model\Repository;
use Doctrine\ORM\EntityRepository;
class WebhookRepository extends EntityRepository
{
}
<?php
namespace App\Model;
use Doctrine\ORM\Mapping as ORM;
use ICanBoogie\DateTime;
/**
* @ORM\Entity(repositoryClass="App\Model\Repository\WebhookRepository")
**/
class Webhook
{
protected static $handledEvents = [
'updateServer',
'updateProject',
];
/**
* @var string
* @ORM\Id
* @ORM\Column(type="string", length=36, options={"fixed" = true})
* @ORM\GeneratedValue(strategy="UUID")
*/
protected $id;
/**
* @var string
* @ORM\Column(type="string")
*/
protected $url;
/**
* @var string[]
* @ORM\Column(type="json_array")
*/
protected $events;
/**
* @var DateTime
* @ORM\Column(type="datetime")