PhoenixKit.Users.Referrals (phoenix_kit v2.2.0)

Copy Markdown View Source

Core-side runtime bridge to the optional phoenix_kit_referrals package.

The referral-codes feature lives in the standalone phoenix_kit_referrals module. Core has no compile-time dependency on it — this facade resolves the installed module at runtime by its PhoenixKit.Module key ("referrals") via PhoenixKit.ModuleRegistry and dispatches through it.

When the package isn't installed (or doesn't export a given function) every call degrades safely: the system reads as disabled, lookups return nil, and use_code/2 is a no-op. That lets the registration / OAuth / magic-link flows treat referrals as optional — with the module absent, the referral field never appears and nothing is recorded.

The function surface here mirrors exactly what the signup flows call, so those call sites only had to swap their alias to this module.

Invite-only access gate

With referral_codes_required on, a referral code is supposed to be the only way in. Enforcing that at each creation path does not work: OAuth and magic-link signups can be started from URLs that never pass through the registration form, and blocking them there produces dead ends rather than admission control.

So enforcement is a post-signup access gate. An account may be created by any means; until it is satisfied it can reach nothing but /users/referral (where a code admits it) and log-out. access_satisfied?/1 is the single predicate, called from the same choke points that enforce email confirmation — see PhoenixKitWeb.Users.Auth.

An account is satisfied when any of these holds:

  1. It has custom_fields["referral_satisfied_at"] — written by mark_satisfied/2 when a code is used at signup, entered on the parked screen, or accepted as part of an organization invitation.
  2. It was created before invite-only took effect and referral_grandfather_existing is on (the default). See below.
  3. It holds every enabled permission (Scope.holds_all_enabled_permissions?/1). This is a hard exemption, independent of the grandfather setting — an operator who turns grandfathering off on an install whose codes are all spent would otherwise lock themselves out with no way back in. It is keyed on permissions rather than a role name because that is what the rest of the admin gate uses, and Owner satisfies it by construction.
  4. A pending, unexpired organization invitation is addressed to its email. Invitations are email-bound, so this makes org admins de-facto admission issuers on purpose — without it the "you were invited" banner on the registration page is a dead end under invite-only.

Why not confirmed_at

The satisfied flag is deliberately independent of email confirmation. OAuth auto-confirms (PhoenixKit.Users.OAuth), so a gate keyed on confirmed_at would open for exactly the path it most needs to close.

Grandfathering

referral_required_enabled_at records when invite-only took effect, and users created before it are grandfathered while referral_grandfather_existing (default true) is on. Core stamps and clears that setting itself, from access_required?/0, rather than relying on the referrals admin toggle — that way it stays correct whoever flips the switch, including an older phoenix_kit_referrals that knows nothing about the gate. Turning invite-only off clears the stamp, so switching it on again grandfathers everyone admitted in between.

If the package is uninstalled

The gate requires enabled?/0, which goes through the installed module. With the package gone there is no way to validate a code, so gating on a left-behind referral_codes_required row would park every non-exempt user permanently.

Summary

Functions

Whether invite-only enforcement is currently active.

Whether this account may use the application, or must first enter a code.

Whether a referrals module is installed and enabled.

Whether the given code is expired.

Look up a referral code struct by its string, or nil.

Referral-codes configuration map. Disabled defaults when the module is absent.

Records that an account has satisfied invite-only, and why.

Whether this account has already been marked as satisfying invite-only.

One sweep's worth of rows, before the per-account authority check.

Deactivates accounts that were created under invite-only and never satisfied it, so "registered" keeps meaning "admitted".

Records a signup's use of a referral code and marks the account admitted.

Claims one use of code_string for user and marks the account admitted, atomically.

The custom_fields key recording that an account satisfied invite-only.

How long an unadmitted account is left alone before the janitor deactivates it, in days. 0 (the default) means never.

Whether the given code hit its usage limit. Fails closed — see expired?/1.

Record a use of code_string by user_uuid.

Validates a referral code for a signup attempt.

Functions

access_required?()

@spec access_required?() :: boolean()

Whether invite-only enforcement is currently active.

Also keeps referral_required_enabled_at in step with the answer, so the grandfather boundary always describes the current invite-only period.

access_satisfied?(subject)

@spec access_satisfied?(
  PhoenixKit.Users.Auth.Scope.t()
  | PhoenixKit.Users.Auth.User.t()
  | nil
) ::
  boolean()

Whether this account may use the application, or must first enter a code.

Accepts a Scope or a User; anything else (including nil) is satisfied, because deciding admission for a visitor with no account is the authentication gate's job, not this one.

The User form exists for the older user-based on_mount hook, which has no scope. It has to build one to evaluate the full-access exemption, so it is the more expensive form — but only for an account that is not already satisfied on cheaper grounds.

enabled?()

Whether a referrals module is installed and enabled.

expired?(code)

Whether the given code is expired.

⚠️ Answers true when nothing can answer. These predicates gate admission under invite-only, so "we could not check" has to mean "do not admit" — the old false default let a code past an expiry check that never ran.

