Login and Registration
Six views, one split layout, one step machine. The auth surface is built once, then rearranged in the browser.
Overview
The login page is one document holding seven states. Nothing reloads, so every string, field and consent must be present in the markup the server produced.
Registration is the opposite problem. Almost every field is optional, so the form is a set of conditionals.
Structure
All six go through one private helper on the sign controller. They share a guard, a canonical link and a title convention.
| View | Guest only | Notes |
|---|---|---|
auth/login | Yes | Seven states, one document |
auth/register | Yes, unless a verification is pending | Countries, custom fields, contracts, gating |
auth/forget-password | Yes | Always reports success, existing address or not |
auth/reset-password | Yes | The token resolves first: the form, or an expired notice |
auth/activate | Yes | The email confirmation landing page |
auth/accept-invite | No | Guests and members; the state says which of four cases |
Step by Step
Build the Login State Machine
- Print every state in one document, one visible and the rest hidden. Each carries a step attribute:
login,password,totp,sms,email,code-login,restricted. - Gate the machine behind the login switch, with a notice in the other branch.
- Put the passwordless call to action before the social buttons, each behind its own switch and divider.
- Use one shared one time code component for all four code states. It binds to the input class, not the state, so a second one gets no paste or auto verify.
Gate the Registration Fields
- Read visibility from the gating map, never from a configuration key.
- Mark required fields with a data attribute, not the native one alone, so the validator skips hidden ones.
- Print custom fields and contracts from the arrays given; neither has a fixed set.
- The server is the authority — every browser check is repeated on submit.
Decide Whether Your Form Is Minimal
- Set the manifest flag true only when your register view prints none of phone, landline and national id.
- Leave it false when you print them behind the visibility switches, as two of three shipped themes do.
- It is read from the manifest, not the request, so a submission cannot claim it.
Wrap Every Auth Link in Its Switch
- Wrap a sign up link in the registration switch, a sign in link in the login switch. The account menu and cart use the combined one.
- Do not rely on hiding — the server refuses a closed registration or login itself.
Reference
Auth Core Signatures
// The single entry point for creating a member. See the input keys below.
public static function register(array $input): array;
// Password login. $type is 'member' or 'admin'; the member master switch is checked here.
public static function attemptLogin(string $type, string $email, string $password, bool $remember = false): array;
// The ONE place a session is established, password or not. Every passwordless flow
// (code login, social, post-registration, email verification) ends here.
public static function completeLogin(string $type, int $userId, object $user, bool $remember = false, array $sso = []): string;
// Passwordless: issue a one-time code, then exchange it for a session.
// issueLoginCode reports success whether or not the address exists.
public static function jetpassEnabled(): bool;
public static function issueLoginCode(string $email): array;
public static function jetpassLogin(string $email, string $code, bool $remember = false): array;
// Password reset. issueReset is also enumeration-safe.
public static function issueReset(string $type, string $email): array;
public static function verifyResetToken(string $rawKey, string $type): object|false;
public static function resetPassword(string $type, int $userId, string $password): void;
// Providers to offer, for a given mode and audience.
public static function activeProviders(string $mode, string $context): array;
What register() Accepts
$input = [
// Identity
'first_name' => 'Ada',
'last_name' => 'Lovelace',
'email' => '[email protected]',
'password' => 'read with the pass-through filter, never a text filter',
// Account type and the corporate fields it unlocks
'account_type' => 'individual', // or 'corporate'
'company_name' => '',
'tax_number' => '',
'tax_office' => '',
// Optional contact fields, each behind its own visibility switch
'phone' => '',
'landline_phone' => '',
'national_id' => '',
// Billing address
'address1' => '',
'state' => '', // id when picked from the list, free text otherwise
'city' => '', // same rule as state
'postal_code' => '',
'country' => 0, // resolved to an id before it gets here
// Consents
'marketing_email' => 0,
'marketing_sms' => 0,
'contract' => 1, // the terms checkbox
'contracts_required' => true, // whether any contract is actually configured
// Operator-defined extra fields, collected by the operation
'custom_fields' => [],
// Declared by the THEME MANIFEST, never by the request. True skips the required
// checks for phone, landline and national id, and nothing else.
'minimal_fields' => false,
];
The Field Gating Map
| Template variable | Default | Controls |
|---|---|---|
$registration.account_type_visible | on | The individual/corporate chooser |
$registration.company_name_required | on | Company name, corporate branch |
$registration.tax_number_required, tax_office_required | off | The corporate tax fields |
$registration.phone_visible, phone_required | on, on | Mobile number |
$registration.landline_visible, landline_required | off, off | Landline number |
$registration.national_id_visible, national_id_required | off, off | National identity, individual branch |
$registration.password_min_length | 12 | Enforced on both sides |
What Each View Receives
| View | Variable | Holds |
|---|---|---|
auth/register | $countries | id, a2_iso, name. States and cities cascade over AJAX |
auth/register | $custom_fields | Operator-defined extra fields, localised. No fixed set |
auth/register | $contracts | Contract pages for sign up; each carries a link |
auth/register | $contract_names | Those titles joined with commas, for a naming label |
auth/register | $registration | The gating map above |
auth/register | $verify_email, $verify_name | Filled when $verify_pending is true, so the step names the member |
auth/reset-password | $reset_valid | Whether the token resolved; false means the expired notice |
auth/reset-password | $verify_key | The raw token, posted back with the new password |
auth/login | $social_providers, $jetpass_enabled | Set by the login page; register sets its own |
Page and Feature Switches
Example
{extends file='layouts/auth.tpl'}
{block name=stage_title}{lang key='auth_stage_login_title'}{/block}
{block name=stage_text}{lang key='auth_stage_login_text'}{/block}
{* The cross link is gated: offering sign-up on a closed installation is a dead end. *}
{block name=stage_foot}
{if $registration_enabled}
{lang key='auth_stage_login_foot'} <a href="{link route='sign-up'}">{lang key='auth_stage_login_foot_link'}</a>
{/if}
{/block}
{block name=content}
{if $login_enabled}
{* State 1: email. Visible; every other state ships hidden in the SAME document. *}
<div data-auth-step="login">
<form data-auth-form="login">
{csrf form='sign-in'}
<input type="email" name="email" class="form-control" required>
{captcha area='sign-in' tray='login-captcha'}
<button type="submit" class="btn">{lang key='website/sign/continue'}</button>
</form>
{* Passwordless BEFORE social, each behind its own switch and divider. *}
{if $jetpass_enabled}
<div class="auth-divider">{lang key='website/sign/or'}</div>
<button type="button" class="btn" data-auth-action="auth-code-login">{lang key='website/sign/jetpass-cta'}</button>
{/if}
{if $social_providers}
<div class="auth-divider">{lang key='website/sign/or'}</div>
{foreach $social_providers as $p}
<a class="btn" href="{$p.url}"><i class="bi {$p.icon}"></i>{$p.label}</a>
{/foreach}
{/if}
</div>
<div class="d-none" data-auth-step="password">{* ... *}</div>
{* All four code states reuse ONE component: the script binds to .otp-input,
not to the state, so a second implementation gets none of its behaviour. *}
<div class="d-none" data-auth-step="code-login">
<div class="otp-group" data-otp>
<input class="otp-input" inputmode="numeric" maxlength="1">
</div>
</div>
<div class="d-none" data-auth-step="restricted">{* ... *}</div>
{else}
<p>{lang key='website/sign/login-disabled'}</p>
{/if}
{/block}
// templates/website/{Theme}/theme.php
return [
'meta' => [
'name' => 'Acme',
'version' => '1.0.0',
'author' => 'Acme',
// TRUE only when the register view renders NONE of phone, landline and
// national id. Auth::register then skips the operator's required checks
// for exactly those three fields; every other validation still runs.
//
// FALSE when the view renders them behind $registration.*_visible, which
// is what two of the three bundled themes do.
//
// Setting it true while still printing the fields is the one wrong answer:
// the requirement is skipped even though the visitor saw an empty input.
'signup_minimal' => false,
],
'engine' => 'smarty',
'status' => 'ready',
];
Pitfalls
Password reset and the passwordless code report success for an unknown address on purpose. Your script must advance in every case.
Every text filter strips the characters that make a password strong. The stored value then differs from what was typed, with no error.
It is read from your manifest and trusted, so it must describe what your form prints. Declare it while printing the optional fields and an empty submission passes unreported.
One action name is the token form key, the captcha area and the rate limit bucket at once. A new name opts out of all three. The passwordless request and its verification share the login action.
Post registration sign in calls the session establishing method directly, not the password path where the switch is checked.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.