Skip to content

Commit 834b7e9

Browse files
committed
Add self-service password change option.
This can be enabled in two ways: - by adding roles to the list of roles that are allowed to change their password (e.g. `jury`) - by enabling password change for specific team categories
1 parent 3c5fddd commit 834b7e9

11 files changed

Lines changed: 251 additions & 0 deletions

File tree

etc/db-config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,12 @@
370370
default_value: false
371371
public: false
372372
description: Enable to skip the login page when using IP authentication.
373+
- name: password_change_roles
374+
type: array_val
375+
default_value:
376+
- admin
377+
public: true
378+
description: Roles that are allowed to change their password. Note that password change can also be enabled per team category.
373379
- category: External systems
374380
description: Miscellaneous configuration options.
375381
items:
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DoctrineMigrations;
6+
7+
use Doctrine\DBAL\Schema\Schema;
8+
use Doctrine\Migrations\AbstractMigration;
9+
10+
/**
11+
* Auto-generated Migration: Please modify to your needs!
12+
*/
13+
final class Version20260118153404 extends AbstractMigration
14+
{
15+
public function getDescription(): string
16+
{
17+
return 'add allow_password_change to team_category';
18+
}
19+
20+
public function up(Schema $schema): void
21+
{
22+
// this up() migration is auto-generated, please modify it to your needs
23+
$this->addSql('ALTER TABLE team_category ADD allow_password_change TINYINT(1) DEFAULT 0 NOT NULL COMMENT \'Are teams in this category allowed to change their own password?\'');
24+
}
25+
26+
public function down(Schema $schema): void
27+
{
28+
// this down() migration is auto-generated, please modify it to your needs
29+
$this->addSql('ALTER TABLE team_category DROP allow_password_change');
30+
}
31+
32+
public function isTransactional(): bool
33+
{
34+
return false;
35+
}
36+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace App\Controller;
4+
5+
use App\Form\Type\ChangePasswordType;
6+
use App\Service\ConfigurationService;
7+
use App\Service\DOMJudgeService;
8+
use App\Service\EventLogService;
9+
use Doctrine\ORM\EntityManagerInterface;
10+
use Symfony\Component\DependencyInjection\Attribute\Autowire;
11+
use Symfony\Component\HttpFoundation\Request;
12+
use Symfony\Component\HttpFoundation\Response;
13+
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
14+
use Symfony\Component\HttpKernel\KernelInterface;
15+
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
16+
use Symfony\Component\Routing\Attribute\Route;
17+
use Symfony\Component\Security\Http\Attribute\IsGranted;
18+
19+
#[Route(path: '/profile')]
20+
#[IsGranted('IS_AUTHENTICATED_FULLY')]
21+
class ProfileController extends BaseController
22+
{
23+
public function __construct(
24+
EntityManagerInterface $em,
25+
DOMJudgeService $dj,
26+
EventLogService $eventLogService,
27+
KernelInterface $kernel,
28+
) {
29+
parent::__construct($em, $eventLogService, $dj, $kernel);
30+
}
31+
32+
#[Route(path: '', name: 'profile_index')]
33+
public function changePasswordAction(Request $request, UserPasswordHasherInterface $passwordHasher): Response
34+
{
35+
$isJury = $this->isGranted('ROLE_JURY') || $this->isGranted('ROLE_ADMIN') || $this->isGranted('ROLE_BALLOON');
36+
$redirectRoute = $isJury ? 'jury_index' : 'team_index';
37+
38+
if (!$this->dj->canChangePassword()) {
39+
throw new AccessDeniedHttpException('You are not allowed to change your password.');
40+
}
41+
42+
$user = $this->dj->getUser();
43+
$form = $this->createForm(ChangePasswordType::class);
44+
$form->handleRequest($request);
45+
46+
if ($form->isSubmitted() && $form->isValid()) {
47+
$newPassword = $form->get('newPassword')->getData();
48+
49+
$user->setPassword($passwordHasher->hashPassword($user, $newPassword));
50+
$this->saveEntity($user, $user->getUserid(), false);
51+
$this->addFlash('success', 'Password changed successfully.');
52+
return $this->redirectToRoute($redirectRoute);
53+
}
54+
55+
return $this->render('profile/change_password.html.twig', [
56+
'form' => $form,
57+
'redirect_route' => $redirectRoute,
58+
]);
59+
}
60+
}

webapp/src/Entity/TeamCategory.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,13 @@ class TeamCategory extends BaseApiEntity implements
127127
#[Serializer\Groups([ARC::GROUP_NONSTRICT])]
128128
private bool $allow_self_registration = false;
129129

130+
#[ORM\Column(options: [
131+
'comment' => 'Are teams in this category allowed to change their own password?',
132+
'default' => 0,
133+
])]
134+
#[Serializer\Groups([ARC::GROUP_NONSTRICT])]
135+
private bool $allow_password_change = false;
136+
130137
#[ORM\Column(
131138
nullable: true,
132139
options: ['comment' => 'CSS class to apply to scoreboard rows (only for TYPE_CSS_CLASS)']
@@ -264,6 +271,17 @@ public function getAllowSelfRegistration(): bool
264271
return $this->allow_self_registration;
265272
}
266273

