Skip to content

Members Area ​

Customers who booked want somewhere to go back to: their bookings, and the materials of their course. This chapter lets visitors create accounts on the site, gives them a members area, and opens each course's materials only to the people who booked it.

Members and staff share one users table and one sign-in. Roles decide who opens the admin, and new accounts get none that do.

Sign-up and sign-in ​

The site signs everyone in with Laravel Fortify, at the site's root: /login, /register, /forgot-password. Publish its config with registration on:

php
<?php

declare(strict_types=1);

use Laravel\Fortify\Features;

/*
 * The site's own sign-in, for members and staff alike. Because this file exists, Lynx's admin
 * uses these screens too (LYNX_ADMIN_AUTH=auto), and keeps its invitations for staff.
 */

return [
    'guard' => 'web',
    'passwords' => 'users',
    'username' => 'email',
    'email' => 'email',
    'lowercase_usernames' => true,
    'home' => '/account',
    'prefix' => '',
    'domain' => null,
    'middleware' => ['web'],
    'limiters' => ['login' => 'login', 'two-factor' => 'two-factor'],
    'views' => true,
    'features' => [
        Features::registration(),
        Features::resetPasswords(),
        // Staff turn it on under Account security in the admin
        Features::twoFactorAuthentication(['confirm' => true, 'confirmPassword' => true]),
    ],
];

Because config/fortify.php exists, Lynx's admin stops showing its own sign-in screens and uses these (LYNX_ADMIN_AUTH=auto). Staff sign in at /login too, and a staff member who opens /lynx while signed out comes back to the admin after signing in. Lynx keeps inviting staff from Access › Users, and keeps the admin's Account security screen. See Who signs users in.

A provider gives Fortify the site's screens and its sign-up action:

php
/**
 * Fortify's screens in the site's design (chapter 8): sign in, sign up, reset a password, and the
 * second factor for staff who turned it on.
 */
final class FortifyServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Fortify::createUsersUsing(CreateNewUser::class);
        Fortify::resetUserPasswordsUsing(ResetUserPassword::class);

        Fortify::loginView(fn (): Factory|View => view('auth.login'));
        Fortify::registerView(fn (): Factory|View => view('auth.register'));
        Fortify::requestPasswordResetLinkView(fn (): Factory|View => view('auth.forgot-password'));
        Fortify::resetPasswordView(fn (Request $request): Factory|View => view('auth.reset-password', ['request' => $request]));
        Fortify::twoFactorChallengeView(fn (): Factory|View => view('auth.two-factor-challenge'));

        RateLimiter::for('login', fn (Request $request): Limit => Limit::perMinute(5)
            ->by(Str::lower((string) $request->input('email')).'|'.$request->ip()));
        RateLimiter::for('two-factor', fn (Request $request): Limit => Limit::perMinute(5)
            ->by((string) $request->session()->get('login.id')));
    }
}

The screens are Blade views in the Bloom style, around one card. The sign-in screen, for example:

blade
<x-auth-card :title="__('Sign in')">
    <form method="post" action="{{ route('login') }}" class="grid gap-4">
        @csrf
        <label class="grid gap-1 text-sm">Email
            <x-ui.input type="email" name="email" :value="old('email')" required autofocus autocomplete="username" :aria-invalid="$errors->has('email') ? 'true' : null" />
        </label>
        @error('email')<p class="text-sm text-red-700" role="alert">{{ $message }}</p>@enderror
        <label class="grid gap-1 text-sm">{{ __('Password') }}
            <x-ui.input type="password" name="password" required autocomplete="current-password" />
        </label>
        <label class="flex items-center gap-2 text-sm"><input type="checkbox" name="remember"> {{ __('Remember me') }}</label>
        <button type="submit" class="atlas-pill-btn atlas-pill-btn-primary">{{ __('Sign in') }}</button>
    </form>
    <p class="atlas-muted text-center text-sm">
        <a href="{{ route('password.request') }}" class="underline">{{ __('Forgot your password?') }}</a>
        · <a href="{{ route('register') }}" class="underline">{{ __('Create an account') }}</a>
    </p>
</x-auth-card>

Who gets what ​

When someone signs up, Fortify dispatches Laravel's Registered event, and Lynx gives the new account the guest role. A guest has no grants: they see the website and their members area, and the admin answers 403. You did nothing for this beyond running lynx:install in chapter 1, which made the owner a superuser. See People who sign up.

The members area ​

Fortify sends members to /account after they sign in. It is a route of the app's, listing the member's bookings:

php
Route::get('/account', AccountController::class)->middleware('auth')->name('account');
php
/** The members area: the member's bookings, with a link to each paid course's materials. */
final class AccountController
{
    public function __invoke(Request $request): View
    {
        $bookings = Booking::query()->where('user_id', $request->user()->getAuthIdentifier())->latest()->get();

        return view('account.show', ['bookings' => $bookings, 'page' => lynx()->pages->get('/')]);
    }
}

