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 70f9d36c..fa4a54c4 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -2,6 +2,59 @@ ## 0.2 → 0.3 +### 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 + `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 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 The UI bundle now ships three layouts — `ui_layout_app` (sidebar + top navbar), 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/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/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 f7246294..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; @@ -196,7 +197,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,13 +217,17 @@ 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 - `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. +- **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 @@ -409,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 44bd2595..4a08239e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,8 +7,10 @@ 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 +- [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/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..0a83eb6a --- /dev/null +++ b/docs/security/profile.md @@ -0,0 +1,339 @@ +# User Profile + +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 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: + +- 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 | +|----------|--------| +| `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 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 + +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` | + +A replacement page still extends `profile_layout` if you want the navigation; extend anything else +and it becomes a standalone page. + +--- + +## 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. + +Both are ordinary KnpMenus, so both take entries through `#[MenuBuilder]`. + +--- + +## Reference + +```yaml +# platform.yaml — every profile key, with its default +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' + password: + min_length: 12 + strength: medium + check_compromised: true +``` 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 9549d2fc..06a92786 100644 --- a/platform-schema.json +++ b/platform-schema.json @@ -118,6 +118,98 @@ }, "additionalProperties": false }, + "profile": { + "description": "The user profile pages: details, edit, and change password.", + "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" + }, + "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": { + "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", + "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..3e3bbede --- /dev/null +++ b/src/Bundle/Platform/Config/Builder/ProfileConfigBuilder.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\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; + } + + /** + * 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; + 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..d19f59e8 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,76 @@ 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('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.') + ->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..de5679a0 --- /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/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 d13ed3ee..cf7d4737 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{layout: string, 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,23 @@ 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.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']); + $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..8e7703d9 --- /dev/null +++ b/src/Bundle/Platform/Form/Type/Profile/ChangePasswordType.php @@ -0,0 +1,131 @@ + + * + * 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, + ) { + } + + #[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..c1824350 --- /dev/null +++ b/src/Bundle/Platform/Form/Type/Profile/ProfileType.php @@ -0,0 +1,124 @@ + + * + * 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, + ) { + } + + #[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/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 new file mode 100644 index 00000000..5afd3b89 --- /dev/null +++ b/src/Bundle/Platform/Menu/ProfileMenuBuilder.php @@ -0,0 +1,73 @@ + + * + * 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\ChangePassword; +use SolidWorx\Platform\PlatformBundle\Controller\Profile\ShowProfile; + +/** + * The platform's own entries in the two menus the profile section uses. + * + * 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 + { + $menu->addChild( + 'Profile', + Options::create() + ->route(ShowProfile::ROUTE_NAME) + ->icon('user') + ->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 1c21c4f0..b888b452 100644 --- a/src/Bundle/Platform/Menu/UserMenu.php +++ b/src/Bundle/Platform/Menu/UserMenu.php @@ -22,17 +22,20 @@ * #[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 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,10 +45,19 @@ 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. */ 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/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 41b7055f..5a6349ef 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' %}
    @@ -58,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 new file mode 100644 index 00000000..9f100f77 --- /dev/null +++ b/src/Bundle/Platform/Resources/views/Profile/change_password.html.twig @@ -0,0 +1,92 @@ +{## + 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 profile_layout %} + +{% 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_title %}{{ 'Change password'|trans }}{% endblock %} + +{## 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 %} + +
      + {% for requirement in password_requirements %} +
    • + {{ 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 %} +
    + + {{ ux_icon('tabler:arrow-left', {class: 'icon'}) }} + {{ 'Cancel'|trans }} + + + +
    +{% endblock %} + +{% 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_start(form, {attr: {autocomplete: 'off'}}) }} + + + {{ form_errors(form) }} + + {{ form_row(form.currentPassword) }} + +
    + + {{ form_row(form.newPassword.first) }} + {{ form_row(form.newPassword.second) }} +
    + + + {{ 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 new file mode 100644 index 00000000..98da1392 --- /dev/null +++ b/src/Bundle/Platform/Resources/views/Profile/edit.html.twig @@ -0,0 +1,70 @@ +{## + 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 profile_layout %} + +{% 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_title %}{{ 'Update 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 %} +
    + + {{ ux_icon('tabler:arrow-left', {class: 'icon'}) }} + {{ 'Cancel'|trans }} + + + +
    +{% endblock %} + +{% 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_start(form) }} + + + {{ form_errors(form) }} + + {{ form_fields|raw }} + + + + {{ 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 new file mode 100644 index 00000000..d58f995b --- /dev/null +++ b/src/Bundle/Platform/Resources/views/Profile/show.html.twig @@ -0,0 +1,180 @@ +{## + The profile page: what the platform holds about the signed-in user, and the ways to change it. + + 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: + + 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()` 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. + + 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 profile_layout %} + +{% 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 %} + +{% import _self as profile %} + +{% block page_title %}{{ '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 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 -}} +{% 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 "Personal information" card. ##} +{% block profile_details %} + {# 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') %} + + + +
    + {{ 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 %} + + + {{ 'Change password'|trans }} + + + + {% if two_factor_enabled %} + + + {{ 'Configure'|trans }} + + + {% endif %} +{% endblock %} + +{## The whole "Security" card. ##} +{% block profile_security %} + {% 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 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/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/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/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/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 @@ }) %} - +