The fourth delivered slice opens the platform to Students. A visitor can browse the Course catalog, click “Enroll”, create an account via magic link, and land on the Course page already enrolled — in one uninterrupted flow. Returning Students click “Continue Learning” and go straight to their first Lesson.

What was built

A Student can now:

  • Browse a Course catalog listing all published, public Courses
  • Click “Enroll — it’s free” on any Course page and be prompted to register or sign in
  • Complete account registration via a magic link email — no password required
  • Land on the Course page already enrolled after clicking the magic link
  • See the CTA change to “Continue Learning →” and a green ENROLLED badge after enrolling
  • Click into any Lesson in the curriculum and read it in a focused sidebar layout
  • Re-enroll in a Course they’re already enrolled in — silently redirected, no error

Enrollment flow

sequenceDiagram
    actor Visitor
    participant Catalog as CatalogLive.Show
    participant Auth as UserSessionController
    participant DB as Repo
    participant Email as Swoosh (local)
    participant Enrollment as EnrollmentController

    Visitor->>Catalog: GET /catalog/4
    Catalog-->>Visitor: Course page — "Enroll — it's free"
    Visitor->>Enrollment: GET /catalog/4/enroll
    Enrollment->>Auth: require_authenticated_user plug
    Auth-->>Visitor: Redirect /users/log_in?return_to=/catalog/4/enroll
    Visitor->>Auth: POST /users/log_in (email)
    Auth->>Email: deliver_login_instructions(user, magic_link_url)
    Auth-->>Visitor: "Check your inbox"
    Visitor->>Auth: GET /users/log-in/:token
    Auth->>DB: login_user_by_magic_link(token)
    Auth-->>Visitor: Session created → redirect user_return_to
    Visitor->>Enrollment: GET /catalog/4/enroll (now authenticated)
    Enrollment->>DB: Enrollments.enroll(user, course) — idempotent
    Enrollment-->>Visitor: Redirect /catalog/4
    Catalog-->>Visitor: Course page — "ENROLLED" + "Continue Learning →"

The key mechanism is user_return_to: when require_authenticated_user redirects the unauthenticated visitor to the login page, it stores /catalog/4/enroll in the session. After the magic link is clicked and the session is created, the user is redirected back to the stored path — which completes the enrollment. The whole flow feels like one action to the Student.


Domain model

erDiagram
    User {
        int id
        string email
        bool admin
        datetime confirmed_at
    }
    Course {
        int id
        string title
        string status
        string visibility
    }
    Enrollment {
        int id
        int user_id
        int course_id
        datetime inserted_at
    }
    Chapter {
        int id
        string title
        int position
        int course_id
    }
    Lesson {
        int id
        string title
        text body
        int position
        int chapter_id
    }

    User ||--o{ Enrollment : "enrolls in"
    Course ||--o{ Enrollment : "has"
    Course ||--o{ Chapter : "contains"
    Chapter ||--o{ Lesson : "contains"

Enrollment is the join between User and Course. The enroll/2 function in Skilll.Enrollments is idempotent — it does a get_by before inserting, so hitting the enroll route twice silently redirects the Student rather than erroring.


Screenshots

Course catalog

Course catalog

The catalog lists all published, public Courses as cards with language badge and price tag. Unauthenticated visitors see "Log in" and "Register" in the nav.

Course page — enrolled state

Course page enrolled

After enrolling, the CTA changes to "Continue Learning →" and a green "ENROLLED" badge appears. Lesson rows become clickable "View →" links instead of lock icons.

Lesson reading

Lesson reading

The lesson reading layout: a full-width content area on the left, a sticky sidebar on the right with chapter and lesson navigation. The current lesson is highlighted with a filled clay dot. The top chrome bar shows the chapter name and user avatar initials.

Sign in / Create account

Sign in page

The auth card follows the design system: tabbed "Sign in / Create account", magic link form, and an inline dev notice pointing to the local mailbox. Flash messages render inline in the card.


Acceptance criteria

  • Browse a listing of all published, public Courses
  • Click "Enroll" and complete account registration in the same flow, landing enrolled
  • Enroll directly via the "Enroll" button when already logged in
  • See "Continue Learning →" CTA and ENROLLED badge after enrolling
  • Access all Lessons of an enrolled Course via the lesson reading layout
  • Re-enroll silently redirects to the Course page — no duplicate Enrollment record
  • Only published and public Courses appear in the catalog
  • Email confirmation delivered via magic link (no password required)
  • Users registered with an @example.com email are granted admin access

Key decisions

Magic link auth, no passwordmix phx.gen.auth with --no-live generated the full controller-based auth stack. The Student only needs an email address; the platform sends a one-time login link. No password storage, no “forgot password” flow to maintain.

Idempotent enroll/2 — The enrollment controller is a plain GET route, which means the browser can navigate to it freely (bookmarks, back button, the magic-link redirect). Enrollments.enroll/2 does a get_by before inserting, so repeat visits are silent redirects. No unique-constraint error surfaces to the Student.

Email domain as admin gate (ADR 0004) — Rather than building a role system, users with @example.com emails are flagged as admins at registration time via User.admin?/1. This unblocks enrollment without building role assignment UI. A proper roles table is a future slice.

user_return_to for the enroll-then-login flow — The enrollment route is protected by require_authenticated_user. When the unauthenticated visitor hits it, the plug stores the path in the session. After the magic link creates the session, the redirect goes back to the enrollment route, which completes enrollment and redirects to the Course page. No custom redirect logic — it’s the stock Phoenix auth pattern.

LiveViews read current_user from session manuallyCatalogLive.Index, CatalogLive.Show, and CatalogLive.Lesson call get_user_by_session_token/1 in mount/3 rather than relying on an on_mount hook (none is wired globally yet). If a global auth hook is ever added, the manual lookups in these LiveViews should be removed.


Next up: PBI 0030 — Track Lesson Progress (recording Completions per Student per Lesson).