get_code_by_string(code_string)

Look up a referral code struct by its string, or nil.

get_config()

Referral-codes configuration map. Disabled defaults when the module is absent.

mark_satisfied(user, reason)

@spec mark_satisfied(PhoenixKit.Users.Auth.User.t(), String.t()) ::
  {:ok, PhoenixKit.Users.Auth.User.t()} | {:error, term()}

Records that an account has satisfied invite-only, and why.

Idempotent: an already-marked account keeps its original timestamp, so a second call cannot rewrite when admission happened. reason is stored alongside for operators auditing how an account got in.

marked_satisfied?(arg1)

@spec marked_satisfied?(PhoenixKit.Users.Auth.User.t() | nil) :: boolean()

Whether this account has already been marked as satisfying invite-only.

prune_candidates(days)

@spec prune_candidates(pos_integer()) :: [PhoenixKit.Users.Auth.User.t()]

One sweep's worth of rows, before the per-account authority check.

Separated from prune_unadmitted/1 so the grandfather exclusion can be asserted directly. Testing it through the sweep would take 500 accounts to demonstrate — the starvation it prevents only appears once exempt rows can fill the batch — and a suite that registers five hundred users to prove one WHERE clause is a suite nobody runs.

prune_unadmitted(days)

@spec prune_unadmitted(non_neg_integer()) :: {:ok, non_neg_integer()}

Deactivates accounts that were created under invite-only and never satisfied it, so "registered" keeps meaning "admitted".

Deactivates; never deletes. Users are the foreign-key target of most of the ecosystem, and an account that was merely slow to enter its code is one an operator will want back.

Nothing happens unless invite-only is on AND days is positive — an install that turned invite-only off should not have the janitor sweeping up the accounts it just legitimised. Candidates are re-checked one by one through access_satisfied?/1 rather than trusted from the query, because the exemptions that matter most (full permissions, a pending invitation) are the ones SQL cannot see.

Returns {:ok, count}.

record_signup_use(user, code_string)

@spec record_signup_use(PhoenixKit.Users.Auth.User.t(), String.t() | map() | nil) ::
  :ok

Records a signup's use of a referral code and marks the account admitted.

The two halves belong together. use_code/2 on its own leaves an account that supplied a perfectly good code sitting behind the invite-only gate — the exact opposite of what supplying it meant — and every signup path had its own copy of the first half only.

Accepts the code as a string or as an already-resolved code struct; nil (no code supplied) is a no-op. Marking happens even when invite-only is currently off, so an operator who turns it on later does not park the people who did bring a code.

redeem(user, code_string)

@spec redeem(PhoenixKit.Users.Auth.User.t(), String.t()) ::
  {:ok, PhoenixKit.Users.Auth.User.t()} | {:error, term()}

Claims one use of code_string for user and marks the account admitted, atomically.

The two writes have to succeed or fail together. Claiming without marking burns a use of a possibly single-use code and leaves the user still parked, with the same code now rejecting them; marking without claiming lets a code past its limit admit everyone who raced for it.

Returns {:ok, user} or {:error, reason}.

satisfied_field()

The custom_fields key recording that an account satisfied invite-only.

unadmitted_retention_days()

@spec unadmitted_retention_days() :: non_neg_integer()

How long an unadmitted account is left alone before the janitor deactivates it, in days. 0 (the default) means never.

Deliberately opt-in. The alternative — a default that silently starts deactivating accounts as soon as an operator switches invite-only on — is the kind of destructive default that has to be asked for, not inherited.

usage_limit_reached?(code)

Whether the given code hit its usage limit. Fails closed — see expired?/1.

use_code(code_string, user_uuid)

Record a use of code_string by user_uuid.

No-op returning {:error, :referrals_not_installed} when the module is absent.

validate_for_signup(code, opts)

Validates a referral code for a signup attempt.

Shared by every signup surface so the rules cannot drift apart — the password and magic-link forms previously carried byte-identical private copies.

Options

  • :enabled? / :required? — from get_config/0 (required)
  • :context:change while the user types, :submit on the final attempt
  • :ip_address — used to rate-limit code checking; omit and no limit applies

Why :context matters

On :change, nothing is rejected. Validation runs on every keystroke anywhere in the form, so treating blank-and-required as invalid made "Referral code is required" appear while the user was still typing their email — before they had reached the field. :submit enforces presence.

A typed code is not checked on change either, which reverses an earlier decision that a touched field was fair to complain about. Two reasons pointing the same way: checking per keystroke hands an attacker a far faster oracle than the submit button does, and it spends one rate-limit token per character, so a real person typing an eight-character code and fixing a typo can exhaust their own budget while holding a good code.

Why rejections are indistinguishable

Every failure returns the same message. Separate strings for missing / inactive / expired / limit-reached confirmed which guesses named a real code. The specific reason is logged at debug level for operators.

Returns {:ok, code_or_nil} or {:error, message}.