preflow / members
Preflow members — member accounts for a Preflow site: registration, sign-in, the account page, pluggable admission; Folio adds the admin
Requires
- php: >=8.4
- nyholm/psr7: ^1.8
- preflow/auth: ^0.65 || @dev
- preflow/core: ^0.72 || @dev
- preflow/data: ^0.42 || @dev
- preflow/devtools: ^0.26 || @dev
- preflow/i18n: ^0.38 || @dev
- preflow/notify: ^0.68 || @dev
- preflow/routing: ^0.59 || @dev
- preflow/validation: ^0.26 || @dev
- preflow/view: ^0.25 || @dev
Requires (Dev)
- phpunit/phpunit: ^11.0
Suggests
- preflow/folio: The members admin: the Members list, the owner's decision panel and Settings → Members (Folio carries it when this package is installed)
- preflow/rbac: Roles and permissions beyond members.access (Folio's members admin needs it)
Provides
None
Conflicts
- preflow/auth: <0.65
- preflow/core: <0.72
- preflow/data: <0.42
- preflow/folio: <0.68
- preflow/i18n: <0.38
- preflow/notify: <0.68
- preflow/routing: <0.59
Replaces
None
- dev-main
- v0.74.0
- v0.73.0
- v0.72.1
- v0.72.0
- v0.71.1
- v0.71.0
- v0.70.0
- v0.69.2
- v0.69.1
- v0.69.0
- v0.68.1
- v0.68.0
- v0.67.0
- v0.66.2
- v0.66.1
- v0.66.0
- v0.65.0
- v0.64.0
- v0.63.2
- v0.63.1
- v0.63.0
- v0.62.0
- v0.61.0
- v0.60.1
- v0.60.0
- v0.59.0
- v0.58.1
- v0.58.0
- v0.57.0
- v0.56.1
- v0.56.0
- v0.55.0
- v0.54.1
- v0.54.0
- v0.53.2
- v0.53.1
- v0.53.0
- v0.52.0
- v0.51.0
- v0.50.0
- v0.49.1
- v0.49.0
- v0.48.5
- v0.48.4
- v0.48.3
- v0.48.2
- v0.48.1
- v0.48.0
- v0.47.1
- v0.47.0
- v0.46.1
- v0.46.0
- v0.45.0
- v0.44.0
- v0.43.0
- v0.42.1
- v0.42.0
- v0.41.2
- v0.41.1
- v0.41.0
- v0.40.0
- v0.39.0
- v0.38.0
- v0.37.0
- v0.36.0
- v0.35.0
- v0.34.1
- v0.34.0
- v0.33.0
- v0.32.0
- v0.31.0
- v0.30.0
- v0.29.0
- v0.28.0
- v0.27.1
- v0.27.0
- v0.26.0
This package is auto-updated.
Last update: 2026-11-13 14:26:04 UTC
README
Member accounts for a Preflow site: registration with a confirmed address, sign-in and sign-out, forgot and reset password, the account page with self-deletion, the member mails, a pluggable admission rule and hooks for a project's own steps. It needs neither Folio nor rbac (it requires auth, core, data, routing, i18n, notify, validation, view and devtools). On a Folio site, Folio adds the admin: the member list with a waiting badge, a state column and a Decision panel (accept or decline with an editable message), Settings → Members and the member roles (rbac).
Without rbac, an admitted member (role member) has members.access and nothing more; with
rbac, rbac's roles decide. The member type (member) is the package's own
(models/member.json); a site adds fields with a model file member.json that says
"extends": true.
Install
Without Folio:
composer require preflow/members
php preflow migrate
On a Folio site, with the members admin:
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(on a Folio site afterFolioServiceProviderandRbacServiceProvider). -
Without Folio the owner's settings come from
config/members.php(see Configuration), the owner decides waiting members withphp preflow members:decide <email> accept|decline, and the member pages use the package's own layout unlesslayoutnames the site's. -
The members admin (the
membersextension: member list, Decision panel, Settings → Members) is Folio's, there once this package is installed. Its 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\Auth\Record\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 give no session and no cookie to visitors without one, and resume a signed-in member's session (their cookie), so a public header can show the account. 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' => '@members/_layout.twig', // the layout the member pages extend (a Folio site: @folio/frontend/_site.twig) 'mail' => ['from_name' => null, 'layout' => null, 'accent' => null], // null: notify's (config/notify.php mail.layout / mail.accent) 'admission' => Preflow\Members\Admission\AcceptOnConfirm::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'] 'next' => ['allow' => []], // more places "next" may lead besides the member pages: ['/games/'] // The owner's settings, without Folio (on a Folio site: Settings → Members): 'registration_open' => true, 'notice_email' => null, // told about a member waiting for the owner's decision 'accept_message' => null, // one text, or ['en' => …, 'de' => …]; null = the lang files' 'decline_message' => null, ];
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). path is registered as non-public (NonPublicPaths, see
preflow/core): a 404 under it never redirects and never shows up in Folio's not-found list.
| Method | Path | Page |
|---|---|---|
| GET | {path} |
the members home: a default landing (greeting, account, sign out) unless the site has its own page there |
| 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}): a site that answers that path itself (a pageapp/pages/members/index.twig, a controller route, a dynamic/{slug}route that matches it) keeps its page; otherwise the package serves@members/landing.twig(a greeting, the account link and sign out; signed out or not admitted: to the login). Only routes registered before members boots count: a provider that adds the same path must be listed beforeMembersServiceProvider. Sign-out and staff who open the account page go to{path}(staff: signed in on the default guard when it is a staff guard, as Folio's is or as'population' => 'staff'makes it); 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. next(where to go afterwards) is kept only when it is a member page ({path}) or under a path ofnext.allow('/games/'allows/gamesand/games/catan, not/gamesfoo), each in any locale; anything else lands onlanding. A scheme,//, a backslash or a dot segment is never kept, percent-encoded neither (core'sSafeReturnPathchecks it, with the member path and theallowpaths in every locale as prefixes). Anallowentry that is no site path is an error.nextworks for sign-in (?next=on the login page) and for registration: the register page carries?next=as a hidden field (the site's own register page posts anextfield too), the member keeps it (the hidden member fieldnext; runpreflow migrateonce for its column) and an accepted confirmation goes there once instead oflanding. The confirmation clears it, also when the owner decides.- Every form needs the CSRF token (
csrf_field()in the templates); every page isprivate, no-store. The login and the registration ({path}/login,{path}/register, in any locale:/de/konto/loginis/konto/login) are CSRF sign-in paths: a login form left open until the session expired still signs in when the browser's origin check passes. Other member forms whose session expired are sent back with a warning and their fields kept (passwords excepted), never applied; the package pages show thewarningflash above the form (a site's own templates readwarning). A public page outside the session paths may post into these two (a registration form on the home page):csrf_field()prints the stateless token there (with a realapp.key), which they accept with a passing origin, also from a visitor who has a session. Every other member form needs the session's own token. - 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). - A trap for bots (
Http\Trap): the register form has a text fieldmembers_websitethat people never see or reach (off screen,tabindex="-1",aria-hidden,autocomplete="off"). A registration with it filled in creates nobody and sends no mail, and answers "check your inbox" as a real one does; it counts for the registration throttle.
Accounts a project creates
A project that has proven the address itself (an invitation mailed to it) creates the account confirmed, and may sign it in:
$members = $container->get(Members::class); if ($members->findByEmail($email) !== null) { // the address has an account already: send the person to the sign-in instead } else { $member = $members->register($email, $name, $password, $locale, emailVerified: true); if (MemberState::fromRecord($member) === MemberState::Member) { $container->get(SignIn::class)->in($member, $request); // the members guard, remembered } }
An address has one account: register() throws Preflow\Members\EmailTakenException
(its email property holds the normalised address) when one exists already. The email column
is unique (a unique index on SQL, length 320), so two registrations racing for one address end in
EmailTakenException for the second. A site that already holds duplicates sees them in
php tools/convert-typed-columns.php <site-dir> --check (the data README, "Converting record
tables") and must resolve them before the unique index can be added.
emailVerified: true sends no confirmation mail; the admission decides at once as after a
confirmation (AcceptOnConfirm: a member; OwnerApproval: pending, and the owner is told).
The package never assumes an address is proven: only the caller says so. SignIn::in()
refuses a member who is not admitted (a LogicException).
Admission
After a member confirms the e-mail, the configured Preflow\Members\Admission\Admission
decides: AcceptOnConfirm (a member at once, the default) or OwnerApproval (opt-in:
'admission' => OwnerApproval::class; the owner decides in Folio's Decision panel, with
members:decide, or in code with Members::decide()). 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).
An admission that also implements Preflow\Members\Admission\ComposesName supplies the stored
name from the site's own fields (first and last name): name(array $input): string. The
register form then has no name field, a posted name is ignored, and validate() sees the
composed name as $input['name']; an empty one fails like a missing name.
Hooks
Preflow\Members\MemberEvents (a container singleton) lets a project add its steps without
watching routes. Register listeners in a provider's boot():
$events = $container->get(MemberEvents::class); $events->on(MemberEvents::REGISTERED, fn (array $p) => $invitations->accept($p['member'], $p['request'])); $events->on(MemberEvents::DELETING, fn (array $p) => !$certificates->pendingFor($p['member'])); // false refuses
| Event | Payload | When |
|---|---|---|
REGISTERED |
member, request (null when none was given) |
a new account (the form, or Members::register()) |
CONFIRMED |
member, request |
the address is proven (the link, or emailVerified); before the admission decides |
DECIDED |
member, role (member, pending, rejected) |
the admission or the owner decided |
SIGNED_IN |
member, request |
the member pages or SignIn::in() signed the member in |
DELETING |
member, plan, by_member |
before an account is deleted; false refuses |
DELETED |
id, member (as it was), plan (applied), by_member |
after |
DELETION_REFUSED |
member, plan, reason (restricted, listener), notice_sent |
the member's own deletion was refused; return true when you told the site |
on() with an unknown event name throws. Listeners run in registration order. An exception in
a DELETING listener propagates (nothing has changed yet); in every other listener it is logged,
the remaining listeners run and the member action stands. by_member is false for a clean-up
no one asked for (members:prune, an account removed after its confirm mail failed); those
raise no DELETION_REFUSED and mail nobody.
Interfaces a package or site may bind (all optional except the settings, which have a default):
MembersSettings:registrationOpen(),noticeEmail(),acceptMessage($locale),declineMessage($locale). The default readsconfig/members.php; Folio's members admin binds Settings → Members.MemberLinks::decisionUrl($member): the page where the owner decides, linked from the owner's mails. Folio's members admin binds the member's edit page; without a binding the mails are text only.MemberDeleteCopies::of($definition): tables holding copies of records (Folio's published snapshots), counted by arestrictself rule.PageLocale::set($locale): told a member page's locale before it renders (Folio sets its content locale, so the site layout renders in the page's language).MembersDefaults: a package's defaults under the site'sconfig/members.php(Folio names its frontend layout aslayout).
Deleting an account
A member deletes their own account on the account page; members:prune removes unconfirmed and
declined ones. Both go through the data layer's delete plan with the self rules: a relation
pointing at member may say what happens when the member deletes themselves, apart from what
staff deletes do:
"owner": { "type": "relation", "to": "member", "onDelete": "restrict", "onSelfDelete": "detach" }
Without onSelfDelete the relation's onDelete applies to both. Members::delete($member)
returns a DeletionResult (deleted, refused, told, plan, reason):
- a
restrictself rule, or aDELETINGlistener returningfalse, refuses: nothing is deleted, the owner gets a notice mail atnotice_email(when set) andDELETION_REFUSEDis raised; - the account page then answers 409 and tells the member either that the site has been told
(the notice went out, or a listener returned
true) or to contact the site; the member stays signed in; - otherwise detaches and cascades are applied, then the member is deleted, signed out and their tokens and remember rows revoked.
On a Folio site the deletion also reaches published snapshots (cleared pointers, unpublished
cascaded pages). Folio's own record hooks (#[AfterDelete] and the like on admin types) do not
run for the records a member's deletion detaches or deletes.
A staff delete in Folio's admin raises no MemberEvents: a project that cleans up on every
member deletion, by the member or by staff, also listens to Folio's #[AfterDelete] on member
(a #[FolioType('member')] class) besides DELETED.
Overriding templates
The pages are @members/{landing,register,confirm,login,forgot,reset,resend,check-inbox,thanks,closed,account}.twig
plus the shared _card.twig, all extending the configured layout (default @members/_layout.twig,
a standalone page; a Folio site's default is @folio/frontend/_site.twig when the site has that template). @members/decision.twig is
the Decision panel of Folio's members admin (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). A register form of your own (an override of the form, or a site page) includes
the bots' trap: {% include '@members-package/_trap.twig' %} inside the <form>. The mail layout (mail.layout) is a template
too. Words are changed in the lang files (see Copy and mails), not in templates.
Your own look, or your own pages
The pages ship neutral styles: a calm card on a light surface, readable at 375 px, with a visible
focus ring, and a dark variant that follows the page's color-scheme (the standalone layout declares light dark; a site page that declares none stays light). They read seven custom
properties, each with a fallback, so a site that sets them wins (light or dark):
| Property | Fallback (light) | Used for |
|---|---|---|
--members-bg |
#ffffff |
card and field background |
--members-ink |
#1a1a1a |
text |
--members-muted |
#5c5c5c |
hints, secondary text |
--members-line |
#d0d0d0 |
borders |
--members-accent |
#1a1a1a |
links, focus ring, primary button |
--members-radius |
6px |
corners |
--members-font |
inherit |
font family |
For more, each page keeps its CSS in {% block members_styles %} (_card.twig, which every
status page uses, register.twig; account.twig adds to the card's with parent()). A site
override that extends @members-package/<page>.twig replaces the block, or extends it with
{{ parent() }}:
{% extends '@members-package/register.twig' %}
{% block members_styles %}{{ parent() }}.members-card { box-shadow: 0 1px 4px rgb(0 0 0 / .1); }{% endblock %}
The standalone layout has its own block, members_layout_styles. Markup and class names stay
the same, so a site stylesheet keyed to them keeps working.
Cron
The package brings two console commands, members:prune and members:decide. It lists both in
composer.json (extra.preflow.commands), so they need no setup and preflow list shows them.
Run members:prune nightly. The commands need preflow/devtools 0.26. With an older devtools only
the commands are missing, while the member pages and Folio keep working (the package conflicts with
auth, core, data, folio, i18n and routing before 0.38, 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. An account a self rule or a hook keeps is
counted (; kept N (refused by a self rule or a hook), only when N > 0).
members:decide <email> accept|decline decides a member waiting for the owner (OwnerApproval
without Folio's panel): the accepted or declined mail goes out first (the Members settings'
message, else the lang files'), and only when it was sent the role changes.
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.
Locking, sessions and sign-in info
Members::lock($m)/unlock($m)/isLocked($m):locked_at(readonly) is set or cleared; a lock ends the sessions (endSessions()) and refuses every sign-in path like a declined member, without saying "locked" on a public page. Unlock keeps the old password; sessions stay ended.Members::endSessions($m):auth_epoch+ 1 and the remember rows go.Members::resendConfirmation($m): sends the confirm link again to the stored address (earlier links stop working);\LogicExceptionunless the member is unconfirmed,TransportExceptionwhen the mail fails.last_login_at(readonly) is set on every sign-in: login, confirmation and reset.
In Folio
- Members (sidebar section Members) with a waiting badge and a State column, newest first, filterable by Registered, Email confirmed and Decided dates (from/until).
- 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). - Lock and unlock (the Decision panel): a locked member keeps the state and the password but
cannot sign in anywhere and is signed out everywhere; the State column says Locked, never
Declined. Resend confirmation mails an unconfirmed member the link again, to the stored address.
The panel also shows the member's last sign-in. All of it needs
members.decide.
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},{decision_url},{decide_command}.{decision_url}is the decision page a boundMemberLinksnames (an admin package's page for the member); without one the notice usesmail.notice.body_without_link, which names the command{decide_command} (or decline):php preflow members:decide '<address>' accept, the address shell-quoted (escapeshellarg), so the command is safe to paste whatever the address holds. The mail about a refused deletion names each record type still pointing at the account by its label (a boundPreflow\Data\TypeLabels, else the type'slabelwhen it is a plain string, else its key). {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 words come from
lang/<locale>/members.php(override single keys). The mails are composed and sent throughpreflow/notify'sMailer: the HTML part is notify's layout (config/notify.phpmail.layout,mail.accent, default the neutral#1a1a1a) unlessmail.layout/mail.accenthere name the site's own. A site layout getslang,subject,accentandparagraphs(escaped text, URLs linked in the accent colour), and for the confirm, reset and existing-account mailsaction({label, url}, the primary link; label frommail.{confirm,reset,existing}.action). The shipped layout shows it as a button with the URL below (data-mail-action); a site's layout may ignore it. The text part is unchanged. - 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.
Upgrading to 0.42.1
- The register form has a trap field for bots. The package form has it; a site that writes
its own register form adds
{% include '@members-package/_trap.twig' %}inside the<form>(without it nothing breaks, the form just has no trap). New textmembers.form.trap.
Upgrading to 0.42
emailis unique. Runpreflow migrate, then convert the table once withtools/convert-typed-columns.php(a site with duplicate addresses sees them listed by--checkand keeps its table unchanged until they are resolved).register()still throwsEmailTakenException, also when two registrations race.- Requires
preflow/data0.42.
Upgrading to 0.39
- Run
preflow migrateafter updating. The member type has two new columns,locked_atandlast_login_at, on SQL storage; without them registration and every sign-in fail. - Lock / unlock:
Members::lock($m)locks the account (it keeps roles and password, cannot sign in on any path, and its sessions end);unlock($m)lifts it. A locked account gets no mail. Members::endSessions($m)ends every session of a member (the epoch moves on, remember rows go).Members::resendConfirmation($m)mails an unconfirmed, unlocked member a fresh link at once (it throwsLogicExceptionotherwise, andTransportExceptionwhen the mail fails).- Last sign-in:
last_login_atis set on every sign-in (login, reset, confirmation). - Folio's members admin offers Lock, Unlock and Resend confirmation to staff with
members.decide.
Upgrading to 0.38
The full list is in the framework's 0.38 release checklist; what a site with members checks:
- The default admission is
AcceptOnConfirm(was owner approval). A site that wants the owner to decide sets'admission' => Preflow\Members\Admission\OwnerApproval::class. - The default layout is the package's
@members/_layout.twigon a site without Folio; a Folio site that has@folio/frontend/_site.twigkeeps it. Setlayoutto keep a layout of your own. - No Folio or rbac needed. The package no longer requires them; Folio carries the members
admin when this package is installed (it still needs the
rbacextension). config/auth.php: the provider class isPreflow\Auth\Record\RecordUserProvider.Preflow\Folio\Users\RecordUserProviderworked until 0.71 and is gone since 0.72;newwith the old constructor (aTypeCatalog) no longer works.preflow migrateon SQL storage: the member type gains the hidden fieldnext; without the column a registration that carries anextfails.next.allow:nextkeeps today's rule (member pages only) unless you add prefixes.GET {path}now has a default landing where the site had none (a 404 before). A site route on that path wins.Members::delete()returns aDeletionResultand follows the self rules (onSelfDelete, elseonDelete): arestrictrelation now keeps the account instead of leaving a dangling id.MemberEvents,ComposesName, the mailactionare new; a mail layout that ignoresactionis unchanged.- Owner notice without Folio: without a
MemberLinksbinding the notice usesmail.notice.body_without_link; a site that overrode onlymail.notice.bodygets the package's text without the link there. Since 0.68 that text names the command with{decide_command}(the address shell-quoted); a site's ownmail.notice.body_without_linkwithmembers:decide {email}should use it. - Mails through notify (0.68): the shipped
@members/mail/layout.twigis gone: the HTML part is notify's layout, with the neutral accent#1a1a1a(was#d42a34) anddata-mail-actionon the button (wasdata-members-action). A site that liked the old look setsmail.accent(or notify's) in its config. The placeholder{folio_url}is now{decision_url}(no alias); a site's ownmail.notice.body/mail.deletion_refused.bodymust use the new one. Links::folioMember()is gone (0.68); the owner's notice links what a boundMemberLinksnames.