Skip to content

Commit

Permalink
[FEATURE] [MER-4068] Reimplement user invite functionality (#5322)
Browse files Browse the repository at this point in the history
As discussed in the ticket, this PR also introduced the :pending_confirmation and :rejected statuses for and enrollment, to allow the user to decide what to do with an invitation (in the previous version the user was enrolled and the email invitation ended up being an information email)

So, the student's overview page (in the instructor dashboard) was expanded to allow the instructor to filter by these two new values.
On the other hand, now the student workspace won't show the course until the student ends up accepting the invitation (in a future iteration we might want to show a pending confirmation course as "grayed" with a link to the UserInvite liveview)
  • Loading branch information
nicocirio authored Jan 3, 2025
1 parent aa5dcc1 commit 5ab0dd6
Show file tree
Hide file tree
Showing 28 changed files with 1,558 additions and 127 deletions.
71 changes: 68 additions & 3 deletions lib/oli/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -94,20 +94,21 @@ defmodule Oli.Accounts do
@doc """
Creates multiple invited users
## Examples
iex> bulk_create_invited_users(["email_1@test.com", "email_2@test.com"], %Author{id: 1})
iex> bulk_create_invited_users(["email_1@test.com", "email_2@test.com"], %User{id: 1})
[%User{id: 3}, %User{id: 4}]
"""
def bulk_create_invited_users(user_emails, inviter_user) do
now = DateTime.utc_now() |> DateTime.truncate(:second)

users =
Enum.map(user_emails, fn email ->
%{changes: changes} = User.invite_changeset(%User{}, inviter_user, %{email: email})
%{changes: changes} =
User.invite_changeset(%User{}, %{email: email, invited_by_id: inviter_user.id})

Enum.into(changes, %{inserted_at: now, updated_at: now})
end)

Repo.insert_all(User, users, returning: [:id, :invitation_token, :email])
Repo.insert_all(User, users, returning: [:id, :email])
end

def create_invited_author(_email) do
Expand Down Expand Up @@ -182,6 +183,8 @@ defmodule Oli.Accounts do
Repo.all(Author)
end

#### MER-3835 TODO: Reconcile these functions with new functions at end of module

@doc """
Gets a single user.
Raises `Ecto.NoResultsError` if the User does not exist.
Expand Down Expand Up @@ -461,6 +464,8 @@ defmodule Oli.Accounts do
Repo.exists?(query)
end

# MER-3835 TODO: reconcile with new functions below

def at_least_content_admin?(%Author{system_role_id: system_role_id}) do
SystemRole.role_id().content_admin == system_role_id or
SystemRole.role_id().account_admin == system_role_id or
Expand Down Expand Up @@ -1003,6 +1008,25 @@ defmodule Oli.Accounts do
## Examples
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
%User{}
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
nil
"""
def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
if User.valid_password?(user, password), do: user
end

def get_user_by_email_and_password(_email, _password), do: nil

@doc """
Gets an independent user by email and password.
## Examples
iex> get_independent_user_by_email_and_password("foo@example.com", "correct_password")
%User{}
Expand Down Expand Up @@ -1052,6 +1076,26 @@ defmodule Oli.Accounts do
)
end

## Email invitations

@doc """
When a new user accepts an invitation to a section, the user -student or instructor- data is updated (password for intance)
and the enrollment status is updated from `:pending_confirmation` to `:enrolled`.
Since both operations are related, they are wrapped in a transaction.
"""
def accept_user_invitation(user, enrollment, attrs \\ %{}) do
Repo.transaction(fn ->
user
|> User.accept_invitation_changeset(attrs)
|> Repo.update!()

enrollment
|> Enrollment.changeset(%{status: :enrolled})
|> Repo.update!()
end)
end

## Settings

@doc """
Expand Down Expand Up @@ -1349,6 +1393,27 @@ defmodule Oli.Accounts do
end
end

@doc """
Gets the user by enrollment invitation token.
## Examples
iex> get_user_by_enrollment_invitation_token("validtoken")
%User{}
iex> get_user_by_enrollment_invitation_token("invalidtoken")
nil
"""
def get_user_token_by_enrollment_invitation_token(token) do
with {:ok, query} <- UserToken.enrollment_invitation_token_query(token),
%UserToken{} = user_token <- Repo.one(query) |> Repo.preload(:user) do
user_token
else
_ -> nil
end
end

@doc """
Resets the user password.
Expand Down
2 changes: 2 additions & 0 deletions lib/oli/accounts/author_token.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ defmodule Oli.Accounts.AuthorToken do

schema "authors_tokens" do
field :token, :binary
# the context is used to differentiate between different types of tokens
# such as "confirm" (for email confirmation), "reset_password".
field :context, :string
field :sent_to, :string
belongs_to :author, Oli.Accounts.Author
Expand Down
48 changes: 37 additions & 11 deletions lib/oli/accounts/schemas/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ defmodule Oli.Accounts.User do
import Ecto.Changeset
import Oli.Utils

alias Ecto.Changeset

schema "users" do
field :email, :string
field :password, :string, virtual: true, redact: true
field :password_hash, :string, redact: true
field :email_confirmed_at, :utc_datetime

field :invitation_token, :string
field :invitation_accepted_at, :utc_datetime

# user fields are based on the openid connect core standard, most of which are provided via LTI 1.3
Expand Down Expand Up @@ -494,24 +495,49 @@ defmodule Oli.Accounts.User do
@doc """
Invites user.
A unique `:invitation_token` will be generated, and `invited_by` association
will be set. Only the user id will be set, and the persisted user won't have
Only the user id will be set, and the persisted user won't have
any password for authentication.
(The user will set the password in the redeem invitation flow)
"""
def invite_changeset(_user, _invited_by, _attrs) do
# MER-4068 TODO
throw("Not implemented")
@spec invite_changeset(Ecto.Schema.t() | Changeset.t(), map()) :: Changeset.t()
def invite_changeset(%Changeset{} = changeset, attrs) do
changeset
|> cast(attrs, [:email])
|> validate_required([:email])
end

def invite_changeset(user, attrs) do
user
|> Ecto.Changeset.change()
|> invite_changeset(attrs)
end

@doc """
Accepts an invitation.
`:invitation_accepted_at` will be updated. The password can be set, and the
user id updated.
`:invitation_accepted_at` and `email_confirmed_at` will be updated. The password can be set,
and the email will be marked as verified since this changeset is used for accepting email invitations
(if they recieved the email invitation and accessed the link to accept it we can conclude that the email exists and belongs to the user).
"""
def accept_invitation_changeset(_user, _attrs) do
# MER-4068 TODO
throw("Not implemented")
def accept_invitation_changeset(user, attrs, opts \\ []) do
now = Oli.DateTime.utc_now() |> DateTime.truncate(:second)

user
|> cast(attrs, [
:email,
:password,
:given_name,
:family_name
])
|> validate_confirmation(:password, message: "does not match password")
|> validate_email(opts)
|> validate_password(opts)
|> put_change(:independent_learner, true)
|> put_change(:invitation_accepted_at, now)
|> put_change(:email_confirmed_at, now)
|> put_change(:email_verified, true)
|> maybe_create_unique_sub()
|> maybe_name_from_given_and_family()
end
end

Expand Down
52 changes: 44 additions & 8 deletions lib/oli/accounts/user_token.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ defmodule Oli.Accounts.UserToken do
@confirm_validity_in_days 7
@change_email_validity_in_days 7
@session_validity_in_days 60
@enrollment_invitation_validity_in_days 30

schema "users_tokens" do
field :token, :binary
# the context is used to differentiate between different types of tokens
# such as "session", "confirm" (for email confirmation), "reset_password", "change:<current_email>", "enrollment_invitation:<section_slug>".
field :context, :string
field :sent_to, :string
belongs_to :user, Oli.Accounts.User
Expand Down Expand Up @@ -78,22 +81,24 @@ defmodule Oli.Accounts.UserToken do
for example, by phone numbers.
"""
def build_email_token(user, context) do
build_hashed_token(user, context, user.email)
end

defp build_hashed_token(user, context, sent_to) do
token = :crypto.strong_rand_bytes(@rand_size)
hashed_token = :crypto.hash(@hash_algorithm, token)
{token, hashed_token} = build_hashed_token()

{Base.url_encode64(token, padding: false),
{token,
%UserToken{
token: hashed_token,
context: context,
sent_to: sent_to,
sent_to: user.email,
user_id: user.id
}}
end

defp build_hashed_token() do
token = :crypto.strong_rand_bytes(@rand_size)
hashed_token = :crypto.hash(@hash_algorithm, token)

{Base.url_encode64(token, padding: false), hashed_token}
end

@doc """
Checks if the token is valid and returns its underlying lookup query.
Expand Down Expand Up @@ -159,6 +164,37 @@ defmodule Oli.Accounts.UserToken do
end
end

@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user_token found by the token, if any.
This is used to validate requests to accept an
email invitation.
The given token is valid if it matches its hashed counterpart in the
database and if it has not expired (after @enrollment_invitation_validity_in_days).
The context must always start with "enrollment_invitation:".
"""
def enrollment_invitation_token_query(token) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)

query =
from(ut in UserToken,
where:
ut.token == ^hashed_token and like(ut.context, "enrollment_invitation:%") and
ut.inserted_at > ago(@enrollment_invitation_validity_in_days, "day")
)

{:ok, query}

:error ->
:error
end
end

@doc """
Returns the token struct for the given token value and context.
"""
Expand Down
30 changes: 22 additions & 8 deletions lib/oli/delivery/sections.ex
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,9 @@ defmodule Oli.Delivery.Sections do
{:error, changeset} # Something went wrong
"""
@spec enroll(list(number()), number(), [%ContextRole{}]) :: {:ok, list(%Enrollment{})}
def enroll(user_ids, section_id, context_roles) when is_list(user_ids) do
def enroll(user_ids, section_id, context_roles, status \\ :enrolled)

def enroll(user_ids, section_id, context_roles, status) when is_list(user_ids) do
Repo.transaction(fn ->
context_roles = EctoProvider.Marshaler.to(context_roles)
date = DateTime.utc_now() |> DateTime.truncate(:second)
Expand All @@ -369,7 +371,7 @@ defmodule Oli.Delivery.Sections do
section_id: section_id,
inserted_at: date,
updated_at: date,
status: :enrolled,
status: status,
state: %{}
}
)
Expand Down Expand Up @@ -398,7 +400,7 @@ defmodule Oli.Delivery.Sections do
end

@spec enroll(number(), number(), [%ContextRole{}]) :: {:ok, %Enrollment{}}
def enroll(user_id, section_id, context_roles) do
def enroll(user_id, section_id, context_roles, status) do
context_roles = EctoProvider.Marshaler.to(context_roles)

case Repo.one(
Expand All @@ -409,7 +411,7 @@ defmodule Oli.Delivery.Sections do
)
) do
# Enrollment doesn't exist, we are creating it
nil -> %Enrollment{user_id: user_id, section_id: section_id}
nil -> %Enrollment{user_id: user_id, section_id: section_id, status: status}
# Enrollment exists, we are potentially just updating it
e -> e
end
Expand Down Expand Up @@ -560,15 +562,26 @@ defmodule Oli.Delivery.Sections do
Repo.aggregate(query, :count, :id) > 0
end

def get_enrollment(section_slug, user_id) do
@doc """
Returns the enrollment for a given section and user.
The `filter_by_status` boolean option is used to filter the enrollment by status
(enrollment status = :enrolled and section status = :active).
"""
def get_enrollment(section_slug, user_id, opts \\ [filter_by_status: true]) do
maybe_filter_by_status =
if opts[:filter_by_status] do
dynamic([e, s], e.status == :enrolled and s.status == :active)
else
true
end

query =
from(
e in Enrollment,
join: s in Section,
on: e.section_id == s.id,
where:
e.user_id == ^user_id and s.slug == ^section_slug and s.status == :active and
e.status == :enrolled,
where: e.user_id == ^user_id and s.slug == ^section_slug,
where: ^maybe_filter_by_status,
select: e
)

Expand Down Expand Up @@ -5362,6 +5375,7 @@ defmodule Oli.Delivery.Sections do
join: ecr in EnrollmentContextRole,
on: e.id == ecr.enrollment_id,
where: e.user_id == ^user_id,
where: e.status == :enrolled,
where: s.open_and_free == true,
where: s.status == :active,
where: ecr.context_role_id in ^context_role_ids
Expand Down
6 changes: 5 additions & 1 deletion lib/oli/delivery/sections/enrollment.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ defmodule Oli.Delivery.Sections.Enrollment do
belongs_to :most_recently_visited_resource, Oli.Resources.Resource

field :state, :map, default: %{}
field :status, Ecto.Enum, values: [:enrolled, :suspended], default: :enrolled

field :status, Ecto.Enum,
values: [:enrolled, :suspended, :pending_confirmation, :rejected],
default: :enrolled

many_to_many :context_roles, Lti_1p3.DataProviders.EctoProvider.ContextRole,
join_through: "enrollments_context_roles",
Expand All @@ -22,5 +25,6 @@ defmodule Oli.Delivery.Sections.Enrollment do
enrollment
|> cast(attrs, [:user_id, :section_id, :state, :status, :most_recently_visited_resource_id])
|> validate_required([:user_id, :section_id])
|> validate_inclusion(:status, Ecto.Enum.values(__MODULE__, :status))
end
end
2 changes: 1 addition & 1 deletion lib/oli/utils/seeder/accounts_fixtures.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ defmodule Oli.Utils.Seeder.AccountsFixtures do
now = DateTime.utc_now() |> DateTime.truncate(:second)

Enum.into(attrs, %{
email: unique_user_email(),
email: attrs[:email] || unique_user_email(),
password: valid_user_password(),
sub: UUID.uuid4(),
given_name: "Andrew",
Expand Down
Loading

0 comments on commit 5ab0dd6

Please sign in to comment.