add invite link and registration validation

This commit is contained in:
shibao 2021-09-10 22:22:18 -04:00 committed by oliviasculley
parent 261b417eb3
commit 5d4d8285fb
11 changed files with 207 additions and 112 deletions

View File

@ -50,13 +50,21 @@ defmodule Cannery.Invites do
@spec get_invite_by_token(String.t()) :: Invite.t() | nil @spec get_invite_by_token(String.t()) :: Invite.t() | nil
def get_invite_by_token(nil), do: nil def get_invite_by_token(nil), do: nil
def get_invite_by_token(""), do: nil def get_invite_by_token(""), do: nil
def get_invite_by_token(token), do: Repo.get_by(Invite, token: token, disabled_at: nil)
def get_invite_by_token(token) do
Repo.one(
from i in Invite,
where: i.token == ^token and i.disabled_at |> is_nil()
)
end
@doc """ @doc """
Uses invite by decrementing uses_left, or markes invite invalid if it's been completely used Uses invite by decrementing uses_left, or marks invite invalid if it's been
completely used.
""" """
@spec use_invite!(Invite.t()) :: Invite.t() @spec use_invite!(Invite.t()) :: Invite.t()
def use_invite!(%Invite{uses_left: nil} = invite), do: invite
def use_invite!(%Invite{uses_left: uses_left} = invite) do def use_invite!(%Invite{uses_left: uses_left} = invite) do
new_uses_left = uses_left - 1 new_uses_left = uses_left - 1
@ -96,7 +104,10 @@ defmodule Cannery.Invites do
attrs attrs
|> Map.merge(%{ |> Map.merge(%{
"user_id" => user_id, "user_id" => user_id,
"token" => :crypto.strong_rand_bytes(@invite_token_length) "token" =>
:crypto.strong_rand_bytes(@invite_token_length)
|> Base.url_encode64()
|> binary_part(0, @invite_token_length)
}) })
%Invite{} |> Invite.changeset(attrs) |> Repo.insert() %Invite{} |> Invite.changeset(attrs) |> Repo.insert()

View File

