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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
53 changes: 53 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<twig:Ui:SettingRow>`, 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
`<div>` 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),
Expand Down
36 changes: 32 additions & 4 deletions assets/controllers/csrf_protection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down
84 changes: 84 additions & 0 deletions assets/controllers/two_factor_controller.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
6 changes: 6 additions & 0 deletions assets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
62 changes: 62 additions & 0 deletions assets/scss/_settings-nav.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// The vertical navigation beside a settings section — `<twig:Ui:SettingsNav />`.
//
// 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;
}
}
1 change: 1 addition & 0 deletions assets/scss/platform.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@
@use '@tabler/core/scss/vendor/tom-select';
@use 'styles';
@use 'text-editor';
@use 'settings-nav';
2 changes: 1 addition & 1 deletion docs/form-types/text-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading