Compare commits

...

6 Commits

12 changed files with 150 additions and 33 deletions

View File

@@ -11,7 +11,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'config:set',
description: 'Add a short description for your command',
description: '[deprecated] This command currently serves no use. It may be re-purposed or removed in the future.',
)]
class ConfigSetCommand extends Command
{

View File

@@ -15,7 +15,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'db:seed',
description: 'Seed the database with required data.',
description: 'Seeds the database with required data. This command is run every time a new container is created from the torsearch-app image and is part of the init process.',
)]
class SeedDatabaseCommand extends Command
{

View File

@@ -11,7 +11,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'startup:status',
description: 'Add a short description for your command',
description: 'Used by the Docker healthcheck system to signal when the container is healthy.',
)]
class StartupStatusCommand extends Command
{

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Base\Framework\Command;
use App\User\Framework\Repository\UserRepository;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[AsCommand(name: 'user:reset-password', description: 'Resets the password for the given user. Requires either the ID or email of the User. You will be asked for the password after running the command.')]
class UserResetPasswordCommand extends Command
{
private readonly Security $security;
private readonly UserRepository $userRepository;
private readonly UserPasswordHasherInterface $hasher;
public function __construct(
Security $security,
UserRepository $userRepository,
UserPasswordHasherInterface $hasher,
) {
parent::__construct();
$this->security = $security;
$this->userRepository = $userRepository;
$this->hasher = $hasher;
}
protected function configure(): void
{
$this
->addOption('id', null, InputOption::VALUE_REQUIRED, 'The ID of the user in the database.')
->addOption('email', null, InputOption::VALUE_REQUIRED, 'The email of the user.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$queryParams = $this->parseInput($input, $io);
if ([] === $queryParams) {
$io->error('No ID or Email specified. Please run again and pass the "--id" or "--email" option.');
return Command::FAILURE;
}
$user = $this->userRepository->findOneBy($queryParams);
if (null === $user) {
$io->error('No such user exists.');
return Command::FAILURE;
}
try {
$newPassword = $this->askForPassword($input, $output);
$this->updateUsersPassword($user, $newPassword);
} catch (\Throwable $exception) {
$io->error($exception->getMessage());
return Command::FAILURE;
}
$io->success('Success. The password has been reset.');
return Command::SUCCESS;
}
private function parseInput(InputInterface $input, SymfonyStyle $io): array
{
if ($input->getOption('id')) {
return ['id' => $input->getOption('id')];
} elseif ($input->getOption('email')) {
return ['email' => $input->getOption('email')];
}
return [];
}
private function askForPassword(InputInterface $input, OutputInterface $output): ?string
{
$questionHelper = new QuestionHelper();
$question = new Question('New password (input is hidden): ')
->setHidden(true)
->setHiddenFallback(false)
->setNormalizer(function (?string $value): string {
return $value ?? '';
})
->setValidator(function (string $value): string {
if ('' === trim($value)) {
throw new \Exception('The password cannot be empty');
}
return $value;
})
->setMaxAttempts(5)
;
return $questionHelper->ask($input, $output, $question);
}
private function updateUsersPassword(UserInterface $user, string $newPassword): void
{
$user->setPassword(
$this->hasher->hashPassword($user, $newPassword)
);
$this->userRepository->getEntityManager()->flush();
}
}

View File

@@ -6,16 +6,16 @@ use App\User\Framework\Entity\User;
use App\User\Framework\Form\ChangePasswordForm;
use App\User\Framework\Form\ResetPasswordRequestForm;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface;
use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
@@ -28,7 +28,8 @@ class ResetPasswordController extends AbstractController
public function __construct(
private ResetPasswordHelperInterface $resetPasswordHelper,
private EntityManagerInterface $entityManager
private EntityManagerInterface $entityManager,
private readonly Security $security
) {
}
@@ -36,17 +37,18 @@ class ResetPasswordController extends AbstractController
* Display & process form to request a password reset.
*/
#[Route('', name: 'app_forgot_password_request')]
public function request(Request $request, MailerInterface $mailer, TranslatorInterface $translator): Response
{
public function request(
Request $request,
MailerInterface $mailer,
LoggerInterface $logger
): Response {
$form = $this->createForm(ResetPasswordRequestForm::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var string $email */
$email = $form->get('email')->getData();
return $this->processSendingPasswordResetEmail($email, $mailer, $translator
);
return $this->processSendingPasswordResetEmail($email, $mailer, $logger);
}
return $this->render('user/reset_password/request.html.twig', [
@@ -75,8 +77,12 @@ class ResetPasswordController extends AbstractController
* Validates and process the reset URL that the user clicked in their email.
*/
#[Route('/reset/{token}', name: 'app_reset_password')]
public function reset(Request $request, UserPasswordHasherInterface $passwordHasher, TranslatorInterface $translator, ?string $token = null): Response
{
public function reset(
Request $request,
UserPasswordHasherInterface $passwordHasher,
TranslatorInterface $translator,
?string $token = null
): Response {
if ($token) {
// We store the token in session and remove it from the URL, to avoid the URL being
// loaded in a browser and potentially leaking the token to 3rd party JavaScript.
@@ -130,8 +136,11 @@ class ResetPasswordController extends AbstractController
]);
}
private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer, TranslatorInterface $translator): RedirectResponse
{
private function processSendingPasswordResetEmail(
string $emailFormData,
MailerInterface $mailer,
LoggerInterface $logger
): RedirectResponse {
$user = $this->entityManager->getRepository(User::class)->findOneBy([
'email' => $emailFormData,
]);
@@ -144,21 +153,17 @@ class ResetPasswordController extends AbstractController
try {
$resetToken = $this->resetPasswordHelper->generateResetToken($user);
} catch (ResetPasswordExceptionInterface $e) {
// If you want to tell the user why a reset email was not sent, uncomment
// the lines below and change the redirect to 'app_forgot_password_request'.
// Caution: This may reveal if a user is registered or not.
//
// $this->addFlash('reset_password_error', sprintf(
// '%s - %s',
// $translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_HANDLE, [], 'ResetPasswordBundle'),
// $translator->trans($e->getReason(), [], 'ResetPasswordBundle')
// ));
$logger->error('> [ResetPasswordController@processSendingPasswordResetEmail] ' . $e->getMessage());
$this->addFlash(
'reset_password_error',
'Your password reset token could not be generated. If you\'re the system administrator, check the server logs for more details.'
);
return $this->redirectToRoute('app_check_email');
}
$email = (new TemplatedEmail())
->from(new Address('notify@caldwell.digital', 'Torsearch'))
->to((string) $user->getEmail())
->subject('Your password reset request')
->htmlTemplate('user/reset_password/email.html.twig')

View File

@@ -15,7 +15,7 @@
</head>
<body class="bg-cyan-950 flex flex-col h-full">
<h1 class="px-4 py-4 text-3xl font-extrabold text-orange-500">Torsearch</h1>
<div class="flex flex-col justify-center items-center">
<div class="p-4 flex flex-col justify-center items-center">
{% block body %}{% endblock %}
<div class="mt-2 inline-flex gap-4 justify-between text-white">
<a class="text-sm" href="{{ path('app_login') }}">Sign In</a>

View File

@@ -9,6 +9,6 @@
mediaType: mediaType,
imdbId: imdbId
}) }}">
<h3 class="text-center text-white text-xl md:text-base md:max-w-[16ch]">{{ title }}</h3>
<h3 class="text-center text-white md:text-xl md:text-base md:max-w-[16ch]">{{ title }}</h3>
</a>
</div>

View File

@@ -20,7 +20,7 @@
</twig:Card>
</div>
<div class="flex flex-col gap-4">
<twig:Card title="Popular Movies" contentClass="flex flex-col gap-4 md:flex-row md:justify-between w-full">
<twig:Card title="Popular Movies" contentClass="grid grid-cols-2 gap-4 md:flex md:flex-row md:justify-between w-full">
{% for movie in popular_movies %}
<twig:Poster imdbId="{{ movie.imdbId }}"
tmdbId="{{ movie.tmdbId }}"
@@ -32,7 +32,7 @@
/>
{% endfor %}
</twig:Card>
<twig:Card title="Popular TV Shows" contentClass="flex flex-col md:flex-row justify-between w-full">
<twig:Card title="Popular TV Shows" contentClass="grid grid-cols-2 gap-4 md:flex flex-col md:flex-row justify-between w-full">
{% for movie in popular_tvshows %}
<twig:Poster imdbId="{{ movie.imdbId }}"
tmdbId="{{ movie.tmdbId }}"

View File

@@ -3,7 +3,7 @@
{% block title %}Log in &mdash; Torsearch{% endblock %}
{% block body %}
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 min-w-96 border-orange-500 border-2 text-gray-50">
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50">
<h2 class="text-xl font-bold">Login</h2>
<form method="post" class="flex flex-col gap-2">
{% if error %}

View File

@@ -3,7 +3,7 @@
{% block title %}Password Reset Email Sent{% endblock %}
{% block body %}
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 max-w-[420px] border-orange-500 border-2 text-gray-50">
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50">
<h2 class="text-xl font-bold">Head over to your email</h2>
<div class="mb-3 flex flex-col gap-4">

View File

@@ -3,7 +3,7 @@
{% block title %}Reset your password &mdash; Torsearch{% endblock %}
{% block body %}
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 max-w-[420px] border-orange-500 border-2 text-gray-50">
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50">
<h2 class="text-xl font-bold">Reset your password</h2>
<div class="mb-3">

View File

@@ -3,7 +3,7 @@
{% block title %}Reset your password &mdash; Torsearch{% endblock %}
{% block body %}
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 min-w-96 border-orange-500 border-2 text-gray-50">
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50">
<h2 class="text-xl font-bold text-white">Reset your password</h2>
<div class="mb-2">