Continue to work on templates / object edition

This commit is contained in:
Cyril 2019-10-29 20:03:56 +01:00
parent 7d6cf16c4d
commit ebeb304fc1
27 changed files with 507 additions and 26 deletions

View File

@ -2,6 +2,7 @@ twig:
default_path: '%kernel.project_dir%/templates'
debug: '%kernel.debug%'
strict_variables: '%kernel.debug%'
form_themes: ['bootstrap_4_horizontal_layout.html.twig']
globals:
invite_from_address: '%env(INVITE_FROM_ADDRESS)%'
calDAVEnabled: '%env(CALDAV_ENABLED)%'

View File

@ -23,11 +23,14 @@ services:
resource: '../src/Controller'
tags: ['controller.service_arguments']
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
App\Controller\DAVController:
arguments:
$calDAVEnabled: "%env(CALDAV_ENABLED)%"
$cardDAVEnabled: "%env(CARDDAV_ENABLED)%"
$inviteAddress: '%env(INVITE_FROM_ADDRESS)%'
$authRealm: '%env(AUTH_REALM)%'
App\Controller\AdminController:
arguments:
$authRealm: '%env(AUTH_REALM)%'

View File

@ -1,3 +1,11 @@
body {
padding-top: calc(56px + 30px);
}
.display-4 {
border-bottom: 1px solid #CCC;
}
h3 {
margin-top: 1em;
}

View File

