Skip to content

Commit

Permalink
Merge pull request #5597 from christianbeeznest/ofaj-21546
Browse files Browse the repository at this point in the history
Internal: Make password edition a single independent page - refs BT#21546
  • Loading branch information
christianbeeznest authored Jun 20, 2024
2 parents 26f979c + fe02e1a commit 52fc9ab
Show file tree
Hide file tree
Showing 8 changed files with 367 additions and 0 deletions.
14 changes: 14 additions & 0 deletions assets/css/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,20 @@ form .field {
margin-right: 1rem;
}

.toggle-password {
position: absolute !important;
top: 65% !important;
transform: translateY(-50%);
right: 0.75rem;
display: flex;
align-items: center;
cursor: pointer;
}

.toggle-password i {
font-size: 24px;
}

#legacy_content {
.exercise-overview {
padding: 30px 10px 60px;
Expand Down
12 changes: 12 additions & 0 deletions assets/vue/components/social/UserProfileCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@
type="primary"
@click="editProfile"
/>
<BaseButton
v-if="isCurrentUser || securityStore.isAdmin"
:label="t('Change Password')"
class="mt-2"
icon="lock"
type="secondary"
@click="changePassword"
/>
</div>
</BaseCard>
</template>
Expand Down Expand Up @@ -140,6 +148,10 @@ const editProfile = () => {
window.location = "/account/edit"
}
const changePassword = () => {
window.location = "/account/change-password"
}
async function fetchUserProfile(userId) {
try {
const { data } = await axios.get(`/social-network/user-profile/${userId}`)
Expand Down
81 changes: 81 additions & 0 deletions src/CoreBundle/Controller/AccountController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,23 @@
namespace Chamilo\CoreBundle\Controller;

use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Form\ChangePasswordType;
use Chamilo\CoreBundle\Form\ProfileType;
use Chamilo\CoreBundle\Repository\Node\IllustrationRepository;
use Chamilo\CoreBundle\Repository\Node\UserRepository;
use Chamilo\CoreBundle\ServiceHelper\UserHelper;
use Chamilo\CoreBundle\Settings\SettingsManager;
use Chamilo\CoreBundle\Traits\ControllerTrait;
use Security;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

/**
* @author Julio Montoya <[email protected]>
Expand All @@ -29,6 +35,7 @@ class AccountController extends BaseController

public function __construct(
private readonly UserHelper $userHelper,
private readonly TranslatorInterface $translator
) {}

#[Route('/edit', name: 'chamilo_core_account_edit', methods: ['GET', 'POST'])]
Expand Down Expand Up @@ -69,4 +76,78 @@ public function edit(Request $request, UserRepository $userRepository, Illustrat
'user' => $user,
]);
}

#[Route('/change-password', name: 'chamilo_core_account_change_password', methods: ['GET', 'POST'])]
public function changePassword(Request $request, UserRepository $userRepository, CsrfTokenManagerInterface $csrfTokenManager): Response
{
$user = $this->getUser();

if (!\is_object($user) || !$user instanceof UserInterface) {
throw $this->createAccessDeniedException('This user does not have access to this section');
}

$form = $this->createForm(ChangePasswordType::class);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
$submittedToken = $request->request->get('_token');

if (!$csrfTokenManager->isTokenValid(new CsrfToken('change_password', $submittedToken))) {
$form->addError(new FormError($this->translator->trans('CSRF token is invalid. Please try again.')));
} else {
$currentPassword = $form->get('currentPassword')->getData();
$newPassword = $form->get('newPassword')->getData();
$confirmPassword = $form->get('confirmPassword')->getData();

if (!$userRepository->isPasswordValid($user, $currentPassword)) {
$form->get('currentPassword')->addError(new FormError($this->translator->trans('Current password is incorrect.')));
} elseif ($newPassword !== $confirmPassword) {
$form->get('confirmPassword')->addError(new FormError($this->translator->trans('Passwords do not match.')));
} else {
$errors = $this->validatePassword($newPassword);
if (count($errors) > 0) {
foreach ($errors as $error) {
$form->get('newPassword')->addError(new FormError($error));
}
} else {
$user->setPlainPassword($newPassword);
$userRepository->updateUser($user);
$this->addFlash('success', $this->translator->trans('Password changed successfully.'));
return $this->redirectToRoute('chamilo_core_account_home');
}
}
}
}

return $this->render('@ChamiloCore/Account/change_password.html.twig', [
'form' => $form->createView(),
]);
}

/**
* Validate the password against the same requirements as the client-side validation.
*/
private function validatePassword(string $password): array
{
$errors = [];
$minRequirements = Security::getPasswordRequirements()['min'];

if (strlen($password) < $minRequirements['length']) {
$errors[] = $this->translator->trans('Password must be at least %length% characters long.', ['%length%' => $minRequirements['length']]);
}
if ($minRequirements['lowercase'] > 0 && !preg_match('/[a-z]/', $password)) {
$errors[] = $this->translator->trans('Password must contain at least %count% lowercase characters.', ['%count%' => $minRequirements['lowercase']]);
}
if ($minRequirements['uppercase'] > 0 && !preg_match('/[A-Z]/', $password)) {
$errors[] = $this->translator->trans('Password must contain at least %count% uppercase characters.', ['%count%' => $minRequirements['uppercase']]);
}
if ($minRequirements['numeric'] > 0 && !preg_match('/[0-9]/', $password)) {
$errors[] = $this->translator->trans('Password must contain at least %count% numerical (0-9) characters.', ['%count%' => $minRequirements['numeric']]);
}
if ($minRequirements['specials'] > 0 && !preg_match('/[\W]/', $password)) {
$errors[] = $this->translator->trans('Password must contain at least %count% special characters.', ['%count%' => $minRequirements['specials']]);
}

return $errors;
}
}
41 changes: 41 additions & 0 deletions src/CoreBundle/Form/ChangePasswordType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

/* For licensing terms, see /license.txt */

namespace Chamilo\CoreBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class ChangePasswordType extends AbstractType

Check failure on line 14 in src/CoreBundle/Form/ChangePasswordType.php

View workflow job for this annotation

GitHub Actions / PHP 8.1 Test on ubuntu-latest

MissingTemplateParam

src/CoreBundle/Form/ChangePasswordType.php:14:7: MissingTemplateParam: Chamilo\CoreBundle\Form\ChangePasswordType has missing template params when extending Symfony\Component\Form\AbstractType, expecting 1 (see https://psalm.dev/182)
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('currentPassword', PasswordType::class, [
'label' => 'Current Password',
'required' => true,
])
->add('newPassword', PasswordType::class, [
'label' => 'New Password',
'required' => true,
])
->add('confirmPassword', PasswordType::class, [
'label' => 'Confirm New Password',
'required' => true,
]);
}

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'change_password',
]);
}
}
5 changes: 5 additions & 0 deletions src/CoreBundle/Repository/Node/UserRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ public function updatePassword(User $user): void
}
}

public function isPasswordValid(User $user, string $plainPassword): bool
{
return $this->hasher->isPasswordValid($user, $plainPassword);
}

public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
/** @var User $user */
Expand Down
3 changes: 3 additions & 0 deletions src/CoreBundle/Resources/config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ services:
public: true
autoconfigure: true

csrf.token_manager:
class: Symfony\Component\Security\Csrf\CsrfTokenManager

chamilo_core.translation.loader.po:
class: Symfony\Component\Translation\Loader\PoFileLoader
tags:
Expand Down
93 changes: 93 additions & 0 deletions src/CoreBundle/Resources/views/Account/change_password.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
{% extends "@ChamiloCore/Layout/layout_one_col.html.twig" %}

{% block content %}
<section id="change-password" class="py-8">
<div class="mx-auto w-full">
<h2 class="text-2xl font-semibold text-center mb-6">{{ "Change Password"|trans }}</h2>

{{ form_start(form, {'attr': {'class': 'bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4'}}) }}

{% for message in app.flashes('success') %}
<div class="alert alert-success">
{{ message }}
</div>
{% endfor %}

{% if form.vars.errors|length > 0 %}
<div class="alert alert-danger" id="server-errors">
{{ form_errors(form) }}
</div>
{% endif %}

<div class="mb-4 relative">
{{ form_label(form.currentPassword) }}
{{ form_widget(form.currentPassword, {'attr': {'class': 'shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline', 'id': 'change_password_currentPassword'}}) }}
<span class="toggle-password absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer" data-target="#change_password_currentPassword">
<i class="mdi mdi-eye-outline text-gray-700"></i>
</span>
{{ form_errors(form.currentPassword) }}
</div>

<div class="mb-4 relative">
{{ form_label(form.newPassword) }}
{{ form_widget(form.newPassword, {'attr': {'class': 'shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline', 'id': 'change_password_newPassword'}}) }}
<span class="toggle-password absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer" data-target="#change_password_newPassword">
<i class="mdi mdi-eye-outline text-gray-700"></i>
</span>
<ul id="password-requirements" class="text-sm text-red-500 mt-2" style="display: none;"></ul>
<div id="new-password-errors">
{{ form_errors(form.newPassword) }}
</div>
</div>

<div class="mb-4 relative">
{{ form_label(form.confirmPassword) }}
{{ form_widget(form.confirmPassword, {'attr': {'class': 'shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline', 'id': 'change_password_confirmPassword'}}) }}
<span class="toggle-password absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer" data-target="#change_password_confirmPassword">
<i class="mdi mdi-eye-outline text-gray-700"></i>
</span>
{{ form_errors(form.confirmPassword) }}
</div>

<div class="flex items-center justify-center">
<input type="hidden" name="_token" value="{{ csrf_token('change_password') }}">
<button type="submit" class="btn btn--primary mt-4">{{ "Change Password"|trans }}</button>
</div>

{{ form_end(form) }}
</div>
</section>

{{ password_checker_js('#change_password_newPassword') }}

<script>
document.addEventListener('DOMContentLoaded', function() {
const togglePasswordButtons = document.querySelectorAll('.toggle-password');
togglePasswordButtons.forEach(button => {
button.addEventListener('click', function() {
const input = document.querySelector(this.getAttribute('data-target'));
if (input) {
const type = input.getAttribute('type') === 'password' ? 'text' : 'password';
input.setAttribute('type', type);
this.querySelector('i').classList.toggle('mdi-eye-outline');
this.querySelector('i').classList.toggle('mdi-eye-off-outline');
}
});
});
const newPasswordInput = document.querySelector('#change_password_newPassword');
const newPasswordErrors = document.querySelector('#new-password-errors');
const serverErrors = document.querySelector('#server-errors');
newPasswordInput.addEventListener('input', function() {
if (serverErrors) {
serverErrors.style.display = 'none';
}
if (newPasswordErrors) {
newPasswordErrors.style.display = 'none';
}
});
});
</script>
{% endblock %}
Loading

0 comments on commit 52fc9ab

Please sign in to comment.