preflow / members
Preflow members — member accounts for a Preflow + Folio site: the member type, the owner's decision, pluggable admission
Requires
- php: >=8.4
- nyholm/psr7: ^1.8
- preflow/auth: ^0.26 || @dev
- preflow/core: ^0.25 || @dev
- preflow/data: ^0.26 || @dev
- preflow/devtools: ^0.26 || @dev
- preflow/folio: ^0.26 || @dev
- preflow/i18n: ^0.26 || @dev
- preflow/notify: ^0.25 || @dev
- preflow/rbac: ^0.25 || @dev
- preflow/routing: ^0.25 || @dev
- preflow/validation: ^0.26 || @dev
- preflow/view: ^0.25 || @dev
Requires (Dev)
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
- preflow/auth: <0.26
- preflow/data: <0.26
- preflow/folio: <0.26
- preflow/i18n: <0.26
Replaces
None
README
Member accounts for a Preflow + Folio site: the member type in Folio with a waiting badge, a state column and a Decision panel (accept or decline with an editable message), the member roles, and a pluggable admission rule.
Install
composer require preflow/members
php preflow folio:extensions:enable rbac
php preflow folio:extensions:enable members
php preflow migrate
php preflow folio:extensions:apply
-
Add
Preflow\Members\MembersServiceProvidertoconfig/providers.php(afterFolioServiceProviderandRbacServiceProvider). -
The member roles (
pending,member,rejected) need therbacextension; booting without it is an error.folio:extensions:applycreates the roles that are missing and never changes one that exists. -
Members live in the storage alias
memberswhenconfig/data.phphas it, else ondefault:'aliases' => ['members' => 'sqlite'],
-
Members sign in through their own guard. In
config/auth.php:'guards' => [ 'members' => [ 'class' => Preflow\Auth\SessionGuard::class, 'provider' => 'members', 'population' => 'members', 'remember' => ['cookie' => 'site_member', 'days' => 30, 'path' => '/'], ], ], 'providers' => [ 'members' => [ 'class' => Preflow\Folio\Users\RecordUserProvider::class, 'type' => 'member', 'email' => 'email', 'password' => 'password_hash', 'roles' => 'roles', ], ], // Sessions (and their cookie) only where they are needed: Folio and the member pages. 'session' => ['cookie' => 'site_session', 'paths' => ['/folio', '/members']],
-
The session
pathsmust cover the memberspath(/membersabove); public pages then get no session and no cookie. Locale prefixes are stripped first, so/de/members/logincounts as/members/login. -
A password change or reset ends the member's other sessions: the member type's
auth_epochfield is the session epoch the guard checks on every request (provider keyepoch, defaultauth_epoch), and the remember rows are revoked. -
The members provider must keep the default epoch field
auth_epoch(leaveepochout): the package writes that field.
Configuration
config/members.php, every key optional:
return [ 'path' => '/members', // base path of the member pages 'landing' => null, // where sign-in lands; null = path 'register' => null, // where the pages link for registering; null = path/register 'layout' => '@folio/frontend/_site.twig', // the layout the member pages extend 'mail' => ['from_name' => null, 'layout' => '@members/mail/layout.twig', 'accent' => '#d42a34'], 'admission' => Preflow\Members\Admission\OwnerApproval::class, 'throttle' => ['login' => [10, 900], 'register' => [5, 3600], 'forgot' => [5, 3600], 'resend' => [3, 3600]], 'password_min' => 10, 'confirm_ttl' => 86400, 'reset_ttl' => 3600, 'retention' => ['unconfirmed_days' => 7, 'declined_days' => 30], 'privacy_url' => '', // the consent's privacy link: one URL or ['en' => '/privacy', 'de' => '/de/datenschutz'] ];
pathmust be below the site root (''or'/'is a boot error): the member pages own everything under it.privacy_urlas a map: a locale without an entry gets the default locale's; empty means the consent reads without a link.
Fixed names (stored data depends on them; MembersConfig lists them as constants): type
and table member, guard members, permissions members.access and members.decide,
roles pending, member and rejected, token purposes members.confirm and
members.reset, throttle purposes members.login, members.register, members.forgot and
members.resend.
Links in mails use app.url and the locale prefixes of config/i18n.php (the default
locale at the root): https://site.test/de/members/login.
Pages and routes
The provider registers these routes under path (in every locale: /de/members/login, the
default locale without a prefix):
| Method | Path | Page |
|---|---|---|
| GET, POST | {path}/register |
register form; POST registers (same answer for known addresses); GET is a 303 to register when that is another page |
| GET | {path}/confirm/{token} |
the confirm form (a mail scanner's GET uses nothing) |
| POST | {path}/confirm |
confirms; then the admission decides |
| GET, POST | {path}/login |
sign in (30-day remember cookie) |
| POST | {path}/logout |
sign out, to {path} |
| GET, POST | {path}/forgot |
ask for a reset link |
| GET | {path}/reset/{token} |
the new-password form |
| POST | {path}/reset |
sets the password, signs in, to landing |
| GET, POST | {path}/resend |
POST sends the confirm link again; GET goes to the login |
| GET | {path}/account |
the member's account page |
| POST | {path}/account/password |
change the password |
| POST | {path}/account/delete |
delete the account, to register |
- The members home (
GET {path}) is the site's own page (a toolbox, "my objects"); the package only sends people there: after sign-out, and staff who open the account page. Sign-in, reset and an accepted confirmation go tolanding. - After a confirmation
Decision::Acceptmakes a member, signs them in (remembered) and redirects tolanding;Decision::Pendinggives thependingrole, shows the thanks page and mails the owner (Members settingsnotice_email, when set). The admission'sconfirmNotice()(when not null) is shown on that thanks page, or, for an accepted member, flashed asmembers.noticefor the landing to show. - A sign-in's
nextis kept only when it is a member page ({path}in any locale); anything else lands onlanding. - Every form needs the CSRF token (
csrf_field()in the templates); every page isprivate, no-store. - Flash notices:
members.notice(e.g. "Your account is deleted.") is shown by the package pages of the register controller; a site whoseregisteris its own page reads it there.members.accountis the account page's own. - Throttles (
throttleconfig): sign-in per client and per address, registration per client, forgot per client and per address, resend per address (over the limit the page stays the same, only the mail is skipped).
Admission
After a member confirms the e-mail, the configured Preflow\Members\Admission\Admission
decides: AcceptOnConfirm (a member at once) or OwnerApproval (the owner decides in
Folio, the default). A site's own rule implements the interface:
interface Admission { public function validate(array $input, ServerRequestInterface $request, array $baseErrors): array; // field => message public function recordValues(array $input): array; // stored on the member public function afterConfirm(DynamicRecord $member, ServerRequestInterface $request): Decision; // Accept or Pending public function confirmNotice(DynamicRecord $member): ?string; // a line after confirming, or null public function details(DynamicRecord $member, string $for): array; // label => value }
validate() is asked on every post, so its errors show together with those of the form's own
fields (name, email, password, consent). $baseErrors are those (field => key): skip a costly
or throttled check (a code lookup) when $baseErrors !== [], the post fails anyway.
details($member, Admission::PANEL) appear in the Decision panel (Registered … · label: value),
details($member, Admission::NOTICE) in the owner's notice mail, one paragraph each; an entry
with an int key is shown without a label. The keys of
recordValues() must be fields of the member type; a site adds fields with its own
config/models/member.json:
{ "key": "member", "extends": true, "fields": { "company": { "type": "string" } } }
Site fields come after the package's in the edit form; "after": "name" on a field puts it
right after that field instead. The same works for the Members settings (members_settings.json
with "extends": true).
Overriding templates
The pages are @members/{register,confirm,login,forgot,reset,resend,check-inbox,thanks,closed,account}.twig
plus the shared _card.twig, all extending the configured layout. @members/decision.twig is
the Decision panel in Folio (a fragment in the admin, not a page). A site overrides one by
putting a file of the same name in resources/members/ (searched before the package; a new
directory needs a fresh request to be picked up).
To change only a part of a page, extend the original through @members-package (extending
@members/register.twig from the override of the same name would recurse into itself):
{# resources/members/register.twig #} {% extends '@members-package/register.twig' %} {% block admission_fields %} <label for="f-code">{{ t('members.code.label') }}</label> <input id="f-code" name="code" value="{{ old.code|default('') }}"> {% endblock %}
register.twig has the blocks intro, admission_fields (the admission rule's own fields),
extra_fields (fields you added to the member type) and consent_text; set privacy_url in
config/members.php for the privacy link, or override consent_text (e.g. for a link a Folio
setting chooses). The posted values are in old (old.code), the errors in errors
(errors.code). The mail layout (mail.layout) is a template
too. Words are changed in the lang files (see Copy and mails), not in templates.
Cron
Run members:prune nightly (the package registers the command through
extra.preflow.commands, preflow list shows it). That needs preflow/devtools 0.26; with an
older devtools only the command is missing, the member pages and Folio work (the package
conflicts with auth, data, folio and i18n before 0.26, not with devtools):
17 3 * * * cd /path/to/site/current && php preflow members:prune
Unconfirmed accounts go after retention.unconfirmed_days (7, from registration), declined
ones after retention.declined_days (30, from the decision); expired tokens, remember rows and
throttle hits are pruned. It prints counts only (no addresses or names) and a lock file in
storage/locks keeps two runs from overlapping.
Locales
Locales and URL prefixes come from config/i18n.php; the package lang files ship en, de
and fr. Without config/i18n.php the pages run in a single locale with no prefix.
In Folio
- Members (sidebar section Members) with a waiting badge and a State column.
- The Decision panel offers Accept from Waiting or Declined and Decline from Waiting or Member; the server refuses anything else. The mail goes out first; when it fails, nothing changes. Declining ends the member's remembered sessions.
- Settings → Members settings:
registration_open,notice_email,accept_message,decline_message(translatable;{name}and{login_url}are filled in). Left empty, the panel takesmail.accepted.body/mail.declined.bodyof the lang files; the settings form shows that standard text, in the language being edited, as the field's placeholder. A text stored only for another locale is not used (the member gets the lang default in their language).
Copy and mails
The words of the member pages and mails are in lang/{en,de,fr}/members.php. A site changes
single keys in its own lang/<locale>/members.php; the rest stays the package's. German is
in the formal "Sie" form; a site that says "du" overrides the German keys.
// lang/de/members.php return ['mail' => ['confirm' => ['subject' => 'Bitte bestätige deine Adresse']]];
MemberMailersendsconfirmLater,existingLater,resetLaterandnoticeLaterafter the response (a failure goes to the optional$onFailure), andaccepted/declinedat once (aTransportExceptionaborts the decision).- Each mail is in the member's language (the site default when the site lacks it); the owner's
notice is in the site default locale. Mail bodies use
{name},{link},{login_url},{reset_url},{validity},{email},{details},{folio_url}. {validity}(and:validityininbox.hint) is how long the link works, fromconfirm_ttl/reset_ttl:LinkValidity::confirm($locale)/reset($locale)give "24 hours", "eine Stunde" (lang keysvalidity.hours/validity.minutes, plural forms).- The HTML part renders
mail.layout(default the neutral@members/mail/layout.twig) withlang,subject,accentandparagraphs(escaped text, URLs linked in the accent colour). - The sender is
mail.from_namewith the address ofconfig/notify.phpchannels.email.from; without a from name the channel's own sender applies. - When a new member's confirm mail fails, remove the account with
Members::removeIfUnconfirmed($id): it keeps a member who confirmed meanwhile.