@ -9,19 +9,34 @@ use App\Entity\CalendarObject;
use App\Entity\Card;
use App\Entity\Principal;
use App\Entity\User;
use App\Form\AddressBookType;
use App\Form\CalendarInstanceType;
use App\Form\UserType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
class AdminController extends AbstractController
{
/**
* HTTP authentication realm.
*
* @var string
*/
protected $authRealm;
public function __construct(?string $authRealm)
{
$this->authRealm = $authRealm ?? 'SabreDAV';
}
/**
* @Route("/dashboard", name="dashboard")
*/
public function dashboard()
{
$users = $this->get('doctrine')->getRepository(User::class)->findAll();
$calendars = $this->get('doctrine')->getRepository(Calendar::class)->findAll();
$calendars = $this->get('doctrine')->getRepository(CalendarInstance::class)->findAll();
$addressbooks = $this->get('doctrine')->getRepository(AddressBook::class)->findAll();
$events = $this->get('doctrine')->getRepository(CalendarObject::class)->findAll();
$contacts = $this->get('doctrine')->getRepository(Card::class)->findAll();
@ -30,6 +45,8 @@ class AdminController extends AbstractController
'users' => $users,
'calendars' => $calendars,
'addressbooks' => $addressbooks,
'events' => $events,
'contacts' => $contacts,
]);
}
@ -49,21 +66,68 @@ class AdminController extends AbstractController
* @Route("/users/new", name="user_create")
* @Route("/users/edit/{username}", name="user_edit")
*/
public function userCreate(?string $username)
public function userCreate(Request $request, ?string $username)
{
if ($username) {
$user = $this->get('doctrine')->getRepository(User::class)->findOneByUsername($username);
if (!$user) {
throw new \Exception('User not found');
}
$principal = $this->get('doctrine')->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username);
} else {
$user = new User();
$principal = new Principal();
}
$form = $this->createForm(UserType::class, $user);
$form = $this->createForm(UserType::class, $user, ['new' => !$username]);
$form->get('displayName')->setData($principal->getDisplayName());
$form->get('email')->setData($principal->getEmail());
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$displayName = $form->get('displayName')->getData();
$email = $form->get('email')->getData();
// Create password for user
$hash = md5($user->getUsername().':'.$this->authRealm.':'.$user->getPassword());
$user->setPassword($hash);
$entityManager = $this->get('doctrine')->getManager();
// If it's a new user, create default calendar and address book, and principal
if (null === $user->getId()) {
$principal->setUri(Principal::PREFIX.$user->getUsername());
$calendarInstance = new CalendarInstance();
$calendar = new Calendar();
$calendarInstance->setPrincipalUri(Principal::PREFIX.$user->getUsername())
->setDisplayName('Default Calendar')
->setDescription('Default Calendar for '.$displayName)
->setCalendar($calendar);
$addressbook = new AddressBook();
$addressbook->setPrincipalUri(Principal::PREFIX.$user->getUsername())
->setDisplayName('Default Address Book')
->setDescription('Default Address book for '.$displayName);
$entityManager->persist($calendarInstance);
$entityManager->persist($addressbook);
$entityManager->persist($principal);
}
$principal->setDisplayName($displayName)
->setEmail($email);
$entityManager->persist($user);
$entityManager->flush();
return $this->redirectToRoute('users');
}
return $this->render('users/edit.html.twig', [
'form' => $form->createView(),
'username' => $username,
]);
}
@ -86,6 +150,66 @@ class AdminController extends AbstractController
return $this->render('calendars/index.html.twig', [
'calendars' => $calendars,
'principal' => $principal,
'username' => $username,
]);
}
/**
* @Route("/calendars/{username}/new", name="calendar_create")
* @Route("/calendars/{username}/edit/{id}", name="calendar_edit")
*/
public function calendarCreate(Request $request, string $username, ?int $id)
{
$principal = $this->get('doctrine')->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username);
if (!$principal) {
throw new \Exception('User not found');
}
if ($id) {
$calendarInstance = $this->get('doctrine')->getRepository(CalendarInstance::class)->findOneById($id);
if (!$calendarInstance) {
throw new \Exception('Calendar not found');
}
} else {
$calendarInstance = new CalendarInstance();
$calendar = new Calendar();
$calendarInstance->setCalendar($calendar);
}
$form = $this->createForm(CalendarInstanceType::class, $calendarInstance, ['new' => !$id]);
$components = explode(',', $calendarInstance->getCalendar()->getComponents());
$form->get('todos')->setData(in_array(Calendar::COMPONENT_TODOS, $components));
$form->get('notes')->setData(in_array(Calendar::COMPONENT_NOTES, $components));
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$components = [Calendar::COMPONENT_EVENT]; // We always need VEVENT
if ($form->get('todos')->getData()) {
$components[] = Calendar::COMPONENT_TODOS;
}
if ($form->get('notes')->getData()) {
$components[] = Calendar::COMPONENT_NOTES;
}
$calendarInstance->setPrincipalUri(Principal::PREFIX.$username);
$calendarInstance->getCalendar()->setComponents(implode(',', $components));
$entityManager = $this->get('doctrine')->getManager();
$entityManager->persist($calendarInstance);
$entityManager->flush();
return $this->redirectToRoute('calendars', ['username' => $username]);
}
return $this->render('calendars/edit.html.twig', [
'form' => $form->createView(),
'principal' => $principal,
'calendar' => $calendarInstance,
]);
}
@ -100,6 +224,49 @@ class AdminController extends AbstractController
return $this->render('addressbooks/index.html.twig', [
'addressbooks' => $addressbooks,
'principal' => $principal,
'username' => $username,
]);
}
/**
* @Route("/adressbooks/{username}/new", name="addressbook_create")
* @Route("/adressbooks/{username}/edit/{id}", name="addressbook_edit")
*/
public function addressbookCreate(Request $request, string $username, ?int $id)
{
$principal = $this->get('doctrine')->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username);
if (!$principal) {
throw new \Exception('User not found');
}
if ($id) {
$addressbook = $this->get('doctrine')->getRepository(AddressBook::class)->findOneById($id);
if (!$addressbook) {
throw new \Exception('Address book not found');
}
} else {
$addressbook = new AddressBook();
}
$form = $this->createForm(AddressBookType::class, $addressbook, ['new' => !$id]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager = $this->get('doctrine')->getManager();
$addressbook->setPrincipalUri(Principal::PREFIX.$username);
$entityManager->persist($addressbook);
$entityManager->flush();
return $this->redirectToRoute('address_books', ['username' => $username]);
}
return $this->render('addressbooks/edit.html.twig', [
'form' => $form->createView(),
'principal' => $principal,
'addressbook' => $addressbook,
]);
}
}

