Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions src/Controller/ProfileController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\User;
use App\Security\UserManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[IsGranted('IS_AUTHENTICATED_FULLY')]
final class ProfileController extends AbstractController
{
public function __construct(private readonly UserManager $userManager)
{
}

#[Route(path: '/profile', name: 'app_profile_show', methods: ['GET'])]
public function show(): Response
{
return $this->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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this live as public mehotd in UserManager? @tuj ?

{
$user = $this->getUser();
\assert($user instanceof User);

return $user;
}
}
22 changes: 22 additions & 0 deletions src/Security/UserManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
43 changes: 43 additions & 0 deletions templates/profile/edit.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{% extends 'base.html.twig' %}

{% block title %}{{ 'profile.edit.title'|trans({'%brand%': brand_name}) }}{% endblock %}

{% block body %}
<section class="max-w-md">
<twig:Eyebrow as="p" class="mb-4">{{ 'profile.edit.eyebrow'|trans }}</twig:Eyebrow>
<h1 class="mb-6 font-display text-[clamp(1.75rem,3vw,2.25rem)] font-medium leading-tight tracking-tight text-ink">
Comment thread
martinyde marked this conversation as resolved.
{{ 'profile.edit.heading'|trans }}
</h1>

{% if error %}
<div class="mb-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm text-text" role="alert">
Comment thread
martinyde marked this conversation as resolved.
{{ error|trans }}
</div>
{% endif %}

<form action="{{ path('app_profile_edit') }}" method="post" class="grid gap-4">
<twig:Form:Label for="inputName" text="{{ 'profile.edit.name_label'|trans }}">
<twig:Form:TextInput
id="inputName"
name="name"
type="text"
value="{{ submitted_name }}"
autocomplete="name"
required
autofocus
/>
</twig:Form:Label>

<input type="hidden" name="_token" value="{{ csrf_token('profile-edit') }}">

<div class="flex items-center gap-4">
<twig:Form:Button type="submit">
{{ 'profile.edit.submit'|trans }}
</twig:Form:Button>
<a class="text-sm text-text underline hover:text-primary" href="{{ path('app_profile_show') }}">
{{ 'profile.edit.cancel'|trans }}
</a>
</div>
</form>
</section>
{% endblock %}
35 changes: 35 additions & 0 deletions templates/profile/show.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{% extends 'base.html.twig' %}

{% block title %}{{ 'profile.show.title'|trans({'%brand%': brand_name}) }}{% endblock %}

{% block body %}
<section class="max-w-md">
<twig:Eyebrow as="p" class="mb-4">{{ 'profile.show.eyebrow'|trans }}</twig:Eyebrow>
<h1 class="mb-6 font-display text-[clamp(1.75rem,3vw,2.25rem)] font-medium leading-tight tracking-tight text-ink">
Comment thread
martinyde marked this conversation as resolved.
{{ 'profile.show.heading'|trans }}
</h1>

{% for flash in app.flashes('success') %}
<div class="mb-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm text-text" role="status">
Comment thread
martinyde marked this conversation as resolved.
{{ flash|trans }}
</div>
{% endfor %}

<dl class="grid gap-4 text-sm">
Comment thread
martinyde marked this conversation as resolved.
<div>
<dt class="font-medium text-ink">{{ 'profile.show.name_label'|trans }}</dt>
<dd class="text-text">{{ user.name }}</dd>
</div>
<div>
<dt class="font-medium text-ink">{{ 'profile.show.email_label'|trans }}</dt>
<dd class="text-text">{{ user.email }}</dd>
</div>
</dl>

<p class="mt-6">
<a class="text-primary underline hover:text-primary-hover" href="{{ path('app_profile_edit') }}">
{{ 'profile.show.edit_link'|trans }}
</a>
</p>
</section>
{% endblock %}
143 changes: 143 additions & 0 deletions tests/Integration/Controller/ProfileControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

declare(strict_types=1);

namespace App\Tests\Integration\Controller;

use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

/**
* End-to-end profile view + edit flow for an authenticated user.
*
* Relies on the baseline `UserFixtures` (alice + bob with display
* names "Alice" / "Bob", password `password`, `status = Approved`).
*/
final class ProfileControllerTest extends WebTestCase
Comment thread
martinyde marked this conversation as resolved.
{
private KernelBrowser $client;

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');

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();
}
}
21 changes: 21 additions & 0 deletions translations/messages.da.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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%"
Expand Down