@ -20,6 +20,7 @@ defmodule Cannery.Invites.Invite do
invite invite
|> cast(attrs, [:name, :token, :uses_left, :disabled_at, :user_id]) |> cast(attrs, [:name, :token, :uses_left, :disabled_at, :user_id])
|> validate_required([:name, :token, :user_id]) |> validate_required([:name, :token, :user_id])
|> validate_number(:uses_left, greater_than_or_equal_to: 0)
end end
@type t :: %{ @type t :: %{

View File

@ -1,18 +1,67 @@
defmodule CanneryWeb.UserRegistrationController do defmodule CanneryWeb.UserRegistrationController do
use CanneryWeb, :controller use CanneryWeb, :controller
alias Cannery.Accounts alias Cannery.{Accounts, Invites}
alias Cannery.Accounts.User alias Cannery.Accounts.User
alias CanneryWeb.UserAuth alias CanneryWeb.UserAuth
def new(conn, _params) do def new(conn, %{"invite" => invite_token}) do
changeset = Accounts.change_user_registration(%User{}) invite = Invites.get_invite_by_token(invite_token)
render(conn, "new.html", changeset: changeset)
if invite do
conn |> render_new(invite)
else
conn
|> put_flash(:error, "Sorry, this invite was not found or expired")
|> redirect(to: Routes.home_path(CanneryWeb.Endpoint, :index))
end
end end
def create(conn, %{"user" => user_params}) do def new(conn, _params) do
if Accounts.allow_registration?() do
conn |> render_new()
else
conn
|> put_flash(:error, "Sorry, public registration is disabled")
|> redirect(to: Routes.home_path(CanneryWeb.Endpoint, :index))
end
end
# renders new user registration page
defp render_new(conn, invite \\ nil) do
changeset = Accounts.change_user_registration(%User{})
conn |> render("new.html", changeset: changeset, invite: invite)
end
def create(conn, %{"user" => %{"invite_token" => invite_token}} = attrs) do
invite = Invites.get_invite_by_token(invite_token)
if invite do
conn |> create_user(attrs, invite)
else
conn
|> put_flash(:error, "Sorry, this invite was not found or expired")
|> redirect(to: Routes.home_path(CanneryWeb.Endpoint, :index))
end
end
def create(conn, attrs) do
if Accounts.allow_registration?() do
conn |> create_user(attrs)
else
conn
|> put_flash(:error, "Sorry, public registration is disabled")
|> redirect(to: Routes.home_path(CanneryWeb.Endpoint, :index))
end
end
defp create_user(conn, %{"user" => user_params}, invite \\ nil) do
case Accounts.register_user(user_params) do case Accounts.register_user(user_params) do
{:ok, user} -> {:ok, user} ->
unless invite |> is_nil() do
invite |> Invites.use_invite!()
end
{:ok, _} = {:ok, _} =
Accounts.deliver_user_confirmation_instructions( Accounts.deliver_user_confirmation_instructions(
user, user,
@ -24,7 +73,7 @@ defmodule CanneryWeb.UserRegistrationController do
|> UserAuth.log_in_user(user) |> UserAuth.log_in_user(user)
{:error, %Ecto.Changeset{} = changeset} -> {:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset) render(conn, "new.html", changeset: changeset, invite: invite)
end end
end end
end end

View File

@ -37,7 +37,7 @@ defmodule CanneryWeb.InviteLive.FormComponent do
{:error, %Ecto.Changeset{} = changeset} -> {:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :changeset, changeset)} {:noreply, assign(socket, :changeset, changeset)}
end end
end end
defp save_invite(socket, :new, invite_params) do defp save_invite(socket, :new, invite_params) do

View File

@ -1,14 +1,28 @@
<h2><%= @title %></h2> <h2 class="title text-xl text-primary-500">
<%= @title %>
</h2>
<%= f = form_for @changeset, "#", <%= f = form_for @changeset, "#",
id: "invite-form", id: "invite-form",
class: "grid grid-cols-3 justify-center items-center space-y-4",
phx_target: @myself, phx_target: @myself,
phx_change: "validate", phx_change: "validate",
phx_submit: "save" %> phx_submit: "save" %>
<%= label f, :name, class: "title text-lg text-primary-500" %> <%= label f, :name, class: "title text-lg text-primary-500" %>
<%= text_input f, :name, class: "input input-primary" %> <%= text_input f, :name, class: "input input-primary col-span-2" %>
<%= error_tag f, :name %> <span class="col-span-3">
<%= error_tag f, :name %>
</span>
<%= submit "Save", phx_disable_with: "Saving..." %> <%= label f, :uses_left, class: "title text-lg text-primary-500" %>
<%= number_input f, :uses_left, min: 0, class: "input input-primary col-span-2" %>
<span class="col-span-3">
<%= error_tag f, :uses_left %>
</span>
<div class="flex flex-row justify-center items-center space-x-4 col-span-3">
<%= submit "Save", class: "btn btn-primary",
phx_disable_with: "Saving..." %>
</div>
</form> </form>

View File

@ -1,46 +1,62 @@
defmodule CanneryWeb.InviteLive.Index do defmodule CanneryWeb.InviteLive.Index do
use CanneryWeb, :live_view use CanneryWeb, :live_view
alias Cannery.Invites alias Cannery.{Invites}
alias Cannery.Invites.Invite alias Cannery.Invites.{Invite}
@impl true @impl true
def mount(_params, session, socket) do def mount(_params, session, socket) do
{:ok, socket |> assign_defaults(session) |> assign(invites: list_invites())} {:ok, socket |> assign_defaults(session) |> display_invites()}
end end
@impl true @impl true
def handle_params(params, _url, socket) do def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)} {:noreply, socket |> apply_action(socket.assigns.live_action, params)}
end end
defp apply_action(socket, :edit, %{"id" => id}) do defp apply_action(socket, :edit, %{"id" => id}) do
socket socket
|> assign(:page_title, "Edit Invite") |> assign(page_title: "Edit Invite", invite: Invites.get_invite!(id))
|> assign(:invite, Invites.get_invite!(id))
end end
defp apply_action(socket, :new, _params) do defp apply_action(socket, :new, _params) do
socket socket
|> assign(:page_title, "New Invite") |> assign(page_title: "New Invite", invite: %Invite{})
|> assign(:invite, %Invite{})
end end
defp apply_action(socket, :index, _params) do defp apply_action(socket, :index, _params) do
socket socket
|> assign(:page_title, "Listing Invites") |> assign(page_title: "Listing Invites", invite: nil)
|> assign(:invite, nil)
end end
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
invite = Invites.get_invite!(id) invite = Invites.get_invite!(id)
{:ok, _} = Invites.delete_invite(invite) {:ok, _} = Invites.delete_invite(invite)
{:noreply, socket |> display_invites()}
{:noreply, assign(socket, :invites, list_invites())}
end end
defp list_invites do def handle_event("set_unlimited", %{"id" => id}, socket) do
Invites.list_invites() id |> Invites.get_invite!() |> Invites.update_invite(%{"uses_left" => nil})
{:noreply, socket |> display_invites()}
end
def handle_event("enable", %{"id" => id}, socket) do
attrs = %{"uses_left" => nil, "disabled_at" => nil}
id |> Invites.get_invite!() |> Invites.update_invite(attrs)
{:noreply, socket |> display_invites()}
end
def handle_event("disable", %{"id" => id}, socket) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
attrs = %{"uses_left" => 0, "disabled_at" => now}
id |> Invites.get_invite!() |> Invites.update_invite(attrs)
{:noreply, socket |> display_invites()}
end
# redisplays invites to socket
defp display_invites(socket) do
invites = Invites.list_invites()
socket |> assign(:invites, invites)
end end
end end

View File

@ -1,4 +1,82 @@
<h1>Listing Invites</h1> <div class="flex flex-col space-y-8 justify-center items-center">
<h1 class="title text-2xl title-primary-500">
Listing Invites
</h1>
<%= if @invites |> Enum.empty?() do %>
<div class="flex flex-col space-y-4 justify-center items-center">
<h1 class="title text-xl text-primary-500">
No invites 😔
</h1>
<%= live_patch to: Routes.invite_index_path(@socket, :new),
class: "btn btn-primary" do %>
Invite someone new!
<% end %>
</div>
<% else %>
<%= live_patch to: Routes.invite_index_path(@socket, :new),
class: "btn btn-primary" do %>
Invite
<% end %>
<% end %>
<div class="flex flex-row flex-wrap space-x-4 space-y-4">
<%= for invite <- @invites do %>
<div class="px-8 py-4 flex flex-col justify-center items-center space-y-4
border border-gray-400 rounded-lg shadow-lg hover:shadow-md">
<h1 class="title text-xl text-primary-500">
<%= invite.name %>
</h1>
<%= if invite.disabled_at |> is_nil() do %>
<h2 class="title text-md text-primary-500">
Uses Left: <%= invite.uses_left || "Unlimited" %>
</h2>
<% else %>
<h2 class="title text-md text-primary-500">
Invite Disabled
</h2>
<% end %>
<code class="text-xs px-4 py-2 rounded-lg text-gray-100 bg-primary-800">
<%= Routes.user_registration_url(@socket, :new, invite: invite.token) %>
</code>
<div class="flex space-x-4 justify-center items-center">
<%= live_patch "Edit", to: Routes.invite_index_path(@socket, :edit, invite),
class: "text-primary-500 link" %>
<%= link "Delete", to: "#",
class: "text-primary-500 link",
phx_click: "delete",
phx_value_id: invite.id,
data: [confirm: "Are you sure?"] %>
<%= if invite.disabled_at |> is_nil() do %>
<a href="#" class="text-primary-500 link"
phx-click="disable" phx-value-id="<%= invite.id %>">
Disable
</a>
<% else %>
<a href="#" class="text-primary-500 link"
phx-click="enable" phx-value-id="<%= invite.id %>">
Enable
</a>
<% end %>
<%= if invite.disabled_at |> is_nil() and not(invite.uses_left |> is_nil()) do %>
<a href="#" class="text-primary-500 link"
phx-click="set_unlimited" phx-value-id="<%= invite.id %>"
data-confirm="Are you sure you want to make this invite unlimited?">
Set Unlimited
</a>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
<%= if @live_action in [:new, :edit] do %> <%= if @live_action in [:new, :edit] do %>
<%= live_modal CanneryWeb.InviteLive.FormComponent, <%= live_modal CanneryWeb.InviteLive.FormComponent,
@ -6,33 +84,6 @@
title: @page_title, title: @page_title,
action: @live_action, action: @live_action,
invite: @invite, invite: @invite,
return_to: Routes.invite_index_path(@socket, :index) %> return_to: Routes.invite_index_path(@socket, :index),
current_user: @current_user %>
<% end %> <% end %>
<table>
<thead>
<tr>
<th>Name</th>
<th>Token</th>
<th>Uses left</th>
<th></th>
</tr>
</thead>
<tbody id="invites">
<%= for invite <- @invites do %>
<tr id="invite-<%= invite.id %>">
<td><%= invite.name %></td>
<td><%= invite.token %></td>
<td><%= invite.uses_left || "Unlimited" %></td>
<td>
<span><%= live_redirect "Show", to: Routes.invite_show_path(@socket, :show, invite) %></span>
<span><%= live_patch "Edit", to: Routes.invite_index_path(@socket, :edit, invite) %></span>
<span><%= link "Delete", to: "#", phx_click: "delete", phx_value_id: invite.id, data: [confirm: "Are you sure?"] %></span>
</td>
</tr>
<% end %>
</tbody>
</table>
<span><%= live_patch "New Invite", to: Routes.invite_index_path(@socket, :new) %></span>

View File

@ -1,21 +0,0 @@
defmodule CanneryWeb.InviteLive.Show do
use CanneryWeb, :live_view
alias Cannery.Invites
@impl true
def mount(_params, session, socket) do
{:ok, socket |> assign_defaults(session)}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:invite, Invites.get_invite!(id))}
end
defp page_title(:show), do: "Show Invite"
defp page_title(:edit), do: "Edit Invite"
end

