Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEATURE] [MER-4132] Reimplement authors invite functionality #5337

Draft
wants to merge 20 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
bd4c466
add status to authors_projects to track collaboration invitation status
nicocirio Jan 2, 2025
b682259
fix typo
nicocirio Jan 3, 2025
80395d9
invite and remove collaborators
nicocirio Jan 6, 2025
67a1338
exclude not yet accepted project's invitations from course author wor…
nicocirio Jan 6, 2025
c9533a7
create AuthorsInvite liveview to handle -reject or accept- collaborat…
nicocirio Jan 7, 2025
f6101cb
remove unused invitation_token field from authors table
nicocirio Jan 7, 2025
71036bb
remove old project overview liveview - before migrating to new worksp…
nicocirio Jan 8, 2025
e49b758
update invitation changesets to validate email
nicocirio Jan 8, 2025
a439ab8
rename project_authors/1 to authors_projects/1
nicocirio Jan 8, 2025
38f1105
add missing catch all add_collaborator/2
nicocirio Jan 8, 2025
a3f7e61
fix tests: remove non-existing author invitation_token field
nicocirio Jan 8, 2025
0e276e9
fix invited accepted/not accepted logic and re-enable corresponding t…
nicocirio Jan 8, 2025
e3289e5
re-enable skipped tests
nicocirio Jan 8, 2025
4d662f3
fix test
nicocirio Jan 8, 2025
2684b01
Merge branch 'master' into MER-4132-reimplement-authors-invite-functi…
nicocirio Jan 8, 2025
27454ea
add invite_controller tests
nicocirio Jan 8, 2025
95c85a3
remove unused add_collaborator/3 + small Collaborators.invite_collabo…
nicocirio Jan 9, 2025
d40e2dc
test for Collaborators.invite_collaborator/3
nicocirio Jan 9, 2025
067959a
test AuthorsInviteView
nicocirio Jan 9, 2025
ce21389
set path names + use absolute url for email invitations
nicocirio Jan 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 59 additions & 23 deletions lib/oli/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ defmodule Oli.Accounts do
UserPreferences
}

alias Oli.Authoring.Authors.AuthorProject
alias Oli.Authoring.Course.Project

alias Oli.Groups
alias Oli.Groups.CommunityAccount
alias Oli.Institutions.Institution
Expand Down Expand Up @@ -111,9 +114,10 @@ defmodule Oli.Accounts do
Repo.insert_all(User, users, returning: [:id, :email])
end

def create_invited_author(_email) do
# MER-4068 TODO
throw("Not implemented")
def create_invited_author(email) do
%Author{}
|> Author.invite_changeset(%{email: email})
|> Repo.insert()
end

def browse_authors(
Expand Down Expand Up @@ -821,31 +825,24 @@ defmodule Oli.Accounts do
)
end