View File

@ -42,6 +42,11 @@ class AddressBook
*/
private $synctoken;
public function __construct()
{
$this->synctoken = 1;
}
public function getId(): ?int
{
return $this->id;
@ -49,6 +54,10 @@ class AddressBook
public function getPrincipalUri(): ?string
{
if (is_resource($this->principalUri)) {
$this->principalUri = stream_get_contents($this->principalUri);
}
return $this->principalUri;
}
@ -73,6 +82,10 @@ class AddressBook
public function getUri(): ?string
{
if (is_resource($this->uri)) {
$this->uri = stream_get_contents($this->uri);
}
return $this->uri;
}

View File

@ -18,7 +18,7 @@ class AddressBookChange
private $id;
/**
* @ORM\Column(type="string", length=255)
* @ORM\Column(type="binary", length=255)
*/
private $uri;
@ -45,6 +45,10 @@ class AddressBookChange
public function getUri(): ?string
{
if (is_resource($this->uri)) {
$this->uri = stream_get_contents($this->uri);
}
return $this->uri;
}

View File

@ -10,6 +10,10 @@ use Doctrine\ORM\Mapping as ORM;
*/
class Calendar
{
const COMPONENT_EVENT = 'VEVENT';
const COMPONENT_TODOS = 'VTODO';
const COMPONENT_NOTES = 'VJOURNAL';
/**
* @ORM\Id()
* @ORM\GeneratedValue()
@ -27,6 +31,12 @@ class Calendar
*/
private $components;
public function __construct()
{
$this->synctoken = 1;
$this->components = 'VEVENT,VTODO';
}
public function getId(): ?int
{
return $this->id;

View File

@ -45,6 +45,9 @@ class CalendarChange
public function getUri(): ?string
{
if (is_resource($this->uri)) {
$this->uri = stream_get_contents($this->uri);
}
return $this->uri;
}

View File

@ -10,6 +10,15 @@ use Doctrine\ORM\Mapping as ORM;
*/
class CalendarInstance
{
const INVITE_STATUS_NORESPONSE = 1;
const INVITE_STATUS_ACCEPTED = 2;
const INVITE_STATUS_DECLINED = 3;
const INVITE_STATUS_INVALID = 4;
const ACCESS_OWNER = 1;
const ACCESS_READ = 2;
const ACCESS_READWRITE = 3;
/**
* @ORM\Id()
* @ORM\GeneratedValue()
@ -18,7 +27,7 @@ class CalendarInstance
private $id;
/**
* @ORM\ManyToOne(targetEntity="App\Entity\Calendar")
* @ORM\ManyToOne(targetEntity="App\Entity\Calendar", cascade={"persist"})
* @ORM\JoinColumn(name="calendarid", nullable=false)
*/
private $calendar;
@ -83,6 +92,14 @@ class CalendarInstance
*/
private $shareInviteStatus;
public function __construct()
{
$this->shareInviteStatus = self::INVITE_STATUS_ACCEPTED;
$this->transparent = 0;
$this->calendarOrder = 0;
$this->access = self::ACCESS_OWNER;
}
public function getId(): ?int
{
return $this->id;
@ -102,7 +119,7 @@ class CalendarInstance
public function getPrincipalUri(): ?string
{
return $this->principalUri;
return stream_get_contents($this->principalUri);
}
public function setPrincipalUri(?string $principalUri): self
@ -138,6 +155,10 @@ class CalendarInstance
public function getUri(): ?string
{
if (is_resource($this->uri)) {
$this->uri = stream_get_contents($this->uri);
}
return $this->uri;
}
@ -174,6 +195,10 @@ class CalendarInstance
public function getCalendarColor(): ?string
{
if (is_resource($this->calendarColor)) {
$this->calendarColor = stream_get_contents($this->calendarColor);
}
return $this->calendarColor;
}
@ -210,6 +235,10 @@ class CalendarInstance
public function getShareHref(): ?string
{
if (is_resource($this->shareHref)) {
$this->shareHref = stream_get_contents($this->shareHref);
}
return $this->shareHref;
}

View File

@ -87,6 +87,10 @@ class CalendarObject
public function getUri(): ?string
{
if (is_resource($this->uri)) {
$this->uri = stream_get_contents($this->uri);
}
return $this->uri;
}
@ -123,6 +127,10 @@ class CalendarObject
public function getEtag(): ?string
{
if (is_resource($this->etag)) {
$this->etag = stream_get_contents($this->etag);
}
return $this->etag;
}
@ -147,6 +155,10 @@ class CalendarObject
public function getComponentType(): ?string
{
if (is_resource($this->componentType)) {
$this->componentType = stream_get_contents($this->componentType);
}
return $this->componentType;
}
@ -183,6 +195,10 @@ class CalendarObject
public function getUid(): ?string
{
if (is_resource($this->uid)) {
$this->uid = stream_get_contents($this->uid);
}
return $this->uid;
}

View File

@ -79,6 +79,10 @@ class CalendarSubscription
public function getUri(): ?string
{
if (is_resource($this->uri)) {
$this->uri = stream_get_contents($this->uri);
}
return $this->uri;
}
@ -91,6 +95,10 @@ class CalendarSubscription
public function getPrincipalUri(): ?string
{
if (is_resource($this->principalUri)) {
$this->principalUri = stream_get_contents($this->principalUri);
}
return $this->principalUri;
}
@ -151,6 +159,10 @@ class CalendarSubscription
public function getCalendarColor(): ?string
{
if (is_resource($this->calendarColor)) {
$this->calendarColor = stream_get_contents($this->calendarColor);
}
return $this->calendarColor;
}

View File

@ -79,6 +79,10 @@ class Card
public function getUri(): ?string
{
if (is_resource($this->uri)) {
$this->uri = stream_get_contents($this->uri);
}
return $this->uri;
}
@ -103,6 +107,10 @@ class Card
public function getEtag(): ?string
{
if (is_resource($this->etag)) {
$this->etag = stream_get_contents($this->etag);
}
return $this->etag;
}

View File

@ -95,6 +95,10 @@ class Lock
public function getToken(): ?string
{
if (is_resource($this->token)) {
$this->token = stream_get_contents($this->token);
}
return $this->token;
}
@ -131,6 +135,10 @@ class Lock
public function getUri(): ?string
{
if (is_resource($this->uri)) {
$this->uri = stream_get_contents($this->uri);
}
return $this->uri;
}

View File

@ -20,12 +20,12 @@ class Principal
private $id;
/**
* @ORM\Column(type="string", length=255)
* @ORM\Column(type="binary", length=255)
*/
private $uri;
/**
* @ORM\Column(type="string", length=255, nullable=true)
* @ORM\Column(type="binary", length=255, nullable=true)
*/
private $email;
@ -41,6 +41,10 @@ class Principal
public function getUri(): ?string
{
if (is_resource($this->uri)) {
$this->uri = stream_get_contents($this->uri);
}
return $this->uri;
}
@ -53,11 +57,15 @@ class Principal
public function getUsername(): ?string
{
return str_replace(self::PREFIX, '', $this->uri);
return str_replace(self::PREFIX, '', $this->getUri());
}
public function getEmail(): ?string
{
if (is_resource($this->email)) {
$this->email = stream_get_contents($this->email);
}
return $this->email;
}

View File

@ -44,6 +44,10 @@ class PropertyStorage
public function getPath(): ?string
{
if (is_resource($this->path)) {
$this->path = stream_get_contents($this->path);
}
return $this->path;
}
@ -56,6 +60,10 @@ class PropertyStorage
public function getName(): ?string
{
if (is_resource($this->name)) {
$this->name = stream_get_contents($this->name);
}
return $this->name;
}

View File

@ -54,6 +54,10 @@ class SchedulingObject
public function getPrincipalUri(): ?string
{
if (is_resource($this->principalUri)) {
$this->principalUri = stream_get_contents($this->principalUri);
}
return $this->principalUri;
}
@ -78,6 +82,10 @@ class SchedulingObject
public function getUri(): ?string
{
if (is_resource($this->uri)) {
$this->uri = stream_get_contents($this->uri);
}
return $this->uri;
}
@ -102,6 +110,10 @@ class SchedulingObject
public function getEtag(): ?string
{
if (is_resource($this->etag)) {
$this->etag = stream_get_contents($this->etag);
}
return $this->etag;
}

View File

@ -18,12 +18,12 @@ class User
private $id;
/**
* @ORM\Column(type="string", length=255)
* @ORM\Column(type="binary", length=255)
*/
private $username;
/**
* @ORM\Column(name="digesta1", type="string", length=255)
* @ORM\Column(name="digesta1", type="binary", length=255)
*/
private $password;
@ -34,6 +34,10 @@ class User
public function getUsername(): ?string
{
if (is_resource($this->username)) {
$this->username = stream_get_contents($this->username);
}
return $this->username;
}
@ -46,6 +50,10 @@ class User
public function getPassword(): ?string
{
if (is_resource($this->password)) {
$this->password = stream_get_contents($this->password);
}
return $this->password;
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Form;
use App\Entity\AddressBook;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AddressBookType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('uri', TextType::class, ['disabled' => !$options['new'], 'help' => "Allowed characters are digits, lowercase letters and the dash symbol '-'."])
->add('displayName')
->add('description')
->add('save', SubmitType::class);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'new' => false,
'data_class' => AddressBook::class,
]);
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Form;
use App\Entity\CalendarInstance;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CalendarInstanceType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('uri', TextType::class, ['disabled' => !$options['new'], 'help' => "Allowed characters are digits, lowercase letters and the dash symbol '-'."])
->add('displayName')
->add('description')
->add('calendarColor')
->add('todos', CheckboxType::class, [
'mapped' => false,
'help' => "If checked, todos will be enabled on this calendar.",
])
->add('notes', CheckboxType::class, [
'mapped' => false,
'help' => "If checked, notes will be enabled on this calendar.",
])
->add('save', SubmitType::class);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'new' => false,
'data_class' => CalendarInstance::class,
]);
}
}