View File

@ -1,27 +0,0 @@
<h1>Show Invite</h1>
<%= if @live_action in [:edit] do %>
<%= live_modal CanneryWeb.InviteLive.FormComponent,
id: @invite.id,
title: @page_title,
action: @live_action,
invite: @invite,
return_to: Routes.invite_show_path(@socket, :show, @invite) %>
<% end %>
<ul>
<li>
<strong>Name:</strong>
<%= @invite.name %>
</li>
<li>
<strong>Token:</strong>
<%= @invite.token %>
</li>
</ul>
<span><%= live_patch "Edit", to: Routes.invite_show_path(@socket, :edit, @invite), class: "button" %></span>
<span><%= live_redirect "Back", to: Routes.invite_index_path(@socket, :index) %></span>

View File

@ -87,9 +87,6 @@ defmodule CanneryWeb.Router do
live "/invites", InviteLive.Index, :index live "/invites", InviteLive.Index, :index
live "/invites/new", InviteLive.Index, :new live "/invites/new", InviteLive.Index, :new
live "/invites/:id/edit", InviteLive.Index, :edit live "/invites/:id/edit", InviteLive.Index, :edit
live "/invites/:id", InviteLive.Show, :show
live "/invites/:id/show/edit", InviteLive.Show, :edit
end end
scope "/", CanneryWeb do scope "/", CanneryWeb do

View File

@ -11,6 +11,10 @@
</div> </div>
<% end %> <% end %>
<%= if @invite do %>
<%= hidden_input f, :invite_token, value: @invite.token %>
<% end %>
<div class="grid grid-cols-3 justify-center items-center text-center space-x-4"> <div class="grid grid-cols-3 justify-center items-center text-center space-x-4">
<%= label f, :email, class: "title text-lg text-primary-500" %> <%= label f, :email, class: "title text-lg text-primary-500" %>
<%= email_input f, :email, required: true, class: "input input-primary col-span-2" %> <%= email_input f, :email, required: true, class: "input input-primary col-span-2" %>