274+
public function setAllowPasswordChange(bool $allowPasswordChange): TeamCategory
275+
{
276+
$this->allow_password_change = $allowPasswordChange;
277+
return $this;
278+
}
279+
280+
public function getAllowPasswordChange(): bool
281+
{
282+
return $this->allow_password_change;
283+
}
284+
267285

268286
public function hasType(int $type): bool
269287
{
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace App\Form\Type;
4+
5+
use Symfony\Component\DependencyInjection\Attribute\Autowire;
6+
use Symfony\Component\Form\AbstractType;
7+
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
8+
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
9+
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
10+
use Symfony\Component\Form\FormBuilderInterface;
11+
use Symfony\Component\Security\Core\Validator\Constraints\UserPassword;
12+
use Symfony\Component\Validator\Constraints\Length;
13+
use Symfony\Component\Validator\Constraints\NotBlank;
14+
15+
class ChangePasswordType extends AbstractType
16+
{
17+
public function __construct(
18+
#[Autowire(param: 'min_password_length')]
19+
private readonly int $minimumPasswordLength
20+
) {
21+
}
22+
23+
public function buildForm(FormBuilderInterface $builder, array $options): void
24+
{
25+
$builder->add('currentPassword', PasswordType::class, [
26+
'label' => 'Current password',
27+
'mapped' => false,
28+
'constraints' => [
29+
new NotBlank(),
30+
new UserPassword(),
31+
],
32+
'attr' => [
33+
'autocomplete' => 'current-password',
34+
],
35+
]);
36+
$builder->add('newPassword', RepeatedType::class, [
37+
'type' => PasswordType::class,
38+
'invalid_message' => 'The password fields must match.',
39+
'mapped' => false,
40+
'first_options' => [
41+
'label' => 'New password',
42+
'help' => sprintf('Minimum length: %d characters', $this->minimumPasswordLength),
43+
'attr' => [
44+
'autocomplete' => 'new-password',
45+
'minlength' => $this->minimumPasswordLength,
46+
],
47+
],
48+
'second_options' => [
49+
'label' => 'Repeat new password',
50+
'attr' => [
51+
'autocomplete' => 'new-password',
52+
'minlength' => $this->minimumPasswordLength,
53+
],
54+
],
55+
'constraints' => [
56+
new NotBlank(),
57+
new Length([
58+
'min' => $this->minimumPasswordLength,
59+
]),
60+
],
61+
]);
62+
$builder->add('save', SubmitType::class, [
63+
'label' => 'Change password',
64+
]);
65+
}
66+
}

webapp/src/Form/Type/TeamCategoryType.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,15 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
8686
'data-off' => 'No',
8787
],
8888
]);
89+
$builder->add('allow_password_change', ChoiceType::class, [
90+
'label' => 'Allow password change',
91+
'expanded' => true,
92+
'choices' => [
93+
'Yes' => true,
94+
'No' => false,
95+
],
96+
'help' => 'Allow users in this category to change their own password.',
97+
]);
8998
$builder->add('save', SubmitType::class);
9099
}
91100

webapp/src/Service/DOMJudgeService.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,32 @@ public function getCookie(string $cookieName): bool|float|int|string|InputBag|nu
260260
return $this->requestStack->getCurrentRequest()->cookies->get($cookieName);
261261
}
262262

263+
public function canChangePassword(): bool
264+
{
265+
$user = $this->getUser();
266+
if (!$user) {
267+
return false;
268+
}
269+
270+
$roles = $user->getRoleList();
271+
$allowedRoles = $this->config->get('password_change_roles');
272+
foreach ($roles as $role) {
273+
if (in_array($role, $allowedRoles)) {
274+
return true;
275+
}
276+
}
277+
278+
if ($team = $user->getTeam()) {
279+
foreach ($team->getCategories() as $category) {
280+
if ($category->getAllowPasswordChange()) {
281+
return true;
282+
}
283+
}
284+
}
285+
286+
return false;
287+
}
288+
263289
public function setCookie(
264290
string $cookieName,
265291
string $value = '',

webapp/src/Twig/TwigGlobalsExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ public function getGlobals(): array
9797
'side-by-side' => ["name" => "Side-by-side"],
9898
'inline' => ["name" => "Inline"],
9999
],
100+
'can_change_password' => $this->dj->canChangePassword(),
100101
];
101102
}
102103
}

webapp/templates/jury/menu.html.twig

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,10 @@
154154
</a>
155155
</div>
156156

157+
{% if can_change_password %}
158+
<a class="dropdown-item" href="{{ path('profile_index') }}"><i class="fas fa-key fa-fw"></i> Change password</a>
159+
{% endif %}
160+
157161
<a class="dropdown-item" href="{{ path('logout') }}"><i class="fas fa-sign-out-alt fa-fw"></i>Logout </a>
158162
</div>
159163
</li>

webapp/templates/partials/menu_login_logout_button.html.twig

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
{% if is_granted('IS_AUTHENTICATED_FULLY') %}
2+
{% if can_change_password %}
3+
<a class="btn btn-info btn-sm me-2" href="{{ path('profile_index') }}">
4+
<i class="fas fa-key"></i> Change password
5+
</a>
6+
{% endif %}
27
<a class="btn btn-info btn-sm me-2" href="{{ path('logout') }}"
38
{% if confirmLogout is defined %}
49
onclick="return confirmLogout();"

0 commit comments

Comments
 (0)