A booking made while signed in carries the member's id (chapter 7's controller stores $request->user()), so it appears here. Bookings are matched by account, never by email address alone: without email verification, anyone could sign up with someone else's address.

Materials for the people who booked ​

Each course has a child page on the materials template: reading lists, preparation, links. Two pieces keep it for the people who booked:

  • A visibility rule leaves materials pages out of everything visitors find (lists, menus, search, page references) unless they booked that course.
  • A page gate guards the URL, and the page's private downloads: guests are sent to sign in, and members who didn't book see what the page is for.
php
/**
 * A course's materials page is for the people who booked it: guests and other members don't find
 * it in lists, menus or search. The gate in AtlasServiceProvider guards its URL.
 */
final class CourseMaterials implements VariesByVisitor, VisibilityRule
{
    /** @var array<int|string, list<int>> course page ids each user has a paid booking on */
    private array $courses = [];

    public function apply(Builder $query): void
    {
        $query->whereNotIn('lynx_pages.id', fn (Builder $materials) => $materials
            ->select('id')->from('lynx_pages')
            ->where('template_id', lynx()->templates->get('materials')->id)
            ->whereNotIn('parent_id', $this->bookedCourses() ?: [0]));
    }

    public function allows(Page $page): bool
    {
        return $page->template()->name !== 'materials' || in_array($page->parentId, $this->bookedCourses(), true);
    }

    /** Each member sees their own courses' materials, so caches keep a copy per member. */
    public function audience(): ?string
    {
        return auth()->id() === null ? null : 'member-'.auth()->id();
    }

    /** @return list<int> */
    public function bookedCourses(): array
    {
        $user = auth()->id();

        if ($user === null) {
            return [];
        }

        return $this->courses[$user] ??= Booking::query()->where('user_id', $user)->where('status', 'paid')
            ->pluck('session_id')
            ->map(fn (int $session): ?int => lynx()->pages->get($session)?->parentId)
            ->filter()->unique()->values()->all();
    }
}
php
$materials = new CourseMaterials;
app(PageVisibility::class)->add($materials);

// Lists and search leave materials out; this guards their URL
app(PageGates::class)->add(fn (Page $page) => match (true) {
    $materials->allows($page) => null,
    auth()->guest() => redirect()->guest(route('login')),
    default => response()->view('account.not-booked', ['page' => $page], 403),
});

audience() tells Lynx's caches that members see different things, so a copy of a page made for one member is never served to another. Page Access explains rules and gates in full.

Downloads only for them ​

Materials pages have a downloads field with Private on (chapter 2's seeder sets it). Files in a public field have public addresses, which anyone with a link could share. A private field's files live off the public disk, and their links go through Lynx, which asks the materials page's own questions. People who booked get the file. Guests are sent to sign in by the same gate, and other members see what the page is for.

php
lynx()->fields->add('downloads', 'files', label: 'Downloads', settings: ['private' => true, 'extensions' => 'pdf zip mp3 mp4']);

The view lists them like any files, and $file->url() gives the private address:

blade
{{-- A course's materials: only the people who booked the course get here (CourseMaterials). --}}
<x-layouts.site :page="$page" :seo="['robots' => 'noindex, nofollow']">
    <article class="px-4 py-12">
        <div class="mx-auto max-w-3xl">
            <p class="atlas-muted text-sm"><a href="{{ $page->parent()?->url }}">@field($page->parent()?->title)</a></p>
            <h1 class="atlas-section-title mt-2">@field($page->title)</h1>
            <div class="atlas-prose mt-6">@field($page->body)</div>

            {{-- A private field: these addresses work only for people who may open this page. --}}
            @if ($page->downloads?->count())
                <ul class="mt-8 grid gap-3">
                    @foreach ($page->downloads as $file)
                        <li><a href="{{ $file->url() }}" download class="atlas-session">{{ $file->description() ?: $file->name() }}</a></li>
                    @endforeach
                </ul>
            @endif

            {{-- The course survey (chapter 9): members only, one answer each. The page it is sent
                 from says which course it is about. --}}
            <section class="atlas-clay mt-10 p-6" aria-labelledby="feedback">
                <h2 id="feedback" class="text-xl">@string('materials.feedback', ['en' => 'Tell us how it went', 'pt' => 'Diga-nos como correu'], scope: 'site')</h2>
                <livewire:lynx-form form="course-feedback" :page-id="$page->id" />
            </section>
        </div>
    </article>
</x-layouts.site>

Staff upload the files in the page editor as usual. See Private files.

What you get ​

  • The client: customers who sign up, find their bookings and get their course materials without emailing the office.
  • You: one sign-in for members and staff, roles that keep members out of the admin by default, and access rules that apply everywhere a page can show up.

Released under the MIT License.