Guide 07 · Developer docs

Members, accounts and gated content

Building a member area on Core dna: who a member is in the data, the sign-in flows and their templates, single sign-on and passkeys, the my-account routing convention, members-only content, sessions and the page cache, and the security checks to run before launch.

L2
Unit 04 · The my-account area and its routing convention

When the catch-all is not enough

The catch-all serves a GET with the signed-in user and nothing else. What to use instead when a member page has to take a form, carry an id in its path, follow other access rules or feed a script.

Who this is for. The theme developer whose member page needs more than a template, before they ask the platform team for a route. Most of these needs already have an answer on the platform.

What the catch-all does, and the answer to each limit

A catch-all page answers GET, for anyone signed in, with one variable, user, and the template named by its path (The catch-all). Beyond that:

The page needs toUse
Accept a formA route built for it: the profile route, the address and password routes, a Forms module form, or a synchronous job endpoint (below)
Carry an idA query string parameter, or the record's own URL (below)
Be open to signed-out visitorsAn ordinary page outside /my-account
Be open to some members onlyA check in the template backed by restricted records, or a page record restricted to a group (below)
Feed a scriptThe page requested as JSON, or a synchronous job endpoint (below)
Apply a different rule at the routeNothing a site can configure: a request to the platform team

A page that takes a form

A POST to a catch-all URL is refused: with a 403 when it carries no CSRF token, and otherwise with a 405, because the catch-all's controller has nothing to handle a POST. Send the form to a route that takes one:

  • The member's own details: the profile route, PUT /profile (route name updateProfile). By default it accepts the person's name fields and custom fields, and asks for the current password; Profile data lists the fields and settings.
  • Addresses and passwords: the address routes (The screens that change something) and /change-password (Changing a password).
  • Anything else the member submits: a form built with the Forms module, whose submissions are stored, validated and can start a job (Submissions), or a synchronous job endpoint when the page needs an answer you write yourself (Custom endpoints with synchronous jobs).

A form posted the ordinary way from a member page carries the CSRF token in a hidden _csrf field, and a PUT or DELETE travels as a POST with a _method field:

Template code A profile form on a catch-all page
<form method="post" action="{$_url->getPath('updateProfile')}">
  <input type="hidden" name="_method" value="PUT">
  <input type="hidden" name="_csrf" value="{$_csrfToken}">
  <label>First name <input name="person[firstName]" value="{$user->getPerson()->getFirstName()|escape}"></label>
  <label>Current password <input type="password" name="currentPassword" required></label>
  <button>Save</button>
</form>
<form method="post" action="{{ _url.getPath('updateProfile') }}">
  <input type="hidden" name="_method" value="PUT">
  <input type="hidden" name="_csrf" value="{{ _csrfToken }}">
  <label>First name <input name="person[firstName]" value="{{ user.getPerson().getFirstName()|e }}"></label>
  <label>Current password <input type="password" name="currentPassword" required></label>
  <button>Save</button>
</form>

A page that needs an id

The catch-all takes the whole remaining path as the template's name, so /my-account/courses/42 looks for my-account/courses/42.html. Put the id in the query string instead, /my-account/course?id=42, and look the record up in the template. Then treat the result with suspicion: the member typed that id, and it can name a record that does not exist, or one that is not theirs.

Template code templates/my-account/course.html
{$course = $_query->findCustomEntityRecordBy('course', ['id' => intval($_request->getParameter('id'))])}
{if $course}
  <h1>{$course->getName()|escape}</h1>
{else}
  <p>That course was not found.</p>
{/if}
{% set course = _query.findCustomEntityRecordBy('course', {'id': (_request.getParameter('id') ?? '0')|integer}) %}
{% if course %}
  <h1>{{ course.getName()|e }}</h1>
{% else %}
  <p>That course was not found.</p>
{% endif %}

The lookup obeys the record's permissions, so the member must be allowed to read course records for it to find one. Whether this member may see this course is a separate question, and the template has to ask it: Entitlements: access per record shows the check. Where the record has a public URL of its own, linking to that URL and restricting the record there is often simpler than a member page.

A page with other access rules

Every route under /my-account requires someone to be signed in, and a site cannot attach other checks to a route. Choose by who may see the page:

  • Signed-out visitors too: an ordinary page, outside /my-account.
  • Some members only: either keep the page under /my-account and decide in the template, or make it a page record restricted to a group, which the platform enforces wherever the record is read (Restricting a record to a group). A template check protects that page only, so whatever it shows must be restricted at the record as well.
  • A rule neither covers, such as a route that admits one user type: a request to the platform team.

Data for a script

A member page requested with Accept: application/json answers JSON instead of HTML: {"result": "success", "data": {…}}, where data holds the controller's variables without the globals. For a catch-all page that is the signed-in user's record, never including the password. That suits a script that needs the member's id or name. For anything shaped to a purpose, a synchronous job endpoint answers exactly the JSON you design (Custom endpoints with synchronous jobs).

JavaScript
const response = await fetch('/my-account/welcome', { headers: { Accept: 'application/json' } });
const { data } = await response.json();
console.log(data.user.id);

The browser sends the session cookie with that request on its own, since it is the same site. A signed-out request is redirected to the sign-in page rather than answered with JSON, so check response.redirected before reading the body.

Go deeper

Was this page helpful?

Your answer goes to the docs team.