Packages
phoenix_kit
1.7.185
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/users/invitations.ex
defmodule PhoenixKit.Users.Invitations do
@moduledoc """
Context for organization invitation lifecycle management.
Handles creating, listing, accepting, declining, and cancelling invitations.
## Token Security
Follows the UserToken pattern:
- Raw token (32 bytes) is generated with `:crypto.strong_rand_bytes/1`
- SHA-256 hash is stored in the database
- URL-safe Base64 encoded raw token is sent via email or flash
## Flow
1. Admin calls `create_invitation/3` — invitation is created, encoded_token returned
2. For existing users: banner shown via `list_pending_for_email/1` + InvitationHook
3. For new users: invitation email with registration link containing encoded_token
4. User accepts via `accept_invitation_by_uuid/2` or declines via `decline_invitation_by_uuid/1`
"""
import Ecto.Query
use Gettext, backend: PhoenixKitWeb.Gettext
alias PhoenixKit.RepoHelper
alias PhoenixKit.Users.Auth
alias PhoenixKit.Users.Auth.User
alias PhoenixKit.Users.Auth.UserNotifier
alias PhoenixKit.Users.OrganizationInvitation
alias PhoenixKit.Utils.Routes
@invitation_validity_days 7
@doc """
Creates an invitation for the given email to join the organization.
Returns `{:ok, invitation, encoded_token}` on success.
The `encoded_token` must be sent to the invitee (via email for new users,
or the UI banner handles lookup by invitation uuid for existing users).
## Validation
- Cannot invite a user already belonging to an organization
- Cannot create a duplicate pending invitation from the same org for the same email
"""
def create_invitation(%User{} = organization, email, %User{} = invited_by)
when is_binary(email) do
with :ok <- validate_invitation_allowed(organization, email) do
raw_token = :crypto.strong_rand_bytes(32)
hashed_token = :crypto.hash(:sha256, raw_token)
encoded_token = Base.url_encode64(raw_token, padding: false)
expires_at =
DateTime.utc_now()
|> DateTime.add(@invitation_validity_days * 24 * 3600, :second)
|> DateTime.truncate(:second)
attrs = %{
organization_uuid: organization.uuid,
email: email,
invited_by_uuid: invited_by.uuid,
token: hashed_token,
expires_at: expires_at
}
changeset = OrganizationInvitation.create_changeset(%OrganizationInvitation{}, attrs)
repo = RepoHelper.repo()
case repo.insert(changeset) do
{:ok, invitation} ->
maybe_send_invitation_email(invitation, organization, encoded_token)
{:ok, invitation, encoded_token}
{:error, changeset} ->
{:error, changeset}
end
end
end
@doc """
Lists all invitations for an organization, ordered by most recent first.
"""
def list_invitations(organization_uuid) do
repo = RepoHelper.repo()
from(i in OrganizationInvitation,
where: i.organization_uuid == ^organization_uuid,
order_by: [desc: i.inserted_at]
)
|> repo.all()
end
@doc """
Lists pending, non-expired invitations for a given email.
Preloads the `:organization` association for display in banners.
"""
def list_pending_for_email(email) when is_binary(email) do
repo = RepoHelper.repo()
now = DateTime.utc_now()
from(i in OrganizationInvitation,
where:
i.email == ^email and
i.status == :pending and
i.expires_at > ^now,
preload: [:organization]
)
|> repo.all()
end
@doc """
Looks up a pending, non-expired invitation by encoded token.
Used during registration to show org name and store the token.
Returns `{:ok, invitation}` or `{:error, :not_found | :invalid_token}`.
"""
def get_by_token(encoded_token) when is_binary(encoded_token) do
case Base.url_decode64(encoded_token, padding: false) do
{:ok, raw_token} ->
hashed_token = :crypto.hash(:sha256, raw_token)
repo = RepoHelper.repo()
now = DateTime.utc_now()
result =
from(i in OrganizationInvitation,
where:
i.token == ^hashed_token and
i.status == :pending and
i.expires_at > ^now,
preload: [:organization]
)
|> repo.one()
case result do
nil -> {:error, :not_found}
invitation -> {:ok, invitation}
end
:error ->
{:error, :invalid_token}
end
end
@doc """
Accepts an invitation by UUID in a single transaction.
Sets invitation status to `:accepted` and links the user to the organization.
Returns `{:ok, {invitation, user}}` on success.
## Errors
- `{:error, :not_found}` — invitation not found
- `{:error, :not_pending}` — invitation is not in :pending status
- `{:error, :expired}` — invitation has expired
- `{:error, reason}` — database or validation error
"""
def accept_invitation_by_uuid(invitation_uuid, %User{} = user) do
repo = RepoHelper.repo()
invitation = repo.get(OrganizationInvitation, invitation_uuid)
cond do
is_nil(invitation) ->
{:error, :not_found}
invitation.status != :pending ->
{:error, :not_pending}
DateTime.compare(DateTime.utc_now(), invitation.expires_at) == :gt ->
{:error, :expired}
true ->
repo.transaction(fn -> do_accept_invitation(repo, invitation, user) end)
end
end
defp do_accept_invitation(repo, invitation, user) do
case invitation |> OrganizationInvitation.accept_changeset() |> repo.update() do
{:ok, updated_inv} ->
case Auth.set_organization(user, invitation.organization_uuid) do
{:ok, updated_user} -> {updated_inv, updated_user}
{:error, reason} -> repo.rollback(reason)
end
{:error, changeset} ->
repo.rollback(changeset)
end
end
@doc """
Declines an invitation by UUID.
Only pending invitations can be declined.
"""
def decline_invitation_by_uuid(invitation_uuid) do
repo = RepoHelper.repo()
case repo.get(OrganizationInvitation, invitation_uuid) do
nil ->
{:error, :not_found}
%OrganizationInvitation{status: :pending} = invitation ->
invitation
|> OrganizationInvitation.decline_changeset()
|> repo.update()
%OrganizationInvitation{} ->
{:error, :not_pending}
end
end
@doc """
Cancels a pending invitation by UUID (admin action).
Only `:pending` invitations can be cancelled.
"""
def cancel_invitation(invitation_uuid) do
repo = RepoHelper.repo()
case repo.get(OrganizationInvitation, invitation_uuid) do
nil ->
{:error, :not_found}
%{status: :pending} = invitation ->
invitation
|> OrganizationInvitation.cancel_changeset()
|> repo.update()
_other ->
{:error, :not_pending}
end
end
# Private Helpers
defp validate_invitation_allowed(organization, email) do
if organization.email == email do
{:error, :self_invite}
else
repo = RepoHelper.repo()
case repo.get_by(User, email: email) do
%User{organization_uuid: org_uuid} when not is_nil(org_uuid) ->
{:error, dgettext("phoenix_kit", "This user already belongs to an organization")}
_ ->
existing =
from(i in OrganizationInvitation,
where:
i.organization_uuid == ^organization.uuid and
i.email == ^email and
i.status == :pending
)
|> repo.one()
if existing do
{:error,
dgettext("phoenix_kit", "A pending invitation already exists for this email")}
else
:ok
end
end
end
end
defp maybe_send_invitation_email(invitation, organization, encoded_token) do
repo = RepoHelper.repo()
case repo.get_by(User, email: invitation.email) do
nil ->
url = Routes.url("/users/register?invitation=#{encoded_token}")
UserNotifier.deliver_organization_invitation(
invitation.email,
organization.organization_name,
url
)
_existing_user ->
# Existing user sees the banner via InvitationHook
:ok
end
end
end