From ed25eb04b2327d20b959c8c63285340512a8d216 Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Tue, 8 Sep 2026 15:06:19 +0300 Subject: [PATCH 1/3] Add user profile pages with configurable form, templates and password rules --- UPGRADE.md | 23 ++ docs/form-types/text-editor.md | 2 +- docs/frontend/layouts.md | 9 +- docs/index.md | 1 + docs/security/index.md | 2 + docs/security/profile.md | 236 ++++++++++++++++ platform-schema.json | 85 ++++++ platform.yaml | 11 + .../Config/Builder/PlatformConfigBuilder.php | 12 + .../Config/Builder/ProfileConfigBuilder.php | 125 +++++++++ .../Platform/Config/PlatformConfiguration.php | 72 +++++ .../Platform/Controller/BaseController.php | 19 ++ .../Controller/Profile/ChangePassword.php | 121 ++++++++ .../Controller/Profile/EditProfile.php | 89 ++++++ .../Controller/Profile/ShowProfile.php | 63 +++++ .../SolidWorxPlatformExtension.php | 28 ++ .../Platform/Enum/PasswordStrengthLevel.php | 83 ++++++ .../Form/Type/Profile/ChangePasswordType.php | 135 +++++++++ .../Form/Type/Profile/ProfileType.php | 128 +++++++++ .../Platform/Menu/ProfileMenuBuilder.php | 39 +++ src/Bundle/Platform/Menu/UserMenu.php | 18 +- src/Bundle/Platform/Model/User.php | 6 +- src/Bundle/Platform/Model/UserInterface.php | 10 + .../Platform/Resources/config/services.php | 3 + .../Resources/views/Form/theme.html.twig | 17 ++ .../views/Profile/change_password.html.twig | 87 ++++++ .../Resources/views/Profile/edit.html.twig | 68 +++++ .../Resources/views/Profile/show.html.twig | 203 ++++++++++++++ .../Security/Password/PasswordPolicy.php | 100 +++++++ .../Password/PasswordPolicyInterface.php | 46 +++ .../Layout/partials/_user_menu.html.twig | 9 +- .../Builder/PlatformConfigBuilderTest.php | 73 +++++ .../Config/PlatformConfigurationTest.php | 105 ++++++- .../ProfileServicesTest.php | 202 ++++++++++++++ .../Enum/PasswordStrengthLevelTest.php | 84 ++++++ .../Form/ConstraintsOptionExtension.php | 46 +++ .../PlatformBundle/Fixtures/ProfileUser.php | 25 ++ .../Type/Profile/ChangePasswordTypeTest.php | 174 ++++++++++++ .../Form/Type/Profile/ProfileTypeTest.php | 156 +++++++++++ .../Menu/ProfileMenuBuilderTest.php | 95 +++++++ .../Profile/ProfileRenderingTest.php | 264 ++++++++++++++++++ .../Profile/ProfileTestKernel.php | 252 +++++++++++++++++ .../Profile/fixtures/build/entrypoints.json | 8 + .../Security/Password/PasswordPolicyTest.php | 126 +++++++++ 44 files changed, 3442 insertions(+), 18 deletions(-) create mode 100644 docs/security/profile.md create mode 100644 src/Bundle/Platform/Config/Builder/ProfileConfigBuilder.php create mode 100644 src/Bundle/Platform/Controller/Profile/ChangePassword.php create mode 100644 src/Bundle/Platform/Controller/Profile/EditProfile.php create mode 100644 src/Bundle/Platform/Controller/Profile/ShowProfile.php create mode 100644 src/Bundle/Platform/Enum/PasswordStrengthLevel.php create mode 100644 src/Bundle/Platform/Form/Type/Profile/ChangePasswordType.php create mode 100644 src/Bundle/Platform/Form/Type/Profile/ProfileType.php create mode 100644 src/Bundle/Platform/Menu/ProfileMenuBuilder.php create mode 100644 src/Bundle/Platform/Resources/views/Profile/change_password.html.twig create mode 100644 src/Bundle/Platform/Resources/views/Profile/edit.html.twig create mode 100644 src/Bundle/Platform/Resources/views/Profile/show.html.twig create mode 100644 src/Bundle/Platform/Security/Password/PasswordPolicy.php create mode 100644 src/Bundle/Platform/Security/Password/PasswordPolicyInterface.php create mode 100644 tests/Bundle/PlatformBundle/DependencyInjection/ProfileServicesTest.php create mode 100644 tests/Bundle/PlatformBundle/Enum/PasswordStrengthLevelTest.php create mode 100644 tests/Bundle/PlatformBundle/Fixtures/Form/ConstraintsOptionExtension.php create mode 100644 tests/Bundle/PlatformBundle/Fixtures/ProfileUser.php create mode 100644 tests/Bundle/PlatformBundle/Form/Type/Profile/ChangePasswordTypeTest.php create mode 100644 tests/Bundle/PlatformBundle/Form/Type/Profile/ProfileTypeTest.php create mode 100644 tests/Bundle/PlatformBundle/Menu/ProfileMenuBuilderTest.php create mode 100644 tests/Bundle/PlatformBundle/Profile/ProfileRenderingTest.php create mode 100644 tests/Bundle/PlatformBundle/Profile/ProfileTestKernel.php create mode 100644 tests/Bundle/PlatformBundle/Profile/fixtures/build/entrypoints.json create mode 100644 tests/Bundle/PlatformBundle/Security/Password/PasswordPolicyTest.php diff --git a/UPGRADE.md b/UPGRADE.md index 70f9d36c..dbe7c5b3 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -2,6 +2,29 @@ ## 0.2 → 0.3 +### User profile pages + +Every signed-in user now gets `/profile`, `/profile/edit` and `/profile/password`, and a +**Profile** entry leading the user dropdown. See [the profile guide](./docs/security/profile.md). + +Nothing is required to upgrade, but three things changed shape: + +- **`SolidWorx\Platform\PlatformBundle\Model\UserInterface` gained `setPassword(string): static`.** + It is what lets the platform rotate a password on the user's behalf. Classes extending + `Model\User` already have it; a class implementing the interface directly has to add it. +- **`Model\User::setMobile()` now accepts `null`.** The column has always been nullable, and an + optional form field submits `null` when it is cleared. Widening a parameter type is + backwards-compatible for callers; an override in your own user class has to widen with it. +- **`@SolidWorxPlatform/Form/theme.html.twig` now `{% use %}`s `bootstrap_5_layout.html.twig`.** + The theme decorates blocks with `{{ parent() }}`, which Twig forbids in a template that neither + extends nor uses another — so the theme could not previously be registered at all. Register it + on its own now; it carries the Bootstrap layout with it, and there is no need to list + `bootstrap_5_layout.html.twig` alongside it. + +`platform.yaml` gained a `platform.profile` section (the form type, the three templates and the +password rules). Every key is optional; regenerate `platform-schema.json` with +`php bin/console platform:generate-schema` to pick them up in your editor. + ### Tabler page layouts The UI bundle now ships three layouts — `ui_layout_app` (sidebar + top navbar), diff --git a/docs/form-types/text-editor.md b/docs/form-types/text-editor.md index c4a609a8..909f3b62 100644 --- a/docs/form-types/text-editor.md +++ b/docs/form-types/text-editor.md @@ -41,7 +41,7 @@ editor markup is applied (without it, the field gracefully falls back to a plain # config/packages/twig.yaml twig: form_themes: - - '@Platform/Form/theme.html.twig' + - '@SolidWorxPlatform/Form/theme.html.twig' ``` ## Usage diff --git a/docs/frontend/layouts.md b/docs/frontend/layouts.md index f7246294..658d1908 100644 --- a/docs/frontend/layouts.md +++ b/docs/frontend/layouts.md @@ -196,7 +196,7 @@ use SolidWorx\Platform\PlatformBundle\Menu\UserMenu; #[MenuBuilder(name: UserMenu::NAME)] public function build(ItemInterface $menu): void { - $menu->addChild('Profile', Options::create()->route('app_profile')->icon('user')->build()); + $menu->addChild('Billing', Options::create()->route('app_billing')->icon('credit-card')->build()); $menu->addChild('API keys', Options::create() ->route('app_api_keys') @@ -216,10 +216,11 @@ sidebar and navbar: Two things are always there, whatever your builders do: -- **The platform's own account entries** — currently just **Two-factor authentication**, and only - when [2FA is enabled](../security/two-factor.md). They are registered with priority +- **The platform's own account entries** — [**Profile**](../security/profile.md), and + **Two-factor authentication** when [2FA is enabled](../security/two-factor.md). Profile leads + the dropdown at `UserMenu::PRIORITY_PROFILE` (`200`) and the account entries follow at `UserMenu::PRIORITY_ACCOUNT` (`100`), so entries you register at the default priority of `0` - land underneath them. Register above it to push your entries to the top. + land underneath them both. Register above them to push your entries to the top. - **Logout**, rendered last under a divider. It is a CSRF-protected form rather than a link, so it is not part of the menu; override the `user_menu_items` block if you need it somewhere else. diff --git a/docs/index.md b/docs/index.md index 44bd2595..10c996b4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,6 +7,7 @@ Welcome to the SolidWorx Platform documentation. This platform provides the foun - [Configuration](./configuration/index.md) - [Authentication & Security](./security/index.md) — default form login and two-factor authentication - [Access Decision Strategies](./security/access-decision.md) — decide chosen permissions unanimously, per attribute +- [User Profile](./security/profile.md) — the profile pages, password rules and how to extend them - [Frontend Assets](./frontend/index.md) — webpack config, Stimulus controllers, theming - [Layouts](./frontend/layouts.md) — the Tabler page layouts, their options and blocks - [Doctrine Types](./doctrine-types/index.md) diff --git a/docs/security/index.md b/docs/security/index.md index b7822b13..9220a1d4 100644 --- a/docs/security/index.md +++ b/docs/security/index.md @@ -317,5 +317,7 @@ final class LoginExtension - [Per-Attribute Access Decision Strategies](./access-decision.md) — decide chosen permissions unanimously (or by consensus) without changing the strategy for the whole application. +- [User Profile](./profile.md) — the profile pages, the password rules, and how to add + your own fields to them. - [Configuration](../configuration/index.md) — the `platform.yaml` reference (`platform.models.user`, `platform.security.two_factor`, `platform.ui.templates`). diff --git a/docs/security/profile.md b/docs/security/profile.md new file mode 100644 index 00000000..651d4937 --- /dev/null +++ b/docs/security/profile.md @@ -0,0 +1,236 @@ +# User Profile + +Every signed-in user gets three pages for maintaining their own account, plus a **Profile** +entry in the user dropdown that leads to them: + +| Page | Route | Path | +|------|-------|------| +| Profile details | `solidworx_platform_profile_show` | `/profile` | +| Edit profile | `solidworx_platform_profile_edit` | `/profile/edit` | +| Change password | `solidworx_platform_profile_change_password` | `/profile/password` | + +The profile page also carries a **Security** card, which links to the change-password page and — +when `platform.security.two_factor.enabled` is on — to the +[two-factor configuration page](./two-factor.md). + +There is nothing to switch on: importing the platform routes is enough. + +--- + +## What a user can change + +`ProfileType` covers the fields every platform user has: + +- first name, +- last name, +- email address (which is also the sign-in identifier), +- mobile number. + +The password is deliberately not part of that form. It has its own page, which asks for the +current password first. + +--- + +## Security model + +The profile routes take **no user identifier**. There is no `{id}` in the path, no `user` in the +query string and no hidden field in the form — every page acts on `getUser()`, resolved from the +session token. "Can user A edit user B's profile" is therefore not a question the code can be +asked, rather than a check that could be forgotten. + +On top of that: + +| Concern | How it is handled | +|---------|-------------------| +| Half-authenticated visitors | Every page requires `IS_AUTHENTICATED_FULLY`, so a remember-me cookie alone does not open them | +| Mass assignment | The form type is the boundary: `roles`, `enabled`, `verified` and `password` are not fields, so a crafted request body cannot set them | +| Cross-site request forgery | Both forms are Symfony forms, so they carry (and check) a CSRF token | +| Password changes from a stolen session | The current password is required, checked with `UserPassword` against the authenticated user | +| A leaked session cookie | The session id is rotated after a successful password change, which invalidates the old one | +| Plain-text passwords | The change-password form is unmapped; the controller hashes the new password and only the hash reaches the entity | +| Taking somebody else's email | `ProfileType` carries a `UniqueEntity` constraint on `email`, so a taken address is a validation error on the field rather than a unique-index violation at flush | + +Two things the platform deliberately leaves to the application, because they are policy rather +than mechanism: + +- **Verifying a changed email address.** Changing the address changes the sign-in identifier + immediately. Add a confirmation flow if your application needs one. +- **Signing other devices out.** The session that made the change is rotated; other sessions and + remember-me cookies are untouched. + +--- + +## Password rules + +The rules live in one place and are used twice: to validate the submission, and to render the +bullet list of requirements on the page. They cannot drift apart. + +```yaml +# platform.yaml +platform: + profile: + password: + # Minimum number of characters. + min_length: 12 + # none | weak | medium | strong | very_strong — an entropy estimate, not a + # character-class checklist, so a long passphrase scores well on its own. + strength: medium + # Reject passwords that appear in a known breach (haveibeenpwned range API). + # Skipped when the API cannot be reached, so an outage never blocks a rotation. + check_compromised: true +``` + +For a rule the configuration does not cover, decorate the policy: + +```php +use SolidWorx\Platform\PlatformBundle\Security\Password\PasswordPolicyInterface; +use Symfony\Component\DependencyInjection\Attribute\AsDecorator; + +#[AsDecorator(PasswordPolicyInterface::class)] +final readonly class CompanyPasswordPolicy implements PasswordPolicyInterface +{ + public function __construct( + #[AutowireDecorated] + private PasswordPolicyInterface $inner, + ) { + } + + public function constraints(): array + { + return [...$this->inner->constraints(), new NotEqualTo(value: 'password')]; + } + + public function requirements(): array + { + return [...$this->inner->requirements(), 'Not literally "password"']; + } +} +``` + +--- + +## Customising the form + +Applications almost always configure their own user class under `platform.models.user`, so the +profile form has two extension points — pick the smaller one that fits. + +### Adding fields to the platform form + +A form type extension keeps the platform fields, their labels and the unique-email constraint, +and appends yours: + +```php +use SolidWorx\Platform\PlatformBundle\Form\Type\Profile\ProfileType; +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; + +final class ProfileTypeExtension extends AbstractTypeExtension +{ + public static function getExtendedTypes(): iterable + { + return [ProfileType::class]; + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('jobTitle', TextType::class, ['required' => false]); + } +} +``` + +Nothing else is needed — the extension is autoconfigured. + +### Replacing the form outright + +When the shape of the form itself is different, name your own type: + +```yaml +# platform.yaml +platform: + profile: + form_type: App\Form\ProfileType +``` + +The type is built with the signed-in user as its data, so it only has to set `data_class` to your +user class. Carry the `UniqueEntity` constraint over from `ProfileType` — it is what stops one +user from taking another's email address — and keep roles, the enabled flag and the password out +of it. + +--- + +## Customising the pages + +### Overriding blocks + +The shipped templates are built out of blocks so that the common case — showing one more field — +does not mean owning the whole page. Extend the template and point the configuration at yours: + +```yaml +# platform.yaml +platform: + profile: + templates: + show: '@App/profile/show.html.twig' +``` + +```twig +{# templates/profile/show.html.twig #} +{% extends '@SolidWorxPlatform/Profile/show.html.twig' %} +{% import '@SolidWorxPlatform/Profile/show.html.twig' as profile %} + +{% block profile_detail_rows %} + {{ parent() }} + {{ profile.detail('Job title'|trans, user.jobTitle) }} +{% endblock %} +``` + +The blocks each page exposes: + +| Template | Blocks | +|----------|--------| +| `show.html.twig` | `profile_initials`, `profile_name`, `profile_identity`, `profile_detail_rows`, `profile_details`, `profile_security_items`, `profile_security`, `profile_sections_extra`, plus the layout's `page_pretitle`, `page_title` and `page_title_actions` | +| `edit.html.twig` | `profile_form_fields`, `profile_form_actions` | +| `change_password.html.twig` | `password_requirements`, `password_form_actions` | + +`show.html.twig` also exports two macros — `detail(label, value)` and +`security_item(title, description, url, action, icon)` — so added rows keep matching the +platform's markup. + +### Replacing a page + +The same configuration keys take an unrelated template. Each page is rendered with: + +| Template | Variables | +|----------|-----------| +| `show` | `user`, `two_factor_enabled` | +| `edit` | `form`, `user` | +| `change_password` | `form`, `user`, `password_requirements` | + +--- + +## Customising the menu entry + +The **Profile** entry is an ordinary KnpMenu item on the `user_menu` menu, registered at +`UserMenu::PRIORITY_PROFILE` so it leads the dropdown. Application entries default to priority +`0` and therefore land underneath it — see +[the user menu](../frontend/layouts.md#the-user-menu) for adding your own. + +--- + +## Reference + +```yaml +# platform.yaml — every profile key, with its default +platform: + profile: + form_type: SolidWorx\Platform\PlatformBundle\Form\Type\Profile\ProfileType + templates: + show: '@SolidWorxPlatform/Profile/show.html.twig' + edit: '@SolidWorxPlatform/Profile/edit.html.twig' + change_password: '@SolidWorxPlatform/Profile/change_password.html.twig' + password: + min_length: 12 + strength: medium + check_compromised: true +``` diff --git a/platform-schema.json b/platform-schema.json index 9549d2fc..c0ca3e78 100644 --- a/platform-schema.json +++ b/platform-schema.json @@ -118,6 +118,91 @@ }, "additionalProperties": false }, + "profile": { + "description": "The user profile pages: details, edit, and change password.", + "default": { + "form_type": "SolidWorx\\Platform\\PlatformBundle\\Form\\Type\\Profile\\ProfileType", + "templates": { + "show": "@SolidWorxPlatform/Profile/show.html.twig", + "edit": "@SolidWorxPlatform/Profile/edit.html.twig", + "change_password": "@SolidWorxPlatform/Profile/change_password.html.twig" + }, + "password": { + "min_length": 12, + "strength": "medium", + "check_compromised": true + } + }, + "type": "object", + "properties": { + "form_type": { + "description": "The form type used to edit the profile. Must implement Symfony\\Component\\Form\\FormTypeInterface", + "default": "SolidWorx\\Platform\\PlatformBundle\\Form\\Type\\Profile\\ProfileType", + "type": "string" + }, + "templates": { + "default": { + "show": "@SolidWorxPlatform/Profile/show.html.twig", + "edit": "@SolidWorxPlatform/Profile/edit.html.twig", + "change_password": "@SolidWorxPlatform/Profile/change_password.html.twig" + }, + "type": "object", + "properties": { + "show": { + "description": "The template showing the profile details.", + "default": "@SolidWorxPlatform/Profile/show.html.twig", + "type": "string" + }, + "edit": { + "description": "The template rendering the edit-profile form.", + "default": "@SolidWorxPlatform/Profile/edit.html.twig", + "type": "string" + }, + "change_password": { + "description": "The template rendering the change-password form.", + "default": "@SolidWorxPlatform/Profile/change_password.html.twig", + "type": "string" + } + }, + "additionalProperties": false + }, + "password": { + "description": "The rules a user-chosen password has to satisfy.", + "default": { + "min_length": 12, + "strength": "medium", + "check_compromised": true + }, + "type": "object", + "properties": { + "min_length": { + "description": "The minimum number of characters a password must have.", + "default": 12, + "type": "integer" + }, + "strength": { + "description": "The estimated strength a password must reach; \"none\" disables the check.", + "default": "medium", + "type": "string", + "enum": [ + "none", + "weak", + "medium", + "strong", + "very_strong" + ] + }, + "check_compromised": { + "description": "Reject passwords that appear in a known data breach (calls the haveibeenpwned range API).", + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "multi_tenancy": { "default": { "enabled": false, diff --git a/platform.yaml b/platform.yaml index 06d14630..55431c99 100644 --- a/platform.yaml +++ b/platform.yaml @@ -15,6 +15,17 @@ platform: models: user: SolidWorx\Platform\PlatformBundle\Model\User + # profile: + # form_type: SolidWorx\Platform\PlatformBundle\Form\Type\Profile\ProfileType + # templates: + # show: '@SolidWorxPlatform/Profile/show.html.twig' + # edit: '@SolidWorxPlatform/Profile/edit.html.twig' + # change_password: '@SolidWorxPlatform/Profile/change_password.html.twig' + # password: + # min_length: 12 + # strength: medium + # check_compromised: true + # saas: # doctrine: # subscriptions: diff --git a/src/Bundle/Platform/Config/Builder/PlatformConfigBuilder.php b/src/Bundle/Platform/Config/Builder/PlatformConfigBuilder.php index 028a21c6..e6425293 100644 --- a/src/Bundle/Platform/Config/Builder/PlatformConfigBuilder.php +++ b/src/Bundle/Platform/Config/Builder/PlatformConfigBuilder.php @@ -38,6 +38,8 @@ final class PlatformConfigBuilder private ?SecurityConfigBuilder $security = null; + private ?ProfileConfigBuilder $profile = null; + private ?bool $enableUtcDate = null; /** @@ -82,6 +84,12 @@ public function security(): SecurityConfigBuilder return $this->security; } + public function profile(): ProfileConfigBuilder + { + $this->profile = ProfileConfigBuilder::create($this); + return $this->profile; + } + public function enableUtcDate(bool $enable = true): self { $this->enableUtcDate = $enable; @@ -135,6 +143,10 @@ public function build(): array $platform['security'] = $this->security->toArray(); } + if ($this->profile instanceof ProfileConfigBuilder) { + $platform['profile'] = $this->profile->toArray(); + } + if ($this->enableUtcDate !== null) { $platform['doctrine'] = [ 'types' => [ diff --git a/src/Bundle/Platform/Config/Builder/ProfileConfigBuilder.php b/src/Bundle/Platform/Config/Builder/ProfileConfigBuilder.php new file mode 100644 index 00000000..40fcf6c8 --- /dev/null +++ b/src/Bundle/Platform/Config/Builder/ProfileConfigBuilder.php @@ -0,0 +1,125 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\PlatformBundle\Config\Builder; + +use SolidWorx\Platform\PlatformBundle\Enum\PasswordStrengthLevel; + +/** + * Builds the `platform.profile` section. + * + * PlatformConfigBuilder::create() + * ->profile() + * ->formType(App\Form\ProfileType::class) + * ->showTemplate('@App/profile/show.html.twig') + * ->passwordMinLength(16) + * ->passwordStrength(PasswordStrengthLevel::Strong) + * ->end() + * ->build(); + */ +final class ProfileConfigBuilder +{ + private ?string $formType = null; + + /** + * @var array + */ + private array $templates = []; + + /** + * @var array + */ + private array $password = []; + + private function __construct( + private readonly PlatformConfigBuilder $parent + ) { + } + + public static function create(PlatformConfigBuilder $parent): self + { + return new self($parent); + } + + /** + * @param class-string $formType + */ + public function formType(string $formType): self + { + $this->formType = $formType; + return $this; + } + + public function showTemplate(string $template): self + { + $this->templates['show'] = $template; + return $this; + } + + public function editTemplate(string $template): self + { + $this->templates['edit'] = $template; + return $this; + } + + public function changePasswordTemplate(string $template): self + { + $this->templates['change_password'] = $template; + return $this; + } + + public function passwordMinLength(int $length): self + { + $this->password['min_length'] = $length; + return $this; + } + + public function passwordStrength(PasswordStrengthLevel $strength): self + { + $this->password['strength'] = $strength->value; + return $this; + } + + public function checkCompromisedPassword(bool $check = true): self + { + $this->password['check_compromised'] = $check; + return $this; + } + + public function end(): PlatformConfigBuilder + { + return $this->parent; + } + + /** + * @return array + */ + public function toArray(): array + { + $config = []; + + if ($this->formType !== null) { + $config['form_type'] = $this->formType; + } + + if ($this->templates !== []) { + $config['templates'] = $this->templates; + } + + if ($this->password !== []) { + $config['password'] = $this->password; + } + + return $config; + } +} diff --git a/src/Bundle/Platform/Config/PlatformConfiguration.php b/src/Bundle/Platform/Config/PlatformConfiguration.php index cde6dd96..569a2c53 100644 --- a/src/Bundle/Platform/Config/PlatformConfiguration.php +++ b/src/Bundle/Platform/Config/PlatformConfiguration.php @@ -17,10 +17,13 @@ use SolidWorx\Platform\PlatformBundle\Entity\Tenant; use SolidWorx\Platform\PlatformBundle\Entity\User; use SolidWorx\Platform\PlatformBundle\Entity\UserTenant; +use SolidWorx\Platform\PlatformBundle\Enum\PasswordStrengthLevel; +use SolidWorx\Platform\PlatformBundle\Form\Type\Profile\ProfileType; use SolidWorx\Platform\PlatformBundle\Form\Type\Tenant\TenantOnboardingType; use SolidWorx\Platform\PlatformBundle\Model\TenantInterface; use SolidWorx\Platform\PlatformBundle\Model\UserTenantInterface; use SolidWorx\Platform\PlatformBundle\Security\Voter\TenantCreationVoter; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Form\FormTypeInterface; use function implode; @@ -131,6 +134,7 @@ public function getTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->append($this->profileNode()) ->arrayNode('multi_tenancy') ->addDefaultsIfNotSet() ->children() @@ -229,4 +233,72 @@ public function getTreeBuilder(): TreeBuilder return $treeBuilder; } + + /** + * The `platform.profile` section: the pages where a user maintains their own account. + * + * Both extension points live here. `form_type` swaps the edit form out for one that knows + * about a custom user class, and `templates` swaps any of the three pages out for a template + * of your own — although overriding a block of the shipped template is usually enough, and + * survives platform upgrades better. + */ + private function profileNode(): ArrayNodeDefinition + { + $node = new ArrayNodeDefinition('profile'); + + // @formatter:off + $node + ->info('The user profile pages: details, edit, and change password.') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('form_type') + ->defaultValue(ProfileType::class) + ->info(sprintf('The form type used to edit the profile. Must implement %s', FormTypeInterface::class)) + ->validate() + ->ifTrue(static fn ($v): bool => ! is_string($v) || ! is_subclass_of($v, FormTypeInterface::class)) + ->thenInvalid(sprintf('The profile form type must implement %s', FormTypeInterface::class)) + ->end() + ->end() + ->arrayNode('templates') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('show') + ->defaultValue('@SolidWorxPlatform/Profile/show.html.twig') + ->info('The template showing the profile details.') + ->end() + ->scalarNode('edit') + ->defaultValue('@SolidWorxPlatform/Profile/edit.html.twig') + ->info('The template rendering the edit-profile form.') + ->end() + ->scalarNode('change_password') + ->defaultValue('@SolidWorxPlatform/Profile/change_password.html.twig') + ->info('The template rendering the change-password form.') + ->end() + ->end() + ->end() + ->arrayNode('password') + ->info('The rules a user-chosen password has to satisfy.') + ->addDefaultsIfNotSet() + ->children() + ->integerNode('min_length') + ->defaultValue(12) + ->min(1) + ->info('The minimum number of characters a password must have.') + ->end() + ->enumNode('strength') + ->values(PasswordStrengthLevel::values()) + ->defaultValue(PasswordStrengthLevel::Medium->value) + ->info('The estimated strength a password must reach; "none" disables the check.') + ->end() + ->booleanNode('check_compromised') + ->defaultTrue() + ->info('Reject passwords that appear in a known data breach (calls the haveibeenpwned range API).') + ->end() + ->end() + ->end() + ->end(); + // @formatter:on + + return $node; + } } diff --git a/src/Bundle/Platform/Controller/BaseController.php b/src/Bundle/Platform/Controller/BaseController.php index f52508b8..fa63b025 100644 --- a/src/Bundle/Platform/Controller/BaseController.php +++ b/src/Bundle/Platform/Controller/BaseController.php @@ -37,4 +37,23 @@ protected function getUser(): ?UserInterface return null; } + + /** + * The signed-in user, for pages that cannot render without one. + * + * `#[IsGranted]` already keeps anonymous visitors out, so reaching this with no user means + * the configured `platform.models.user` class does not implement the platform contract. That + * is a misconfiguration rather than a request to answer — refusing is safer than rendering a + * page about nobody. + */ + protected function currentUser(): UserInterface + { + $user = $this->getUser(); + + if (! $user instanceof UserInterface) { + throw $this->createAccessDeniedException('The authenticated user does not implement the platform user contract.'); + } + + return $user; + } } diff --git a/src/Bundle/Platform/Controller/Profile/ChangePassword.php b/src/Bundle/Platform/Controller/Profile/ChangePassword.php new file mode 100644 index 00000000..6956c18f --- /dev/null +++ b/src/Bundle/Platform/Controller/Profile/ChangePassword.php @@ -0,0 +1,121 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\PlatformBundle\Controller\Profile; + +use Doctrine\ORM\EntityManagerInterface; +use SolidWorx\Platform\PlatformBundle\Controller\BaseController; +use SolidWorx\Platform\PlatformBundle\Enum\Flash; +use SolidWorx\Platform\PlatformBundle\Form\Type\Profile\ChangePasswordType; +use SolidWorx\Platform\PlatformBundle\Response\RedirectResponse; +use SolidWorx\Platform\PlatformBundle\Security\Password\PasswordPolicyInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\IsGranted; +use Webmozart\Assert\Assert; + +/** + * The page a user rotates their own password on. + * + * It is deliberately separate from the profile form. A password change is a different kind of + * act from correcting a phone number: it needs the current password, it should not be mixed into + * a form somebody opened to fix a typo, and it ends with a new session id. + * + * The four things that make it safe: + * + * - the account is {@see self::currentUser()}, never an identifier from the request; + * - {@see ChangePasswordType} requires the current password, so a hijacked session cannot lock + * the owner out; + * - the new password is hashed with the configured hasher and only the hash reaches the entity; + * - the session id is rotated on success, so a session cookie captured before the change stops + * working. + */ +#[Route(path: self::PATH, name: self::ROUTE_NAME, methods: ['GET', 'POST'])] +#[IsGranted(attribute: 'IS_AUTHENTICATED_FULLY')] +final class ChangePassword extends BaseController +{ + /** + * The path of the change-password page. + */ + public const string PATH = '/profile/password'; + + /** + * The route name of the change-password page, linked to from the profile page. + */ + public const string ROUTE_NAME = 'solidworx_platform_profile_change_password'; + + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly UserPasswordHasherInterface $passwordHasher, + private readonly PasswordPolicyInterface $passwordPolicy, + #[Autowire(param: 'solidworx_platform.profile.templates.change_password')] + private readonly string $template, + ) { + } + + public function __invoke(Request $request): Response + { + $user = $this->currentUser(); + + $form = $this->createForm(ChangePasswordType::class); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $newPassword = $form->get(ChangePasswordType::NEW_PASSWORD)->getData(); + + Assert::stringNotEmpty($newPassword); + + $user->setPassword($this->passwordHasher->hashPassword($user, $newPassword)); + + $this->entityManager->flush(); + + $this->rotateSession($request); + + return new RedirectResponse($this->generateUrl(ShowProfile::ROUTE_NAME)) + ->withFlash(Flash::Success, 'Your password has been changed.'); + } + + return $this->render( + $this->template, + [ + 'form' => $form, + 'user' => $user, + 'password_requirements' => $this->passwordPolicy->requirements(), + ], + new Response(status: $form->isSubmitted() ? Response::HTTP_UNPROCESSABLE_ENTITY : Response::HTTP_OK), + ); + } + + /** + * Issues a new session id and destroys the old one, keeping the user signed in here while + * invalidating a session cookie that leaked before the password changed. + */ + private function rotateSession(Request $request): void + { + if (! $request->hasSession()) { + return; + } + + $session = $request->getSession(); + + if (! $session->isStarted()) { + return; + } + + $session->migrate(destroy: true); + } +} diff --git a/src/Bundle/Platform/Controller/Profile/EditProfile.php b/src/Bundle/Platform/Controller/Profile/EditProfile.php new file mode 100644 index 00000000..5a0ff411 --- /dev/null +++ b/src/Bundle/Platform/Controller/Profile/EditProfile.php @@ -0,0 +1,89 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\PlatformBundle\Controller\Profile; + +use Doctrine\ORM\EntityManagerInterface; +use SolidWorx\Platform\PlatformBundle\Controller\BaseController; +use SolidWorx\Platform\PlatformBundle\Enum\Flash; +use SolidWorx\Platform\PlatformBundle\Form\Type\Profile\ProfileType; +use SolidWorx\Platform\PlatformBundle\Response\RedirectResponse; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Form\FormTypeInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\IsGranted; + +/** + * Edits the signed-in user's own details. + * + * The form is bound to {@see self::currentUser()} and nothing else, so the only account this + * action can ever write to is the one behind the session. The form type is whatever + * `platform.profile.form_type` names — {@see ProfileType} unless an application replaced it — + * which is also the boundary that decides which columns are writable: a field the form does not + * declare cannot be set, no matter what the request body contains. + */ +#[Route(path: self::PATH, name: self::ROUTE_NAME, methods: ['GET', 'POST'])] +#[IsGranted(attribute: 'IS_AUTHENTICATED_FULLY')] +final class EditProfile extends BaseController +{ + /** + * The path of the edit-profile page. + */ + public const string PATH = '/profile/edit'; + + /** + * The route name of the edit-profile page. + */ + public const string ROUTE_NAME = 'solidworx_platform_profile_edit'; + + /** + * @param class-string $formType The class configured under `platform.profile.form_type` + */ + public function __construct( + private readonly EntityManagerInterface $entityManager, + #[Autowire(param: 'solidworx_platform.profile.templates.edit')] + private readonly string $template, + #[Autowire(param: 'solidworx_platform.profile.form_type')] + private readonly string $formType, + ) { + } + + public function __invoke(Request $request): Response + { + $user = $this->currentUser(); + + $form = $this->createForm($this->formType, $user); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->entityManager->flush(); + + return new RedirectResponse($this->generateUrl(ShowProfile::ROUTE_NAME)) + ->withFlash(Flash::Success, 'Your profile has been updated.'); + } + + return $this->render( + $this->template, + [ + 'form' => $form, + 'user' => $user, + ], + // A rejected submission is not a successful GET; 422 keeps Turbo and friends from + // treating the re-rendered form as a new page. + new Response(status: $form->isSubmitted() ? Response::HTTP_UNPROCESSABLE_ENTITY : Response::HTTP_OK), + ); + } +} diff --git a/src/Bundle/Platform/Controller/Profile/ShowProfile.php b/src/Bundle/Platform/Controller/Profile/ShowProfile.php new file mode 100644 index 00000000..c25686ee --- /dev/null +++ b/src/Bundle/Platform/Controller/Profile/ShowProfile.php @@ -0,0 +1,63 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\PlatformBundle\Controller\Profile; + +use SolidWorx\Platform\PlatformBundle\Controller\BaseController; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\IsGranted; + +/** + * The profile landing page: what we hold about the signed-in user, and the two ways to change it. + * + * The route takes no user identifier — there is nothing in the URL, the query string or the body + * to point it at somebody else's account. The subject is always {@see self::getUser()}, which + * resolves from the session token alone. Every page under `/profile` works the same way, which is + * what makes "can user A edit user B" unanswerable rather than merely guarded. + * + * `IS_AUTHENTICATED_FULLY` (rather than `IS_AUTHENTICATED_REMEMBERED`) means a remember-me cookie + * on its own does not open the page: viewing an email address and a phone number, next to the + * buttons that change them, asks for a real sign-in. + */ +#[Route(path: self::PATH, name: self::ROUTE_NAME, methods: ['GET'])] +#[IsGranted(attribute: 'IS_AUTHENTICATED_FULLY')] +final class ShowProfile extends BaseController +{ + /** + * The path of the profile page. + */ + public const string PATH = '/profile'; + + /** + * The route name of the profile page, linked to from the user menu. + */ + public const string ROUTE_NAME = 'solidworx_platform_profile_show'; + + public function __construct( + #[Autowire(param: 'solidworx_platform.profile.templates.show')] + private readonly string $template, + #[Autowire(param: 'solidworx_platform.security.two_factor.enabled')] + private readonly bool $twoFactorEnabled, + ) { + } + + public function __invoke(): Response + { + return $this->render($this->template, [ + 'user' => $this->currentUser(), + 'two_factor_enabled' => $this->twoFactorEnabled, + ]); + } +} diff --git a/src/Bundle/Platform/DependencyInjection/SolidWorxPlatformExtension.php b/src/Bundle/Platform/DependencyInjection/SolidWorxPlatformExtension.php index d13ed3ee..1eefb75d 100644 --- a/src/Bundle/Platform/DependencyInjection/SolidWorxPlatformExtension.php +++ b/src/Bundle/Platform/DependencyInjection/SolidWorxPlatformExtension.php @@ -31,6 +31,7 @@ use SolidWorx\Platform\PlatformBundle\Doctrine\EventListener\TenantWriteGuardListener; use SolidWorx\Platform\PlatformBundle\Doctrine\Filter\TenantFilter; use SolidWorx\Platform\PlatformBundle\Doctrine\Type\URLType; +use SolidWorx\Platform\PlatformBundle\Enum\PasswordStrengthLevel; use SolidWorx\Platform\PlatformBundle\Form\Type\Tenant\TenantOnboardingType; use SolidWorx\Platform\PlatformBundle\Logger\Processor\TenantLoggingProcessor; use SolidWorx\Platform\PlatformBundle\Menu\TwoFactorMenuBuilder; @@ -85,6 +86,12 @@ * write_guard: array{check_user_access: bool} * } * + * @phpstan-type ProfileConfig array{ + * form_type: class-string, + * templates: array{show: string, edit: string, change_password: string}, + * password: array{min_length: int, strength: string, check_compromised: bool} + * } + * * @phpstan-type PlatformConfig array{ * name: string, * version: string, @@ -94,6 +101,7 @@ * }, * doctrine: array{types: array{enable_utc_date: bool}}, * models: array{user: string}, + * profile: ProfileConfig, * multi_tenancy: MultiTenancyConfig * } */ @@ -184,6 +192,10 @@ public function load(array $configs, ContainerBuilder $container): void PlatformConfiguration::PLATFORM_ACCESS_DECISION_STRATEGIES + $config['security']['access_decision']['strategies'], ); + $this->loadProfile($container, $config['profile']); + + $container->setParameter('solidworx_platform.security.two_factor.enabled', $config['security']['two_factor']['enabled']); + if (! $config['security']['two_factor']['enabled']) { // @TODO: Need to remove the 2FA routes as well if 2fa is not configured $container->removeDefinition(ResendTwoFactorCode::class); @@ -198,6 +210,22 @@ public function load(array $configs, ContainerBuilder $container): void $this->loadMultiTenancy($container, $config['multi_tenancy']); } + /** + * @param ProfileConfig $config + */ + private function loadProfile(ContainerBuilder $container, array $config): void + { + $container->setParameter('solidworx_platform.profile.form_type', $config['form_type']); + $container->setParameter('solidworx_platform.profile.templates.show', $config['templates']['show']); + $container->setParameter('solidworx_platform.profile.templates.edit', $config['templates']['edit']); + $container->setParameter('solidworx_platform.profile.templates.change_password', $config['templates']['change_password']); + $container->setParameter('solidworx_platform.profile.password.min_length', $config['password']['min_length']); + // Stored as the enum rather than its backing value, so PasswordPolicy receives something + // already validated instead of re-parsing a string it would have to guard against. + $container->setParameter('solidworx_platform.profile.password.strength', PasswordStrengthLevel::from($config['password']['strength'])); + $container->setParameter('solidworx_platform.profile.password.check_compromised', $config['password']['check_compromised']); + } + /** * @param MultiTenancyConfig $config */ diff --git a/src/Bundle/Platform/Enum/PasswordStrengthLevel.php b/src/Bundle/Platform/Enum/PasswordStrengthLevel.php new file mode 100644 index 00000000..2844f1eb --- /dev/null +++ b/src/Bundle/Platform/Enum/PasswordStrengthLevel.php @@ -0,0 +1,83 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\PlatformBundle\Enum; + +use Symfony\Component\Validator\Constraints\PasswordStrength; +use function array_column; + +/** + * The strength a new password has to reach, as configured under + * `platform.profile.password.strength`. + * + * Every level except {@see self::None} maps onto a `minScore` of Symfony's + * {@see PasswordStrength} constraint, which estimates entropy rather than counting character + * classes — a long passphrase scores well without needing a symbol in it. + */ +enum PasswordStrengthLevel: string +{ + /** + * Do not check the strength at all; only the remaining rules (length, breach check) apply. + */ + case None = 'none'; + + case Weak = 'weak'; + + case Medium = 'medium'; + + case Strong = 'strong'; + + case VeryStrong = 'very_strong'; + + /** + * The configuration values accepted for `platform.profile.password.strength`. + * + * @return list + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * The `minScore` to build {@see PasswordStrength} with, or `null` when strength is not enforced. + * + * @return PasswordStrength::STRENGTH_*|null + */ + public function minScore(): ?int + { + return match ($this) { + self::None => null, + self::Weak => PasswordStrength::STRENGTH_WEAK, + self::Medium => PasswordStrength::STRENGTH_MEDIUM, + self::Strong => PasswordStrength::STRENGTH_STRONG, + self::VeryStrong => PasswordStrength::STRENGTH_VERY_STRONG, + }; + } + + /** + * A human description of the level, listed to the user next to the new-password field. + * + * `null` for {@see self::None}, which has nothing to tell the user about. + */ + public function requirement(): ?string + { + return match ($this) { + self::None => null, + self::Weak => 'Not one of the most commonly used passwords', + self::Medium => 'Not a common word or an obvious pattern', + self::Strong => 'A long, unpredictable mix of words, numbers or symbols', + self::VeryStrong => 'A long passphrase, or an unpredictable mix of letters, numbers and symbols', + }; + } +} diff --git a/src/Bundle/Platform/Form/Type/Profile/ChangePasswordType.php b/src/Bundle/Platform/Form/Type/Profile/ChangePasswordType.php new file mode 100644 index 00000000..062109b5 --- /dev/null +++ b/src/Bundle/Platform/Form/Type/Profile/ChangePasswordType.php @@ -0,0 +1,135 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\PlatformBundle\Form\Type\Profile; + +use Override; +use SolidWorx\Platform\PlatformBundle\Security\Password\PasswordPolicyInterface; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\PasswordType; +use Symfony\Component\Form\Extension\Core\Type\RepeatedType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Security\Core\Validator\Constraints\UserPassword; +use Symfony\Component\Validator\Constraints\NotBlank; + +/** + * The change-password form: prove you know the current password, then pick a new one twice. + * + * The current-password field is what makes the page safe to leave open on a shared machine — a + * stolen session alone is not enough to lock the real owner out. {@see UserPassword} checks it + * against the *authenticated* user, never against a user named in the request. + * + * The rules the new password has to satisfy come from {@see PasswordPolicyInterface}, so the + * bullet list rendered on the page and the constraints enforced here are the same list. + * + * The form is unmapped: it never touches the user object. Hashing and persisting are the + * controller's job, which keeps the plain-text password out of the entity entirely. + * + * @extends AbstractType + */ +final class ChangePasswordType extends AbstractType +{ + /** + * The field holding the password the user is signing in with today. + */ + public const string CURRENT_PASSWORD = 'currentPassword'; + + /** + * The repeated field holding the password they want instead. + */ + public const string NEW_PASSWORD = 'newPassword'; + + public function __construct( + private readonly PasswordPolicyInterface $passwordPolicy, + ) { + } + + /** + * @param FormBuilderInterface $builder + * @param array $options + */ + #[Override] + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add(self::CURRENT_PASSWORD, PasswordType::class, [ + 'label' => 'Current password', + 'required' => true, + 'attr' => [ + 'autofocus' => true, + 'autocomplete' => 'current-password', + ], + 'constraints' => [ + new NotBlank(), + new UserPassword(message: 'The current password you entered is not correct.'), + ], + ]) + ->add(self::NEW_PASSWORD, RepeatedType::class, [ + 'type' => PasswordType::class, + 'required' => true, + 'invalid_message' => 'The two passwords do not match.', + 'first_options' => [ + 'label' => 'New password', + 'attr' => [ + 'autocomplete' => 'new-password', + ], + ], + 'second_options' => [ + 'label' => 'Repeat new password', + 'attr' => [ + 'autocomplete' => 'new-password', + ], + ], + 'constraints' => $this->passwordPolicy->constraints(), + ]) + ; + + $builder->addEventListener(FormEvents::POST_SUBMIT, $this->rejectUnchangedPassword(...)); + } + + #[Override] + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => null, + 'csrf_token_id' => 'solidworx_platform_change_password', + ]); + } + + /** + * Rotating a password onto itself looks like it worked but changes nothing, so say so. + * + * It runs on the root form because neither field can see the other one's data on its own. + */ + private function rejectUnchangedPassword(FormEvent $event): void + { + $form = $event->getForm(); + + if (! $form->isRoot()) { + return; + } + + $current = $form->get(self::CURRENT_PASSWORD)->getData(); + $new = $form->get(self::NEW_PASSWORD)->getData(); + + if ($current !== null && $current === $new) { + $form->get(self::NEW_PASSWORD)->addError( + new FormError('Your new password has to be different from your current one.') + ); + } + } +} diff --git a/src/Bundle/Platform/Form/Type/Profile/ProfileType.php b/src/Bundle/Platform/Form/Type/Profile/ProfileType.php new file mode 100644 index 00000000..ee1ffd74 --- /dev/null +++ b/src/Bundle/Platform/Form/Type/Profile/ProfileType.php @@ -0,0 +1,128 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\PlatformBundle\Form\Type\Profile; + +use Override; +use SolidWorx\Platform\PlatformBundle\Model\UserInterface; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\EmailType; +use Symfony\Component\Form\Extension\Core\Type\TelType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * The form behind "Edit profile", covering the fields every platform user has. + * + * There are two ways to change it, and which one fits depends on how much you are changing: + * + * 1. **Add to it.** A custom user class usually only adds a handful of columns, so a form type + * extension keeps the platform fields (and this validation) and appends yours: + * + * final class ProfileTypeExtension extends AbstractTypeExtension + * { + * public static function getExtendedTypes(): iterable + * { + * return [ProfileType::class]; + * } + * + * public function buildForm(FormBuilderInterface $builder, array $options): void + * { + * $builder->add('jobTitle', TextType::class, ['required' => false]); + * } + * } + * + * 2. **Replace it.** Point `platform.profile.form_type` at your own type when the shape of the + * form itself is different. It is built with the signed-in user as its data, so it only has + * to set `data_class` to your user class — remember to carry over the unique-email constraint + * below, which is what stops one user from taking another's address. + * + * The password is deliberately absent: it is changed on its own page, behind the current + * password. Nothing here can grant a role or enable an account either — those columns are not + * exposed, so a crafted request cannot set them. + * + * @extends AbstractType + */ +final class ProfileType extends AbstractType +{ + /** + * @param class-string $userClass The class configured under `platform.models.user` + */ + public function __construct( + #[Autowire(param: 'solidworx_platform.models.user')] + private readonly string $userClass, + ) { + } + + /** + * @param FormBuilderInterface $builder + * @param array $options + */ + #[Override] + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('firstName', TextType::class, [ + 'label' => 'First name', + 'required' => true, + 'attr' => [ + 'autofocus' => true, + 'autocomplete' => 'given-name', + ], + ]) + ->add('lastName', TextType::class, [ + 'label' => 'Last name', + 'required' => true, + 'attr' => [ + 'autocomplete' => 'family-name', + ], + ]) + ->add('email', EmailType::class, [ + 'label' => 'Email address', + 'required' => true, + 'help' => 'This is also the address you sign in with.', + 'attr' => [ + 'autocomplete' => 'email', + ], + ]) + ->add('mobile', TelType::class, [ + 'label' => 'Mobile number', + 'required' => false, + 'attr' => [ + 'autocomplete' => 'tel', + ], + ]) + ; + } + + #[Override] + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => $this->userClass, + // A class constraint on the form's own data, so a taken address fails validation and + // is reported on the email field instead of blowing up on the unique index at flush. + 'constraints' => [ + new UniqueEntity( + fields: ['email'], + message: 'An account with this email address already exists.', + entityClass: $this->userClass, + errorPath: 'email', + ), + ], + ]); + } +} diff --git a/src/Bundle/Platform/Menu/ProfileMenuBuilder.php b/src/Bundle/Platform/Menu/ProfileMenuBuilder.php new file mode 100644 index 00000000..d58c75cb --- /dev/null +++ b/src/Bundle/Platform/Menu/ProfileMenuBuilder.php @@ -0,0 +1,39 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\PlatformBundle\Menu; + +use Knp\Menu\ItemInterface; +use SolidWorx\Platform\PlatformBundle\Attributes\Menu\MenuBuilder; +use SolidWorx\Platform\PlatformBundle\Controller\Profile\ShowProfile; + +/** + * Adds the "Profile" entry to the user dropdown. + * + * It is registered above {@see UserMenu::PRIORITY_ACCOUNT} so it leads the dropdown, ahead of + * the two-factor entry and anything an application appends. + */ +final class ProfileMenuBuilder +{ + #[MenuBuilder(name: UserMenu::NAME, priority: UserMenu::PRIORITY_PROFILE)] + public function build(ItemInterface $menu): void + { + $menu->addChild( + 'Profile', + Options::create() + ->route(ShowProfile::ROUTE_NAME) + ->icon('user') + ->build(), + ); + } +} diff --git a/src/Bundle/Platform/Menu/UserMenu.php b/src/Bundle/Platform/Menu/UserMenu.php index 1c21c4f0..246c4829 100644 --- a/src/Bundle/Platform/Menu/UserMenu.php +++ b/src/Bundle/Platform/Menu/UserMenu.php @@ -22,14 +22,14 @@ * #[MenuBuilder(name: UserMenu::NAME)] * public function build(ItemInterface $menu): void * { - * $menu->addChild('Profile', Options::create()->route('app_profile')->icon('user')->build()); + * $menu->addChild('Billing', Options::create()->route('app_billing')->icon('credit-card')->build()); * } * * Builders run from the highest priority to the lowest, and each one appends to the menu, so - * priority decides where entries end up in the dropdown. The platform's own account entries - * (currently only two-factor authentication) use {@see self::PRIORITY_ACCOUNT}, which is above - * the default of `0` — application entries therefore land underneath them without having to - * pick a priority at all. + * priority decides where entries end up in the dropdown. The platform's own entries — the profile + * page at {@see self::PRIORITY_PROFILE}, two-factor authentication at + * {@see self::PRIORITY_ACCOUNT} — are both above the default of `0`, so application entries land + * underneath them without having to pick a priority at all. * * The logout link is not part of this menu: it is a CSRF-protected form rather than a link, and * the UI bundle always renders it last, under a divider. @@ -48,4 +48,12 @@ final class UserMenu * priority at its default of `0`) to append underneath the platform entries. */ public const int PRIORITY_ACCOUNT = 100; + + /** + * The priority of the "Profile" entry, which sits above the rest of the account entries. + * + * The profile page is the way in to everything else about the account — the details, the + * password, the second factors — so it leads the dropdown. + */ + public const int PRIORITY_PROFILE = 200; } diff --git a/src/Bundle/Platform/Model/User.php b/src/Bundle/Platform/Model/User.php index 3e14af99..62a7f3a1 100644 --- a/src/Bundle/Platform/Model/User.php +++ b/src/Bundle/Platform/Model/User.php @@ -105,7 +105,11 @@ public function getMobile(): ?string return $this->mobile; } - public function setMobile(string $mobile): static + /** + * The column is nullable, and an optional form field submits `null` when it is cleared, so + * the setter has to accept it. + */ + public function setMobile(?string $mobile): static { $this->mobile = $mobile; diff --git a/src/Bundle/Platform/Model/UserInterface.php b/src/Bundle/Platform/Model/UserInterface.php index bbd95f9f..7e3b5014 100644 --- a/src/Bundle/Platform/Model/UserInterface.php +++ b/src/Bundle/Platform/Model/UserInterface.php @@ -32,4 +32,14 @@ interface UserInterface extends SecurityUserInterface, PasswordAuthenticatedUserInterface, Stringable, UserTwoFactorInterface { public function getId(): Ulid; + + /** + * Stores an already-hashed password. + * + * Declared here because the platform has to be able to rotate a password on the user's + * behalf — see the change-password page. Callers pass the output of + * {@see \Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface::hashPassword()}; + * a plain-text password must never reach it. + */ + public function setPassword(string $password): static; } diff --git a/src/Bundle/Platform/Resources/config/services.php b/src/Bundle/Platform/Resources/config/services.php index a4a33c91..4b5bcfa0 100644 --- a/src/Bundle/Platform/Resources/config/services.php +++ b/src/Bundle/Platform/Resources/config/services.php @@ -25,6 +25,8 @@ use SolidWorx\Platform\PlatformBundle\Feature\NoopFeatureGate; use SolidWorx\Platform\PlatformBundle\Feature\NullSubscriberResolver; use SolidWorx\Platform\PlatformBundle\Feature\SubscriberResolver; +use SolidWorx\Platform\PlatformBundle\Security\Password\PasswordPolicy; +use SolidWorx\Platform\PlatformBundle\Security\Password\PasswordPolicyInterface; use SolidWorx\Platform\PlatformBundle\SolidWorxPlatformBundle; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; @@ -63,6 +65,7 @@ $services->alias(FeatureGate::class, NoopFeatureGate::class); $services->alias(SubscriberResolver::class, NullSubscriberResolver::class); + $services->alias(PasswordPolicyInterface::class, PasswordPolicy::class); // Disposable / throwaway email detection. // diff --git a/src/Bundle/Platform/Resources/views/Form/theme.html.twig b/src/Bundle/Platform/Resources/views/Form/theme.html.twig index 41b7055f..8aaa5fef 100644 --- a/src/Bundle/Platform/Resources/views/Form/theme.html.twig +++ b/src/Bundle/Platform/Resources/views/Form/theme.html.twig @@ -1,3 +1,20 @@ +{## + The platform form theme: Bootstrap 5 (which is what Tabler is), with an input-group icon in + front of the fields where one helps, plus the rich text editor widget. + + Register it on its own — it already carries the Bootstrap layout: + + twig: + form_themes: + - '@SolidWorxPlatform/Form/theme.html.twig' + + The `{% use %}` below is what makes `{{ parent() }}` resolve: Twig forbids `parent()` in a + template that neither extends nor uses another, so a theme that decorates blocks has to name + the theme it decorates. +##} + +{% use 'bootstrap_5_layout.html.twig' %} + {% block date_widget %} {% if widget == 'single_text' %}
diff --git a/src/Bundle/Platform/Resources/views/Profile/change_password.html.twig b/src/Bundle/Platform/Resources/views/Profile/change_password.html.twig new file mode 100644 index 00000000..9e473438 --- /dev/null +++ b/src/Bundle/Platform/Resources/views/Profile/change_password.html.twig @@ -0,0 +1,87 @@ +{## + The change-password page. + + It is its own page rather than a section of the profile form on purpose: changing a password + needs the current one, and mixing that into a form somebody opened to fix a phone number + trains people to type their password into whatever asks for it. + + `password_requirements` comes from the same PasswordPolicy that validates the submission, so + the list below always describes the rules actually in force. + + Point `platform.profile.templates.change_password` at a template that extends this one to + redefine a block. It is rendered with `form`, `user` and `password_requirements`. +##} + +{% extends ui_layout_app %} + +{% types { + ## The change-password form: current password, plus the new one twice. + form: 'object', + ## The signed-in user, whose password is the one being changed. + user: 'object', + ## The password rules as short sentences, from PasswordPolicy::requirements(). + password_requirements: 'iterable', +} %} + +{% form_theme form '@SolidWorxPlatform/Form/theme.html.twig' %} + +{% block page_pretitle %}{{ 'Security'|trans }}{% endblock %} + +{% block page_title %}{{ 'Change password'|trans }}{% endblock %} + +{## The rules the new password has to satisfy, listed before the fields so they are read first. ##} +{% block password_requirements %} + {% if password_requirements is not empty %} +
+
{{ 'Your new password must be'|trans }}
+ +
    + {% for requirement in password_requirements %} +
  • + {{ ux_icon('tabler:circle-check', {class: 'icon icon-sm text-success me-2 flex-shrink-0'}) }} + {{ requirement|trans }} +
  • + {% endfor %} +
+
+ {% endif %} +{% endblock %} + +{## The buttons under the form. ##} +{% block password_form_actions %} +
+ {{ 'Cancel'|trans }} + + +
+{% endblock %} + +{% block content %} +
+
+ {{ form_start(form, {attr: {autocomplete: 'off'}}) }} +
+
+ {{ form_errors(form) }} + + {{ form_row(form.currentPassword) }} + +
+ + {{ block('password_requirements') }} + + {{ form_row(form.newPassword.first) }} + {{ form_row(form.newPassword.second) }} +
+ + +
+ {{ form_end(form) }} +
+
+{% endblock %} diff --git a/src/Bundle/Platform/Resources/views/Profile/edit.html.twig b/src/Bundle/Platform/Resources/views/Profile/edit.html.twig new file mode 100644 index 00000000..37163bee --- /dev/null +++ b/src/Bundle/Platform/Resources/views/Profile/edit.html.twig @@ -0,0 +1,68 @@ +{## + The edit-profile form. + + The fields come from `platform.profile.form_type`, so adding a field to that form type — or to + a form type extension of it — is enough to have it rendered here; this template never names a + field. Override `profile_form_fields` only when the *layout* of the form has to change. + + Point `platform.profile.templates.edit` at a template that extends this one to redefine a + block, or at an unrelated template to replace the page. It is rendered with `form` and `user`. +##} + +{% extends ui_layout_app %} + +{% types { + ## The profile form, built from `platform.profile.form_type` and bound to the signed-in user. + form: 'object', + ## The signed-in user, the same object the form is bound to. + user: 'object', +} %} + +{## The platform form theme is Bootstrap 5 — which is what Tabler is — plus the input-group icons + on the email, tel and password widgets. ##} +{% form_theme form '@SolidWorxPlatform/Form/theme.html.twig' %} + +{% block page_pretitle %}{{ 'Account'|trans }}{% endblock %} + +{% block page_title %}{{ 'Edit profile'|trans }}{% endblock %} + +{## Every field of the form, in the order the form type declares them. ##} +{% block profile_form_fields %} + {{ form_widget(form) }} +{% endblock %} + +{## The buttons under the form. ##} +{% block profile_form_actions %} +
+ {{ 'Cancel'|trans }} + + +
+{% endblock %} + +{% block content %} +
+
+ {{ form_start(form) }} +
+
+

{{ 'Your details'|trans }}

+
+ +
+ {{ form_errors(form) }} + + {{ block('profile_form_fields') }} +
+ + +
+ {{ form_end(form) }} +
+
+{% endblock %} diff --git a/src/Bundle/Platform/Resources/views/Profile/show.html.twig b/src/Bundle/Platform/Resources/views/Profile/show.html.twig new file mode 100644 index 00000000..b13cc27a --- /dev/null +++ b/src/Bundle/Platform/Resources/views/Profile/show.html.twig @@ -0,0 +1,203 @@ +{## + The profile page: what the platform holds about the signed-in user, and the ways to change it. + + It extends the UI bundle's application layout, so it picks up whatever `platform.ui.templates` + an application configures. + + Three levels of customisation, from cheapest to most involved: + + 1. **Override a block.** Point `platform.profile.templates.show` at a template of your own that + extends this one and redefines only what differs — this is what the blocks are for: + + {% extends '@SolidWorxPlatform/Profile/show.html.twig' %} + {% import '@SolidWorxPlatform/Profile/show.html.twig' as profile %} + + {% block profile_detail_rows %} + {{ parent() }} + {{ profile.detail('Job title'|trans, user.jobTitle) }} + {% endblock %} + + The `detail()` and `security_item()` macros below are part of that contract — import them + as above rather than hand-rolling the markup, so your rows keep matching the platform's. + + 2. **Add a section.** `profile_sections_extra` is an empty block at the bottom of the page, + there so an application can add cards without redefining the layout above it. + + 3. **Replace the page.** Point `platform.profile.templates.show` at an unrelated template. + It is rendered with `user` and `two_factor_enabled`, and nothing else is expected of it. +##} + +{% extends ui_layout_app %} + +{% types { + ## The signed-in user — always the account the page is about, never one named in the request. + user: 'object', + ## Whether `platform.security.two_factor.enabled` is on. The 2FA route only exists when it is. + two_factor_enabled: 'boolean', +} %} + +{## + One label/value pair in the details card. + + `value` may be null or empty, in which case a muted dash is shown rather than a blank row — + an empty row reads as a rendering bug, a dash reads as "nothing here yet". +##} +{% macro detail(label, value) %} +
+
{{ label }}
+
+ {%- if value is not empty -%} + {{ value }} + {%- else -%} + — + {%- endif -%} +
+
+{% endmacro %} + +{## + One row in the security card: a title, an explanation, and the action that changes it. +##} +{% macro security_item(title, description, url, action, icon) %} +
+
+
+ {{ ux_icon('tabler:' ~ icon, {class: 'icon'}) }} +
+ +
+
{{ title }}
+
{{ description }}
+
+ + +
+
+{% endmacro %} + +{% import _self as profile %} + +{% block page_pretitle %}{{ 'Account'|trans }}{% endblock %} + +{% block page_title %}{{ 'Profile'|trans }}{% endblock %} + +{## The buttons in the page header. ##} +{% block page_title_actions %} + + {{ ux_icon('tabler:edit', {class: 'icon'}) }} + {{ 'Edit profile'|trans }} + +{% endblock %} + +{## The user's initials, shown in the avatar. Falls back to the sign-in identifier. ##} +{% block profile_initials %} + {%- set initials = (user.firstName|default('')|slice(0, 1) ~ user.lastName|default('')|slice(0, 1))|upper -%} + {{- initials is not empty ? initials : user.userIdentifier|slice(0, 2)|upper -}} +{% endblock %} + +{## The name shown at the top of the details card. Falls back to the sign-in identifier. ##} +{% block profile_name %} + {%- set name = [user.firstName|default(''), user.lastName|default('')]|filter(part => part is not empty)|join(' ') -%} + {{- name is not empty ? name : user.userIdentifier -}} +{% endblock %} + +{## The avatar and name banner above the details. ##} +{% block profile_identity %} +
+ {{ block('profile_initials') }} + +
+

{{ block('profile_name') }}

+
{{ user.userIdentifier }}
+
+
+{% endblock %} + +{## The label/value rows. Call `{{ parent() }}` and append to keep the platform fields. ##} +{% block profile_detail_rows %} + {{ profile.detail('First name'|trans, user.firstName|default(null)) }} + {{ profile.detail('Last name'|trans, user.lastName|default(null)) }} + {{ profile.detail('Email address'|trans, user.email|default(null)) }} + {{ profile.detail('Mobile number'|trans, user.mobile|default(null)) }} +{% endblock %} + +{## The whole "Profile details" card. ##} +{% block profile_details %} +
+
+

{{ 'Profile details'|trans }}

+
+ +
+ {{ block('profile_identity') }} + +
+ {{ block('profile_detail_rows') }} +
+
+ + +
+{% endblock %} + +{## The rows of the security card. The two-factor row is only rendered when 2FA is enabled — + its route does not exist otherwise. ##} +{% block profile_security_items %} + {{ profile.security_item( + 'Password'|trans, + 'Change the password you sign in with'|trans, + path('solidworx_platform_profile_change_password'), + 'Change'|trans, + 'lock' + ) }} + + {% if two_factor_enabled %} + {{ profile.security_item( + 'Two-factor authentication'|trans, + 'Add a second step to your sign-in'|trans, + path('solidworx_platform_security_two_factor_configure'), + 'Configure'|trans, + 'shield-lock' + ) }} + {% endif %} +{% endblock %} + +{## The whole "Security" card. ##} +{% block profile_security %} +
+
+

{{ 'Security'|trans }}

+
+ +
+ {{ block('profile_security_items') }} +
+
+{% endblock %} + +{## Empty by design — somewhere to add your own cards under the two the platform ships. ##} +{% block profile_sections_extra %}{% endblock %} + +{% block content %} +
+
+ {{ block('profile_details') }} +
+ +
+ {{ block('profile_security') }} +
+ + {% set profile_sections_extra = block('profile_sections_extra')|trim %} + {% if profile_sections_extra is not empty %} +
{{ profile_sections_extra|raw }}
+ {% endif %} +
+{% endblock %} diff --git a/src/Bundle/Platform/Security/Password/PasswordPolicy.php b/src/Bundle/Platform/Security/Password/PasswordPolicy.php new file mode 100644 index 00000000..b2f434f9 --- /dev/null +++ b/src/Bundle/Platform/Security/Password/PasswordPolicy.php @@ -0,0 +1,100 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\PlatformBundle\Security\Password; + +use Override; +use SolidWorx\Platform\PlatformBundle\Enum\PasswordStrengthLevel; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotCompromisedPassword; +use Symfony\Component\Validator\Constraints\PasswordStrength; +use function sprintf; + +/** + * The password rules described by `platform.profile.password`. + * + * The breach check is deliberately built with `skipOnError: true`: it calls the + * haveibeenpwned range API, and an outage there must not stop somebody from rotating a password + * they believe to be compromised. + */ +final readonly class PasswordPolicy implements PasswordPolicyInterface +{ + /** + * Matches the `max` Symfony's own security recommendations use, and keeps a submitted + * "password" from being large enough to be worth hashing. + */ + private const int MAX_LENGTH = 4096; + + /** + * @param int<1, max> $minLength The `min_length` node refuses anything lower than 1 + */ + public function __construct( + #[Autowire(param: 'solidworx_platform.profile.password.min_length')] + private int $minLength, + #[Autowire(param: 'solidworx_platform.profile.password.strength')] + private PasswordStrengthLevel $strength, + #[Autowire(param: 'solidworx_platform.profile.password.check_compromised')] + private bool $checkCompromised, + ) { + } + + /** + * @return list + */ + #[Override] + public function constraints(): array + { + $constraints = [ + new NotBlank(), + new Length( + min: $this->minLength, + max: self::MAX_LENGTH, + minMessage: 'Your password must be at least {{ limit }} characters long.', + maxMessage: 'Your password cannot be longer than {{ limit }} characters.', + ), + ]; + + $minScore = $this->strength->minScore(); + + if ($minScore !== null) { + $constraints[] = new PasswordStrength(minScore: $minScore); + } + + if ($this->checkCompromised) { + $constraints[] = new NotCompromisedPassword(skipOnError: true); + } + + return $constraints; + } + + #[Override] + public function requirements(): array + { + $requirements = [sprintf('At least %d characters long', $this->minLength)]; + + $strength = $this->strength->requirement(); + + if ($strength !== null) { + $requirements[] = $strength; + } + + if ($this->checkCompromised) { + $requirements[] = 'Not found in any known data breach'; + } + + return $requirements; + } +} diff --git a/src/Bundle/Platform/Security/Password/PasswordPolicyInterface.php b/src/Bundle/Platform/Security/Password/PasswordPolicyInterface.php new file mode 100644 index 00000000..f7b9efdb --- /dev/null +++ b/src/Bundle/Platform/Security/Password/PasswordPolicyInterface.php @@ -0,0 +1,46 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\PlatformBundle\Security\Password; + +use Symfony\Component\Validator\Constraint; + +/** + * The rules a user-chosen password has to satisfy. + * + * One service owns both halves of a password rule — the constraint that enforces it and the + * sentence that explains it — so the list shown on the change-password page can never drift + * away from what is actually validated. + * + * The default implementation is driven by `platform.profile.password`. Replace it wholesale to + * enforce something the configuration does not cover: + * + * #[AsDecorator(PasswordPolicyInterface::class)] + * final readonly class MyPasswordPolicy implements PasswordPolicyInterface { … } + */ +interface PasswordPolicyInterface +{ + /** + * The constraints to validate a submitted plain-text password with. + * + * @return list + */ + public function constraints(): array; + + /** + * The same rules as short sentences, for display next to the password field. + * + * @return list + */ + public function requirements(): array; +} diff --git a/src/Bundle/Ui/templates/Layout/partials/_user_menu.html.twig b/src/Bundle/Ui/templates/Layout/partials/_user_menu.html.twig index 884f2778..853aee60 100644 --- a/src/Bundle/Ui/templates/Layout/partials/_user_menu.html.twig +++ b/src/Bundle/Ui/templates/Layout/partials/_user_menu.html.twig @@ -11,17 +11,18 @@ #[MenuBuilder(name: UserMenu::NAME)] public function build(ItemInterface $menu): void { - $menu->addChild('Profile', Options::create()->route('app_profile')->icon('user')->build()); + $menu->addChild('Billing', Options::create()->route('app_billing')->icon('credit-card')->build()); } - The platform registers the two-factor entry there when 2FA is enabled. Logout is not a menu - entry — it is a CSRF-protected form — so it is always rendered last, under a divider. The + The platform registers the profile entry there, and the two-factor entry when 2FA is enabled. + Logout is not a menu entry — it is a CSRF-protected form — so it is always rendered last, + under a divider. The workspace switcher is a menu of its own, next to this one — see `_navbar.html.twig`. Everything is still a block, so a page can bypass the menu altogether: {% block user_menu_items %} - {{ 'Profile'|trans }} + {{ 'Billing'|trans }} {{ parent() }} {% endblock %} diff --git a/tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php b/tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php index 2b50f002..5511d27d 100644 --- a/tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php +++ b/tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php @@ -17,10 +17,14 @@ use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use SolidWorx\Platform\PlatformBundle\Config\Builder\PlatformConfigBuilder; +use SolidWorx\Platform\PlatformBundle\Config\Builder\ProfileConfigBuilder; use SolidWorx\Platform\PlatformBundle\Config\Builder\SecurityConfigBuilder; +use SolidWorx\Platform\PlatformBundle\Enum\PasswordStrengthLevel; #[CoversClass(PlatformConfigBuilder::class)] #[UsesClass(SecurityConfigBuilder::class)] +#[UsesClass(ProfileConfigBuilder::class)] +#[UsesClass(PasswordStrengthLevel::class)] final class PlatformConfigBuilderTest extends TestCase { public function testBuildAlwaysWrapsUnderPlatformKey(): void @@ -123,6 +127,75 @@ public function testSecurityBuilderChainReturnsParent(): void self::assertSame($builder, $securityBuilder->end()); } + public function testProfileBuilderChainReturnsParent(): void + { + $builder = PlatformConfigBuilder::create(); + $profileBuilder = $builder->profile(); + + self::assertSame($builder, $profileBuilder->end()); + } + + public function testProfileAbsentWhenNotSet(): void + { + $result = PlatformConfigBuilder::create()->build(); + + self::assertArrayNotHasKey('profile', self::section($result, 'platform')); + } + + /** + * Only what was actually set is emitted, so the configuration tree keeps supplying the + * defaults for everything else. + */ + public function testProfileOnlyEmitsWhatWasSet(): void + { + $result = PlatformConfigBuilder::create() + ->profile() + ->passwordMinLength(16) + ->end() + ->build(); + + self::assertSame( + [ + 'password' => [ + 'min_length' => 16, + ], + ], + self::section($result, 'platform', 'profile'), + ); + } + + public function testProfileBuildsTheWholeSection(): void + { + $result = PlatformConfigBuilder::create() + ->profile() + ->formType(SecurityConfigBuilder::class) + ->showTemplate('@App/profile/show.html.twig') + ->editTemplate('@App/profile/edit.html.twig') + ->changePasswordTemplate('@App/profile/password.html.twig') + ->passwordMinLength(16) + ->passwordStrength(PasswordStrengthLevel::Strong) + ->checkCompromisedPassword(false) + ->end() + ->build(); + + self::assertSame( + [ + 'form_type' => SecurityConfigBuilder::class, + 'templates' => [ + 'show' => '@App/profile/show.html.twig', + 'edit' => '@App/profile/edit.html.twig', + 'change_password' => '@App/profile/password.html.twig', + ], + 'password' => [ + 'min_length' => 16, + 'strength' => 'strong', + 'check_compromised' => false, + ], + ], + self::section($result, 'platform', 'profile'), + ); + } + /** * Walk a nested key path, asserting each step is an array, and return the sub-array. * diff --git a/tests/Bundle/PlatformBundle/Config/PlatformConfigurationTest.php b/tests/Bundle/PlatformBundle/Config/PlatformConfigurationTest.php index f2aa1c1b..7276754a 100644 --- a/tests/Bundle/PlatformBundle/Config/PlatformConfigurationTest.php +++ b/tests/Bundle/PlatformBundle/Config/PlatformConfigurationTest.php @@ -18,10 +18,20 @@ use PHPUnit\Framework\TestCase; use SolidWorx\Platform\PlatformBundle\Config\PlatformConfiguration; use SolidWorx\Platform\PlatformBundle\Entity\User; +use SolidWorx\Platform\PlatformBundle\Enum\PasswordStrengthLevel; +use SolidWorx\Platform\PlatformBundle\Form\Type\Profile\ProfileType; use Symfony\Component\Config\Definition\ArrayNode; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Config\Definition\Processor; - +use Symfony\Component\Form\Extension\Core\Type\TextType; + +/** + * @phpstan-type ProfileConfig array{ + * form_type: string, + * templates: array{show: string, edit: string, change_password: string}, + * password: array{min_length: int, strength: string, check_compromised: bool}, + * } + */ #[CoversClass(PlatformConfiguration::class)] final class PlatformConfigurationTest extends TestCase { @@ -202,6 +212,95 @@ public function testCustomUserModelIsApplied(): void self::assertSame('App\\Entity\\User', $result['models']['user']); } + public function testProfileDefaultsToThePlatformFormTypeAndTemplates(): void + { + $result = $this->process([]); + + self::assertSame(ProfileType::class, $result['profile']['form_type']); + self::assertSame('@SolidWorxPlatform/Profile/show.html.twig', $result['profile']['templates']['show']); + self::assertSame('@SolidWorxPlatform/Profile/edit.html.twig', $result['profile']['templates']['edit']); + self::assertSame('@SolidWorxPlatform/Profile/change_password.html.twig', $result['profile']['templates']['change_password']); + } + + public function testProfilePasswordRulesDefaultToTwelveCharactersMediumStrengthAndABreachCheck(): void + { + $result = $this->process([]); + + self::assertSame(12, $result['profile']['password']['min_length']); + self::assertSame(PasswordStrengthLevel::Medium->value, $result['profile']['password']['strength']); + self::assertTrue($result['profile']['password']['check_compromised']); + } + + public function testACustomProfileFormTypeIsApplied(): void + { + $result = $this->process([ + 'profile' => [ + 'form_type' => TextType::class, + ], + ]); + + self::assertSame(TextType::class, $result['profile']['form_type']); + } + + /** + * The controller passes the configured class straight to `createForm()`, so a class that is + * not a form type has to be rejected while the container is being built rather than on the + * first request to the page. + */ + public function testAProfileFormTypeThatIsNotAFormTypeIsRejected(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->process([ + 'profile' => [ + 'form_type' => User::class, + ], + ]); + } + + public function testAnUnknownPasswordStrengthIsRejected(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->process([ + 'profile' => [ + 'password' => [ + 'strength' => 'unbreakable', + ], + ], + ]); + } + + public function testAPasswordMinimumLengthBelowOneIsRejected(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->process([ + 'profile' => [ + 'password' => [ + 'min_length' => 0, + ], + ], + ]); + } + + public function testProfilePasswordRulesCanBeTightened(): void + { + $result = $this->process([ + 'profile' => [ + 'password' => [ + 'min_length' => 16, + 'strength' => PasswordStrengthLevel::VeryStrong->value, + 'check_compromised' => false, + ], + ], + ]); + + self::assertSame(16, $result['profile']['password']['min_length']); + self::assertSame('very_strong', $result['profile']['password']['strength']); + self::assertFalse($result['profile']['password']['check_compromised']); + } + public function testUnknownKeysAreRejected(): void { $this->expectException(InvalidConfigurationException::class); @@ -242,11 +341,11 @@ public function testFullConfigIsProcessed(): void /** * @param array $config * - * @return array{name: string, version: string, security: array{access_decision: array{strategies: array}, two_factor: array{enabled: bool, base_template: string|null}}, doctrine: array{types: array{enable_utc_date: bool}}, models: array{user: string}} + * @return array{name: string, version: string, security: array{access_decision: array{strategies: array}, two_factor: array{enabled: bool, base_template: string|null}}, doctrine: array{types: array{enable_utc_date: bool}}, models: array{user: string}, profile: ProfileConfig} */ private function process(array $config): array { - /** @var array{name: string, version: string, security: array{access_decision: array{strategies: array}, two_factor: array{enabled: bool, base_template: string|null}}, doctrine: array{types: array{enable_utc_date: bool}}, models: array{user: string}} */ + /** @var array{name: string, version: string, security: array{access_decision: array{strategies: array}, two_factor: array{enabled: bool, base_template: string|null}}, doctrine: array{types: array{enable_utc_date: bool}}, models: array{user: string}, profile: ProfileConfig} */ return $this->processor->process($this->configuration->getTreeBuilder()->buildTree(), [$config]); } } diff --git a/tests/Bundle/PlatformBundle/DependencyInjection/ProfileServicesTest.php b/tests/Bundle/PlatformBundle/DependencyInjection/ProfileServicesTest.php new file mode 100644 index 00000000..664a4427 --- /dev/null +++ b/tests/Bundle/PlatformBundle/DependencyInjection/ProfileServicesTest.php @@ -0,0 +1,202 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\Tests\Bundle\PlatformBundle\DependencyInjection; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use ReflectionClass; +use SolidWorx\Platform\PlatformBundle\Config\PlatformConfiguration; +use SolidWorx\Platform\PlatformBundle\Controller\Profile\ChangePassword; +use SolidWorx\Platform\PlatformBundle\Controller\Profile\EditProfile; +use SolidWorx\Platform\PlatformBundle\Controller\Profile\ShowProfile; +use SolidWorx\Platform\PlatformBundle\DependencyInjection\SolidWorxPlatformExtension; +use SolidWorx\Platform\PlatformBundle\Enum\PasswordStrengthLevel; +use SolidWorx\Platform\PlatformBundle\Form\Type\Profile\ProfileType; +use SolidWorx\Platform\PlatformBundle\Menu\ProfileMenuBuilder; +use SolidWorx\Platform\PlatformBundle\Security\Password\PasswordPolicy; +use SolidWorx\Platform\PlatformBundle\Security\Password\PasswordPolicyInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use function is_string; +use function sprintf; + +/** + * The profile services are wired from container parameters, and a parameter name is a string on + * both sides — the extension that sets it and the `#[Autowire]` attribute that reads it. Nothing + * checks that the two agree until the container is compiled, which these tests do up front. + */ +#[CoversClass(SolidWorxPlatformExtension::class)] +#[UsesClass(PlatformConfiguration::class)] +#[UsesClass(PasswordStrengthLevel::class)] +final class ProfileServicesTest extends TestCase +{ + /** + * Every class the profile pages are built from, in the order a request touches them. + * + * @return iterable + */ + public static function profileServices(): iterable + { + yield 'menu entry' => [ProfileMenuBuilder::class]; + yield 'profile page' => [ShowProfile::class]; + yield 'edit page' => [EditProfile::class]; + yield 'change password page' => [ChangePassword::class]; + yield 'profile form' => [ProfileType::class]; + yield 'password policy' => [PasswordPolicy::class]; + } + + /** + * @param class-string $service + */ + #[DataProvider('profileServices')] + public function testItIsRegisteredInTheContainer(string $service): void + { + self::assertTrue(self::container()->hasDefinition($service)); + } + + /** + * The profile services that read configuration out of container parameters. + * + * @return iterable + */ + public static function parameterisedServices(): iterable + { + yield 'profile page' => [ShowProfile::class]; + yield 'edit page' => [EditProfile::class]; + yield 'change password page' => [ChangePassword::class]; + yield 'profile form' => [ProfileType::class]; + yield 'password policy' => [PasswordPolicy::class]; + } + + /** + * @param class-string $service + */ + #[DataProvider('parameterisedServices')] + public function testEveryParameterItAutowiresExists(string $service): void + { + $container = self::container(); + + $parameters = self::autowiredParameters($service); + + self::assertNotSame([], $parameters, sprintf('%s autowires no parameters at all — has the wiring moved?', $service)); + + foreach ($parameters as $parameter) { + self::assertTrue( + $container->hasParameter($parameter), + sprintf('%s autowires the parameter "%s", which nothing sets.', $service, $parameter), + ); + } + } + + public function testThePasswordPolicyIsResolvableThroughItsContract(): void + { + $container = self::container(); + + self::assertTrue($container->hasAlias(PasswordPolicyInterface::class)); + self::assertSame(PasswordPolicy::class, (string) $container->getAlias(PasswordPolicyInterface::class)); + } + + public function testTheDefaultParametersMatchTheConfigurationDefaults(): void + { + $container = self::container(); + + self::assertSame(ProfileType::class, $container->getParameter('solidworx_platform.profile.form_type')); + self::assertSame('@SolidWorxPlatform/Profile/show.html.twig', $container->getParameter('solidworx_platform.profile.templates.show')); + self::assertSame('@SolidWorxPlatform/Profile/edit.html.twig', $container->getParameter('solidworx_platform.profile.templates.edit')); + self::assertSame('@SolidWorxPlatform/Profile/change_password.html.twig', $container->getParameter('solidworx_platform.profile.templates.change_password')); + self::assertSame(12, $container->getParameter('solidworx_platform.profile.password.min_length')); + self::assertSame(PasswordStrengthLevel::Medium, $container->getParameter('solidworx_platform.profile.password.strength')); + self::assertTrue($container->getParameter('solidworx_platform.profile.password.check_compromised')); + } + + /** + * The strength is stored as the enum rather than its backing value, so the policy never has + * to re-parse — and never has to handle — a string the configuration already validated. + */ + public function testTheConfiguredStrengthIsStoredAsTheEnum(): void + { + $container = self::container([ + 'profile' => [ + 'password' => [ + 'strength' => 'very_strong', + ], + ], + ]); + + self::assertSame( + PasswordStrengthLevel::VeryStrong, + $container->getParameter('solidworx_platform.profile.password.strength'), + ); + } + + public function testTheProfilePagesLearnWhetherTwoFactorIsEnabled(): void + { + self::assertFalse(self::container()->getParameter('solidworx_platform.security.two_factor.enabled')); + + self::assertTrue( + self::container([ + 'security' => [ + 'two_factor' => [ + 'enabled' => true, + ], + ], + ]) + ->getParameter('solidworx_platform.security.two_factor.enabled'), + ); + } + + /** + * @param array $config + */ + private static function container(array $config = []): ContainerBuilder + { + $container = new ContainerBuilder(); + + new SolidWorxPlatformExtension($config)->load([], $container); + + return $container; + } + + /** + * The parameter names a class pulls in through `#[Autowire(param: …)]` on its constructor. + * + * @param class-string $service + * + * @return list + */ + private static function autowiredParameters(string $service): array + { + $constructor = new ReflectionClass($service)->getConstructor(); + + if ($constructor === null) { + return []; + } + + $parameters = []; + + foreach ($constructor->getParameters() as $parameter) { + foreach ($parameter->getAttributes(Autowire::class) as $attribute) { + $param = $attribute->newInstance()->value; + + if (is_string($param) && str_starts_with($param, '%') && str_ends_with($param, '%')) { + $parameters[] = trim($param, '%'); + } + } + } + + return $parameters; + } +} diff --git a/tests/Bundle/PlatformBundle/Enum/PasswordStrengthLevelTest.php b/tests/Bundle/PlatformBundle/Enum/PasswordStrengthLevelTest.php new file mode 100644 index 00000000..92e075c0 --- /dev/null +++ b/tests/Bundle/PlatformBundle/Enum/PasswordStrengthLevelTest.php @@ -0,0 +1,84 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\Tests\Bundle\PlatformBundle\Enum; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use SolidWorx\Platform\PlatformBundle\Enum\PasswordStrengthLevel; +use Symfony\Component\Validator\Constraints\PasswordStrength; + +#[CoversClass(PasswordStrengthLevel::class)] +final class PasswordStrengthLevelTest extends TestCase +{ + /** + * @return iterable + */ + public static function scores(): iterable + { + yield 'none' => [PasswordStrengthLevel::None, null]; + yield 'weak' => [PasswordStrengthLevel::Weak, PasswordStrength::STRENGTH_WEAK]; + yield 'medium' => [PasswordStrengthLevel::Medium, PasswordStrength::STRENGTH_MEDIUM]; + yield 'strong' => [PasswordStrengthLevel::Strong, PasswordStrength::STRENGTH_STRONG]; + yield 'very strong' => [PasswordStrengthLevel::VeryStrong, PasswordStrength::STRENGTH_VERY_STRONG]; + } + + /** + * @param PasswordStrength::STRENGTH_*|null $expected + */ + #[DataProvider('scores')] + public function testItMapsOntoTheConstraintScore(PasswordStrengthLevel $level, ?int $expected): void + { + self::assertSame($expected, $level->minScore()); + } + + /** + * Every score it returns has to be one the constraint accepts, or building the constraint + * throws at runtime instead of failing the configuration. + * + * @param PasswordStrength::STRENGTH_*|null $minScore + */ + #[DataProvider('scores')] + public function testEveryScoreIsAcceptedByTheConstraint(PasswordStrengthLevel $level, ?int $minScore): void + { + if ($minScore === null) { + self::assertSame(PasswordStrengthLevel::None, $level); + + return; + } + + self::assertSame($minScore, new PasswordStrength(minScore: $minScore)->minScore); + } + + public function testOnlyTheDisabledLevelHasNothingToTellTheUser(): void + { + self::assertNull(PasswordStrengthLevel::None->requirement()); + + foreach (PasswordStrengthLevel::cases() as $level) { + if ($level === PasswordStrengthLevel::None) { + continue; + } + + self::assertNotNull($level->requirement(), $level->value); + } + } + + public function testValuesCoversEveryCase(): void + { + self::assertSame( + ['none', 'weak', 'medium', 'strong', 'very_strong'], + PasswordStrengthLevel::values(), + ); + } +} diff --git a/tests/Bundle/PlatformBundle/Fixtures/Form/ConstraintsOptionExtension.php b/tests/Bundle/PlatformBundle/Fixtures/Form/ConstraintsOptionExtension.php new file mode 100644 index 00000000..9c320371 --- /dev/null +++ b/tests/Bundle/PlatformBundle/Fixtures/Form/ConstraintsOptionExtension.php @@ -0,0 +1,46 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\Tests\Bundle\PlatformBundle\Fixtures\Form; + +use Override; +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * Defines the `constraints` option without wiring a validator behind it. + * + * `TypeTestCase` builds a bare form factory, so the option a form type declares its constraints + * through does not exist. Registering Symfony's own ValidatorExtension would define it, but it + * would also validate on submit — which would need a real user token and a live breach-check API + * for the constraints these form types use. Defining the option on its own is enough to assert + * that a type declares the right constraints, and keeps submission tests free of side effects. + */ +final class ConstraintsOptionExtension extends AbstractTypeExtension +{ + /** + * @return list + */ + #[Override] + public static function getExtendedTypes(): iterable + { + return [FormType::class]; + } + + #[Override] + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('constraints', []); + } +} diff --git a/tests/Bundle/PlatformBundle/Fixtures/ProfileUser.php b/tests/Bundle/PlatformBundle/Fixtures/ProfileUser.php new file mode 100644 index 00000000..ee3c6f35 --- /dev/null +++ b/tests/Bundle/PlatformBundle/Fixtures/ProfileUser.php @@ -0,0 +1,25 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\Tests\Bundle\PlatformBundle\Fixtures; + +use SolidWorx\Platform\PlatformBundle\Model\User; + +/** + * A concrete user, standing in for whatever an application configures under + * `platform.models.user`, so the profile form and pages can be exercised against a real instance + * of the mapped base class. + */ +final class ProfileUser extends User +{ +} diff --git a/tests/Bundle/PlatformBundle/Form/Type/Profile/ChangePasswordTypeTest.php b/tests/Bundle/PlatformBundle/Form/Type/Profile/ChangePasswordTypeTest.php new file mode 100644 index 00000000..05c19fd8 --- /dev/null +++ b/tests/Bundle/PlatformBundle/Form/Type/Profile/ChangePasswordTypeTest.php @@ -0,0 +1,174 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\Tests\Bundle\PlatformBundle\Form\Type\Profile; + +use Override; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +use SolidWorx\Platform\PlatformBundle\Enum\PasswordStrengthLevel; +use SolidWorx\Platform\PlatformBundle\Form\Type\Profile\ChangePasswordType; +use SolidWorx\Platform\PlatformBundle\Security\Password\PasswordPolicy; +use SolidWorx\Platform\Tests\Bundle\PlatformBundle\Fixtures\Form\ConstraintsOptionExtension; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\PreloadedExtension; +use Symfony\Component\Form\Test\TypeTestCase; +use Symfony\Component\Security\Core\Validator\Constraints\UserPassword; +use Symfony\Component\Validator\Constraints\NotCompromisedPassword; +use Symfony\Component\Validator\Constraints\PasswordStrength; +use function array_keys; +use function iterator_to_array; + +#[CoversClass(ChangePasswordType::class)] +#[UsesClass(PasswordPolicy::class)] +#[UsesClass(PasswordStrengthLevel::class)] +#[AllowMockObjectsWithoutExpectations] +final class ChangePasswordTypeTest extends TypeTestCase +{ + public function testItAsksForTheCurrentPasswordAndTheNewOneTwice(): void + { + $form = $this->factory->create(ChangePasswordType::class); + + self::assertSame( + [ChangePasswordType::CURRENT_PASSWORD, ChangePasswordType::NEW_PASSWORD], + array_keys(iterator_to_array($form)), + ); + + self::assertSame( + ['first', 'second'], + array_keys(iterator_to_array($form->get(ChangePasswordType::NEW_PASSWORD))), + ); + } + + /** + * Knowing the current password is what keeps a stolen session from locking the owner out, so + * the constraint that checks it has to be on the field itself. + */ + public function testTheCurrentPasswordIsCheckedAgainstTheAuthenticatedUser(): void + { + $form = $this->factory->create(ChangePasswordType::class); + + $constraints = $form->get(ChangePasswordType::CURRENT_PASSWORD)->getConfig()->getOption('constraints'); + + self::assertContains(UserPassword::class, self::classesOf($constraints)); + } + + /** + * The rules come from the policy rather than being spelled out here, so configuration and + * enforcement cannot drift apart. + */ + public function testTheNewPasswordIsValidatedWithThePasswordPolicy(): void + { + $form = $this->factory->create(ChangePasswordType::class); + + $classes = self::classesOf($form->get(ChangePasswordType::NEW_PASSWORD)->getConfig()->getOption('constraints')); + + self::assertContains(PasswordStrength::class, $classes); + self::assertContains(NotCompromisedPassword::class, $classes); + } + + public function testTwoDifferentNewPasswordsAreRejected(): void + { + $form = $this->factory->create(ChangePasswordType::class); + + $form->submit([ + ChangePasswordType::CURRENT_PASSWORD => 'the-current-one', + ChangePasswordType::NEW_PASSWORD => [ + 'first' => 'a-brand-new-password', + 'second' => 'a-different-password', + ], + ]); + + self::assertFalse($form->get(ChangePasswordType::NEW_PASSWORD)->isSynchronized()); + } + + /** + * Rotating a password onto itself would report success while changing nothing. + */ + public function testReusingTheCurrentPasswordIsRejected(): void + { + $form = $this->factory->create(ChangePasswordType::class); + + $form->submit([ + ChangePasswordType::CURRENT_PASSWORD => 'the-current-one', + ChangePasswordType::NEW_PASSWORD => [ + 'first' => 'the-current-one', + 'second' => 'the-current-one', + ], + ]); + + self::assertCount(1, $form->get(ChangePasswordType::NEW_PASSWORD)->getErrors()); + } + + public function testAGenuinelyNewPasswordIsAccepted(): void + { + $form = $this->factory->create(ChangePasswordType::class); + + $form->submit([ + ChangePasswordType::CURRENT_PASSWORD => 'the-current-one', + ChangePasswordType::NEW_PASSWORD => [ + 'first' => 'a-brand-new-password', + 'second' => 'a-brand-new-password', + ], + ]); + + self::assertCount(0, $form->get(ChangePasswordType::NEW_PASSWORD)->getErrors()); + self::assertSame('a-brand-new-password', $form->get(ChangePasswordType::NEW_PASSWORD)->getData()); + } + + /** + * Nothing about the form touches the user object: the plain-text password never leaves the + * form, and hashing it is the controller's job. + */ + public function testItIsNotBoundToTheUser(): void + { + $form = $this->factory->create(ChangePasswordType::class); + + self::assertNull($form->getConfig()->getOption('data_class')); + } + + /** + * @return list + */ + private static function classesOf(mixed $constraints): array + { + self::assertIsIterable($constraints); + + $classes = []; + + foreach ($constraints as $constraint) { + self::assertIsObject($constraint); + + $classes[] = $constraint::class; + } + + return $classes; + } + + /** + * @return list + */ + #[Override] + protected function getExtensions(): array + { + return [ + new PreloadedExtension( + [new ChangePasswordType(new PasswordPolicy(12, PasswordStrengthLevel::Medium, true))], + [ + FormType::class => [new ConstraintsOptionExtension()], + ], + ), + ]; + } +} diff --git a/tests/Bundle/PlatformBundle/Form/Type/Profile/ProfileTypeTest.php b/tests/Bundle/PlatformBundle/Form/Type/Profile/ProfileTypeTest.php new file mode 100644 index 00000000..bf5f666b --- /dev/null +++ b/tests/Bundle/PlatformBundle/Form/Type/Profile/ProfileTypeTest.php @@ -0,0 +1,156 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\Tests\Bundle\PlatformBundle\Form\Type\Profile; + +use Override; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; +use PHPUnit\Framework\Attributes\CoversClass; +use SolidWorx\Platform\PlatformBundle\Form\Type\Profile\ProfileType; +use SolidWorx\Platform\Tests\Bundle\PlatformBundle\Fixtures\Form\ConstraintsOptionExtension; +use SolidWorx\Platform\Tests\Bundle\PlatformBundle\Fixtures\ProfileUser; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\PreloadedExtension; +use Symfony\Component\Form\Test\TypeTestCase; +use function array_keys; +use function iterator_to_array; + +#[CoversClass(ProfileType::class)] +#[AllowMockObjectsWithoutExpectations] +final class ProfileTypeTest extends TypeTestCase +{ + public function testItExposesTheFieldsEveryPlatformUserHas(): void + { + $form = $this->factory->create(ProfileType::class, new ProfileUser()); + + self::assertSame( + ['firstName', 'lastName', 'email', 'mobile'], + array_keys(iterator_to_array($form)), + ); + } + + public function testItWritesTheSubmittedDetailsOntoTheUser(): void + { + $user = new ProfileUser(); + $user->setEmail('old@example.com'); + + $form = $this->factory->create(ProfileType::class, $user); + + $form->submit([ + 'firstName' => 'Ada', + 'lastName' => 'Lovelace', + 'email' => 'ada@example.com', + 'mobile' => '+27 82 000 0000', + ]); + + self::assertTrue($form->isSynchronized()); + self::assertSame('Ada', $user->getFirstName()); + self::assertSame('Lovelace', $user->getLastName()); + self::assertSame('ada@example.com', $user->getEmail()); + self::assertSame('+27 82 000 0000', $user->getMobile()); + } + + /** + * The mobile number is optional, so clearing the field has to be allowed to reach the setter. + */ + public function testTheMobileNumberCanBeCleared(): void + { + $user = new ProfileUser(); + $user->setEmail('ada@example.com'); + $user->setMobile('+27 82 000 0000'); + + $form = $this->factory->create(ProfileType::class, $user); + + $form->submit([ + 'firstName' => 'Ada', + 'lastName' => 'Lovelace', + 'email' => 'ada@example.com', + 'mobile' => '', + ]); + + self::assertTrue($form->isSynchronized()); + self::assertNull($user->getMobile()); + } + + /** + * The form is the boundary that decides which columns a user can write to. Roles, the enabled + * flag and the password hash are not fields, so a crafted request cannot set them. + */ + public function testItRefusesToWriteFieldsItDoesNotDeclare(): void + { + $user = new ProfileUser(); + $user->setEmail('ada@example.com'); + $user->setPassword('hashed'); + + $form = $this->factory->create(ProfileType::class, $user); + + $form->submit([ + 'firstName' => 'Ada', + 'lastName' => 'Lovelace', + 'email' => 'ada@example.com', + 'mobile' => '', + 'roles' => ['ROLE_ADMIN'], + 'enabled' => '1', + 'password' => 'chosen-by-the-attacker', + ]); + + self::assertSame(['ROLE_USER'], $user->getRoles()); + self::assertFalse($user->isEnabled()); + self::assertSame('hashed', $user->getPassword()); + } + + public function testItIsBoundToTheConfiguredUserClass(): void + { + $form = $this->factory->create(ProfileType::class, new ProfileUser()); + + self::assertSame(ProfileUser::class, $form->getConfig()->getOption('data_class')); + } + + /** + * Taking somebody else's address would take their sign-in identifier with it, so the + * uniqueness check is part of the form rather than left to the database index. + */ + public function testItRejectsAnEmailAddressAnotherAccountAlreadyUses(): void + { + $form = $this->factory->create(ProfileType::class, new ProfileUser()); + + $constraints = $form->getConfig()->getOption('constraints'); + + self::assertIsArray($constraints); + self::assertCount(1, $constraints); + + $constraint = $constraints[0]; + + self::assertInstanceOf(UniqueEntity::class, $constraint); + self::assertSame(['email'], $constraint->fields); + self::assertSame(ProfileUser::class, $constraint->entityClass); + self::assertSame('email', $constraint->errorPath); + } + + /** + * @return list + */ + #[Override] + protected function getExtensions(): array + { + return [ + new PreloadedExtension( + [new ProfileType(ProfileUser::class)], + [ + FormType::class => [new ConstraintsOptionExtension()], + ], + ), + ]; + } +} diff --git a/tests/Bundle/PlatformBundle/Menu/ProfileMenuBuilderTest.php b/tests/Bundle/PlatformBundle/Menu/ProfileMenuBuilderTest.php new file mode 100644 index 00000000..b78e9ee9 --- /dev/null +++ b/tests/Bundle/PlatformBundle/Menu/ProfileMenuBuilderTest.php @@ -0,0 +1,95 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\Tests\Bundle\PlatformBundle\Menu; + +use Knp\Menu\Integration\Symfony\RoutingExtension; +use Knp\Menu\MenuFactory; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; +use SolidWorx\Platform\PlatformBundle\Attributes\Menu\MenuBuilder; +use SolidWorx\Platform\PlatformBundle\Controller\Profile\ShowProfile; +use SolidWorx\Platform\PlatformBundle\Menu\Options; +use SolidWorx\Platform\PlatformBundle\Menu\ProfileMenuBuilder; +use SolidWorx\Platform\PlatformBundle\Menu\TwoFactorMenuBuilder; +use SolidWorx\Platform\PlatformBundle\Menu\UserMenu; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; + +#[CoversClass(ProfileMenuBuilder::class)] +#[UsesClass(UserMenu::class)] +#[UsesClass(Options::class)] +final class ProfileMenuBuilderTest extends TestCase +{ + public function testItLinksToTheProfilePage(): void + { + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator + ->expects(self::once()) + ->method('generate') + ->with(ShowProfile::ROUTE_NAME, [], UrlGeneratorInterface::ABSOLUTE_PATH) + ->willReturn(ShowProfile::PATH); + + $factory = new MenuFactory(); + $factory->addExtension(new RoutingExtension($urlGenerator)); + + $menu = $factory->createItem('root'); + + new ProfileMenuBuilder()->build($menu); + + $item = $menu->getChild('Profile'); + + self::assertNotNull($item); + self::assertSame(ShowProfile::PATH, $item->getUri()); + self::assertSame('user', $item->getExtra('icon')); + } + + /** + * Builders run highest priority first and each one appends, so "Profile" only leads the + * dropdown as long as it outranks both the platform's other account entries and an + * application's, which default to `0`. + */ + public function testItLeadsTheUserDropdown(): void + { + $attributes = new ReflectionMethod(ProfileMenuBuilder::class, 'build')->getAttributes(MenuBuilder::class); + + self::assertCount(1, $attributes); + + $attribute = $attributes[0]->newInstance(); + + self::assertSame(UserMenu::NAME, $attribute->name); + self::assertGreaterThan(UserMenu::PRIORITY_ACCOUNT, $attribute->priority); + } + + /** + * The two-factor entry belongs under the profile entry, not above it. + */ + public function testItOutranksTheTwoFactorEntry(): void + { + self::assertGreaterThan( + self::priorityOf(TwoFactorMenuBuilder::class), + self::priorityOf(ProfileMenuBuilder::class), + ); + } + + /** + * @param class-string $builder + */ + private static function priorityOf(string $builder): int + { + $attributes = new ReflectionMethod($builder, 'build')->getAttributes(MenuBuilder::class); + + return $attributes[0]->newInstance()->priority; + } +} diff --git a/tests/Bundle/PlatformBundle/Profile/ProfileRenderingTest.php b/tests/Bundle/PlatformBundle/Profile/ProfileRenderingTest.php new file mode 100644 index 00000000..49a1c865 --- /dev/null +++ b/tests/Bundle/PlatformBundle/Profile/ProfileRenderingTest.php @@ -0,0 +1,264 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\Tests\Bundle\PlatformBundle\Profile; + +use Override; +use PHPUnit\Framework\Attributes\CoversNothing; +use SolidWorx\Platform\PlatformBundle\Controller\Profile\ChangePassword; +use SolidWorx\Platform\PlatformBundle\Controller\Profile\EditProfile; +use SolidWorx\Platform\PlatformBundle\Controller\Profile\ShowProfile; +use SolidWorx\Platform\PlatformBundle\Controller\Security\TwoFactorConfiguration; +use SolidWorx\Platform\PlatformBundle\Form\Type\Profile\ChangePasswordType; +use SolidWorx\Platform\PlatformBundle\Form\Type\Profile\ProfileType; +use SolidWorx\Platform\PlatformBundle\Security\Password\PasswordPolicyInterface; +use SolidWorx\Platform\Tests\Bundle\PlatformBundle\Fixtures\ProfileUser; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; +use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Twig\Environment; +use function restore_exception_handler; + +/** + * Renders the three profile templates through the real Twig runtime. + * + * These templates are a customisation surface — applications are told to extend them and override + * blocks — so the blocks, the macros and the fields they render are a contract worth asserting, + * not just markup. + */ +#[CoversNothing] +final class ProfileRenderingTest extends KernelTestCase +{ + private ProfileUser $user; + + #[Override] + protected function setUp(): void + { + parent::setUp(); + + self::bootKernel(); + + $this->user = new ProfileUser(); + $this->user + ->setFirstName('Ada') + ->setLastName('Lovelace') + ->setEmail('ada@example.com') + ->setMobile('+27 82 000 0000'); + + $request = Request::create(ShowProfile::PATH); + $request->setSession(new Session(new MockArraySessionStorage())); + + $this->requestStack()->push($request); + + $this->tokenStorage()->setToken(new UsernamePasswordToken($this->user, 'main', $this->user->getRoles())); + } + + #[Override] + protected function tearDown(): void + { + parent::tearDown(); + + // Booting the kernel in debug mode registers a Symfony exception handler that it does not + // remove; restore it so PHPUnit does not flag the test as risky. + restore_exception_handler(); + } + + public function testTheProfilePageShowsEveryDetailThePlatformHolds(): void + { + $html = $this->renderShowPage(); + + self::assertStringContainsString('Ada', $html); + self::assertStringContainsString('Lovelace', $html); + self::assertStringContainsString('ada@example.com', $html); + self::assertStringContainsString('+27 82 000 0000', $html); + } + + public function testTheProfilePageLinksToBothWaysOfChangingSomething(): void + { + $html = $this->renderShowPage(); + + self::assertStringContainsString('href="' . EditProfile::PATH . '"', $html); + self::assertStringContainsString('href="' . ChangePassword::PATH . '"', $html); + } + + /** + * An empty field has to read as "nothing here yet" rather than as a rendering bug. + */ + public function testADetailThatHasNotBeenFilledInShowsADash(): void + { + $this->user->setMobile(null); + + self::assertStringContainsString('—', $this->renderShowPage()); + } + + /** + * The two-factor route only exists when 2FA is enabled, so the entry has to disappear with it + * — otherwise the page cannot render at all for an application that left 2FA off. + */ + public function testTheTwoFactorEntryIsOnlyShownWhenTwoFactorIsEnabled(): void + { + self::assertStringNotContainsString(TwoFactorConfiguration::PATH, $this->renderShowPage(twoFactorEnabled: false)); + self::assertStringContainsString(TwoFactorConfiguration::PATH, $this->renderShowPage(twoFactorEnabled: true)); + } + + public function testTheEditPageRendersEveryFieldOfTheProfileForm(): void + { + $html = $this->renderEditPage(); + + self::assertStringContainsString('name="profile[firstName]"', $html); + self::assertStringContainsString('name="profile[lastName]"', $html); + self::assertStringContainsString('name="profile[email]"', $html); + self::assertStringContainsString('name="profile[mobile]"', $html); + } + + public function testTheEditFormPostsBackToTheEditPage(): void + { + $html = $this->renderEditPage(); + + self::assertStringContainsString('method="post"', $html); + self::assertStringContainsString('name="profile[_token]"', $html); + } + + public function testTheEditFormIsPrefilledWithTheCurrentDetails(): void + { + self::assertStringContainsString('value="ada@example.com"', $this->renderEditPage()); + } + + public function testTheChangePasswordPageAsksForTheCurrentPasswordAndTheNewOneTwice(): void + { + $html = $this->renderChangePasswordPage(); + + self::assertStringContainsString('name="change_password[currentPassword]"', $html); + self::assertStringContainsString('name="change_password[newPassword][first]"', $html); + self::assertStringContainsString('name="change_password[newPassword][second]"', $html); + } + + /** + * The fields are rendered one at a time, so the token — which `form_end()` emits with the + * rest — is the easiest thing to lose to a template edit. + */ + public function testTheChangePasswordFormCarriesACsrfToken(): void + { + self::assertStringContainsString('name="change_password[_token]"', $this->renderChangePasswordPage()); + } + + /** + * Rules that are enforced but never stated are just failed submissions, so the page has to + * list what the policy actually requires. + */ + public function testTheChangePasswordPageListsTheRulesInForce(): void + { + $html = $this->renderChangePasswordPage(); + + foreach ($this->passwordPolicy()->requirements() as $requirement) { + self::assertStringContainsString($requirement, $html); + } + } + + /** + * Browsers offer to fill and to save passwords based on these hints; getting them wrong is + * what makes a password manager overwrite the wrong entry. + */ + public function testThePasswordFieldsCarryTheRightAutocompleteHints(): void + { + $html = $this->renderChangePasswordPage(); + + self::assertStringContainsString('autocomplete="current-password"', $html); + self::assertStringContainsString('autocomplete="new-password"', $html); + } + + private function renderShowPage(bool $twoFactorEnabled = false): string + { + return $this->twig()->render('@SolidWorxPlatform/Profile/show.html.twig', [ + 'user' => $this->user, + 'two_factor_enabled' => $twoFactorEnabled, + ]); + } + + private function renderEditPage(): string + { + $form = $this->formFactory()->create(ProfileType::class, $this->user); + + return $this->twig()->render('@SolidWorxPlatform/Profile/edit.html.twig', [ + 'form' => $form->createView(), + 'user' => $this->user, + ]); + } + + private function renderChangePasswordPage(): string + { + $form = $this->formFactory()->create(ChangePasswordType::class); + + return $this->twig()->render('@SolidWorxPlatform/Profile/change_password.html.twig', [ + 'form' => $form->createView(), + 'user' => $this->user, + 'password_requirements' => $this->passwordPolicy()->requirements(), + ]); + } + + private function twig(): Environment + { + return $this->service('twig', Environment::class); + } + + private function formFactory(): FormFactoryInterface + { + return $this->service(ProfileTestKernel::FORM_FACTORY, FormFactoryInterface::class); + } + + private function passwordPolicy(): PasswordPolicyInterface + { + return $this->service(PasswordPolicyInterface::class, PasswordPolicyInterface::class); + } + + private function requestStack(): RequestStack + { + return $this->service('request_stack', RequestStack::class); + } + + private function tokenStorage(): TokenStorageInterface + { + return $this->service('security.token_storage', TokenStorageInterface::class); + } + + /** + * @param array $options + */ + #[Override] + protected static function createKernel(array $options = []): KernelInterface + { + return new ProfileTestKernel('test', true); + } + + /** + * @template T of object + * + * @param class-string $type + * + * @return T + */ + private function service(string $id, string $type): object + { + $service = self::getContainer()->get($id); + + self::assertInstanceOf($type, $service); + + return $service; + } +} diff --git a/tests/Bundle/PlatformBundle/Profile/ProfileTestKernel.php b/tests/Bundle/PlatformBundle/Profile/ProfileTestKernel.php new file mode 100644 index 00000000..ddbe82f1 --- /dev/null +++ b/tests/Bundle/PlatformBundle/Profile/ProfileTestKernel.php @@ -0,0 +1,252 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\Tests\Bundle\PlatformBundle\Profile; + +use Knp\Bundle\MenuBundle\KnpMenuBundle; +use Override; +use SolidWorx\Platform\PlatformBundle\Controller\Profile\ChangePassword; +use SolidWorx\Platform\PlatformBundle\Controller\Profile\EditProfile; +use SolidWorx\Platform\PlatformBundle\Controller\Profile\ShowProfile; +use SolidWorx\Platform\PlatformBundle\Controller\Security\TwoFactorConfiguration; +use SolidWorx\Platform\PlatformBundle\Enum\PasswordStrengthLevel; +use SolidWorx\Platform\PlatformBundle\Form\Type\Profile\ChangePasswordType; +use SolidWorx\Platform\PlatformBundle\Form\Type\Profile\ProfileType; +use SolidWorx\Platform\PlatformBundle\Security\Password\PasswordPolicy; +use SolidWorx\Platform\PlatformBundle\Security\Password\PasswordPolicyInterface; +use SolidWorx\Platform\PlatformBundle\Twig\Extension\MenuExtension; +use SolidWorx\Platform\PlatformBundle\Twig\Runtime\MenuRuntime; +use SolidWorx\Platform\Tests\Bundle\PlatformBundle\Fixtures\ProfileUser; +use SolidWorx\Platform\UiBundle\Layout\LayoutResolver; +use SolidWorx\Platform\UiBundle\Twig\Runtime\LayoutRuntime; +use SolidWorx\Platform\UiBundle\Twig\UiExtension; +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; +use Symfony\Bundle\SecurityBundle\SecurityBundle; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\HttpKernel\Bundle\Bundle; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; +use Symfony\Component\Security\Core\User\InMemoryUser; +use Symfony\UX\Icons\UXIconsBundle; +use Symfony\UX\StimulusBundle\StimulusBundle; +use Symfony\UX\TwigComponent\TwigComponentBundle; +use Symfony\WebpackEncoreBundle\WebpackEncoreBundle; +use Twig\Extra\TwigExtraBundle\TwigExtraBundle; +use function dirname; +use function Symfony\Component\DependencyInjection\Loader\Configurator\service; +use function sys_get_temp_dir; + +/** + * Boots just enough of Symfony to render the profile pages. + * + * Same trade-off as {@see \SolidWorx\Platform\Tests\Bundle\Ui\Layout\LayoutTestKernel}: the + * platform bundles are not registered — they would drag in Doctrine and a database — so the few + * services the templates need are wired by hand. The templates, the form types, the layouts and + * the Twig runtime are all the real ones, which is the point: a broken block, a renamed macro or + * a field that stops rendering shows up here. + * + * The three profile routes and the two-factor route exist so the templates can generate the URLs + * they link to; they deliberately have no controllers, because nothing here dispatches a request. + */ +final class ProfileTestKernel extends Kernel +{ + use MicroKernelTrait; + + /** + * A public alias for the form factory, which the container would otherwise inline. + */ + public const string FORM_FACTORY = 'test.profile.form_factory'; + + /** + * @return iterable + */ + #[Override] + public function registerBundles(): iterable + { + yield new FrameworkBundle(); + yield new SecurityBundle(); + yield new TwigBundle(); + yield new TwigExtraBundle(); + yield new TwigComponentBundle(); + yield new StimulusBundle(); + yield new UXIconsBundle(); + yield new WebpackEncoreBundle(); + yield new KnpMenuBundle(); + } + + #[Override] + public function getCacheDir(): string + { + return sys_get_temp_dir() . '/solidworx_profile_test/cache/' . $this->environment; + } + + #[Override] + public function getBuildDir(): string + { + return sys_get_temp_dir() . '/solidworx_profile_test/build/' . $this->environment; + } + + #[Override] + public function getLogDir(): string + { + return sys_get_temp_dir() . '/solidworx_profile_test/log'; + } + + protected function configureContainer(ContainerConfigurator $container): void + { + $projectDir = dirname(__DIR__, 4); + $fixtures = __DIR__ . '/fixtures'; + + $container->extension('framework', [ + 'secret' => 'profile-test', + 'test' => true, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'router' => [ + 'utf8' => true, + ], + 'assets' => [], + 'csrf_protection' => true, + 'form' => true, + 'validation' => [ + 'enabled' => true, + 'email_validation_mode' => 'html5', + ], + 'session' => [ + 'storage_factory_id' => 'session.storage.factory.mock_file', + 'handler_id' => null, + 'cookie_secure' => 'auto', + 'cookie_samesite' => 'lax', + ], + ]); + + $container->extension('security', [ + 'password_hashers' => [ + InMemoryUser::class => [ + 'algorithm' => 'plaintext', + ], + ], + 'providers' => [ + 'in_memory' => [ + 'memory' => [ + 'users' => [], + ], + ], + ], + 'firewalls' => [ + 'main' => [ + 'lazy' => true, + 'provider' => 'in_memory', + // The user dropdown in the layout renders a logout form, which needs a + // logout listener registered for the firewall the token names. + 'logout' => [ + 'path' => '/logout', + ], + ], + ], + ]); + + $container->extension('twig', [ + 'paths' => [ + $projectDir . '/src/Bundle/Ui/templates' => 'Ui', + $projectDir . '/src/Bundle/Platform/Resources/views' => 'SolidWorxPlatform', + ], + ]); + + $container->extension('twig_component', [ + 'defaults' => [], + 'anonymous_template_directory' => 'components', + ]); + + $container->extension('ux_icons', [ + 'icon_dir' => $fixtures, + 'ignore_not_found' => true, + 'iconify' => [ + 'on_demand' => false, + ], + ]); + + $container->extension('webpack_encore', [ + 'output_path' => $fixtures . '/build', + 'strict_mode' => false, + ]); + + $container->extension('knp_menu', [ + 'default_renderer' => 'twig', + 'twig' => [ + 'template' => '@SolidWorxPlatform/Menu/menu.html.twig', + ], + ]); + + $services = $container->services() + ->defaults() + ->autoconfigure() + ->autowire(); + + // Both are private, and the profile tests build forms and read the policy directly. + $services->alias(self::FORM_FACTORY, 'form.factory') + ->public(); + + $services->alias(PasswordPolicyInterface::class, PasswordPolicy::class) + ->public(); + + $services->set(MenuRuntime::class) + ->arg('$menuProvider', service('knp_menu.menu_provider')) + ->tag('twig.runtime'); + + $services->set(MenuExtension::class) + ->tag('twig.extension'); + + $services->set(LayoutResolver::class) + ->args([[]]); + + $services->set(LayoutRuntime::class) + ->args([service(LayoutResolver::class)]) + ->tag('twig.runtime'); + + $services->set(UiExtension::class) + ->args([ + '@Ui/Layout/base.html.twig', + [ + 'app' => '@Ui/Layout/app.html.twig', + 'condensed' => '@Ui/Layout/condensed.html.twig', + 'clean' => '@Ui/Layout/clean.html.twig', + ], + 'Acme Platform', + ]) + ->tag('twig.extension'); + + // The two form types under test, wired the way the platform wires them: the profile form + // against the configured user class, and the password form against the configured policy. + $services->set(ProfileType::class) + ->args([ProfileUser::class]) + ->tag('form.type'); + + $services->set(PasswordPolicy::class) + ->args([12, PasswordStrengthLevel::Medium, true]); + + $services->set(ChangePasswordType::class) + ->args([service(PasswordPolicy::class)]) + ->tag('form.type'); + } + + protected function configureRoutes(RoutingConfigurator $routes): void + { + $routes->add(ShowProfile::ROUTE_NAME, ShowProfile::PATH); + $routes->add(EditProfile::ROUTE_NAME, EditProfile::PATH); + $routes->add(ChangePassword::ROUTE_NAME, ChangePassword::PATH); + $routes->add(TwoFactorConfiguration::ROUTE_NAME, TwoFactorConfiguration::PATH); + } +} diff --git a/tests/Bundle/PlatformBundle/Profile/fixtures/build/entrypoints.json b/tests/Bundle/PlatformBundle/Profile/fixtures/build/entrypoints.json new file mode 100644 index 00000000..ebb86411 --- /dev/null +++ b/tests/Bundle/PlatformBundle/Profile/fixtures/build/entrypoints.json @@ -0,0 +1,8 @@ +{ + "entrypoints": { + "_platform_ui": { + "js": ["/build/platform.js"], + "css": ["/build/platform.css"] + } + } +} diff --git a/tests/Bundle/PlatformBundle/Security/Password/PasswordPolicyTest.php b/tests/Bundle/PlatformBundle/Security/Password/PasswordPolicyTest.php new file mode 100644 index 00000000..4bc312d2 --- /dev/null +++ b/tests/Bundle/PlatformBundle/Security/Password/PasswordPolicyTest.php @@ -0,0 +1,126 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\Tests\Bundle\PlatformBundle\Security\Password; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use SolidWorx\Platform\PlatformBundle\Enum\PasswordStrengthLevel; +use SolidWorx\Platform\PlatformBundle\Security\Password\PasswordPolicy; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotCompromisedPassword; +use Symfony\Component\Validator\Constraints\PasswordStrength; +use function array_map; + +#[CoversClass(PasswordPolicy::class)] +#[UsesClass(PasswordStrengthLevel::class)] +final class PasswordPolicyTest extends TestCase +{ + public function testTheDefaultPolicyChecksLengthStrengthAndBreaches(): void + { + $policy = new PasswordPolicy(12, PasswordStrengthLevel::Medium, true); + + self::assertSame( + [NotBlank::class, Length::class, PasswordStrength::class, NotCompromisedPassword::class], + self::classesOf($policy->constraints()), + ); + } + + public function testTheConfiguredMinimumLengthIsTheOneEnforced(): void + { + $constraints = new PasswordPolicy(20, PasswordStrengthLevel::None, false)->constraints(); + + $length = $constraints[1]; + + self::assertInstanceOf(Length::class, $length); + self::assertSame(20, $length->min); + } + + public function testTheConfiguredStrengthIsTheOneEnforced(): void + { + $constraints = new PasswordPolicy(12, PasswordStrengthLevel::VeryStrong, false)->constraints(); + + $strength = $constraints[2]; + + self::assertInstanceOf(PasswordStrength::class, $strength); + self::assertSame(PasswordStrength::STRENGTH_VERY_STRONG, $strength->minScore); + } + + public function testStrengthCanBeTurnedOff(): void + { + $policy = new PasswordPolicy(12, PasswordStrengthLevel::None, true); + + self::assertSame( + [NotBlank::class, Length::class, NotCompromisedPassword::class], + self::classesOf($policy->constraints()), + ); + } + + public function testTheBreachCheckCanBeTurnedOff(): void + { + $policy = new PasswordPolicy(12, PasswordStrengthLevel::Medium, false); + + self::assertSame( + [NotBlank::class, Length::class, PasswordStrength::class], + self::classesOf($policy->constraints()), + ); + } + + /** + * An outage at the breach API must not stand between somebody and a password rotation. + */ + public function testTheBreachCheckIsSkippedWhenTheApiCannotBeReached(): void + { + $constraints = new PasswordPolicy(12, PasswordStrengthLevel::None, true)->constraints(); + + $breachCheck = $constraints[2]; + + self::assertInstanceOf(NotCompromisedPassword::class, $breachCheck); + self::assertTrue($breachCheck->skipOnError); + } + + /** + * The list shown to the user is what makes the rules discoverable, so it has to describe + * every rule that is actually switched on — and nothing that is not. + */ + public function testTheRequirementsDescribeExactlyTheRulesInForce(): void + { + $requirements = new PasswordPolicy(16, PasswordStrengthLevel::Strong, true)->requirements(); + + self::assertCount(3, $requirements); + self::assertStringContainsString('16', $requirements[0]); + self::assertSame(PasswordStrengthLevel::Strong->requirement(), $requirements[1]); + self::assertStringContainsString('data breach', $requirements[2]); + } + + public function testTheRequirementsDropTheRulesThatAreTurnedOff(): void + { + $requirements = new PasswordPolicy(8, PasswordStrengthLevel::None, false)->requirements(); + + self::assertCount(1, $requirements); + self::assertStringContainsString('8', $requirements[0]); + } + + /** + * @param list $constraints + * + * @return list + */ + private static function classesOf(array $constraints): array + { + return array_map(static fn (Constraint $constraint): string => $constraint::class, $constraints); + } +} From 259d5d8b150ff5e4ed417d13cae92908a79644c0 Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Tue, 8 Sep 2026 15:14:14 +0300 Subject: [PATCH 2/3] Fix PHPStan: specify TData on FormTypeInterface and inherit buildForm param types --- src/Bundle/Platform/Controller/Profile/EditProfile.php | 2 +- src/Bundle/Platform/Form/Type/Profile/ChangePasswordType.php | 4 ---- src/Bundle/Platform/Form/Type/Profile/ProfileType.php | 4 ---- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/Bundle/Platform/Controller/Profile/EditProfile.php b/src/Bundle/Platform/Controller/Profile/EditProfile.php index 5a0ff411..de5679a0 100644 --- a/src/Bundle/Platform/Controller/Profile/EditProfile.php +++ b/src/Bundle/Platform/Controller/Profile/EditProfile.php @@ -49,7 +49,7 @@ final class EditProfile extends BaseController public const string ROUTE_NAME = 'solidworx_platform_profile_edit'; /** - * @param class-string $formType The class configured under `platform.profile.form_type` + * @param class-string> $formType The class configured under `platform.profile.form_type` */ public function __construct( private readonly EntityManagerInterface $entityManager, diff --git a/src/Bundle/Platform/Form/Type/Profile/ChangePasswordType.php b/src/Bundle/Platform/Form/Type/Profile/ChangePasswordType.php index 062109b5..8e7703d9 100644 --- a/src/Bundle/Platform/Form/Type/Profile/ChangePasswordType.php +++ b/src/Bundle/Platform/Form/Type/Profile/ChangePasswordType.php @@ -58,10 +58,6 @@ public function __construct( ) { } - /** - * @param FormBuilderInterface $builder - * @param array $options - */ #[Override] public function buildForm(FormBuilderInterface $builder, array $options): void { diff --git a/src/Bundle/Platform/Form/Type/Profile/ProfileType.php b/src/Bundle/Platform/Form/Type/Profile/ProfileType.php index ee1ffd74..c1824350 100644 --- a/src/Bundle/Platform/Form/Type/Profile/ProfileType.php +++ b/src/Bundle/Platform/Form/Type/Profile/ProfileType.php @@ -67,10 +67,6 @@ public function __construct( ) { } - /** - * @param FormBuilderInterface $builder - * @param array $options - */ #[Override] public function buildForm(FormBuilderInterface $builder, array $options): void { From 2116b128168fe528cc95d76d6033f149da87b1b4 Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Wed, 16 Sep 2026 13:26:05 +0200 Subject: [PATCH 3/3] Update profile and 2fa styles --- CLAUDE.md | 23 + UPGRADE.md | 48 +- assets/controllers/csrf_protection.js | 36 +- assets/controllers/two_factor_controller.js | 84 ++++ assets/package.json | 6 + assets/scss/_settings-nav.scss | 62 +++ assets/scss/platform.scss | 1 + docs/frontend/components.md | 172 +++++++ docs/frontend/controllers.md | 75 +++ docs/frontend/index.md | 1 + docs/frontend/layouts.md | 15 +- docs/index.md | 1 + docs/security/profile.md | 131 +++++- docs/security/two-factor.md | 20 +- platform-schema.json | 7 + .../Config/Builder/ProfileConfigBuilder.php | 10 + .../Platform/Config/PlatformConfiguration.php | 4 + .../Security/TwoFactorConfiguration.php | 2 +- .../SolidWorxPlatformExtension.php | 3 +- src/Bundle/Platform/Menu/ProfileMenu.php | 68 +++ .../Platform/Menu/ProfileMenuBuilder.php | 40 +- .../Platform/Menu/TwoFactorMenuBuilder.php | 9 +- src/Bundle/Platform/Menu/UserMenu.php | 18 +- .../Components/Security/LogoutLink.html.twig | 2 +- .../Components/Security/two_factor.html.twig | 434 ++++++++++++------ .../Resources/views/Form/theme.html.twig | 18 +- .../views/Menu/settings_nav.html.twig | 86 ++++ .../views/Profile/change_password.html.twig | 75 +-- .../Resources/views/Profile/edit.html.twig | 54 +-- .../Resources/views/Profile/layout.html.twig | 55 +++ .../Resources/views/Profile/show.html.twig | 175 +++---- .../Security/TwoFactor/configure.html.twig | 24 +- .../Twig/Components/Security/TwoFactor.php | 19 + .../Twig/Extension/ProfileExtension.php | 52 +++ .../Ui/templates/Security/login.html.twig | 22 +- .../Ui/templates/components/Card.html.twig | 23 +- .../components/PasswordField.html.twig | 55 +++ .../components/Security/LogoutLink.html.twig | 2 +- .../templates/components/SettingRow.html.twig | 105 +++++ .../components/SettingsNav.html.twig | 37 ++ .../Builder/PlatformConfigBuilderTest.php | 2 + .../Config/PlatformConfigurationTest.php | 3 +- .../ProfileServicesTest.php | 3 + .../Fixtures/StubBackupCodeGenerator.php | 38 ++ .../Fixtures/StubTotpAuthenticator.php | 48 ++ .../Fixtures/StubTrustedDeviceManager.php | 41 ++ .../Fixtures/StubUserProvider.php | 70 +++ .../Fixtures/StubUserRepository.php | 33 ++ .../Menu/ProfileMenuBuilderTest.php | 114 +++-- .../Menu/TwoFactorMenuBuilderTest.php | 10 +- .../Profile/ProfileRenderingTest.php | 57 +++ .../Profile/ProfileTestKernel.php | 44 ++ .../Security/TwoFactorComponentTest.php | 188 ++++++++ .../Security/TwoFactorFilenameTest.php | 67 +++ .../Security/TwoFactorRenderingTest.php | 306 ++++++++++++ .../Security/TwoFactorTestKernel.php | 253 ++++++++++ 56 files changed, 2928 insertions(+), 423 deletions(-) create mode 100644 assets/controllers/two_factor_controller.js create mode 100644 assets/scss/_settings-nav.scss create mode 100644 docs/frontend/components.md create mode 100644 src/Bundle/Platform/Menu/ProfileMenu.php create mode 100644 src/Bundle/Platform/Resources/views/Menu/settings_nav.html.twig create mode 100644 src/Bundle/Platform/Resources/views/Profile/layout.html.twig create mode 100644 src/Bundle/Platform/Twig/Extension/ProfileExtension.php create mode 100644 src/Bundle/Ui/templates/components/PasswordField.html.twig create mode 100644 src/Bundle/Ui/templates/components/SettingRow.html.twig create mode 100644 src/Bundle/Ui/templates/components/SettingsNav.html.twig create mode 100644 tests/Bundle/PlatformBundle/Fixtures/StubBackupCodeGenerator.php create mode 100644 tests/Bundle/PlatformBundle/Fixtures/StubTotpAuthenticator.php create mode 100644 tests/Bundle/PlatformBundle/Fixtures/StubTrustedDeviceManager.php create mode 100644 tests/Bundle/PlatformBundle/Fixtures/StubUserProvider.php create mode 100644 tests/Bundle/PlatformBundle/Fixtures/StubUserRepository.php create mode 100644 tests/Bundle/PlatformBundle/Security/TwoFactorComponentTest.php create mode 100644 tests/Bundle/PlatformBundle/Security/TwoFactorFilenameTest.php create mode 100644 tests/Bundle/PlatformBundle/Security/TwoFactorRenderingTest.php create mode 100644 tests/Bundle/PlatformBundle/Security/TwoFactorTestKernel.php diff --git a/CLAUDE.md b/CLAUDE.md index cd96aa94..5335a730 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,29 @@ The frontend is decoupled from the backend logic but integrated via Webpack Enco - **Styles:** SCSS files in `assets/scss/`. - **Build:** Run `bun run build` in the `assets/` directory. +### UI Consistency (STRICT — no exceptions) + +**The same thing must look the same everywhere in the app.** A visual pattern belongs to the +application, never to the page that happened to need it first. + +1. **Never write page-scoped or section-scoped styles.** A class like `.profile-section-header` + or `.billing-status-row` is a bug: it guarantees that the next page rendering the same + information will diverge. There is no such thing as "just for this page". +2. **Reach for Tabler/Bootstrap first.** They cover cards, badges, avatars, datagrids, + list-groups, steps and nav variants. If a class already exists, use it. +3. **When Tabler does not cover it, build a shared Twig component** in + `src/Bundle/Ui/templates/components/`, styled once in `assets/scss/`. Components take props; + they do not take a page name. +4. **Changing a shared pattern means changing every use of it.** Before you alter a section + header, an icon tile, a status row or a nav item, grep for every place that renders the same + information and update them in the same commit. A restyle that lands on one page only is not + finished. +5. **Two pieces of markup showing the same kind of information must be the same component.** + If you find yourself copying markup between templates, that markup is a component you have + not extracted yet. + +This applies to templates, SCSS and Stimulus controllers alike. + ### Creating a New Stimulus Controller 1. Create file in `assets/controllers/my_feature_controller.js` (**must be `.js`, not `.ts`** — controllers are distributed via npm and consumed by 3rd-party apps that do not process TypeScript from `node_modules`). 2. Extend `Controller` from `@hotwired/stimulus`. diff --git a/UPGRADE.md b/UPGRADE.md index dbe7c5b3..fa4a54c4 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -2,12 +2,35 @@ ## 0.2 → 0.3 -### User profile pages - -Every signed-in user now gets `/profile`, `/profile/edit` and `/profile/password`, and a -**Profile** entry leading the user dropdown. See [the profile guide](./docs/security/profile.md). - -Nothing is required to upgrade, but three things changed shape: +### User profile section + +Every signed-in user now gets a profile section — `/profile`, `/profile/edit`, `/profile/password` +and, when 2FA is enabled, `/profile/two-factor` — with a navigation column listing its pages, and +a **Profile** entry leading the user dropdown. See +[the profile guide](./docs/security/profile.md). + +Nothing is required to upgrade, but these changed shape: + +- **Two-factor authentication moved out of the user dropdown.** It is now an entry on the new + `profile_menu`, so it sits with the other account pages instead of behind the avatar. The route + and the path are unchanged, so existing links keep working. If you linked to it from a menu + builder of your own, nothing breaks — but the platform no longer puts it in `user_menu`. +- **`UserMenu::PRIORITY_ACCOUNT` no longer has a platform entry at it.** The constant stays, as the + anchor for registering an account-level entry in the dropdown. Anything you registered relative to + it still lands where it did. +- **Profile pages extend `profile_layout` rather than `ui_layout_app`.** A template of your own that + extends a shipped profile page picks the navigation up automatically. One that *replaced* a page + outright keeps working as it is, and can opt into the section by extending `profile_layout`. +- **`show.html.twig` no longer exports the `security_item()` macro.** Security rows are now + ``, the shared component the two-factor page uses as well, so a setting reads + the same wherever it appears. Replace `{{ profile.security_item(…) }}` calls with the component — + see [the profile guide](./docs/security/profile.md#overriding-blocks). The `detail()` macro is + unchanged. +- **`show.html.twig` no longer defines `page_title_actions`.** The "Update profile" button lives in + the details card footer. Override the block yourself if you want a header action back. +- **Every Symfony password field now renders a show/hide toggle**, from the form theme's + `password_widget`. No markup changes are needed; if you had built your own toggle around a + `PasswordType`, remove it or you will get two. - **`SolidWorx\Platform\PlatformBundle\Model\UserInterface` gained `setPassword(string): static`.** It is what lets the platform rotate a password on the user's behalf. Classes extending @@ -21,9 +44,16 @@ Nothing is required to upgrade, but three things changed shape: on its own now; it carries the Bootstrap layout with it, and there is no need to list `bootstrap_5_layout.html.twig` alongside it. -`platform.yaml` gained a `platform.profile` section (the form type, the three templates and the -password rules). Every key is optional; regenerate `platform-schema.json` with -`php bin/console platform:generate-schema` to pick them up in your editor. +`platform.yaml` gained a `platform.profile` section (the form type, the four templates — including +`templates.layout`, the section's chrome — and the password rules). Every key is optional; +regenerate `platform-schema.json` with `php bin/console platform:generate-schema` to pick them up in +your editor. + +The UI bundle also gained three shared Twig components — `Ui:SettingRow`, `Ui:SettingsNav` and +`Ui:PasswordField` — and `Ui:Card` gained `icon` and `iconColor` props. See +[UI Components](./docs/frontend/components.md). `Ui:Card` now wraps its title and subtitle in a +`
` so the subtitle sits under the title rather than beside it; a stylesheet targeting +`.card-header > .card-title` directly may need adjusting. ### Tabler page layouts diff --git a/assets/controllers/csrf_protection.js b/assets/controllers/csrf_protection.js index c651c031..a30c895c 100644 --- a/assets/controllers/csrf_protection.js +++ b/assets/controllers/csrf_protection.js @@ -20,11 +20,41 @@ document.addEventListener('turbo:submit-end', function (event) { removeCsrfToken(event.detail.formSubmission.formElement); }); +// A live component never submits its form: the payload goes out by fetch, and the button that sends +// it is a plain `type="button"`. So none of the events above ever fire, the field keeps the token +// *id* Symfony rendered into it, and the matching cookie is never written — every submission then +// fails as an invalid CSRF token. +// +// Catching the click in the capture phase puts the token in place before the component reads the +// form: `generateCsrfToken` swaps the id for a real token, sets the cookie, and fires `change`, +// which is what the component listens to in order to pick the new value up. +// +// It is a no-op under session-based CSRF, where the rendered value is already a token rather than +// an id, so this is safe whichever mode an application configures. +document.addEventListener('click', function (event) { + const target = event.target; + + if (!(target instanceof Element)) { + return; + } + + const trigger = target.closest('[data-action*="live#action"]'); + + if (!trigger) { + return; + } + + const form = trigger.closest('form') + ?? trigger.closest('[data-controller~="live"]')?.querySelector('form'); + + if (form) { + generateCsrfToken(form); + } +}, true); + export function generateCsrfToken (formElement) { const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); - console.log(csrfField); - if (!csrfField) { return; } @@ -38,8 +68,6 @@ export function generateCsrfToken (formElement) { } csrfField.dispatchEvent(new Event('change', { bubbles: true })); - console.log(csrfCookie && tokenCheck.test(csrfToken)) - if (csrfCookie && tokenCheck.test(csrfToken)) { const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict'; document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie; diff --git a/assets/controllers/two_factor_controller.js b/assets/controllers/two_factor_controller.js new file mode 100644 index 00000000..7716f238 --- /dev/null +++ b/assets/controllers/two_factor_controller.js @@ -0,0 +1,84 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * The client-side half of the two-factor settings page. + * + * Two jobs, both of which would be a round-trip for nothing if the server did them: + * + * - **Stepping through the authenticator setup.** Scanning a QR code and typing the six digits it + * produces are two screens, but one form. Every field stays in the DOM the whole time — the + * steps are shown and hidden, never added and removed — so the live component submits a complete + * form whichever step is on screen. + * - **Downloading backup codes.** The codes are already rendered on the page; asking the server for + * a file containing what the browser is holding would mean a route that returns recovery codes, + * which is a route worth not having. + * + * Anything that belongs to one step — a pane in the body, a button in the footer — carries the + * `step` target and a `data-step` saying which one. Reading the step off the element rather than + * its position lets the footer buttons sit directly in `.modal-footer`, where they pick up its + * spacing and alignment, instead of being grouped into a wrapper per step. + * + * The starting step comes from the server through `stepValue`, so a failed verification reopens on + * the step that failed rather than sending the user back to the QR code. + */ +export default class extends Controller { + static targets = ['step', 'stepItem']; + + static values = { + step: { type: Number, default: 0 }, + codes: { type: Array, default: [] }, + filename: { type: String, default: 'backup-codes.txt' }, + }; + + /** + * Runs on connect as well as on every change, so it is also what renders the initial step. + */ + stepValueChanged(step) { + this.stepTargets.forEach((element) => { + element.classList.toggle('d-none', Number(element.dataset.step) !== step); + }); + + // Tabler marks the *current* step only: everything after an `.active` item is greyed out by + // `.step-item.active ~ .step-item`, so marking the earlier ones active would grey out the + // one the user is actually on. + this.stepItemTargets.forEach((element, index) => { + element.classList.toggle('active', index === step); + }); + } + + next(event) { + event.preventDefault(); + this.stepValue = Math.min(this.stepValue + 1, Math.max(this.stepItemTargets.length - 1, 0)); + } + + previous(event) { + event.preventDefault(); + this.stepValue = Math.max(this.stepValue - 1, 0); + } + + /** + * Hands the codes to the browser as a text file, without them ever leaving the page. + */ + download(event) { + event.preventDefault(); + + if (this.codesValue.length === 0) { + return; + } + + const url = URL.createObjectURL( + new Blob([`${this.codesValue.join('\n')}\n`], { type: 'text/plain;charset=utf-8' }), + ); + + const link = document.createElement('a'); + link.href = url; + link.download = this.filenameValue; + link.hidden = true; + + document.body.appendChild(link); + link.click(); + link.remove(); + + URL.revokeObjectURL(url); + } +} diff --git a/assets/package.json b/assets/package.json index 8b26bb3a..a91dc75a 100644 --- a/assets/package.json +++ b/assets/package.json @@ -53,6 +53,12 @@ "webpackMode": "lazy", "fetch": "lazy", "enabled": true + }, + "two-factor": { + "main": "controllers/two_factor_controller.js", + "webpackMode": "lazy", + "fetch": "lazy", + "enabled": true } }, "importmap": { diff --git a/assets/scss/_settings-nav.scss b/assets/scss/_settings-nav.scss new file mode 100644 index 00000000..c5f9d6ca --- /dev/null +++ b/assets/scss/_settings-nav.scss @@ -0,0 +1,62 @@ +// The vertical navigation beside a settings section — ``. +// +// Everything here is what Tabler's `list-group-transparent` does not give a settings navigation: +// a left accent marking the current page, an icon that picks up the link colour, and a column +// that stays in view while a long settings page scrolls. +// +// It is keyed off the component's own class, so every settings navigation in the application — +// profile, workspace, admin — looks identical. Never restyle one of them from a page. + +.swp-settings-nav { + // Colours come from Tabler's own custom properties, so an application that themes Tabler + // themes this with it, and dark mode needs no second rule. + --swp-settings-nav-accent: var(--tblr-primary); + + // The rows run edge to edge and carry their own background, so the card has to clip them — + // otherwise the first and last square off its rounded corners. + overflow: hidden; + + .list-group-item { + display: flex; + align-items: center; + gap: .75rem; + padding: .625rem 1rem; + // Every border is ours: `list-group-flush` would otherwise draw a divider under each row, + // and the rows read as one list without them. + border: 0; + // The accent is a transparent border on every item, so the row does not shift by 3px + // when it becomes the current one. + border-inline-start: 3px solid transparent; + color: var(--tblr-body-color); + border-radius: 0; + } + + .list-group-item:hover:not(.active, .disabled) { + background: var(--tblr-bg-surface-secondary); + color: var(--tblr-body-color); + } + + .list-group-item.active { + background: rgba(var(--tblr-primary-rgb), .06); + border-inline-start-color: var(--swp-settings-nav-accent); + color: var(--swp-settings-nav-accent); + font-weight: var(--tblr-font-weight-medium); + } + + &__icon { + flex-shrink: 0; + // `currentColor` is what ties the icon to the active state without a second rule for it. + color: currentcolor; + } + + &__label { + min-width: 0; + } + + // A settings page is usually longer than its navigation, so the column follows the content + // down rather than leaving a tall empty space. Only once there is room for it to matter. + @media (width >= 992px) { + position: sticky; + top: 1.5rem; + } +} diff --git a/assets/scss/platform.scss b/assets/scss/platform.scss index 3a57e279..5fb62106 100644 --- a/assets/scss/platform.scss +++ b/assets/scss/platform.scss @@ -17,3 +17,4 @@ @use '@tabler/core/scss/vendor/tom-select'; @use 'styles'; @use 'text-editor'; +@use 'settings-nav'; diff --git a/docs/frontend/components.md b/docs/frontend/components.md new file mode 100644 index 00000000..a559cb73 --- /dev/null +++ b/docs/frontend/components.md @@ -0,0 +1,172 @@ +# UI Components + +The UI bundle ships Twig components for the patterns that repeat across an application. They exist +so the same kind of information looks the same everywhere: a setting on the profile page and a +setting on the two-factor page are the same component, not two pieces of markup that drift apart. + +> **The rule they enforce.** Never write page-scoped styles or one-off markup for something another +> page already shows. Reach for Tabler first, then for a component here; if neither fits, add a +> component and use it everywhere the pattern appears. A restyle that lands on one page only is not +> finished. + +All of them are anonymous components under `src/Bundle/Ui/templates/components/`, so they take +props, pass extra attributes through to their root element, and merge any `class` you give them. + +--- + +## `Ui:Card` + +A Tabler card. The header takes an optional icon tile, which is how a section says what it is at a +glance. + +```twig + + … + … + +``` + +| Prop | Default | Description | +|------|---------|-------------| +| `title` / `subtitle` | `''` | The header text. The subtitle sits under the title. | +| `icon` | `''` | A UX icon name, e.g. `tabler:shield`. Rendered as a soft-tinted tile before the title. | +| `iconColor` | `'primary'` | Any Tabler colour — `primary`, `success`, `warning`, `danger`, … | + +Plus the Tabler card variants: `status`, `statusPosition`, `stacked`, `borderless`, `size`, `hover`, +`inactive`, `rotate`, `image`, `imagePosition`, `stamp`, `progress`, `ribbon`. + +Slots: `content` (inside the padded card body), `body` (replaces the body wrapper — use it when the +content is a `list-group`), `header`, `footer`. + +--- + +## `Ui:SettingRow` + +One setting: what it is, what it currently says, and the buttons that change it. Rows are +`list-group-item`s, so they go inside a `list-group list-group-flush` in a card body. + +```twig + + +
+ + + +
+
+
+``` + +| Prop | Default | Description | +|------|---------|-------------| +| `title` | `''` | What the setting is. | +| `description` | `''` | One line saying what it does. | +| `icon` / `iconColor` | `''` / `'primary'` | The tinted icon tile on the left. | +| `status` / `statusColor` / `statusIcon` | `''` / `'secondary'` / `''` | A state badge under the description. | +| `note` / `noteIcon` | `''` / `''` | A small muted line under the badge. | + +The default slot holds the actions. They sit right on wide screens and wrap onto their own line +when narrow. + +--- + +## `Ui:SettingsNav` + +The vertical navigation down the side of a settings section, rendered from a KnpMenu. + +```twig + +``` + +| Prop | Default | Description | +|------|---------|-------------| +| `menu` | *required* | The KnpMenu name to render. | +| `title` | `''` | An optional card header above the entries. | + +Entries come from ordinary menu builders, so a page joins a section by registering one — see +[adding a page to the profile section](../security/profile.md#adding-a-page-to-the-profile-section). +The entry matching the current route is highlighted automatically. It renders nothing when no +builder has registered the menu, so a layout can mount it unconditionally. + +--- + +## `Ui:PasswordField` + +Wraps a password `` in a leading icon and a show/hide toggle. Pass the input as the content — +it has to carry the `input` Stimulus target, because only the caller knows which element is the +field. + +```twig + + + +``` + +| Prop | Default | Description | +|------|---------|-------------| +| `icon` | `'tabler:password'` | The leading icon. Pass `''` to drop it. | +| `toggleLabel` | `'Show password'` | Accessible label on the toggle. | + +**You rarely call this directly.** The platform form theme's `password_widget` already wraps every +Symfony password field in it, so any `PasswordType` in any form gets the toggle without asking. Use +the component by hand only for a password input that is not part of a Symfony form — which is what +the login page does. + +--- + +## `Ui:Alert` and `Ui:Modal` + +Tabler alerts and modals. `Ui:Alert` takes `type`, `title`, `icon`, `avatar`, `dismissible`, +`important` and `link`; `Ui:Modal` takes `id`, `title`, `size`, `centered`, `scrollable`, +`staticBackdrop`, `closeable`, `status` and `show`, with `body`, `header` and `footer` slots. + +--- + +## A caveat about what a slot can see + +A component slot compiles to a Twig `embed`, and the component being rendered becomes the one in +scope. Ordinary variables pass into the slot as you would expect, but three things do **not** mean +what they do outside it: + +| Inside a slot | Resolves against | Do this instead | +|---------------|------------------|-----------------| +| `block('…')` | the component's blocks | render it into a variable first | +| `parent()` | the component's parent | render it into a variable first | +| `this` | the component the slot belongs to | read the property into a variable first | + +```twig +{% set rows = block('profile_detail_rows') %} +{% set qr_image = this.qrContent %} + + + + {{ rows|raw }} + + + +``` + +This bites hardest in a live component, where `this` is how you reach the component's own methods: +`{{ this.qrContent }}` inside a `` slot asks the *modal* for a QR code and fails with +`Neither the property "qrContent" nor … exist … in class AnonymousComponent`. + +--- + +## Next steps + +- [Layouts](./layouts.md) — the page layouts these components sit inside +- [Stimulus controllers reference](./controllers.md) — the behaviour behind them +- [Theming & customization](./customization.md) — SCSS variables and brand colours diff --git a/docs/frontend/controllers.md b/docs/frontend/controllers.md index 8384f956..29a5020e 100644 --- a/docs/frontend/controllers.md +++ b/docs/frontend/controllers.md @@ -10,6 +10,31 @@ The following third-party controllers are also registered globally by the platfo | `password-visibility` | `@stimulus-components/password-visibility` | | `clipboard` | `@stimulus-components/clipboard` | +> `password-visibility` is wired into the platform form theme, so every Symfony password +> field gets a show/hide toggle without any markup of your own — see +> [`Ui:PasswordField`](./components.md#uipasswordfield). + +## csrf-protection + +**File:** `controllers/csrf_protection.js` + +Symfony's stateless (double-submit) CSRF helper. The token field is rendered holding a token *id*; +just before the form goes out, this swaps the id for a random token and writes a matching cookie, +and the server checks the pair. + +It listens for the moments a form is sent: a native `submit`, Turbo's `turbo:submit-start`, and — +added by the platform — the click that triggers a **live component action**. That last one matters +because a live component never submits its form: the payload goes out by fetch from a plain +`type="button"`. Without it the field keeps the raw token id, no cookie is ever written, and every +submission from a live component fails with *"The CSRF token is invalid."* + +The listener runs in the capture phase so the token is in place before the component reads the form, +and `generateCsrfToken()` fires a `change` event, which is how the component picks the new value up. +Under session-based CSRF the rendered value is already a token rather than an id, so the swap is +skipped and the whole thing is a no-op. + +Nothing needs wiring: Symfony puts `data-controller="csrf-protection"` on the token field itself. + --- ## modal @@ -167,3 +192,53 @@ Toolbar buttons are matched by their `data-editor-command` attribute. The contro | Action | Description | |--------|-------------| | `run` | Executes the command specified by `data-editor-command` on the button that triggered the event. Wire to toolbar buttons via `data-action="click->text-editor#run"`. | + +--- + +## two-factor + +**File:** `controllers/two_factor_controller.js` + +Drives the [two-factor settings page](../security/two-factor.md): stepping through the authenticator +setup dialog, and saving backup codes to a file. + +Both jobs stay in the browser deliberately. The setup dialog is two screens but one form, so the +steps are shown and hidden rather than added and removed — every field is in the DOM the whole time +and the live component always receives a complete form. And the backup codes are already rendered on +the page, so building the file client-side avoids having a route that returns recovery codes. + +> This controller is mounted by the `Platform:Security:TwoFactor` component — you do not need to add +> it to your markup manually. + +### Values + +| Value | Type | Default | Description | +|-------|------|---------|-------------| +| `step` | `Number` | `0` | The step to show. Set by the server so a failed verification reopens on the step that failed. | +| `codes` | `Array` | `[]` | The backup codes written to the downloaded file. | +| `filename` | `String` | `'backup-codes.txt'` | Name of the downloaded file. The component sets it from the application name. | + +### Targets + +| Target | Description | +|--------|-------------| +| `step` | Anything belonging to one step — a pane in the body, a button in the footer. Each carries a `data-step` attribute; the ones whose `data-step` matches are shown and the rest get `d-none`. | +| `stepItem` | The entries of the Tabler `steps` indicator, in order. Only the current one gets `active`. | + +Two details of that table are worth the words: + +- **The step is on the element, not implied by its position.** That is what lets the footer buttons + be siblings inside `.modal-footer`, where they pick up its spacing and right alignment, rather + than being grouped into a wrapper per step — which would left-align the dismiss button in this + one dialog and nowhere else. +- **Only the current `step-item` is `active`.** Tabler greys out everything *after* the active one + via `.step-item.active ~ .step-item`, so marking the earlier steps active as well greys out the + step the user is actually on. + +### Actions + +| Action | Description | +|--------|-------------| +| `next` | Advances one step, stopping at the last. | +| `previous` | Goes back one step, stopping at the first. | +| `download` | Writes `codes` to a text file and hands it to the browser. | diff --git a/docs/frontend/index.md b/docs/frontend/index.md index b760f677..2d8dd209 100644 --- a/docs/frontend/index.md +++ b/docs/frontend/index.md @@ -161,5 +161,6 @@ bun run build ## Next steps - [Layouts](./layouts.md) — the Tabler page layouts, their options and blocks +- [UI Components](./components.md) — cards, setting rows, settings navigation and password fields - [Stimulus controllers reference](./controllers.md) — what each built-in controller does and how to use it - [Theming & customization](./customization.md) — overriding SCSS variables and the custom variables file diff --git a/docs/frontend/layouts.md b/docs/frontend/layouts.md index 658d1908..9a09282d 100644 --- a/docs/frontend/layouts.md +++ b/docs/frontend/layouts.md @@ -156,6 +156,7 @@ by name. If no builder registers a menu, that part of the navigation is simply n | `sidebar` | The vertical sidebar of the `app` layout | | `navbar` | The top navigation bar of the `app` and `condensed` layouts | | `user_menu` | The user dropdown in the top-right of the `app` and `condensed` layouts | +| `profile_menu` | The navigation beside the [profile section](../security/profile.md) | ```php use Knp\Menu\ItemInterface; @@ -216,14 +217,17 @@ sidebar and navbar: Two things are always there, whatever your builders do: -- **The platform's own account entries** — [**Profile**](../security/profile.md), and - **Two-factor authentication** when [2FA is enabled](../security/two-factor.md). Profile leads - the dropdown at `UserMenu::PRIORITY_PROFILE` (`200`) and the account entries follow at - `UserMenu::PRIORITY_ACCOUNT` (`100`), so entries you register at the default priority of `0` - land underneath them both. Register above them to push your entries to the top. +- **A [**Profile**](../security/profile.md) entry**, which leads the dropdown at + `UserMenu::PRIORITY_PROFILE` (`200`), so entries you register at the default priority of `0` land + underneath it. Register above it to push your entries to the top. - **Logout**, rendered last under a divider. It is a CSRF-protected form rather than a link, so it is not part of the menu; override the `user_menu_items` block if you need it somewhere else. +The account pages themselves — changing a password, setting up a second factor — are *not* in this +dropdown. They live in the [profile section](../security/profile.md), listed in its own navigation, +so there is one place to look rather than two. Add your own account pages there with +[`ProfileMenu`](../security/profile.md#adding-a-page-to-the-profile-section), not here. + --- ## Blocks @@ -410,5 +414,6 @@ extra `` tags, analytics snippets, or a different asset entry. ## Next steps +- [UI Components](./components.md) — the shared cards, setting rows and navigation used inside these layouts - [Theming & customization](./customization.md) — SCSS variables, brand colours - [Configuration reference](../configuration/index.md#ui-ui) — the `ui` section of `platform.yaml` diff --git a/docs/index.md b/docs/index.md index 10c996b4..4a08239e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,6 +10,7 @@ Welcome to the SolidWorx Platform documentation. This platform provides the foun - [User Profile](./security/profile.md) — the profile pages, password rules and how to extend them - [Frontend Assets](./frontend/index.md) — webpack config, Stimulus controllers, theming - [Layouts](./frontend/layouts.md) — the Tabler page layouts, their options and blocks +- [UI Components](./frontend/components.md) — the shared Twig components, and the consistency rule they enforce - [Doctrine Types](./doctrine-types/index.md) - [Form Types](./form-types/index.md) — reusable form types, including the rich text editor - [Multi-Tenancy](./multi-tenancy/index.md) diff --git a/docs/security/profile.md b/docs/security/profile.md index 651d4937..0a83eb6a 100644 --- a/docs/security/profile.md +++ b/docs/security/profile.md @@ -1,22 +1,96 @@ # User Profile -Every signed-in user gets three pages for maintaining their own account, plus a **Profile** -entry in the user dropdown that leads to them: +Every signed-in user gets a **profile section**: a set of pages for maintaining their own account, +with a navigation column listing them, and a **Profile** entry in the user dropdown that leads in. | Page | Route | Path | |------|-------|------| | Profile details | `solidworx_platform_profile_show` | `/profile` | | Edit profile | `solidworx_platform_profile_edit` | `/profile/edit` | | Change password | `solidworx_platform_profile_change_password` | `/profile/password` | +| [Two-factor authentication](./two-factor.md) | `solidworx_platform_security_two_factor_configure` | `/profile/two-factor` | -The profile page also carries a **Security** card, which links to the change-password page and — -when `platform.security.two_factor.enabled` is on — to the -[two-factor configuration page](./two-factor.md). +The two-factor page is only there when `platform.security.two_factor.enabled` is on; the other +three are always. There is nothing to switch on: importing the platform routes is enough. --- +## Adding a page to the profile section + +The navigation is a KnpMenu named `profile_menu`, so a page joins the section by registering an +entry — no template is edited, and nothing has to know the page exists: + +```php +use Knp\Menu\ItemInterface; +use SolidWorx\Platform\PlatformBundle\Attributes\Menu\MenuBuilder; +use SolidWorx\Platform\PlatformBundle\Menu\Options; +use SolidWorx\Platform\PlatformBundle\Menu\ProfileMenu; + +final class AccountMenu +{ + #[MenuBuilder(name: ProfileMenu::NAME)] + public function build(ItemInterface $menu): void + { + $menu->addChild('Notifications', Options::create()->route('app_notifications')->icon('bell')->build()); + $menu->addChild('API keys', Options::create()->route('app_api_keys')->icon('key')->build()); + } +} +``` + +The page itself extends `profile_layout` — the Twig global, not a path — and fills one block. It +gets the navigation, the page header and the section's spacing for free: + +```twig +{% extends profile_layout %} + +{% block page_title %}{{ 'Notifications'|trans }}{% endblock %} + +{% block profile_content %} + + … + +{% endblock %} +``` + +Builders run from the highest priority to the lowest and each one appends, so priority decides +where entries land. The platform registers its own above the default of `0`: + +| Constant | Value | Entries | +|----------|-------|---------| +| `ProfileMenu::PRIORITY_ACCOUNT` | `200` | Profile, Change password | +| `ProfileMenu::PRIORITY_SECURITY` | `100` | Two-factor authentication | + +Leave the priority alone and your entries land underneath them all; register above `200` to lead +the navigation. + +The entry matching the current route is highlighted automatically, by the same KnpMenu matcher the +sidebar and navbar use. + +### Replacing the section's chrome + +Point `platform.profile.templates.layout` at a template of your own and every page in the section +follows — the platform's and yours, since both extend the `profile_layout` global rather than a +path. Extend the shipped layout to keep the navigation and change only what differs: + +```twig +{# templates/profile/layout.html.twig #} +{% extends '@SolidWorxPlatform/Profile/layout.html.twig' %} + +{% block profile_nav %} + {{ parent() }} +
…a support link under the navigation…
+{% endblock %} +``` + +| Block | Purpose | +|-------|---------| +| `profile_nav` | The navigation column. Override with an empty block to drop it — the content then takes the full width. | +| `profile_content` | The page. This is the block a profile page fills. | + +--- + ## What a user can change `ProfileType` covers the fields every platform user has: @@ -189,13 +263,33 @@ The blocks each page exposes: | Template | Blocks | |----------|--------| -| `show.html.twig` | `profile_initials`, `profile_name`, `profile_identity`, `profile_detail_rows`, `profile_details`, `profile_security_items`, `profile_security`, `profile_sections_extra`, plus the layout's `page_pretitle`, `page_title` and `page_title_actions` | +| `layout.html.twig` | `profile_nav`, `profile_content` | +| `show.html.twig` | `profile_initials`, `profile_name`, `profile_identity`, `profile_detail_rows`, `profile_details`, `profile_security_items`, `profile_security`, `profile_sections_extra`, plus the layout's `page_pretitle` and `page_title` | | `edit.html.twig` | `profile_form_fields`, `profile_form_actions` | | `change_password.html.twig` | `password_requirements`, `password_form_actions` | -`show.html.twig` also exports two macros — `detail(label, value)` and -`security_item(title, description, url, action, icon)` — so added rows keep matching the -platform's markup. +`show.html.twig` also exports a `detail(label, value)` macro, so added rows keep matching the +platform's markup. Security rows are `` — the same component the two-factor +page uses, so a setting reads identically wherever it appears: + +```twig +{% block profile_security_items %} + {{ parent() }} + + + {{ 'Manage'|trans }} + +{% endblock %} +``` + +> **One caveat when overriding a block.** A `` component slot compiles to a Twig `embed`, +> so `block('…')`, `parent()` and `this` *inside* a slot resolve against the component, not your +> page. Read them into a variable first and print that inside the slot — which is what the shipped +> templates do. See [the full list](../frontend/components.md#a-caveat-about-what-a-slot-can-see). ### Replacing a page @@ -207,14 +301,22 @@ The same configuration keys take an unrelated template. Each page is rendered wi | `edit` | `form`, `user` | | `change_password` | `form`, `user`, `password_requirements` | +A replacement page still extends `profile_layout` if you want the navigation; extend anything else +and it becomes a standalone page. + --- -## Customising the menu entry +## Customising the menu entries + +Two menus are involved, and they do different jobs: + +- **`user_menu`** — the dropdown behind the avatar. It carries a single **Profile** entry at + `UserMenu::PRIORITY_PROFILE`, which leads into the section. It is deliberately not a copy of the + section's navigation. See [the user menu](../frontend/layouts.md#the-user-menu). +- **`profile_menu`** — the navigation inside the section, covered in + [Adding a page to the profile section](#adding-a-page-to-the-profile-section) above. -The **Profile** entry is an ordinary KnpMenu item on the `user_menu` menu, registered at -`UserMenu::PRIORITY_PROFILE` so it leads the dropdown. Application entries default to priority -`0` and therefore land underneath it — see -[the user menu](../frontend/layouts.md#the-user-menu) for adding your own. +Both are ordinary KnpMenus, so both take entries through `#[MenuBuilder]`. --- @@ -226,6 +328,7 @@ platform: profile: form_type: SolidWorx\Platform\PlatformBundle\Form\Type\Profile\ProfileType templates: + layout: '@SolidWorxPlatform/Profile/layout.html.twig' show: '@SolidWorxPlatform/Profile/show.html.twig' edit: '@SolidWorxPlatform/Profile/edit.html.twig' change_password: '@SolidWorxPlatform/Profile/change_password.html.twig' diff --git a/docs/security/two-factor.md b/docs/security/two-factor.md index 327aeb97..b648c994 100644 --- a/docs/security/two-factor.md +++ b/docs/security/two-factor.md @@ -115,7 +115,7 @@ When 2FA is enabled the platform registers these routes: | `2fa_login` | `/2fa` | The 2FA challenge form (TOTP / email / backup code). | | `2fa_login_check` | `/2fa_check` | Where the 2FA form posts to. | | `_solidworx_platform_security_two_factor_resend` | `/2fa/resend` | Re-send the email code, then return to the challenge. | -| `solidworx_platform_security_two_factor_configure` | `/settings/two-factor` | Where a signed-in user turns their second factors on and off. | +| `solidworx_platform_security_two_factor_configure` | `/profile/two-factor` | Where a signed-in user turns their second factors on and off. | The challenge pages let users switch provider via `path('2fa_login', {preferProvider: '...'})`. @@ -154,15 +154,21 @@ shadow it (rules are matched top-to-bottom, first match wins). ## The configuration page -`/settings/two-factor` renders the `Platform:Security:TwoFactor` live component, which lets a -signed-in user enable or disable TOTP and email codes, regenerate backup codes and forget a -trusted device. +`/profile/two-factor` renders the `Platform:Security:TwoFactor` live component, which lets a +signed-in user enable or disable TOTP and email codes, view, download and regenerate backup codes, +and forget a trusted device. -You do not have to link to it yourself: while 2FA is enabled the platform registers a -**Two-factor authentication** entry in the `user_menu` dropdown — see -[the user menu](../frontend/layouts.md#the-user-menu). Turn 2FA off and the controller, the menu +The page belongs to the [profile section](./profile.md): it extends the profile layout and appears +in the profile navigation beside **Profile** and **Change password**, which is where a user looks +for it. You do not have to link to it yourself — while 2FA is enabled the platform registers a +**Two-factor authentication** entry on the `profile_menu`. Turn 2FA off and the controller, the menu entry and the component are all removed from the container. +Pairing an authenticator app is a two-step dialog — scan the QR code (or copy the key by hand), +then confirm a generated code. Both steps are one form; the `two-factor` Stimulus controller shows +and hides them, and a failed verification reopens on the step that failed. The same controller +writes the backup codes to a file in the browser, so there is no route that returns recovery codes. + To place the same controls on a page of your own, mount the component directly: ```twig diff --git a/platform-schema.json b/platform-schema.json index c0ca3e78..06a92786 100644 --- a/platform-schema.json +++ b/platform-schema.json @@ -123,6 +123,7 @@ "default": { "form_type": "SolidWorx\\Platform\\PlatformBundle\\Form\\Type\\Profile\\ProfileType", "templates": { + "layout": "@SolidWorxPlatform/Profile/layout.html.twig", "show": "@SolidWorxPlatform/Profile/show.html.twig", "edit": "@SolidWorxPlatform/Profile/edit.html.twig", "change_password": "@SolidWorxPlatform/Profile/change_password.html.twig" @@ -142,12 +143,18 @@ }, "templates": { "default": { + "layout": "@SolidWorxPlatform/Profile/layout.html.twig", "show": "@SolidWorxPlatform/Profile/show.html.twig", "edit": "@SolidWorxPlatform/Profile/edit.html.twig", "change_password": "@SolidWorxPlatform/Profile/change_password.html.twig" }, "type": "object", "properties": { + "layout": { + "description": "The layout every profile page extends, exposed to Twig as the \"profile_layout\" global. It renders the profile navigation beside a \"profile_content\" block.", + "default": "@SolidWorxPlatform/Profile/layout.html.twig", + "type": "string" + }, "show": { "description": "The template showing the profile details.", "default": "@SolidWorxPlatform/Profile/show.html.twig", diff --git a/src/Bundle/Platform/Config/Builder/ProfileConfigBuilder.php b/src/Bundle/Platform/Config/Builder/ProfileConfigBuilder.php index 40fcf6c8..3e3bbede 100644 --- a/src/Bundle/Platform/Config/Builder/ProfileConfigBuilder.php +++ b/src/Bundle/Platform/Config/Builder/ProfileConfigBuilder.php @@ -60,6 +60,16 @@ public function formType(string $formType): self return $this; } + /** + * The layout every profile page extends, and the one an application's own profile pages extend + * through the `profile_layout` Twig global. + */ + public function layoutTemplate(string $template): self + { + $this->templates['layout'] = $template; + return $this; + } + public function showTemplate(string $template): self { $this->templates['show'] = $template; diff --git a/src/Bundle/Platform/Config/PlatformConfiguration.php b/src/Bundle/Platform/Config/PlatformConfiguration.php index 569a2c53..d19f59e8 100644 --- a/src/Bundle/Platform/Config/PlatformConfiguration.php +++ b/src/Bundle/Platform/Config/PlatformConfiguration.php @@ -262,6 +262,10 @@ private function profileNode(): ArrayNodeDefinition ->arrayNode('templates') ->addDefaultsIfNotSet() ->children() + ->scalarNode('layout') + ->defaultValue('@SolidWorxPlatform/Profile/layout.html.twig') + ->info('The layout every profile page extends, exposed to Twig as the "profile_layout" global. It renders the profile navigation beside a "profile_content" block.') + ->end() ->scalarNode('show') ->defaultValue('@SolidWorxPlatform/Profile/show.html.twig') ->info('The template showing the profile details.') diff --git a/src/Bundle/Platform/Controller/Security/TwoFactorConfiguration.php b/src/Bundle/Platform/Controller/Security/TwoFactorConfiguration.php index 8582dbc1..d4d589b9 100644 --- a/src/Bundle/Platform/Controller/Security/TwoFactorConfiguration.php +++ b/src/Bundle/Platform/Controller/Security/TwoFactorConfiguration.php @@ -36,7 +36,7 @@ final class TwoFactorConfiguration extends AbstractController /** * The path of the two-factor configuration page. */ - public const string PATH = '/settings/two-factor'; + public const string PATH = '/profile/two-factor'; /** * The route name of the two-factor configuration page, linked to from the user menu. diff --git a/src/Bundle/Platform/DependencyInjection/SolidWorxPlatformExtension.php b/src/Bundle/Platform/DependencyInjection/SolidWorxPlatformExtension.php index 1eefb75d..cf7d4737 100644 --- a/src/Bundle/Platform/DependencyInjection/SolidWorxPlatformExtension.php +++ b/src/Bundle/Platform/DependencyInjection/SolidWorxPlatformExtension.php @@ -88,7 +88,7 @@ * * @phpstan-type ProfileConfig array{ * form_type: class-string, - * templates: array{show: string, edit: string, change_password: string}, + * templates: array{layout: string, show: string, edit: string, change_password: string}, * password: array{min_length: int, strength: string, check_compromised: bool} * } * @@ -216,6 +216,7 @@ public function load(array $configs, ContainerBuilder $container): void private function loadProfile(ContainerBuilder $container, array $config): void { $container->setParameter('solidworx_platform.profile.form_type', $config['form_type']); + $container->setParameter('solidworx_platform.profile.templates.layout', $config['templates']['layout']); $container->setParameter('solidworx_platform.profile.templates.show', $config['templates']['show']); $container->setParameter('solidworx_platform.profile.templates.edit', $config['templates']['edit']); $container->setParameter('solidworx_platform.profile.templates.change_password', $config['templates']['change_password']); diff --git a/src/Bundle/Platform/Menu/ProfileMenu.php b/src/Bundle/Platform/Menu/ProfileMenu.php new file mode 100644 index 00000000..c032dc15 --- /dev/null +++ b/src/Bundle/Platform/Menu/ProfileMenu.php @@ -0,0 +1,68 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\PlatformBundle\Menu; + +/** + * The navigation down the side of the profile section. + * + * It is an ordinary KnpMenu, so an application adds its own account pages the same way it adds + * sidebar or navbar entries — and they land in the profile navigation, rendered in the profile + * layout, without a template being touched: + * + * #[MenuBuilder(name: ProfileMenu::NAME)] + * public function build(ItemInterface $menu): void + * { + * $menu->addChild('Notifications', Options::create()->route('app_notifications')->icon('bell')->build()); + * $menu->addChild('API keys', Options::create()->route('app_api_keys')->icon('key')->build()); + * } + * + * A page added this way gets the navigation and the page header for free by extending the profile + * layout: + * + * {% extends '@SolidWorxPlatform/Profile/layout.html.twig' %} + * + * {% block page_title %}{{ 'Notifications'|trans }}{% endblock %} + * {% block profile_content %}…{% endblock %} + * + * Builders run from the highest priority to the lowest and each one appends, so priority decides + * where entries end up. The platform's own pages are above the default of `0`, which means + * application entries land underneath them without having to pick a priority at all. + * + * This is a different menu from {@see UserMenu}: that one is the dropdown behind the avatar, which + * holds a single **Profile** entry leading here rather than a copy of this list. + */ +final class ProfileMenu +{ + /** + * The KnpMenu name the profile navigation is rendered from. + */ + public const string NAME = 'profile_menu'; + + /** + * The priority the platform registers the profile and change-password entries with. + * + * Register above it to push an entry to the top of the navigation, below it (or leave the + * priority at its default of `0`) to append underneath the platform entries. + */ + public const int PRIORITY_ACCOUNT = 200; + + /** + * The priority of the second-factor entry, which follows the account pages. + * + * It is separate from {@see self::PRIORITY_ACCOUNT} because the entry is registered by a + * different builder — one that only exists when 2FA is enabled — and two builders sharing a + * priority would order by registration rather than by intent. + */ + public const int PRIORITY_SECURITY = 100; +} diff --git a/src/Bundle/Platform/Menu/ProfileMenuBuilder.php b/src/Bundle/Platform/Menu/ProfileMenuBuilder.php index d58c75cb..5afd3b89 100644 --- a/src/Bundle/Platform/Menu/ProfileMenuBuilder.php +++ b/src/Bundle/Platform/Menu/ProfileMenuBuilder.php @@ -15,16 +15,24 @@ use Knp\Menu\ItemInterface; use SolidWorx\Platform\PlatformBundle\Attributes\Menu\MenuBuilder; +use SolidWorx\Platform\PlatformBundle\Controller\Profile\ChangePassword; use SolidWorx\Platform\PlatformBundle\Controller\Profile\ShowProfile; /** - * Adds the "Profile" entry to the user dropdown. + * The platform's own entries in the two menus the profile section uses. * - * It is registered above {@see UserMenu::PRIORITY_ACCOUNT} so it leads the dropdown, ahead of - * the two-factor entry and anything an application appends. + * The user dropdown gets a single **Profile** entry — the way in to the section — while the + * section's own navigation lists the pages inside it. The dropdown deliberately does not mirror + * that list: it is the shortcut, not a second copy of the navigation. */ final class ProfileMenuBuilder { + /** + * The **Profile** entry in the user dropdown. + * + * It is registered above {@see UserMenu::PRIORITY_ACCOUNT} so it leads the dropdown, ahead of + * anything an application appends. + */ #[MenuBuilder(name: UserMenu::NAME, priority: UserMenu::PRIORITY_PROFILE)] public function build(ItemInterface $menu): void { @@ -36,4 +44,30 @@ public function build(ItemInterface $menu): void ->build(), ); } + + /** + * The pages the platform itself puts in the profile navigation. + * + * The two-factor entry is not here: it is registered by {@see TwoFactorMenuBuilder}, whose + * service only exists when 2FA is enabled. + */ + #[MenuBuilder(name: ProfileMenu::NAME, priority: ProfileMenu::PRIORITY_ACCOUNT)] + public function buildProfileMenu(ItemInterface $menu): void + { + $menu->addChild( + 'Profile', + Options::create() + ->route(ShowProfile::ROUTE_NAME) + ->icon('user') + ->build(), + ); + + $menu->addChild( + 'Change password', + Options::create() + ->route(ChangePassword::ROUTE_NAME) + ->icon('lock') + ->build(), + ); + } } diff --git a/src/Bundle/Platform/Menu/TwoFactorMenuBuilder.php b/src/Bundle/Platform/Menu/TwoFactorMenuBuilder.php index 8b342d13..ecef0e0b 100644 --- a/src/Bundle/Platform/Menu/TwoFactorMenuBuilder.php +++ b/src/Bundle/Platform/Menu/TwoFactorMenuBuilder.php @@ -18,7 +18,12 @@ use SolidWorx\Platform\PlatformBundle\Controller\Security\TwoFactorConfiguration; /** - * Adds the two-factor authentication entry to the user dropdown. + * Adds the two-factor authentication entry to the profile navigation. + * + * It belongs in the profile section rather than the user dropdown: turning a second factor on is + * something a user does once, from the same place they change their password, not a destination + * worth a permanent shortcut behind the avatar. The dropdown keeps a single **Profile** entry, + * which leads here. * * The service is removed from the container when `platform.security.two_factor.enabled` is * false, so the entry — and the route it points at — can never be rendered for an application @@ -26,7 +31,7 @@ */ final class TwoFactorMenuBuilder { - #[MenuBuilder(name: UserMenu::NAME, priority: UserMenu::PRIORITY_ACCOUNT)] + #[MenuBuilder(name: ProfileMenu::NAME, priority: ProfileMenu::PRIORITY_SECURITY)] public function build(ItemInterface $menu): void { $menu->addChild( diff --git a/src/Bundle/Platform/Menu/UserMenu.php b/src/Bundle/Platform/Menu/UserMenu.php index 246c4829..b888b452 100644 --- a/src/Bundle/Platform/Menu/UserMenu.php +++ b/src/Bundle/Platform/Menu/UserMenu.php @@ -26,13 +26,16 @@ * } * * Builders run from the highest priority to the lowest, and each one appends to the menu, so - * priority decides where entries end up in the dropdown. The platform's own entries — the profile - * page at {@see self::PRIORITY_PROFILE}, two-factor authentication at - * {@see self::PRIORITY_ACCOUNT} — are both above the default of `0`, so application entries land - * underneath them without having to pick a priority at all. + * priority decides where entries end up in the dropdown. The platform's only entry — the profile + * page at {@see self::PRIORITY_PROFILE} — is above the default of `0`, so application entries land + * underneath it without having to pick a priority at all. * - * The logout link is not part of this menu: it is a CSRF-protected form rather than a link, and - * the UI bundle always renders it last, under a divider. + * The account pages themselves are not here. Changing a password or setting up a second factor + * lives in the profile section, listed in its own navigation — see {@see ProfileMenu}. The + * dropdown holds the way in, not a copy of that list. + * + * The logout link is not part of this menu either: it is a CSRF-protected form rather than a link, + * and the UI bundle always renders it last, under a divider. */ final class UserMenu { @@ -42,7 +45,8 @@ final class UserMenu public const string NAME = 'user_menu'; /** - * The priority the platform registers its own account entries with. + * The priority to register an account-level entry with, so it sits with the platform's own + * rather than with the application's. * * Register above it to push an entry to the top of the dropdown, below it (or leave the * priority at its default of `0`) to append underneath the platform entries. diff --git a/src/Bundle/Platform/Resources/views/Components/Security/LogoutLink.html.twig b/src/Bundle/Platform/Resources/views/Components/Security/LogoutLink.html.twig index 1a44f5d1..0be525c4 100644 --- a/src/Bundle/Platform/Resources/views/Components/Security/LogoutLink.html.twig +++ b/src/Bundle/Platform/Resources/views/Components/Security/LogoutLink.html.twig @@ -33,7 +33,7 @@ }) %}
- + + {% else %} + + {% endif %} + + + + {% if totp_enabled %} + + {% else %} + + {% endif %} +
- -
  • -
    -
    - {{ 'Authenticator app'|trans }} -
    + + - {% if app.user.isTotpAuthenticationEnabled %} -
    - {{ 'Enabled'|trans }} -
    - - {% else %} -
    - {{ 'Disabled'|trans }} -
    - + {# ------------------------------------------------------------------- Backup codes #} + {% if user.is2FaEnabled and backup_codes is not empty %} + + +
    + + - {% set qrImage = this.qrContent %} - - {{ form_start(form) }} - - -
    - - {{ form_row(form) }} -
    -
    - - - - -
    - {{ form_end(form) }} - {% endif %} -
    -
  • - -
    - - {% if app.user.is2FaEnabled and app.user.backupCodes is not empty %} -
    -
    -

    - {{ "Recovery Options"|trans }} -

    -
    -
    - - - + + +
    + + + {% endif %} + + {# ----------------------------------------------------------------- Trusted device #} + {% if isDeviceTrusted %} + -
    - - Use these backup codes when you have lost access to your authentication device. - - - Store these backup codes in a safe place where it can be accessed when needed. - +
    + + +
    -
    - {% for chunk in app.user.backupCodes|batch(app.user.backupCodes|length / 2) %} -
    - {% for code in chunk %} - {{ code }}
    - {% endfor %} + + + {% endif %} +
    + + {# ------------------------------------------------------- Authenticator setup (2 steps) #} + {% if not totp_enabled %} + {# Read out here, not inside the modal: a component slot compiles to an embed, and the + component being rendered rebinds `this` — so inside the slot it is the modal, not + this component. Ordinary variables pass through; `this` does not. #} + {% set qr_image = this.qrContent %} + + {{ form_start(form) }} + + +
      +
    • + {{ 'Scan the code'|trans }} +
    • +
    • + {{ 'Confirm it works'|trans }} +
    • +
    + + {# Step 1 — pair the app. #} +
    +
    + {{ 'QR code for pairing an authenticator app'|trans }} + +

    + {{ 'Open your authenticator app and scan this code.'|trans }} +

    +
    + +
    + {{ 'Cannot scan it? Enter the key by hand'|trans }} + +
    + {{ totpSecret }} + +
    - {% endfor %} +
    + + {# Step 2 — prove it worked. Hidden, not removed: the fields still submit. #} +
    +
    + {{ form_errors(form) }} + + {{ form_row(form.code, { + label: 'Enter the 6-digit code from your app'|trans, + attr: { + class: 'form-control-lg text-center font-monospace', + placeholder: '000000', + inputmode: 'numeric', + }, + }) }} +
    +
    + + {{ form_row(form.secret) }}
    + + {# Every button sits directly in the footer, so it picks up the modal's own + spacing and right alignment — the same as every other modal in the + application. The step it belongs to is on the element, not implied by its + position, which is what lets them be siblings rather than two groups. + + "Back" is the exception, pushed left with `me-auto`: it moves between the + steps of a wizard rather than dismissing the dialog, and that belongs on the + left. "Cancel" and "Close" dismiss, so they stay with the primary on the + right, the same as in every other modal. #} - - + + + + + + +
    -
    -
    - {% endif %} - - {% if isDeviceTrusted %} -
    -
    -

    - {{ "Trusted Device"|trans }} -

    -
    -
    -
      -
    • -

      - {{ 'Browser Trusted'|trans }}
      - - {{ 'This device is trusted and won\'t require a 2FA code.'|trans }}' - -

      - - {{ 'Enabled'|trans }} - - - {{ 'Disable'|trans }} - -
    • -
    -
    -
    - {% endif %} + {{ form_end(form) }} + {% endif %} + + {# ------------------------------------------------------------------ Backup codes modal #} + {% if backup_codes is not empty %} + + + + {{ 'Each code works once. Store them somewhere you can reach without this account — they are not shown again after you regenerate them.'|trans }} + + +
    + {% for code in backup_codes %} +
    + {{ code }} +
    + {% endfor %} +
    + + +
    + + + + + + +
    + {% endif %} +
    diff --git a/src/Bundle/Platform/Resources/views/Form/theme.html.twig b/src/Bundle/Platform/Resources/views/Form/theme.html.twig index 8aaa5fef..5a6349ef 100644 --- a/src/Bundle/Platform/Resources/views/Form/theme.html.twig +++ b/src/Bundle/Platform/Resources/views/Form/theme.html.twig @@ -75,19 +75,23 @@ {% block email_widget -%}
    - {{ ux_icon('tabler:email') }} + {{ ux_icon('tabler:mail') }}
    {{- parent() -}}
    {%- endblock email_widget %} +{## + Every password field in the application gets the show/hide toggle, not just the ones somebody + remembered to ask for it on. The `input` target is merged into `attr` so it lands on the + widget `parent()` renders — the component wraps that input, it does not build it. +##} {% block password_widget -%} -
    -
    - {{ ux_icon('tabler:password') }} -
    - {{- parent() -}} -
    + {%- set attr = attr|merge({'data-password-visibility-target': 'input'}) -%} + {#- Captured before the component: a component slot compiles to an embed, and `parent()` inside + one resolves against the component rather than this theme. -#} + {%- set widget = parent() -%} + {{ widget|raw }} {%- endblock password_widget %} {% block text_editor_widget -%} diff --git a/src/Bundle/Platform/Resources/views/Menu/settings_nav.html.twig b/src/Bundle/Platform/Resources/views/Menu/settings_nav.html.twig new file mode 100644 index 00000000..56727626 --- /dev/null +++ b/src/Bundle/Platform/Resources/views/Menu/settings_nav.html.twig @@ -0,0 +1,86 @@ +{# +# This file is part of SolidWorx Platform project. +# +# (c) Pierre du Plessis +# +# This source file is subject to the MIT license that is bundled +# with this source code in the file LICENSE. +#} + +{# + Renders a menu as a vertical list of settings pages, for the navigation column beside a + settings section. Mounted through the component rather than by hand: + + + + The entries are `list-group-item`s rather than nav links, which is what gives them the full + width of the card and a hit area that covers the whole row. The current page is marked with + `active`, matched by KnpMenu against the route. + + Nesting is not supported: a settings navigation is one flat list of pages, and an entry with + children would have nowhere to put them. Children are ignored rather than rendered wrongly. +#} + +{% extends 'knp_menu.html.twig' %} +{% import 'knp_menu.html.twig' as ui %} + +{% block list %} + {% if item.level == 0 and item.hasChildren and options.depth is not same as(0) and item.displayChildren %} + {# Not `list-group-transparent`: that carries `margin: 0 -1.25rem`, which is meant to cancel + the padding of a `card-body` it sits inside. This list is a direct child of the card, so + the negative margin would push every row out past the card's edges. #} +
    + {{ block('children') }} +
    + {% endif %} +{% endblock %} + +{% block item %} + {% if item.displayed %} + {% if item.uri is not empty %} + {{ block('linkElement') }} + {% else %} + {{ block('spanElement') }} + {% endif %} + {% endif %} +{% endblock %} + +{% block linkElement %} + {%- set classes = ['list-group-item', 'list-group-item-action'] -%} + {%- if item.linkAttribute('class') is not empty -%} + {%- set classes = classes|merge([item.linkAttribute('class')]) -%} + {%- endif -%} + {%- if matcher.isCurrent(item) or matcher.isAncestor(item, options.matchingDepth) -%} + {%- set classes = classes|merge([options.currentClass, 'active']) -%} + {%- endif -%} + + + {{- block('itemIcon') -}} + {{ block('label') }} + +{% endblock %} + +{% block spanElement %} + + {{- block('itemIcon') -}} + {{ block('label') }} + +{% endblock %} + +{% block itemIcon %} + {%- if item.extras.icon is defined -%} + {{ ux_icon('tabler:' ~ item.extras.icon, {class: 'icon swp-settings-nav__icon'}) }} + {%- endif -%} +{% endblock %} + +{% block label %} + {%- if options.allow_safe_labels and item.getExtra('safe_label', false) -%} + {{ item.label|trans|raw }} + {%- else -%} + {{ item.label|trans }} + {%- endif -%} +{% endblock %} diff --git a/src/Bundle/Platform/Resources/views/Profile/change_password.html.twig b/src/Bundle/Platform/Resources/views/Profile/change_password.html.twig index 9e473438..9f100f77 100644 --- a/src/Bundle/Platform/Resources/views/Profile/change_password.html.twig +++ b/src/Bundle/Platform/Resources/views/Profile/change_password.html.twig @@ -12,7 +12,7 @@ redefine a block. It is rendered with `form`, `user` and `password_requirements`. ##} -{% extends ui_layout_app %} +{% extends profile_layout %} {% types { ## The change-password form: current password, plus the new one twice. @@ -25,63 +25,68 @@ {% form_theme form '@SolidWorxPlatform/Form/theme.html.twig' %} -{% block page_pretitle %}{{ 'Security'|trans }}{% endblock %} - {% block page_title %}{{ 'Change password'|trans }}{% endblock %} -{## The rules the new password has to satisfy, listed before the fields so they are read first. ##} +{## The rules the new password has to satisfy, shown before the fields so they are read first. ##} {% block password_requirements %} {% if password_requirements is not empty %} -
    -
    {{ 'Your new password must be'|trans }}
    - -
      + +
        {% for requirement in password_requirements %} -
      • - {{ ux_icon('tabler:circle-check', {class: 'icon icon-sm text-success me-2 flex-shrink-0'}) }} +
      • + {{ ux_icon('tabler:check', {class: 'icon icon-sm me-2 flex-shrink-0'}) }} {{ requirement|trans }}
      • {% endfor %}
      -
    + {% endif %} {% endblock %} {## The buttons under the form. ##} {% block password_form_actions %} -
    - {{ 'Cancel'|trans }} - -
    {% endblock %} -{% block content %} -
    -
    - {{ form_start(form, {attr: {autocomplete: 'off'}}) }} -
    -
    - {{ form_errors(form) }} +{% block profile_content %} + {# Rendered out here rather than inside the card: a component slot is compiled as an embed, so + `block()` inside one looks the block up in the component, not in this page. #} + {% set form_actions = block('password_form_actions') %} + +
    + {{ block('password_requirements') }} - {{ form_row(form.currentPassword) }} + {{ form_start(form, {attr: {autocomplete: 'off'}}) }} + + + {{ form_errors(form) }} -
    + {{ form_row(form.currentPassword) }} - {{ block('password_requirements') }} +
    - {{ form_row(form.newPassword.first) }} - {{ form_row(form.newPassword.second) }} -
    + {{ form_row(form.newPassword.first) }} + {{ form_row(form.newPassword.second) }} + - -
    - {{ form_end(form) }} -
    + + {{ form_actions|raw }} + + + {{ form_end(form) }}
    {% endblock %} diff --git a/src/Bundle/Platform/Resources/views/Profile/edit.html.twig b/src/Bundle/Platform/Resources/views/Profile/edit.html.twig index 37163bee..98da1392 100644 --- a/src/Bundle/Platform/Resources/views/Profile/edit.html.twig +++ b/src/Bundle/Platform/Resources/views/Profile/edit.html.twig @@ -9,7 +9,7 @@ block, or at an unrelated template to replace the page. It is rendered with `form` and `user`. ##} -{% extends ui_layout_app %} +{% extends profile_layout %} {% types { ## The profile form, built from `platform.profile.form_type` and bound to the signed-in user. @@ -22,9 +22,7 @@ on the email, tel and password widgets. ##} {% form_theme form '@SolidWorxPlatform/Form/theme.html.twig' %} -{% block page_pretitle %}{{ 'Account'|trans }}{% endblock %} - -{% block page_title %}{{ 'Edit profile'|trans }}{% endblock %} +{% block page_title %}{{ 'Update profile'|trans }}{% endblock %} {## Every field of the form, in the order the form type declares them. ##} {% block profile_form_fields %} @@ -33,36 +31,40 @@ {## The buttons under the form. ##} {% block profile_form_actions %} -
    - {{ 'Cancel'|trans }} +
    + + {{ ux_icon('tabler:arrow-left', {class: 'icon'}) }} + {{ 'Cancel'|trans }} + -
    {% endblock %} -{% block content %} -
    -
    - {{ form_start(form) }} -
    -
    -

    {{ 'Your details'|trans }}

    -
    +{% block profile_content %} + {# Rendered out here rather than inside the card: a component slot is compiled as an embed, so + `block()` inside one looks the block up in the component, not in this page. #} + {% set form_fields = block('profile_form_fields') %} + {% set form_actions = block('profile_form_actions') %} -
    - {{ form_errors(form) }} + {{ form_start(form) }} + + + {{ form_errors(form) }} - {{ block('profile_form_fields') }} -
    + {{ form_fields|raw }} + - -
    - {{ form_end(form) }} -
    -
    + + {{ form_actions|raw }} + + + {{ form_end(form) }} {% endblock %} diff --git a/src/Bundle/Platform/Resources/views/Profile/layout.html.twig b/src/Bundle/Platform/Resources/views/Profile/layout.html.twig new file mode 100644 index 00000000..fb2b28ab --- /dev/null +++ b/src/Bundle/Platform/Resources/views/Profile/layout.html.twig @@ -0,0 +1,55 @@ +{## + The chrome every page in the profile section shares: the navigation down the left, the page + content beside it. + + Any page can join the section by extending it and filling one block. It gets the navigation, + the page header and the section's spacing for free: + + {% extends profile_layout %} + + {% block page_title %}{{ 'Notifications'|trans }}{% endblock %} + + {% block profile_content %} + … + {% endblock %} + + Register the page in the navigation with a menu builder on {@see ProfileMenu::NAME} — that is + the whole integration; nothing here has to be touched to add a page. + + Extend `profile_layout`, the Twig global, rather than this path: it points at whatever + `platform.profile.templates.layout` names, so an application can replace the section's chrome + once and have every page — the platform's and its own — follow. + + It extends `ui_layout_app`, so the surrounding application layout is still whatever + `platform.ui.templates.layouts.app` configures. +##} + +{% extends ui_layout_app %} + +{## Shown above the page title on every page in the section. ##} +{% block page_pretitle %}{{ 'Account'|trans }}{% endblock %} + +{## The navigation column. Renders nothing when no builder has registered `profile_menu`, in + which case the content simply takes the full width. ##} +{% block profile_nav %} + +{% endblock %} + +{## The page itself. This is the block a profile page fills. ##} +{% block profile_content %}{% endblock %} + +{% block content %} + {% set profile_nav = block('profile_nav')|trim %} + +
    + {% if profile_nav is not empty %} +
    + {{ profile_nav|raw }} +
    + {% endif %} + +
    + {{ block('profile_content') }} +
    +
    +{% endblock %} diff --git a/src/Bundle/Platform/Resources/views/Profile/show.html.twig b/src/Bundle/Platform/Resources/views/Profile/show.html.twig index b13cc27a..d58f995b 100644 --- a/src/Bundle/Platform/Resources/views/Profile/show.html.twig +++ b/src/Bundle/Platform/Resources/views/Profile/show.html.twig @@ -1,8 +1,8 @@ {## The profile page: what the platform holds about the signed-in user, and the ways to change it. - It extends the UI bundle's application layout, so it picks up whatever `platform.ui.templates` - an application configures. + It extends `profile_layout`, so it picks up the profile navigation and whatever + `platform.ui.templates` an application configures around it. Three levels of customisation, from cheapest to most involved: @@ -17,8 +17,9 @@ {{ profile.detail('Job title'|trans, user.jobTitle) }} {% endblock %} - The `detail()` and `security_item()` macros below are part of that contract — import them - as above rather than hand-rolling the markup, so your rows keep matching the platform's. + The `detail()` macro below is part of that contract — import it as above rather than + hand-rolling the markup, so your rows keep matching the platform's. Security rows are + ``, the same component the two-factor page uses. 2. **Add a section.** `profile_sections_extra` is an empty block at the bottom of the page, there so an application can add cards without redefining the layout above it. @@ -27,7 +28,7 @@ It is rendered with `user` and `two_factor_enabled`, and nothing else is expected of it. ##} -{% extends ui_layout_app %} +{% extends profile_layout %} {% types { ## The signed-in user — always the account the page is about, never one named in the request. @@ -55,49 +56,17 @@
    {% endmacro %} -{## - One row in the security card: a title, an explanation, and the action that changes it. -##} -{% macro security_item(title, description, url, action, icon) %} -
    -
    -
    - {{ ux_icon('tabler:' ~ icon, {class: 'icon'}) }} -
    - -
    -
    {{ title }}
    -
    {{ description }}
    -
    - - -
    -
    -{% endmacro %} - {% import _self as profile %} -{% block page_pretitle %}{{ 'Account'|trans }}{% endblock %} - {% block page_title %}{{ 'Profile'|trans }}{% endblock %} -{## The buttons in the page header. ##} -{% block page_title_actions %} - - {{ ux_icon('tabler:edit', {class: 'icon'}) }} - {{ 'Edit profile'|trans }} - -{% endblock %} - {## The user's initials, shown in the avatar. Falls back to the sign-in identifier. ##} {% block profile_initials %} {%- set initials = (user.firstName|default('')|slice(0, 1) ~ user.lastName|default('')|slice(0, 1))|upper -%} {{- initials is not empty ? initials : user.userIdentifier|slice(0, 2)|upper -}} {% endblock %} -{## The name shown at the top of the details card. Falls back to the sign-in identifier. ##} +{## The name shown at the top of the page. Falls back to the sign-in identifier. ##} {% block profile_name %} {%- set name = [user.firstName|default(''), user.lastName|default('')]|filter(part => part is not empty)|join(' ') -%} {{- name is not empty ? name : user.userIdentifier -}} @@ -105,12 +74,14 @@ {## The avatar and name banner above the details. ##} {% block profile_identity %} -
    - {{ block('profile_initials') }} +
    +
    + {{ block('profile_initials') }} -
    -

    {{ block('profile_name') }}

    -
    {{ user.userIdentifier }}
    +
    +

    {{ block('profile_name') }}

    +
    {{ user.userIdentifier }}
    +
    {% endblock %} @@ -123,81 +94,87 @@ {{ profile.detail('Mobile number'|trans, user.mobile|default(null)) }} {% endblock %} -{## The whole "Profile details" card. ##} +{## The whole "Personal information" card. ##} {% block profile_details %} -
    -
    -

    {{ 'Profile details'|trans }}

    -
    - -
    - {{ block('profile_identity') }} - + {# Rendered out here rather than inside the card: a component slot is compiled as an embed, so + `block()` inside one looks the block up in the component, not in this page. #} + {% set detail_rows = block('profile_detail_rows') %} + + +
    - {{ block('profile_detail_rows') }} + {{ detail_rows|raw }}
    -
    - - -
    + + + + + + {% endblock %} {## The rows of the security card. The two-factor row is only rendered when 2FA is enabled — its route does not exist otherwise. ##} {% block profile_security_items %} - {{ profile.security_item( - 'Password'|trans, - 'Change the password you sign in with'|trans, - path('solidworx_platform_profile_change_password'), - 'Change'|trans, - 'lock' - ) }} + + + {{ 'Change password'|trans }} + + {% if two_factor_enabled %} - {{ profile.security_item( - 'Two-factor authentication'|trans, - 'Add a second step to your sign-in'|trans, - path('solidworx_platform_security_two_factor_configure'), - 'Configure'|trans, - 'shield-lock' - ) }} + + + {{ 'Configure'|trans }} + + {% endif %} {% endblock %} {## The whole "Security" card. ##} {% block profile_security %} -
    -
    -

    {{ 'Security'|trans }}

    -
    - -
    - {{ block('profile_security_items') }} -
    -
    + {% set security_items = block('profile_security_items') %} + + + {# The rows are `list-group-item`s, so the card body is the list itself rather than a + padded box around one. #} + +
    + {{ security_items|raw }} +
    +
    +
    {% endblock %} {## Empty by design — somewhere to add your own cards under the two the platform ships. ##} {% block profile_sections_extra %}{% endblock %} -{% block content %} -
    -
    - {{ block('profile_details') }} -
    - -
    - {{ block('profile_security') }} -
    - - {% set profile_sections_extra = block('profile_sections_extra')|trim %} - {% if profile_sections_extra is not empty %} -
    {{ profile_sections_extra|raw }}
    - {% endif %} +{% block profile_content %} +
    + {{ block('profile_identity') }} + {{ block('profile_details') }} + {{ block('profile_security') }} + {{ block('profile_sections_extra') }}
    {% endblock %} diff --git a/src/Bundle/Platform/Resources/views/Security/TwoFactor/configure.html.twig b/src/Bundle/Platform/Resources/views/Security/TwoFactor/configure.html.twig index 880bcd59..da83b38b 100644 --- a/src/Bundle/Platform/Resources/views/Security/TwoFactor/configure.html.twig +++ b/src/Bundle/Platform/Resources/views/Security/TwoFactor/configure.html.twig @@ -1,16 +1,18 @@ -{# The page behind the "Two-factor authentication" entry in the user menu. It extends the UI - bundle's application layout, so it inherits whatever `platform.ui.templates` an application - configures; override this template outright for full control. #} -{% extends ui_layout_app %} +{## + The page behind the "Two-factor authentication" entry in the profile navigation. -{% block page_pretitle %}{{ 'Security'|trans }}{% endblock %} + It extends `profile_layout`, so it sits in the profile section alongside the profile and + change-password pages and picks up the same navigation — a second factor is an account + setting, and it is found where the other account settings are. + + Everything on it belongs to the live component; override this template outright for full + control, or override the component's template for control over the settings themselves. +##} + +{% extends profile_layout %} {% block page_title %}{{ 'Two-factor authentication'|trans }}{% endblock %} -{% block content %} -
    -
    - -
    -
    +{% block profile_content %} + {% endblock %} diff --git a/src/Bundle/Platform/Twig/Components/Security/TwoFactor.php b/src/Bundle/Platform/Twig/Components/Security/TwoFactor.php index 0c6606e7..6b8786b1 100644 --- a/src/Bundle/Platform/Twig/Components/Security/TwoFactor.php +++ b/src/Bundle/Platform/Twig/Components/Security/TwoFactor.php @@ -30,6 +30,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Form\FormInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\String\Slugger\SluggerInterface; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveAction; use Symfony\UX\LiveComponent\Attribute\LiveProp; @@ -62,9 +63,27 @@ public function __construct( #[Autowire(service: 'scheb_two_factor.default_trusted_device_manager')] private readonly TrustedDeviceManagerInterface $trustedDeviceManager, private readonly BackupCodeGeneratorInterface $backupCodeGenerator, + private readonly SluggerInterface $slugger, + #[Autowire(param: 'solidworx_platform.app.name')] + private readonly string $appName, ) { } + /** + * The name of the file the browser saves the backup codes as. + * + * It leads with the application name because these end up in a downloads folder next to every + * other file called `backup-codes.txt`, and a user with two accounts has no way to tell them + * apart otherwise. + */ + #[ExposeInTemplate] + public function backupCodesFilename(): string + { + $name = $this->slugger->slug($this->appName)->lower()->toString(); + + return ($name !== '' ? $name . '-' : '') . 'backup-codes.txt'; + } + #[PreMount()] public function preMount(): void { diff --git a/src/Bundle/Platform/Twig/Extension/ProfileExtension.php b/src/Bundle/Platform/Twig/Extension/ProfileExtension.php new file mode 100644 index 00000000..c30bf482 --- /dev/null +++ b/src/Bundle/Platform/Twig/Extension/ProfileExtension.php @@ -0,0 +1,52 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\PlatformBundle\Twig\Extension; + +use Override; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Twig\Extension\AbstractExtension; +use Twig\Extension\GlobalsInterface; + +/** + * Exposes the configured profile layout to Twig. + * + * Profile pages extend `profile_layout` rather than hard-coding + * `@SolidWorxPlatform/Profile/layout.html.twig`, so an application can swap the whole profile + * chrome through `platform.profile.templates.layout` and every page follows — including its own. + * + * It is a Twig extension rather than an entry under `twig.globals` for a reason worth keeping: + * template names start with `@`, and a string starting with `@` in the container is a service + * reference. Passing one through `twig.globals` makes the container look for a service called + * `SolidWorxPlatform/Profile/layout.html.twig` and fail at compile time. A constructor argument + * has no such meaning, which is also why the UI bundle exposes its layouts this way. + */ +final class ProfileExtension extends AbstractExtension implements GlobalsInterface +{ + public function __construct( + #[Autowire(param: 'solidworx_platform.profile.templates.layout')] + private readonly string $layout, + ) { + } + + /** + * @return array + */ + #[Override] + public function getGlobals(): array + { + return [ + 'profile_layout' => $this->layout, + ]; + } +} diff --git a/src/Bundle/Ui/templates/Security/login.html.twig b/src/Bundle/Ui/templates/Security/login.html.twig index af15f291..faecaa58 100644 --- a/src/Bundle/Ui/templates/Security/login.html.twig +++ b/src/Bundle/Ui/templates/Security/login.html.twig @@ -44,12 +44,7 @@ Forgot password #} - +
    {% if options.always_remember_me is same as(false) and options.remember_me_parameter is not empty %}
    @@ -82,7 +66,7 @@ {% endif %} {% if options.enable_csrf %} - + {% endif %} {% elseif title is not empty or subtitle is not empty %} + {# `card-header` is a flex row, so the title and subtitle are wrapped — otherwise the + subtitle sits beside the title rather than under it, and an icon tile could not be + placed in front of both. #}
    - {% if title is not empty %} -

    {{ title }}

    - {% endif %} - {% if subtitle is not empty %} -

    {{ subtitle }}

    + {% if icon is not empty %} + + {{ ux_icon(icon, {class: 'icon'}) }} + {% endif %} + +
    + {% if title is not empty %} +

    {{ title }}

    + {% endif %} + {% if subtitle is not empty %} +

    {{ subtitle }}

    + {% endif %} +
    {% endif %} diff --git a/src/Bundle/Ui/templates/components/PasswordField.html.twig b/src/Bundle/Ui/templates/components/PasswordField.html.twig new file mode 100644 index 00000000..107f16e6 --- /dev/null +++ b/src/Bundle/Ui/templates/components/PasswordField.html.twig @@ -0,0 +1,55 @@ +{# + A password input with a leading icon and a show/hide toggle. + + Pass the `` itself as the content — this wraps it, it does not render it, because the + two callers build their input very differently: + + + + + + The input has to carry the `input` Stimulus target, since only the caller knows which element + is the field. Symfony forms get that from the platform form theme's `password_widget`, which + merges the attribute in — so every password field rendered by a form already looks like this + and nothing has to be done per form. + + This exists so the login page and every password field in the application share one markup + rather than two that drift. See the UI consistency rules in CLAUDE.md. +#} + +{% props + icon = 'tabler:password', + toggleLabel = 'Show password' +%} + +
    + {% if icon is not empty %} + + {{ ux_icon(icon, {class: 'icon'}) }} + + {% endif %} + + {% block content %}{% endblock %} + + + + + {{ ux_icon('tabler:eye', {class: 'icon'}) }} + + + {{ ux_icon('tabler:eye-closed', {class: 'icon'}) }} + + + +
    diff --git a/src/Bundle/Ui/templates/components/Security/LogoutLink.html.twig b/src/Bundle/Ui/templates/components/Security/LogoutLink.html.twig index f78b053f..4507088b 100644 --- a/src/Bundle/Ui/templates/components/Security/LogoutLink.html.twig +++ b/src/Bundle/Ui/templates/components/Security/LogoutLink.html.twig @@ -33,7 +33,7 @@ }) %} - +