From 5d4d8285fbf96eac3b03b35ab9942d9dbb06cf28 Mon Sep 17 00:00:00 2001 From: shibao Date: Fri, 10 Sep 2021 22:22:18 -0400 Subject: [PATCH] add invite link and registration validation --- lib/cannery/invites.ex | 19 ++- lib/cannery/invites/invite.ex | 1 + .../user_registration_controller.ex | 61 +++++++++- .../live/invite_live/form_component.ex | 2 +- .../live/invite_live/form_component.html.leex | 22 +++- lib/cannery_web/live/invite_live/index.ex | 44 ++++--- .../live/invite_live/index.html.leex | 111 +++++++++++++----- lib/cannery_web/live/invite_live/show.ex | 21 ---- .../live/invite_live/show.html.leex | 27 ----- lib/cannery_web/router.ex | 3 - .../templates/user_registration/new.html.eex | 8 +- 11 files changed, 207 insertions(+), 112 deletions(-) delete mode 100644 lib/cannery_web/live/invite_live/show.ex delete mode 100644 lib/cannery_web/live/invite_live/show.html.leex diff --git a/lib/cannery/invites.ex b/lib/cannery/invites.ex index e7e4902..0b45916 100644 --- a/lib/cannery/invites.ex +++ b/lib/cannery/invites.ex @@ -50,13 +50,21 @@ defmodule Cannery.Invites do @spec get_invite_by_token(String.t()) :: Invite.t() | nil def get_invite_by_token(nil), 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 """ - 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() + def use_invite!(%Invite{uses_left: nil} = invite), do: invite + def use_invite!(%Invite{uses_left: uses_left} = invite) do new_uses_left = uses_left - 1 @@ -96,7 +104,10 @@ defmodule Cannery.Invites do attrs |> Map.merge(%{ "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() diff --git a/lib/cannery/invites/invite.ex b/lib/cannery/invites/invite.ex index 26462cd..ba45a65 100644 --- a/lib/cannery/invites/invite.ex +++ b/lib/cannery/invites/invite.ex @@ -20,6 +20,7 @@ defmodule Cannery.Invites.Invite do invite |> cast(attrs, [:name, :token, :uses_left, :disabled_at, :user_id]) |> validate_required([:name, :token, :user_id]) + |> validate_number(:uses_left, greater_than_or_equal_to: 0) end @type t :: %{ diff --git a/lib/cannery_web/controllers/user_registration_controller.ex b/lib/cannery_web/controllers/user_registration_controller.ex index 6ba56f8..f9e844e 100644 --- a/lib/cannery_web/controllers/user_registration_controller.ex +++ b/lib/cannery_web/controllers/user_registration_controller.ex @@ -1,18 +1,67 @@ defmodule CanneryWeb.UserRegistrationController do use CanneryWeb, :controller - alias Cannery.Accounts + alias Cannery.{Accounts, Invites} alias Cannery.Accounts.User alias CanneryWeb.UserAuth - def new(conn, _params) do - changeset = Accounts.change_user_registration(%User{}) - render(conn, "new.html", changeset: changeset) + def new(conn, %{"invite" => invite_token}) do + invite = Invites.get_invite_by_token(invite_token) + + 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 - 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 {:ok, user} -> + unless invite |> is_nil() do + invite |> Invites.use_invite!() + end + {:ok, _} = Accounts.deliver_user_confirmation_instructions( user, @@ -24,7 +73,7 @@ defmodule CanneryWeb.UserRegistrationController do |> UserAuth.log_in_user(user) {:error, %Ecto.Changeset{} = changeset} -> - render(conn, "new.html", changeset: changeset) + render(conn, "new.html", changeset: changeset, invite: invite) end end end diff --git a/lib/cannery_web/live/invite_live/form_component.ex b/lib/cannery_web/live/invite_live/form_component.ex index 37731e4..402b1f5 100644 --- a/lib/cannery_web/live/invite_live/form_component.ex +++ b/lib/cannery_web/live/invite_live/form_component.ex @@ -37,7 +37,7 @@ defmodule CanneryWeb.InviteLive.FormComponent do {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign(socket, :changeset, changeset)} - end + end end defp save_invite(socket, :new, invite_params) do diff --git a/lib/cannery_web/live/invite_live/form_component.html.leex b/lib/cannery_web/live/invite_live/form_component.html.leex index 46aea75..59304db 100644 --- a/lib/cannery_web/live/invite_live/form_component.html.leex +++ b/lib/cannery_web/live/invite_live/form_component.html.leex @@ -1,14 +1,28 @@ -

<%= @title %>

+

+ <%= @title %> +

<%= f = form_for @changeset, "#", id: "invite-form", + class: "grid grid-cols-3 justify-center items-center space-y-4", phx_target: @myself, phx_change: "validate", phx_submit: "save" %> <%= label f, :name, class: "title text-lg text-primary-500" %> - <%= text_input f, :name, class: "input input-primary" %> - <%= error_tag f, :name %> + <%= text_input f, :name, class: "input input-primary col-span-2" %> + + <%= error_tag f, :name %> + - <%= 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" %> + + <%= error_tag f, :uses_left %> + + +
+ <%= submit "Save", class: "btn btn-primary", + phx_disable_with: "Saving..." %> +
diff --git a/lib/cannery_web/live/invite_live/index.ex b/lib/cannery_web/live/invite_live/index.ex index 7c6c0ab..93f1bb0 100644 --- a/lib/cannery_web/live/invite_live/index.ex +++ b/lib/cannery_web/live/invite_live/index.ex @@ -1,46 +1,62 @@ defmodule CanneryWeb.InviteLive.Index do use CanneryWeb, :live_view - alias Cannery.Invites - alias Cannery.Invites.Invite + alias Cannery.{Invites} + alias Cannery.Invites.{Invite} @impl true def mount(_params, session, socket) do - {:ok, socket |> assign_defaults(session) |> assign(invites: list_invites())} + {:ok, socket |> assign_defaults(session) |> display_invites()} end @impl true 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 defp apply_action(socket, :edit, %{"id" => id}) do socket - |> assign(:page_title, "Edit Invite") - |> assign(:invite, Invites.get_invite!(id)) + |> assign(page_title: "Edit Invite", invite: Invites.get_invite!(id)) end defp apply_action(socket, :new, _params) do socket - |> assign(:page_title, "New Invite") - |> assign(:invite, %Invite{}) + |> assign(page_title: "New Invite", invite: %Invite{}) end defp apply_action(socket, :index, _params) do socket - |> assign(:page_title, "Listing Invites") - |> assign(:invite, nil) + |> assign(page_title: "Listing Invites", invite: nil) end @impl true def handle_event("delete", %{"id" => id}, socket) do invite = Invites.get_invite!(id) {:ok, _} = Invites.delete_invite(invite) - - {:noreply, assign(socket, :invites, list_invites())} + {:noreply, socket |> display_invites()} end - defp list_invites do - Invites.list_invites() + def handle_event("set_unlimited", %{"id" => id}, socket) do + 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 diff --git a/lib/cannery_web/live/invite_live/index.html.leex b/lib/cannery_web/live/invite_live/index.html.leex index d130327..0b00a3b 100644 --- a/lib/cannery_web/live/invite_live/index.html.leex +++ b/lib/cannery_web/live/invite_live/index.html.leex @@ -1,4 +1,82 @@ -

Listing Invites

+
+

+ Listing Invites +

+ + <%= if @invites |> Enum.empty?() do %> +
+

+ No invites 😔 +

+ + <%= live_patch to: Routes.invite_index_path(@socket, :new), + class: "btn btn-primary" do %> + Invite someone new! + <% end %> +
+ <% else %> + <%= live_patch to: Routes.invite_index_path(@socket, :new), + class: "btn btn-primary" do %> + Invite + <% end %> + <% end %> + +
+ <%= for invite <- @invites do %> +
+

+ <%= invite.name %> +

+ + <%= if invite.disabled_at |> is_nil() do %> +

+ Uses Left: <%= invite.uses_left || "Unlimited" %> +

+ <% else %> +

+ Invite Disabled +

+ <% end %> + + + <%= Routes.user_registration_url(@socket, :new, invite: invite.token) %> + + +
+ <%= 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 %> + + Disable + + <% else %> + + Enable + + <% end %> + + <%= if invite.disabled_at |> is_nil() and not(invite.uses_left |> is_nil()) do %> + + Set Unlimited + + <% end %> +
+
+ <% end %> +
+
<%= if @live_action in [:new, :edit] do %> <%= live_modal CanneryWeb.InviteLive.FormComponent, @@ -6,33 +84,6 @@ title: @page_title, action: @live_action, invite: @invite, - return_to: Routes.invite_index_path(@socket, :index) %> + return_to: Routes.invite_index_path(@socket, :index), + current_user: @current_user %> <% end %> - - - - - - - - - - - - <%= for invite <- @invites do %> - - - - - - - - <% end %> - -
NameTokenUses left
<%= invite.name %><%= invite.token %><%= invite.uses_left || "Unlimited" %> - <%= live_redirect "Show", to: Routes.invite_show_path(@socket, :show, invite) %> - <%= live_patch "Edit", to: Routes.invite_index_path(@socket, :edit, invite) %> - <%= link "Delete", to: "#", phx_click: "delete", phx_value_id: invite.id, data: [confirm: "Are you sure?"] %> -
- -<%= live_patch "New Invite", to: Routes.invite_index_path(@socket, :new) %> diff --git a/lib/cannery_web/live/invite_live/show.ex b/lib/cannery_web/live/invite_live/show.ex deleted file mode 100644 index 5e3150c..0000000 --- a/lib/cannery_web/live/invite_live/show.ex +++ /dev/null @@ -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 diff --git a/lib/cannery_web/live/invite_live/show.html.leex b/lib/cannery_web/live/invite_live/show.html.leex deleted file mode 100644 index 2944516..0000000 --- a/lib/cannery_web/live/invite_live/show.html.leex +++ /dev/null @@ -1,27 +0,0 @@ -

Show Invite

- -<%= 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 %> - - - -<%= live_patch "Edit", to: Routes.invite_show_path(@socket, :edit, @invite), class: "button" %> -<%= live_redirect "Back", to: Routes.invite_index_path(@socket, :index) %> diff --git a/lib/cannery_web/router.ex b/lib/cannery_web/router.ex index a14aa2b..43dfbab 100644 --- a/lib/cannery_web/router.ex +++ b/lib/cannery_web/router.ex @@ -87,9 +87,6 @@ defmodule CanneryWeb.Router do live "/invites", InviteLive.Index, :index live "/invites/new", InviteLive.Index, :new live "/invites/:id/edit", InviteLive.Index, :edit - - live "/invites/:id", InviteLive.Show, :show - live "/invites/:id/show/edit", InviteLive.Show, :edit end scope "/", CanneryWeb do diff --git a/lib/cannery_web/templates/user_registration/new.html.eex b/lib/cannery_web/templates/user_registration/new.html.eex index 4718a19..f3ee7ec 100644 --- a/lib/cannery_web/templates/user_registration/new.html.eex +++ b/lib/cannery_web/templates/user_registration/new.html.eex @@ -11,6 +11,10 @@ <% end %> + <%= if @invite do %> + <%= hidden_input f, :invite_token, value: @invite.token %> + <% end %> +
<%= label f, :email, class: "title text-lg text-primary-500" %> <%= email_input f, :email, required: true, class: "input input-primary col-span-2" %> @@ -24,7 +28,7 @@ <%= error_tag f, :password %> <%= submit "Register", class: "btn btn-primary" %> - +
@@ -34,4 +38,4 @@ class: "btn btn-primary" %>
<% end %> -
\ No newline at end of file +