diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc670b..df9af52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Self-service profile pages at `/profile` (read-only) and + `/profile/edit` (form). Authenticated users can update their + display name; email stays read-only (changing it would interact + with the domain-derived authorisation from #84 — out of scope + here). `UserManager::updateName()` is the persistent home for the + mutation, CSRF-protected via Symfony's `csrf_token('profile-edit')` + helper. Localised flash + form errors via the existing `messages` + domain + ([#13](https://github.com/itk-dev/ai-lib/issues/13)). - `User.name` (display name) and `User.status` (lifecycle enum: `pending | approved | blocked`) per ADR 006, plus the `App\Enum\UserStatus` PHP enum. `UserManager::createUser()` now diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php new file mode 100644 index 0000000..7ac1a64 --- /dev/null +++ b/src/Controller/ProfileController.php @@ -0,0 +1,75 @@ +render('profile/show.html.twig', [ + 'user' => $this->currentUser(), + ]); + } + + #[Route(path: '/profile/edit', name: 'app_profile_edit', methods: ['GET', 'POST'])] + public function edit(Request $request): Response + { + $user = $this->currentUser(); + + if ('POST' !== $request->getMethod()) { + return $this->render('profile/edit.html.twig', [ + 'user' => $user, + 'submitted_name' => $user->getName(), + 'error' => null, + ]); + } + + if (!$this->isCsrfTokenValid('profile-edit', (string) $request->request->get('_token'))) { + return $this->render('profile/edit.html.twig', [ + 'user' => $user, + 'submitted_name' => $user->getName(), + 'error' => 'profile.edit.error.invalid_token', + ], new Response('', Response::HTTP_FORBIDDEN)); + } + + $submitted = (string) $request->request->get('name', ''); + + try { + $this->userManager->updateName($user, $submitted); + } catch (\InvalidArgumentException) { + return $this->render('profile/edit.html.twig', [ + 'user' => $user, + 'submitted_name' => $submitted, + 'error' => 'profile.edit.error.empty_name', + ], new Response('', Response::HTTP_UNPROCESSABLE_ENTITY)); + } + + $this->addFlash('success', 'profile.edit.flash.success'); + + return $this->redirectToRoute('app_profile_show'); + } + + private function currentUser(): User + { + $user = $this->getUser(); + \assert($user instanceof User); + + return $user; + } +} diff --git a/src/Security/UserManager.php b/src/Security/UserManager.php index 97ef954..60639ed 100644 --- a/src/Security/UserManager.php +++ b/src/Security/UserManager.php @@ -78,6 +78,28 @@ public function createUser( return $user; } + /** + * Update a user's display name in place. + * + * Empty names are rejected so the admin user list and the + * profile UI never have to render a blank cell. + * + * @param User $user the user whose name to update + * @param string $name the new display name; must be non-empty after trimming + * + * @throws \InvalidArgumentException when `$name` is empty after trimming + */ + public function updateName(User $user, string $name): void + { + $trimmed = trim($name); + if ('' === $trimmed) { + throw new \InvalidArgumentException('Name must not be empty.'); + } + + $user->setName($trimmed); + $this->entityManager->flush(); + } + /** * Replace a user's password with a freshly hashed copy. * diff --git a/templates/profile/edit.html.twig b/templates/profile/edit.html.twig new file mode 100644 index 0000000..7fbdbe0 --- /dev/null +++ b/templates/profile/edit.html.twig @@ -0,0 +1,43 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'profile.edit.title'|trans({'%brand%': brand_name}) }}{% endblock %} + +{% block body %} + + {{ 'profile.edit.eyebrow'|trans }} + + {{ 'profile.edit.heading'|trans }} + + + {% if error %} + + {{ error|trans }} + + {% endif %} + + + + + + + + + + + {{ 'profile.edit.submit'|trans }} + + + {{ 'profile.edit.cancel'|trans }} + + + + +{% endblock %} diff --git a/templates/profile/show.html.twig b/templates/profile/show.html.twig new file mode 100644 index 0000000..c5d4be6 --- /dev/null +++ b/templates/profile/show.html.twig @@ -0,0 +1,35 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'profile.show.title'|trans({'%brand%': brand_name}) }}{% endblock %} + +{% block body %} + + {{ 'profile.show.eyebrow'|trans }} + + {{ 'profile.show.heading'|trans }} + + + {% for flash in app.flashes('success') %} + + {{ flash|trans }} + + {% endfor %} + + + + {{ 'profile.show.name_label'|trans }} + {{ user.name }} + + + {{ 'profile.show.email_label'|trans }} + {{ user.email }} + + + + + + {{ 'profile.show.edit_link'|trans }} + + + +{% endblock %} diff --git a/tests/Integration/Controller/ProfileControllerTest.php b/tests/Integration/Controller/ProfileControllerTest.php new file mode 100644 index 0000000..f32274e --- /dev/null +++ b/tests/Integration/Controller/ProfileControllerTest.php @@ -0,0 +1,143 @@ +client = self::createClient(); + } + + // Tests that an anonymous visitor is redirected to /login when hitting /profile. + public function testProfilePageRedirectsAnonymousToLogin(): void + { + $this->client->request('GET', '/profile'); + + self::assertResponseRedirects(); + self::assertStringContainsString('/login', (string) $this->client->getResponse()->headers->get('Location')); + } + + // Tests that an anonymous visitor is redirected to /login when hitting /profile/edit. + public function testEditPageRedirectsAnonymousToLogin(): void + { + $this->client->request('GET', '/profile/edit'); + + self::assertResponseRedirects(); + self::assertStringContainsString('/login', (string) $this->client->getResponse()->headers->get('Location')); + } + + // Tests that the profile page renders the signed-in user's name and email. + public function testShowRendersTheCurrentUsersNameAndEmail(): void + { + $this->loginAsAlice(); + + $crawler = $this->client->request('GET', '/profile'); + + self::assertResponseIsSuccessful(); + $body = $crawler->filter('body')->text(); + self::assertStringContainsString('Alice', $body); + self::assertStringContainsString('alice@example.test', $body); + } + + // Ensures the edit form is pre-filled with the user's current name on GET. + public function testEditRendersFormPrefilledWithTheCurrentName(): void + { + $this->loginAsAlice(); + + $crawler = $this->client->request('GET', '/profile/edit'); + + self::assertResponseIsSuccessful(); + self::assertSame('Alice', $crawler->filter('input[name="name"]')->attr('value')); + } + + // Verifies a successful edit persists the new name and surfaces the success flash. + public function testEditPersistsTheNewNameAndShowsTheFlash(): void + { + $this->loginAsAlice(); + + $crawler = $this->client->request('GET', '/profile/edit'); + $form = $crawler->filter('form')->form(); + $form['name'] = 'Alice Andersen'; + $this->client->submit($form); + + self::assertResponseRedirects('/profile'); + $crawler = $this->client->followRedirect(); + self::assertResponseIsSuccessful(); + + $body = $crawler->filter('body')->text(); + self::assertStringContainsString('Alice Andersen', $body); + self::assertStringContainsString('gemt', $body); + + $reloaded = self::getContainer() + ->get(UserRepository::class) + ->findOneBy(['email' => 'alice@example.test']); + self::assertNotNull($reloaded); + self::assertSame('Alice Andersen', $reloaded->getName()); + } + + // Ensures a whitespace-only name is rejected with 422 and the persisted name is unchanged. + public function testEditRejectsEmptyNameAndKeepsTheCurrentValue(): void + { + $this->loginAsAlice(); + + $crawler = $this->client->request('GET', '/profile/edit'); + $form = $crawler->filter('form')->form(); + // Bypass HTML5 `required` by clearing the input value programmatically. + $form->setValues(['name' => ' ']); + $crawler = $this->client->submit($form); + + self::assertResponseStatusCodeSame(422); + $body = $crawler->filter('body')->text(); + self::assertStringContainsString('Navnet må ikke være tomt', $body); + + $reloaded = self::getContainer() + ->get(UserRepository::class) + ->findOneBy(['email' => 'alice@example.test']); + self::assertNotNull($reloaded); + self::assertSame('Alice', $reloaded->getName(), 'Empty submit must not have mutated the persisted name.'); + } + + // Ensures an invalid CSRF token yields 403 and the persisted name is unchanged. + public function testEditRejectsInvalidCsrfTokenAndDoesNotUpdate(): void + { + $this->loginAsAlice(); + + $this->client->request('POST', '/profile/edit', [ + 'name' => 'Hacker', + '_token' => 'nope', + ]); + + self::assertResponseStatusCodeSame(403); + + $reloaded = self::getContainer() + ->get(UserRepository::class) + ->findOneBy(['email' => 'alice@example.test']); + self::assertNotNull($reloaded); + self::assertSame('Alice', $reloaded->getName(), 'CSRF rejection must not have mutated the persisted name.'); + } + + private function loginAsAlice(): void + { + $crawler = $this->client->request('GET', '/login'); + $form = $crawler->filter('form')->form(); + $form['_username'] = 'alice@example.test'; + $form['_password'] = 'password'; + $this->client->submit($form); + $this->client->followRedirect(); + } +} diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index f71b193..5267342 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -42,6 +42,27 @@ security: password_label: "Adgangskode" submit: "Log ind" +profile: + show: + title: "Min profil – %brand%" + eyebrow: "Min profil" + heading: "Profil" + name_label: "Navn" + email_label: "E-mail" + edit_link: "Rediger profil" + edit: + title: "Rediger profil – %brand%" + eyebrow: "Min profil" + heading: "Rediger profil" + name_label: "Navn" + submit: "Gem" + cancel: "Annuller" + error: + empty_name: "Navnet må ikke være tomt." + invalid_token: "Sessionen er udløbet. Indsend formularen igen." + flash: + success: "Dine profiloplysninger er gemt." + assistant: detail: title: "%title% – %brand%"
+ + {{ 'profile.show.edit_link'|trans }} + +