def project_authors(project_ids) when is_list(project_ids) do
def authors_projects(project_ids) when is_list(project_ids) do
Repo.all(
from(assoc in "authors_projects",
from(ap in AuthorProject,
join: author in Author,
on: assoc.author_id == author.id,
where:
assoc.project_id in ^project_ids and
(is_nil(author.invitation_token) or not is_nil(author.invitation_accepted_at)),
select: [author, assoc.project_id]
on: ap.author_id == author.id,
join: project in Project,
on: ap.project_id == project.id,
where: ap.project_id in ^project_ids,
select: %{
author: author,
author_project_status: ap.status,
project_slug: project.slug
}
)
)
end

def project_authors(project) do
Repo.all(
from(assoc in "authors_projects",
join: author in Author,
on: assoc.author_id == author.id,
where:
assoc.project_id == ^project.id and
(is_nil(author.invitation_token) or not is_nil(author.invitation_accepted_at)),
select: author
)
)
end
def authors_projects(project), do: authors_projects([project.id])

@doc """
Get all the communities for which the author is an admin.
Expand Down Expand Up @@ -1399,7 +1396,7 @@ defmodule Oli.Accounts do
## Examples

iex> get_user_by_enrollment_invitation_token("validtoken")
%User{}
%UserToken{}

iex> get_user_by_enrollment_invitation_token("invalidtoken")
nil
Expand Down Expand Up @@ -1542,6 +1539,24 @@ defmodule Oli.Accounts do
)
end

@doc """
When a new author accepts an invitation to a project, the author's data is updated (password for intance)
and the author_project status is updated from `:pending_confirmation` to `:accepted`.

Since both operations are related, they are wrapped in a transaction.
"""
def accept_author_invitation(author, author_project, attrs \\ %{}) do
Repo.transaction(fn ->
author
|> Author.accept_invitation_changeset(attrs)
|> Repo.update!()

author_project
|> AuthorProject.changeset(%{status: :accepted})
|> Repo.update!()
end)
end

## Settings

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

@doc """
Gets the author by collaboration invitation token.

## Examples

iex> get_author_by_collaboration_invitation_token("validtoken")
%Author{}

iex> get_author_by_collaboration_invitation_token("invalidtoken")
nil

"""
def get_author_token_by_collaboration_invitation_token(token) do
with {:ok, query} <- AuthorToken.collaborator_invitation_token_query(token),
%AuthorToken{} = author_token <- Repo.one(query) |> Repo.preload(:author) do
author_token
else
_ -> nil
end
end

@doc """
Resets the author password.

Expand Down
34 changes: 33 additions & 1 deletion lib/oli/accounts/author_token.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ defmodule Oli.Accounts.AuthorToken do
@confirm_validity_in_days 7
@change_email_validity_in_days 7
@session_validity_in_days 60
@collaborator_invitation_validity_in_days 30

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".
# such as "confirm" (for email confirmation), "reset_password", "collaborator_invitation:<project_slug>".
field :context, :string
field :sent_to, :string
belongs_to :author, Oli.Accounts.Author
Expand Down Expand Up @@ -161,6 +162,37 @@ defmodule Oli.Accounts.AuthorToken do
end
end

@doc """
Checks if the token is valid and returns its underlying lookup query.

The query returns the author_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 @collaborator_invitation_validity_in_days).
The context must always start with "collaborator_invitation:".
"""
def collaborator_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(at in AuthorToken,
where:
at.token == ^hashed_token and like(at.context, "collaborator_invitation:%") and
at.inserted_at > ago(@collaborator_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
64 changes: 46 additions & 18 deletions lib/oli/accounts/schemas/author.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ defmodule Oli.Accounts.Author do
field :password_hash, :string, redact: true
field :email_confirmed_at, :utc_datetime

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

field :name, :string
Expand Down Expand Up @@ -87,6 +86,32 @@ defmodule Oli.Accounts.Author do
|> maybe_name_from_given_and_family()
end

@doc """
Invites author.

Only the author id will be set, and the persisted author won't have
any password for authentication.
(The author will set the password in the redeem invitation flow)
"""
@spec invite_changeset(Ecto.Schema.t() | Ecto.Changeset.t(), map(), list()) ::
Ecto.Changeset.t()

def invite_changeset(author, attrs, opts \\ [])

def invite_changeset(%Ecto.Changeset{} = changeset, attrs, opts) do
changeset
|> cast(attrs, [:email])
|> validate_required([:email])
|> validate_email(opts)
|> put_change(:system_role_id, Oli.Accounts.SystemRole.role_id().author)
end

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

defp validate_email(changeset, opts) do
changeset
|> validate_required([:email])
Expand Down Expand Up @@ -333,26 +358,29 @@ defmodule Oli.Accounts.Author do
end
end

@doc """
Invites an author.

A unique `:invitation_token` will be generated, and `invited_by` association
will be set. Only the author id will be set, and the persisted author won't have
any password for authentication.
"""
def invite_changeset(_author, _invited_by, _attrs) do
# MER-4068 TODO
throw("Not implemented")
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 author).
"""
def accept_invitation_changeset(_author, _attrs) do
# MER-4068 TODO
throw("Not implemented")
def accept_invitation_changeset(author, attrs, opts \\ []) do
now = Oli.DateTime.utc_now() |> DateTime.truncate(:second)

author
|> cast(attrs, [
:email,
:password,
:given_name,
:family_name
])
|> validate_confirmation(:password, message: "does not match password")
|> validate_email(opts)
|> validate_password(opts)
|> put_change(:invitation_accepted_at, now)
|> put_change(:email_confirmed_at, now)
|> default_system_role()
|> maybe_name_from_given_and_family()
end
end
11 changes: 7 additions & 4 deletions lib/oli/accounts/schemas/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -499,17 +499,20 @@ defmodule Oli.Accounts.User do
any password for authentication.
(The user will set the password in the redeem invitation flow)
"""
@spec invite_changeset(Ecto.Schema.t() | Changeset.t(), map()) :: Changeset.t()
def invite_changeset(%Changeset{} = changeset, attrs) do
@spec invite_changeset(Ecto.Schema.t() | Changeset.t(), map(), list()) :: Changeset.t()
def invite_changeset(user, attrs, opts \\ [])

def invite_changeset(%Changeset{} = changeset, attrs, opts) do
changeset
|> cast(attrs, [:email])
|> validate_required([:email])
|> validate_email(opts)
end

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

@doc """
Expand Down
7 changes: 6 additions & 1 deletion lib/oli/authoring/authors/author_project.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,18 @@ defmodule Oli.Authoring.Authors.AuthorProject do
field :author_id, :integer, primary_key: true
field :project_id, :integer, primary_key: true
belongs_to :project_role, Oli.Authoring.Authors.ProjectRole

field :status, Ecto.Enum,
values: [:accepted, :pending_confirmation, :rejected],
default: :accepted
end

@doc false
def changeset(author_project, attrs \\ %{}) do
author_project
|> cast(attrs, [:author_id, :project_id, :project_role_id])
|> cast(attrs, [:author_id, :project_id, :project_role_id, :status])
|> validate_required([:author_id, :project_id, :project_role_id])
|> unique_constraint(:author_id, name: :index_author_project)
|> validate_inclusion(:status, Ecto.Enum.values(__MODULE__, :status))
end
end
Loading
Loading