View File

@ -4,6 +4,11 @@ namespace App\Form;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@ -12,13 +17,28 @@ class UserType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('username')
->add('password');
->add('username', TextType::class, ['disabled' => !$options['new'], 'help' => "May be an email, but not forcibly."])
->add('displayName', TextType::class, [
'mapped' => false,
])
->add('email', EmailType::class, [
'mapped' => false,
])
->add('password', RepeatedType::class, [
'type' => PasswordType::class,
'invalid_message' => 'The password fields must match.',
'options' => ['attr' => ['class' => 'password-field']],
'required' => true,
'first_options' => ['label' => 'Password'],
'second_options' => ['label' => 'Repeat Password'],
])
->add('save', SubmitType::class);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'new' => false,
'data_class' => User::class,
]);
}

View File

@ -0,0 +1,14 @@
{% extends 'base.html.twig' %}
{% set menu = 'resources' %}
{% block body %}
{% if addressbook.id %}
<h1 class="display-4">Editing Address Book {{ addressbook.displayName }}</h1>
{% else %}
<h1 class="display-4">New Address Book <small class="text-muted">for {{ principal.displayName }}</small></h1>
{% endif %}
{{ form(form) }}
{% endblock %}

View File

@ -3,9 +3,10 @@
{% block body %}
<h1>Address books for {{ principal.displayName }}</h1>
<h1 class="display-4">Address books <small class="text-muted">for {{ principal.displayName }}</small></h1>
<a href="{{ path('users') }}">Users</a>
<a href="{{ path('addressbook_create', {username: username}) }}" class="btn btn-primary">New address book</a>
<ul>
{% for addressbook in addressbooks %}
<li>{{ addressbook.displayName }} {{ addressbook.description }}</li>

