Skip to content

Commit 2e7817c

Browse files
authored
Add some tooling for admins to manage user emails and investigate (#1665)
1 parent 57da563 commit 2e7817c

File tree

4 files changed

+156
-1
lines changed

4 files changed

+156
-1
lines changed

src/Controller/ProfileController.php

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,15 @@
1717
use App\Entity\Job;
1818
use App\Entity\Package;
1919
use App\Entity\User;
20+
use App\Form\Model\AdminUpdateEmailRequest;
21+
use App\Form\Type\AdminUpdateEmailType;
2022
use App\Form\Type\ProfileFormType;
2123
use App\Model\DownloadManager;
2224
use App\Model\FavoriteManager;
2325
use App\Security\UserNotifier;
2426
use Pagerfanta\Doctrine\ORM\QueryAdapter;
2527
use Pagerfanta\Pagerfanta;
28+
use Psr\Log\LoggerInterface;
2629
use Symfony\Component\HttpFoundation\Request;
2730
use Symfony\Component\HttpFoundation\Response;
2831
use Symfony\Component\Routing\Attribute\Route;
@@ -57,12 +60,62 @@ public function myProfile(Request $req, FavoriteManager $favMgr, DownloadManager
5760
}
5861

5962
#[Route(path: '/users/{name}/', name: 'user_profile')]
60-
public function publicProfile(Request $req, #[VarName('name')] User $user, FavoriteManager $favMgr, DownloadManager $dlMgr, #[CurrentUser] ?User $loggedUser = null): Response
63+
public function publicProfile(Request $req, #[VarName('name')] User $user, FavoriteManager $favMgr, DownloadManager $dlMgr, UserNotifier $userNotifier, LoggerInterface $logger, #[CurrentUser] ?User $loggedUser = null): Response
6164
{
6265
if ($req->attributes->getString('name') !== $user->getUsername()) {
6366
return $this->redirectToRoute('user_profile', ['name' => $user->getUsername()]);
6467
}
6568

69+
// Admin email update form
70+
$adminEmailForm = null;
71+
if ($this->isGranted('ROLE_ADMIN')) {
72+
assert($loggedUser !== null);
73+
$adminUpdateEmailRequest = new AdminUpdateEmailRequest($user->getEmail());
74+
$adminEmailForm = $this->createForm(AdminUpdateEmailType::class, $adminUpdateEmailRequest);
75+
$adminEmailForm->handleRequest($req);
76+
77+
if ($adminEmailForm->isSubmitted() && $adminEmailForm->isValid()) {
78+
$newEmail = $adminUpdateEmailRequest->email;
79+
$oldEmail = $user->getEmail();
80+
81+
if ($newEmail === $oldEmail) {
82+
$this->addFlash('warning', 'Email address unchanged.');
83+
} else {
84+
// Check if email is already in use
85+
$existingUser = $this->getEM()->getRepository(User::class)->findOneBy(['emailCanonical' => strtolower($newEmail)]);
86+
if ($existingUser && $existingUser->getId() !== $user->getId()) {
87+
$this->addFlash('error', 'Email address is already in use by another account.');
88+
} else {
89+
try {
90+
$user->setEmail($newEmail);
91+
92+
// Create audit record
93+
$auditRecord = AuditRecord::emailChanged($user, $loggedUser, $oldEmail);
94+
$this->getEM()->persist($auditRecord);
95+
$this->getEM()->persist($user);
96+
$this->getEM()->flush();
97+
98+
// Send notifications
99+
$reason = 'Your email has been changed by an administrator from ' . $oldEmail . ' to ' . $newEmail;
100+
$userNotifier->notifyChange($oldEmail, $reason);
101+
$userNotifier->notifyChange($newEmail, $reason);
102+
103+
$this->addFlash('success', 'Email address updated successfully.');
104+
return $this->redirectToRoute('user_profile', ['name' => $user->getUsername()]);
105+
} catch (\Exception $e) {
106+
$logger->error('Failed to update user email', [
107+
'user_id' => $user->getId(),
108+
'old_email' => $oldEmail,
109+
'new_email' => $newEmail,
110+
'error' => $e->getMessage(),
111+
]);
112+
$this->addFlash('error', 'Failed to update email address. Please try again.');
113+
}
114+
}
115+
}
116+
}
117+
}
118+
66119
$packages = $this->getUserPackages($req, $user);
67120

68121
$data = [
@@ -77,6 +130,9 @@ public function publicProfile(Request $req, #[VarName('name')] User $user, Favor
77130
if (!\count($packages) && ($this->isGranted('ROLE_ADMIN') || $loggedUser?->getId() === $user->getId())) {
78131
$data['deleteForm'] = $this->createFormBuilder([])->getForm()->createView();
79132
}
133+
if ($adminEmailForm !== null) {
134+
$data['adminEmailForm'] = $adminEmailForm->createView();
135+
}
80136

81137
return $this->render(
82138
'user/public_profile.html.twig',
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php declare(strict_types=1);
2+
3+
/*
4+
* This file is part of Packagist.
5+
*
6+
* (c) Jordi Boggiano <j.boggiano@seld.be>
7+
* Nils Adermann <naderman@naderman.de>
8+
*
9+
* For the full copyright and license information, please view the LICENSE
10+
* file that was distributed with this source code.
11+
*/
12+
13+
namespace App\Form\Model;
14+
15+
use Symfony\Component\Validator\Constraints as Assert;
16+
17+
class AdminUpdateEmailRequest
18+
{
19+
public function __construct(
20+
#[Assert\NotBlank]
21+
#[Assert\Email]
22+
public string $email,
23+
) {}
24+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php declare(strict_types=1);
2+
3+
/*
4+
* This file is part of Packagist.
5+
*
6+
* (c) Jordi Boggiano <j.boggiano@seld.be>
7+
* Nils Adermann <naderman@naderman.de>
8+
*
9+
* For the full copyright and license information, please view the LICENSE
10+
* file that was distributed with this source code.
11+
*/
12+
13+
namespace App\Form\Type;
14+
15+
use App\Form\Model\AdminUpdateEmailRequest;
16+
use Symfony\Component\Form\AbstractType;
17+
use Symfony\Component\Form\Extension\Core\Type\EmailType;
18+
use Symfony\Component\Form\FormBuilderInterface;
19+
use Symfony\Component\OptionsResolver\OptionsResolver;
20+
21+
/**
22+
* @extends AbstractType<AdminUpdateEmailRequest>
23+
*/
24+
class AdminUpdateEmailType extends AbstractType
25+
{
26+
public function buildForm(FormBuilderInterface $builder, array $options): void
27+
{
28+
$builder->add('email', EmailType::class);
29+
}
30+
31+
public function configureOptions(OptionsResolver $resolver): void
32+
{
33+
$resolver->setDefaults([
34+
'data_class' => AdminUpdateEmailRequest::class,
35+
'csrf_token_id' => 'admin_update_email',
36+
]);
37+
}
38+
39+
public function getBlockPrefix(): string
40+
{
41+
return 'admin_update_email_form';
42+
}
43+
}

templates/user/public_profile.html.twig

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,38 @@
3535
</small>
3636
</h2>
3737

38+
{% if is_granted('ROLE_ADMIN') %}
39+
<div class="row" style="background-color: #f9f9f9; padding: 15px; border-radius: 4px; margin-bottom: 20px;">
40+
<div class="col-md-6">
41+
<h4>User Metadata</h4>
42+
<dl class="dl-horizontal">
43+
<dt>Created At:</dt>
44+
<dd>{{ user.createdAt|date('Y-m-d H:i:s') }}</dd>
45+
46+
<dt>Last Login:</dt>
47+
<dd>{{ user.lastLogin ? user.lastLogin|date('Y-m-d H:i:s') : 'Never' }}</dd>
48+
49+
<dt>Pwd Reset Request:</dt>
50+
<dd>{{ user.passwordRequestedAt ? user.passwordRequestedAt|date('Y-m-d H:i:s') : 'N/A' }}</dd>
51+
52+
<dt>GitHub ID:</dt>
53+
<dd>{{ user.githubId ? user.githubId : 'Not linked' }}</dd>
54+
</dl>
55+
</div>
56+
57+
<div class="col-md-6">
58+
<h4>Update Email Address</h4>
59+
<p class="help-block">Current email: {{ user.email }}</p>
60+
{{ form_start(adminEmailForm) }}
61+
<div class="form-group">
62+
{{ form_widget(adminEmailForm.email, {'attr': {'class': 'form-control', 'placeholder': 'New email address'}}) }}
63+
</div>
64+
<button type="submit" class="btn btn-primary">Update Email</button>
65+
{{ form_end(adminEmailForm) }}
66+
</div>
67+
</div>
68+
{% endif %}
69+
3870
<section class="row">
3971
<section class="col-md-12">
4072
{% embed "web/list.html.twig" with {noLayout: 'true', showAutoUpdateWarning: isActualUser} %}

0 commit comments

Comments
 (0)