Packages
phoenix_kit
2.2.0
2.2.0
2.1.0
2.0.1
2.0.0
1.7.236
1.7.235
1.7.234
1.7.233
1.7.232
1.7.231
1.7.230
1.7.229
1.7.228
1.7.227
1.7.226
1.7.225
1.7.224
1.7.223
1.7.222
1.7.221
1.7.220
1.7.219
1.7.218
1.7.217
1.7.216
1.7.215
1.7.214
1.7.213
1.7.212
1.7.211
1.7.210
1.7.209
1.7.208
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/phoenix_kit_web/users/referral_gate.ex
defmodule PhoenixKitWeb.Users.ReferralGate do
@moduledoc """
Where the invite-only gate parks an account that has not been admitted yet.
With `referral_codes_required` on, an account can be created by any route —
password, magic link, OAuth — but cannot use the application until it has
satisfied the requirement. Every authentication gate redirects here; this
page is what unblocks it. See the "Invite-only access gate" section of
`PhoenixKit.Users.Referrals` for the full rule set.
Deliberate properties, all of them inherited from the same reasoning that
hardened the registration form:
- **Submit-only.** There is no `phx-change`, so a code is checked when the
user says they are done, not on every keystroke. Per-keystroke checking
both burns the rate limit on half-typed codes and hands an attacker a much
faster oracle.
- **One message for every failure.** Wrong, expired, inactive, used up — all
of them read the same. Distinguishing them confirms which guesses named a
real code. Operators get the real reason from `Logger.debug`.
- **Limited per account as well as per IP.** This screen is behind login, so
an IP-keyed limit alone is defeated by making another account.
A user who cannot get a code is not stranded: log-out is reachable from here,
and it is deliberately the only other thing that is.
"""
use PhoenixKitWeb, :live_view
require Logger
alias PhoenixKit.Users.RateLimiter
alias PhoenixKit.Users.Referrals
alias PhoenixKit.Utils.IpAddress
alias PhoenixKit.Utils.Routes
@impl true
def mount(params, session, socket) do
user = socket.assigns[:phoenix_kit_current_user]
# `:context` threads the socket's router so `"/"` is only used where the
# host actually declares a root route. Without it the resolver synthesises
# `"/"` literally, which 404s on any host that has no root route — exactly
# the configuration core-owned redirect destinations exists to handle.
destination =
Routes.post_auth_path([params["return_to"], session["user_return_to"]],
context: socket,
scope: socket.assigns[:phoenix_kit_current_scope]
)
cond do
is_nil(user) ->
# Nothing to admit. Sending them to log-in rather than rendering an
# empty form avoids a page that asks an anonymous visitor for a code
# and then has nowhere to put it.
{:ok, redirect(socket, to: Routes.path("/users/log-in"))}
# Prefer the mounted scope (the user form would have to rebuild one to
# evaluate the full-access exemption), but fall back to the user rather
# than passing `nil`: `access_satisfied?/1` answers `true` for a subject
# it cannot judge — correct for an anonymous visitor, and a silent
# admission for a logged-in one whose scope assign is missing.
Referrals.access_satisfied?(socket.assigns[:phoenix_kit_current_scope] || user) ->
# Already admitted — covers a code redeemed in another tab, an
# invitation that arrived while this page sat open, and an operator
# switching invite-only off.
{:ok, redirect(socket, to: destination)}
true ->
{:ok,
socket
|> assign(:page_title, gettext("Enter your referral code"))
|> assign(:destination, destination)
|> assign(:error, nil)
|> assign(:ip_address, IpAddress.extract_from_socket(socket))
|> assign(:form, to_form(%{"code" => ""}, as: "referral"))}
end
end
@impl true
def handle_event("redeem", %{"referral" => %{"code" => code}}, socket) do
user = socket.assigns.phoenix_kit_current_user
with :ok <- RateLimiter.check_referral_redemption_rate_limit(user.uuid),
{:ok, %{} = validated} <- validate(code, socket) do
admit(socket, user, validated)
else
{:error, :rate_limit_exceeded} ->
{:noreply, reject(socket, code, gettext("Too many attempts. Please try again shortly."))}
# A blank submission, or an enabled-but-not-required config. Neither can
# admit anyone, so both read as a plain rejection rather than falling
# through to `admit/3` with nothing to record.
{:ok, nil} ->
{:noreply, reject(socket, code, gettext("That code can't be used"))}
{:error, message} when is_binary(message) ->
{:noreply, reject(socket, code, message)}
# Anything else — an unexpected limiter shape, a future error atom. A
# `with` whose else clauses are not exhaustive raises `WithClauseError`
# and takes the page down, on the one page a parked user can reach.
other ->
Logger.warning("[ReferralGate] unexpected validation result: #{inspect(other)}")
{:noreply, reject(socket, code, gettext("That code can't be used"))}
end
end
defp validate(code, socket) do
Referrals.validate_for_signup(code,
enabled?: true,
required?: true,
context: :submit,
ip_address: socket.assigns.ip_address
)
end
defp admit(socket, user, validated_code) do
# `redeem/2` claims the use and marks the account in ONE transaction.
# Separately, a failed mark after a successful claim would burn a use of a
# possibly single-use code and leave the user still parked — and retrying
# the same code would now legitimately fail.
case Referrals.redeem(user, validated_code.code) do
{:ok, _user} ->
{:noreply,
socket
|> put_flash(:info, gettext("Welcome! Your referral code has been accepted."))
|> redirect(to: socket.assigns.destination)}
{:error, reason} ->
# Includes losing the last use of a code to someone else between
# validation and redemption. Nothing was consumed, so the generic
# rejection is honest here; the real reason goes to the log.
Logger.info("[ReferralGate] #{user.uuid} could not redeem a code: #{inspect(reason)}")
{:noreply, reject(socket, "", gettext("That code can't be used"))}
end
end
defp reject(socket, code, message) do
socket
|> assign(:error, message)
|> assign(:form, to_form(%{"code" => code}, as: "referral"))
end
end