From ff90e17232e82b7a06be14cf2101b69611324220 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Fri, 19 Jun 2026 09:55:45 +0200 Subject: [PATCH 1/3] feat(profile): self-service profile show + edit (name) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `App\Controller\ProfileController` exposing two authenticated routes: - `GET /profile` (`app_profile_show`) — read-only summary of the current user (name + email + a link to edit). - `GET|POST /profile/edit` (`app_profile_edit`) — edit form for the display name. Email stays read-only on purpose: it's load-bearing for the domain-derived authorisation in #84, and changing it silently would be a footgun. CSRF protection via Symfony's built-in `csrf_token('profile-edit')` helper. Empty / whitespace-only names are rejected with a 422 and a localised error; invalid CSRF tokens yield a 403. The mutation lives in a new `UserManager::updateName()` method — the controller stays thin per project conventions. Templates re-use the existing `Form/Label`, `Form/TextInput`, and `Form/Button` components and the `Eyebrow` layout primitive — no new component families introduced. Localised strings land in the existing `messages` translation domain. Sequenced as PR 6 of the User management milestone plan. Stacked on PR 1 (#86) since the show + edit flow reads / writes `User::$name`, which doesn't exist on `develop` yet — labelled `do-not-merge` until #86 lands. Closes #13. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 9 ++ src/Controller/ProfileController.php | 75 ++++++++++ src/Security/UserManager.php | 22 +++ templates/profile/edit.html.twig | 43 ++++++ templates/profile/show.html.twig | 35 +++++ .../Controller/ProfileControllerTest.php | 136 ++++++++++++++++++ translations/messages.da.yaml | 21 +++ 7 files changed, 341 insertions(+) create mode 100644 src/Controller/ProfileController.php create mode 100644 templates/profile/edit.html.twig create mode 100644 templates/profile/show.html.twig create mode 100644 tests/Integration/Controller/ProfileControllerTest.php 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..59183a5 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 (#85) and the + * profile UI (#13) 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 %} + + {% 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..8d36d8e --- /dev/null +++ b/tests/Integration/Controller/ProfileControllerTest.php @@ -0,0 +1,136 @@ +client = self::createClient(); + } + + public function testProfilePageRedirectsAnonymousToLogin(): void + { + $this->client->request('GET', '/profile'); + + self::assertResponseRedirects(); + self::assertStringContainsString('/login', (string) $this->client->getResponse()->headers->get('Location')); + } + + public function testEditPageRedirectsAnonymousToLogin(): void + { + $this->client->request('GET', '/profile/edit'); + + self::assertResponseRedirects(); + self::assertStringContainsString('/login', (string) $this->client->getResponse()->headers->get('Location')); + } + + 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); + } + + 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')); + } + + 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()); + } + + 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.'); + } + + 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%" From f0f738d3451147d8f736cc4ddbe16b7a5fd173f2 Mon Sep 17 00:00:00 2001 From: Martin Yde Granath Date: Fri, 19 Jun 2026 10:14:19 +0200 Subject: [PATCH 2/3] Update docblock for updateUserDisplayName method Removed issue references from docblock for clarity. --- src/Security/UserManager.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Security/UserManager.php b/src/Security/UserManager.php index 59183a5..60639ed 100644 --- a/src/Security/UserManager.php +++ b/src/Security/UserManager.php @@ -81,8 +81,8 @@ public function createUser( /** * Update a user's display name in place. * - * Empty names are rejected so the admin user list (#85) and the - * profile UI (#13) never have to render a blank cell. + * 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 From 9b37b234b5f1e20f3b46ca817e93aa148929ee88 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Fri, 19 Jun 2026 12:33:23 +0200 Subject: [PATCH 3/3] chore(tests): add one-line intent comments to profile controller tests Per review on PR #89 - apply the test-comment convention to the new ProfileControllerTest. Each `test...` method now opens with a single-line "Tests ...", "Ensures ...", or "Verifies ..." comment naming what it asserts. Pure documentation change - no test logic touched. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/Integration/Controller/ProfileControllerTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Integration/Controller/ProfileControllerTest.php b/tests/Integration/Controller/ProfileControllerTest.php index 8d36d8e..f32274e 100644 --- a/tests/Integration/Controller/ProfileControllerTest.php +++ b/tests/Integration/Controller/ProfileControllerTest.php @@ -23,6 +23,7 @@ protected function setUp(): void $this->client = self::createClient(); } + // Tests that an anonymous visitor is redirected to /login when hitting /profile. public function testProfilePageRedirectsAnonymousToLogin(): void { $this->client->request('GET', '/profile'); @@ -31,6 +32,7 @@ public function testProfilePageRedirectsAnonymousToLogin(): void 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'); @@ -39,6 +41,7 @@ public function testEditPageRedirectsAnonymousToLogin(): void 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(); @@ -51,6 +54,7 @@ public function testShowRendersTheCurrentUsersNameAndEmail(): void 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(); @@ -61,6 +65,7 @@ public function testEditRendersFormPrefilledWithTheCurrentName(): void 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(); @@ -85,6 +90,7 @@ public function testEditPersistsTheNewNameAndShowsTheFlash(): void 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(); @@ -106,6 +112,7 @@ public function testEditRejectsEmptyNameAndKeepsTheCurrentValue(): void 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();