View File

@ -0,0 +1,14 @@
{% extends 'base.html.twig' %}
{% set menu = 'resources' %}
{% block body %}
{% if calendar.id %}
<h1 class="display-4">Editing calendar {{ calendar.displayName }} <small class="text-muted">for {{ principal.displayName }}</small></h1>
{% else %}
<h1 class="display-4">New calendar <small class="text-muted">for {{ principal.displayName }}</small></h1>
{% endif %}
{{ form(form) }}
{% endblock %}

View File

@ -3,9 +3,10 @@
{% block body %}
<h1>Calendars for {{ principal.displayName }}</h1>
<h1 class="display-4">Calendars <small class="text-muted">for {{ principal.displayName }}</small></h1>
<a href="{{ path('users') }}">Users</a>
<a href="{{ path('calendar_create', {username: username}) }}" class="btn btn-primary">New calendar</a>
<ul>
{% for calendar in calendars %}
<li>{{ calendar.displayName }} {{ calendar.description }}</li>

View File

@ -3,16 +3,53 @@
{% block body %}
<h1>Dashboard</h1>
<h1 class="display-4">Dashboard</h1>
<h3>Configured environment</h3>
Users : {{ users|length }}
Calendars : {{ calendars|length }}
Address books : {{ addressbooks|length }}
<ul class="list-group">
<li class="list-group-item list-group-item-secondary">Auth Realm : <code>{{ authRealm }}</code></li>
<li class="list-group-item list-group-item-secondary">Invite from address : <code>{{ invite_from_address|default('Not set') }}</code></li>
{% if calDAVEnabled %}
<li class="list-group-item d-flex justify-content-between align-items-center list-group-item-success">CalDAV
<span class="badge badge-success badge-pill">enabled</span></li>
{% else %}
<li class="list-group-item d-flex justify-content-between align-items-center list-group-item-danger">CalDAV
<span class="badge badge-danger badge-pill">enabled</span></li>
{% endif %}
Auth realm : {{ authRealm }}
Invite from address : {{ invite_from_address }}
Cal dav : {{ calDAVEnabled }}
Card dav : {{ cardDAVEnabled }}
{% if cardDAVEnabled %}
<li class="list-group-item d-flex justify-content-between align-items-center list-group-item-success">CardDAV
<span class="badge badge-success badge-pill">enabled</span></li>
{% else %}
<li class="list-group-item d-flex justify-content-between align-items-center list-group-item-danger">CardDAV
<span class="badge badge-danger badge-pill">disabled</span></li>
{% endif %}
</ul>
<h3>Objects</h3>
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center">
Users
<span class="badge badge-primary badge-pill">{{ users|length }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Calendars
<span class="badge badge-primary badge-pill">{{ calendars|length }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
↳ Events
<span class="badge badge-secondary badge-pill">{{ events|length }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Address books
<span class="badge badge-primary badge-pill">{{ addressbooks|length }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
↳ Contacts
<span class="badge badge-secondary badge-pill">{{ contacts|length }}</span>
</li>
</ul>
{% endblock %}

View File

@ -3,6 +3,12 @@
{% block body %}
{% if username %}
<h1 class="display-4">Editing user {{ username }}</h1>
{% else %}
<h1 class="display-4">New user</h1>
{% endif %}
{{ form(form) }}
{% endblock %}

View File

@ -3,7 +3,7 @@
{% block body %}
<h1>Users</h1>
<h1 class="display-4">Users</h1>
<a href="{{ path('user_create') }}" class="btn btn-primary">New user</a>