feat: UI refresh. (#3)

* Redesign the overrides system to be easier to reason about.
* Redesign the password UI to be fancier.
This commit is contained in:
James Harton 2022-10-28 20:13:48 +13:00 committed by GitHub
parent a47204aec2
commit e25a8bf397
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 602 additions and 408 deletions

View file

@ -1,19 +1,24 @@
# Used by "mix format"
[
import_deps: [:ash, :ash_authentication, :phoenix],
inputs: ["{mix,.formatter}.exs", "{dev,config,lib,test}/**/*.{ex,exs}"],
export: [
locals_without_parens: [
sign_in_route: 0,
sign_in_route: 1,
sign_in_route: 2,
sign_in_route: 3,
sign_out_route: 1,
sign_out_route: 2,
sign_out_route: 3,
auth_routes: 1,
auth_routes: 2,
auth_routes: 3
]
]
locals_without_parens = [
sign_in_route: 0,
sign_in_route: 1,
sign_in_route: 2,
sign_in_route: 3,
sign_out_route: 1,
sign_out_route: 2,
sign_out_route: 3,
auth_routes: 1,
auth_routes: 2,
auth_routes: 3,
set: 2
]
[
import_deps: [:ash, :ash_authentication, :phoenix, :phoenix_live_view],
inputs: ["{mix,.formatter}.exs", "{dev,config,lib,test}/**/*.{heex,ex,exs}"],
locals_without_parens: locals_without_parens,
export: [
locals_without_parens: locals_without_parens
],
plugins: [Phoenix.LiveView.HTMLFormatter]
]

View file

@ -1,10 +1,9 @@
<h2>Sorry, no diggity</h2>
<p>
Reason:
<pre>
Reason: <pre>
<%= inspect @failure_reason %>
</pre>
</p>
<%= link "Home", to: Routes.page_path(@conn, :index) %>
<%= link("Home", to: Routes.page_path(@conn, :index)) %>

View file

@ -1,3 +1,3 @@
<h2>Signed out</h2>
<%= link "Home", to: Routes.page_path(@conn, :index) %>
<%= link("Home", to: Routes.page_path(@conn, :index)) %>

View file

@ -1,3 +1 @@
Welcome back <%= @current_user.email %>.
<%= link "Home", to: Routes.page_path(@conn, :index) %>
Welcome back <%= @current_user.email %>. <%= link("Home", to: Routes.page_path(@conn, :index)) %>

View file

@ -1,11 +1,11 @@
<main class="container">
<p class="alert alert-info" role="alert"
phx-click="lv:clear-flash"
phx-value-key="info"><%= live_flash(@flash, :info) %></p>
<p class="alert alert-info" role="alert" phx-click="lv:clear-flash" phx-value-key="info">
<%= live_flash(@flash, :info) %>
</p>
<p class="alert alert-danger" role="alert"
phx-click="lv:clear-flash"
phx-value-key="error"><%= live_flash(@flash, :error) %></p>
<p class="alert alert-danger" role="alert" phx-click="lv:clear-flash" phx-value-key="error">
<%= live_flash(@flash, :error) %>
</p>
<%= @inner_content %>
</main>

View file

@ -1,12 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<%= csrf_meta_tag() %>
<%= live_title_tag assigns[:page_title] || "Dev" %>
<script defer src="https://cdn.tailwindcss.com"></script>
<%= live_title_tag(assigns[:page_title] || "Dev") %>
<script defer src="https://cdn.tailwindcss.com">
</script>
<script type="module" crossorigin>
import 'https://cdn.jsdelivr.net/gh/phoenixframework/phoenix_html@v3.2.0/priv/static/phoenix_html.js';
import { Socket } from 'https://cdn.jsdelivr.net/gh/phoenixframework/phoenix@v1.6.14/priv/static/phoenix.mjs';

View file

@ -6,4 +6,4 @@
<h2>Please sign in</h2>
<%= link("Sign in", to: Routes.auth_path(@conn, :sign_in)) %>
<% end %>
<% end %>

View file

@ -0,0 +1,44 @@
defmodule AshAuthentication.Phoenix.Components.Banner do
use AshAuthentication.Phoenix.Overrides.Overridable,
root_class: "CSS class for the root `div` element.",
href_class: "CSS class for the `a` tag.",
href_url: "A URL for the banner image to link to.",
image_class: "CSS class for the `img` tag.",
image_url: "A URL for the `img` `src` attribute.",
text_class: "CSS class for the text `div`.",
text: "Banner text"
@moduledoc """
Renders a very simple banner at the top of the sign-in component.
#{AshAuthentication.Phoenix.Overrides.Overridable.generate_docs()}
"""
use Phoenix.LiveComponent
alias Phoenix.LiveView.{Rendered, Socket}
@doc false
@impl true
@spec render(Socket.assigns()) :: Rendered.t() | no_return
def render(assigns) do
~H"""
<div class={override_for(@socket, :root_class)}>
<%= case {override_for(@socket, :href_url), override_for(@socket, :image_url)} do %>
<% {nil, img} -> %>
<img class={override_for(@socket, :image_class)} src={img} />
<% {hrf, img} -> %>
<a class={override_for(@socket, :href_class)} href={hrf}>
<img class={override_for(@socket, :image_class)} src={img} />
</a>
<% end %>
<%= case override_for(@socket, :text) do %>
<% text when is_binary(text) -> %>
<div class={override_for(@socket, :text_class)}>
<%= text %>
</div>
<% _ -> %>
<% end %>
</div>
"""
end
end

View file

@ -2,7 +2,7 @@ defmodule AshAuthentication.Phoenix.Components.Helpers do
@moduledoc """
Helpers which are commonly needed inside the various components.
"""
alias AshAuthentication.Phoenix.Overrides
alias Phoenix.LiveView.Socket
@doc """
@ -23,21 +23,4 @@ defmodule AshAuthentication.Phoenix.Components.Helpers do
def route_helpers(socket) do
Module.concat(socket.router, Helpers)
end
@doc """
Find the override for a specific element.
Uses `otp_app_from_socket/1` to find the OTP application name and then extract
the configuration from the application environment.
"""
@spec override_for(Socket.t(), atom) :: nil | String.t()
def override_for(socket, name) do
module =
socket
|> otp_app_from_socket()
|> Application.get_env(AshAuthentication.Phoenix, [])
|> Keyword.get(:override_module, AshAuthentication.Phoenix.Overrides.Default)
apply(module, name, [])
end
end

View file

@ -0,0 +1,39 @@
defmodule AshAuthentication.Phoenix.Components.HorizontalRule do
use AshAuthentication.Phoenix.Overrides.Overridable,
root_class: "CSS class for the root `div` element.",
hr_outer_class: "CSS class for the outer `div` element of the horizontal rule.",
hr_inner_class: "CSS class for the inner `div` element of the horizontal rule.",
text_outer_class: "CSS class for the outer `div` element of the text area.",
text_inner_class: "CSS class for the inner `div` element of the text area.",
text: "Text to display in front of the horizontal rule."
@moduledoc """
A horizontal rule with text.
This component is pretty tailwind-specific, but I (@jimsynz) really wanted a
certain look. If you think I'm wrong then please let me know.
#{AshAuthentication.Phoenix.Overrides.Overridable.generate_docs()}
"""
use Phoenix.LiveComponent
alias Phoenix.LiveView.{Rendered, Socket}
@doc false
@impl true
@spec render(Socket.assigns()) :: Rendered.t() | no_return
def render(assigns) do
~H"""
<div class={override_for(@socket, :root_class)}>
<div class={override_for(@socket, :hr_outer_class)} aria-hidden="true">
<div class={override_for(@socket, :hr_inner_class)}></div>
</div>
<div class={override_for(@socket, :text_outer_class)}>
<span class={override_for(@socket, :text_inner_class)}>
<%= override_for(@socket, :text) %>
</span>
</div>
</div>
"""
end
end

View file

@ -1,5 +1,13 @@
defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication do
@default_debounce 750
use AshAuthentication.Phoenix.Overrides.Overridable,
root_class: "CSS class for the root `div` element.",
hide_class: "CSS class to apply to hide an element.",
show_first: "The form to show on first load. Either `:sign_in` or `:register`.",
interstitial_class: "CSS class for the `div` element between the form and the button.",
sign_in_toggle_text: "Toggle text to display when the sign in form is showing.",
register_toggle_text: "Toggle text to display when the register form is showing.",
toggler_class: "CSS class for the toggler `a` element."
@moduledoc """
Generates sign in and registration forms for a resource.
@ -17,62 +25,94 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication do
* `config` - The configuration man as per
`AshAuthentication.authenticated_resources/1`.
Required.
* `debounce` - The number of milliseconds to wait before firing a change
event to prevent too many events being fired to the server.
Defaults to `#{@default_debounce}`.
* `show_forms` - Explicitly enable/disable a specific form.
A list containing `:sign_in`, `:register` or both.
* `spacer` - A string containing text to display in the spacer element.
Defaults to `"or"`.
Set to `false` to disable.
Also disabled if `show_forms` does not contain both forms.
## Overrides
See `AshAuthentication.Phoenix.Overrides` for more information.
* `password_authentication_box_css_class` - applied to the root `div` element of this component.
* `password_authentication_box_spacer_css_class` - applied to the spacer element, if enabled.
#{AshAuthentication.Phoenix.Overrides.Overridable.generate_docs()}
"""
use Phoenix.LiveComponent
alias __MODULE__
alias AshAuthentication.PasswordAuthentication.Info
alias Phoenix.LiveView.Rendered
import AshAuthentication.Phoenix.Components.Helpers
@type props :: %{
required(:config) => AshAuthentication.resource_config(),
optional(:debounce) => millis :: pos_integer(),
optional(:spacer) => String.t() | false,
optional(:show_forms) => [:sign_in | :register]
}
alias Phoenix.LiveView.{JS, Rendered, Socket}
@doc false
@spec render(props) :: Rendered.t() | no_return
@spec render(Socket.assigns()) :: Rendered.t() | no_return
def render(assigns) do
config = assigns.config
provider = assigns.provider
sign_in_action = Info.sign_in_action_name!(assigns.config.resource)
register_action = Info.register_action_name!(assigns.config.resource)
assigns =
assigns
|> assign(:sign_in_action, Info.sign_in_action_name!(assigns.config.resource))
|> assign(:register_action, Info.register_action_name!(assigns.config.resource))
|> assign_new(:debounce, fn -> @default_debounce end)
|> assign_new(:spacer, fn -> "or" end)
|> assign_new(:show_forms, fn -> [:sign_in, :register] end)
|> assign(:sign_in_action, sign_in_action)
|> assign_new(:sign_in_id, fn ->
"#{config.subject_name}_#{provider.provides}_#{sign_in_action}"
end)
|> assign(:register_action, register_action)
|> assign_new(:register_id, fn ->
"#{config.subject_name}_#{provider.provides}_#{register_action}"
end)
|> assign_new(:show_first, fn ->
override_for(assigns.socket, :show_first, :sign_in)
end)
|> assign_new(:hide_class, fn ->
override_for(assigns.socket, :hide_class)
end)
~H"""
<div class={override_for(@socket, :password_authentication_box_css_class)}>
<%= if :sign_in in @show_forms do %>
<.live_component module={PasswordAuthentication.SignInForm} id={"#{@config.subject_name}_#{@provider.provides}_#{@sign_in_action}"} provider={@provider} config={@config} debounce={@debounce} />
<% end %>
<%= if length(@show_forms) > 1 && @spacer do %>
<div class={override_for(@socket, :password_authentication_box_spacer_css_class)}>
<%= @spacer %>
</div>
<% end %>
<%= if :register in @show_forms do %>
<.live_component module={PasswordAuthentication.RegisterForm} id={"#{@config.subject_name}_#{@provider.provides}_#{@register_action}"} provider={@provider} config={@config} debounce={@debounce} />
<% end %>
<div class={override_for(@socket, :root_class)}>
<div id={"#{@sign_in_id}-wrapper"} class={unless @show_first == :sign_in, do: @hide_class}>
<.live_component
module={PasswordAuthentication.SignInForm}
id={@sign_in_id}
provider={@provider}
config={@config}
label={false}
>
<div class={override_for(@socket, :interstitial_class)}>
<.toggler
socket={@socket}
register_id={@register_id}
sign_in_id={@sign_in_id}
message={override_for(@socket, :sign_in_toggle_text)}
/>
</div>
</.live_component>
</div>
<div id={"#{@register_id}-wrapper"} class={unless @show_first == :register, do: @hide_class}>
<.live_component
module={PasswordAuthentication.RegisterForm}
id={@register_id}
provider={@provider}
config={@config}
label={false}
>
<div class={override_for(@socket, :interstitial_class)}>
<.toggler
socket={@socket}
register_id={@register_id}
sign_in_id={@sign_in_id}
message={override_for(@socket, :register_toggle_text)}
/>
</div>
</.live_component>
</div>
</div>
"""
end
def toggler(assigns) do
~H"""
<a
href="#"
phx-click={
JS.toggle(to: "##{@register_id}-wrapper")
|> JS.toggle(to: "##{@sign_in_id}-wrapper")
}
class={override_for(@socket, :toggler_class)}
>
<%= @message %>
</a>
"""
end
end

View file

@ -1,4 +1,14 @@
defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.Input do
use AshAuthentication.Phoenix.Overrides.Overridable,
field_class: "CSS class for `div` elements surrounding the fields.",
label_class: "CSS class for `label` elements.",
input_class: "CSS class for text/password `input` elements.",
input_class_with_error:
"CSS class for text/password `input` elements when there is a validation error.",
submit_class: "CSS class for the form submit `input` element.",
error_ul: "CSS class for the `ul` element on error lists.",
error_li: "CSS class for the `li` elements on error lists."
@moduledoc """
Function components for dealing with form input during password authentication.
@ -7,6 +17,8 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.Input do
These function components are consumed by
`AshAuthentication.Phoenix.Components.PasswordAuthentication.SignInForm` and
`AshAuthentication.Phoenix.Components.PasswordAuthentication.RegisterForm`.
#{AshAuthentication.Phoenix.Overrides.Overridable.generate_docs()}
"""
use Phoenix.Component
@ -14,7 +26,6 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.Input do
alias AshPhoenix.Form
alias Phoenix.LiveView.{Rendered, Socket}
import Phoenix.HTML.Form
import AshAuthentication.Phoenix.Components.Helpers
@doc """
Generate a form field for the configured identity field.
@ -31,15 +42,6 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.Input do
Required.
* `input_type` - Either `:text` or `:email`.
If not set it will try and guess based on the name of the identity field.
## Overrides
See `AshAuthentication.Phoenix.Overrides` for more information.
* `password_authentication_form_input_surround_css_class` - applied to the div surrounding the `label` and
`input` elements.
* `password_authentication_form_label_css_class` - applied to the `label` tag.
* `password_authentication_form_text_input_css_class` - applied to the `input` tag.
"""
@spec identity_field(%{
required(:socket) => Socket.t(),
@ -62,11 +64,18 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.Input do
_ -> :text
end)
end)
|> assign_new(:input_class, fn ->
if has_error?(assigns.form, identity_field) do
override_for(assigns.socket, :input_class_with_error)
else
override_for(assigns.socket, :input_class)
end
end)
~H"""
<div class={override_for(@socket, :password_authentication_form_input_surround_css_class)}>
<%= label @form, @identity_field, class: override_for(@socket, :password_authentication_form_label_css_class) %>
<%= text_input @form, @identity_field, type: to_string(@input_type), class: override_for(@socket, :password_authentication_form_text_input_css_class) %>
<div class={override_for(@socket, :field_class)}>
<%= label(@form, @identity_field, class: override_for(@socket, :label_class)) %>
<%= text_input(@form, @identity_field, type: to_string(@input_type), class: @input_class) %>
<.error socket={@socket} form={@form} field={@identity_field} />
</div>
"""
@ -85,17 +94,6 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.Input do
Required.
* `form` - An `AshPhoenix.Form`.
Required.
## Overrides
See `AshAuthentication.Phoenix.Overrides` for more information.
* `password_authentication_form_input_surround_css_class` - applied to the div surrounding the `label` and
`input` elements.
* `password_authentication_form_label_css_class` - applied to the `label` tag.
* `password_authentication_form_text_input_css_class` - applied to the `input` tag.
"""
@spec password_field(%{
required(:socket) => Socket.t(),
@ -103,14 +101,26 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.Input do
required(:form) => AshPhoenix.Form.t()
}) :: Rendered.t() | no_return
def password_field(assigns) do
password_field = Info.password_field!(assigns.config.resource)
assigns =
assigns
|> assign(:password_field, Info.password_field!(assigns.config.resource))
|> assign(:password_field, password_field)
|> assign_new(:input_class, fn ->
if has_error?(assigns.form, password_field) do
override_for(assigns.socket, :input_class_with_error)
else
override_for(assigns.socket, :input_class)
end
end)
~H"""
<div class={override_for(@socket, :password_authentication_form_input_surround_css_class)}>
<%= label @form, @password_field, class: override_for(@socket, :password_authentication_form_label_css_class) %>
<%= password_input @form, @password_field, class: override_for(@socket, :password_authentication_form_text_input_css_class), value: input_value(@form, @password_field) %>
<div class={override_for(@socket, :field_class)}>
<%= label(@form, @password_field, class: override_for(@socket, :label_class)) %>
<%= password_input(@form, @password_field,
class: @input_class,
value: input_value(@form, @password_field)
) %>
<.error socket={@socket} form={@form} field={@password_field} />
</div>
"""
@ -129,16 +139,6 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.Input do
Required.
* `form` - An `AshPhoenix.Form`.
Required.
## Overrides
See `AshAuthentication.Phoenix.Overrides` for more information.
* `password_authentication_form_input_surround_css_class` - applied to the div surrounding the `label` and
`input` elements.
* `password_authentication_form_label_css_class` - applied to the `label` tag.
* `password_authentication_form_text_input_css_class` - applied to the `input` tag.
"""
@spec password_confirmation_field(%{
required(:socket) => Socket.t(),
@ -146,17 +146,26 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.Input do
required(:form) => AshPhoenix.Form.t()
}) :: Rendered.t() | no_return
def password_confirmation_field(assigns) do
password_confirmation_field = Info.password_confirmation_field!(assigns.config.resource)
assigns =
assigns
|> assign(
:password_confirmation_field,
Info.password_confirmation_field!(assigns.config.resource)
)
|> assign(:password_confirmation_field, password_confirmation_field)
|> assign_new(:input_class, fn ->
if has_error?(assigns.form, password_confirmation_field) do
override_for(assigns.socket, :input_class_with_error)
else
override_for(assigns.socket, :input_class)
end
end)
~H"""
<div class={override_for(@socket, :password_authentication_form_input_surround_css_class)}>
<%= label @form, @password_confirmation_field, class: override_for(@socket, :password_authentication_form_label_css_class) %>
<%= password_input @form, @password_confirmation_field, class: override_for(@socket, :password_authentication_form_text_input_css_class), value: input_value(@form, @password_confirmation_field) %>
<div class={override_for(@socket, :field_class)}>
<%= label(@form, @password_confirmation_field, class: override_for(@socket, :label_class)) %>
<%= password_input(@form, @password_confirmation_field,
class: @input_class,
value: input_value(@form, @password_confirmation_field)
) %>
<.error socket={@socket} form={@form} field={@password_confirmation_field} />
</div>
"""
@ -180,12 +189,6 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.Input do
* `label` - The text to show in the submit label.
Generated from the configured action name (via
`Phoenix.HTML.Form.humanize/1`) if not supplied.
## Overrides
See `AshAuthentication.Phoenix.Overrides` for more information.
* `password_authentication_form_submit_css_class` - applied to the `button` element.
"""
@spec submit(%{
required(:socket) => Socket.t(),
@ -211,7 +214,7 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.Input do
end)
~H"""
<%= submit @label, class: override_for(@socket, :password_authentication_form_submit_css_class) %>
<%= submit(@label, class: override_for(@socket, :submit_class)) %>
"""
end
@ -227,13 +230,6 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.Input do
Required.
* `field` - The field for which to retrieve the errors.
Required.
## Overrides
See `AshAuthentication.Phoenix.Overrides` for more information.
* `password_authentication_form_error_ul_css_class` - applied to the `ul` element.
* `password_authentication_form_error_li_css_class` - applied to the `li` element.
"""
@spec error(%{
required(:socket) => Socket.t(),
@ -254,9 +250,9 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.Input do
~H"""
<%= if Enum.any?(@errors) do %>
<ul class={override_for(@socket, :password_authentication_form_error_ul_css_class)}>
<ul class={override_for(@socket, :error_ul)}>
<%= for error <- @errors do %>
<li class={override_for(@socket, :password_authentication_form_error_li_css_class)} phx-feedback-for={input_name(@form, @field)}>
<li class={override_for(@socket, :error_li)} phx-feedback-for={input_name(@form, @field)}>
<%= @field_label %> <%= error %>
</li>
<% end %>
@ -264,4 +260,10 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.Input do
<% end %>
"""
end
defp has_error?(form, field) do
form
|> Form.errors()
|> Keyword.has_key?(field)
end
end

View file

@ -1,5 +1,9 @@
defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.RegisterForm do
@default_debounce 750
use AshAuthentication.Phoenix.Overrides.Overridable,
root_class: "CSS class for the root `div` element.",
label_class: "CSS class for the `h2` element.",
form_class: "CSS class for the `form` element.",
slot_class: "CSS class for the `div` surrounding the slot."
@moduledoc """
Generates a default registration form.
@ -24,16 +28,8 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.RegisterFo
Generated from the configured action name (via
`Phoenix.HTML.Form.humanize/1`) if not supplied.
Set to `false` to disable.
* `debounce` - The number of milliseconds to wait before firing a change
event to prevent too many events being fired to the server.
Defaults to `#{@default_debounce}`.
## Overrides
See `AshAuthentication.Phoenix.Overrides` for more information.
* `password_authentication_form_h2_css_class` - applied to the `h2` element used to render the label.
* `password_authentication_form_css_class` - applied to the `form` element.
#{AshAuthentication.Phoenix.Overrides.Overridable.generate_docs()}
"""
use Phoenix.LiveComponent
@ -44,16 +40,9 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.RegisterFo
import Phoenix.HTML.Form
import AshAuthentication.Phoenix.Components.Helpers
@type props :: %{
required(:socket) => Socket.t(),
required(:config) => AshAuthentication.resource_config(),
optional(:label) => String.t() | false,
optional(:debounce) => millis :: pos_integer()
}
@doc false
@impl true
@spec update(props, Socket.t()) :: {:ok, Socket.t()}
@spec update(Socket.assigns(), Socket.t()) :: {:ok, Socket.t()}
def update(assigns, socket) do
config = assigns.config
action = Info.register_action_name!(config.resource)
@ -73,43 +62,65 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.RegisterFo
|> assign(assigns)
|> assign(form: form, trigger_action: false, confirm?: confirm?)
|> assign_new(:label, fn -> humanize(action) end)
|> assign_new(:debounce, fn -> @default_debounce end)
|> assign_new(:inner_block, fn -> nil end)
{:ok, socket}
end
@doc false
@impl true
@spec render(props) :: Rendered.t() | no_return
@spec render(Socket.assigns()) :: Rendered.t() | no_return
def render(assigns) do
~H"""
<div>
<div class={override_for(@socket, :root_class)}>
<%= if @label do %>
<h2 class={override_for(@socket, :password_authentication_form_h2_css_class)}><%= @label %></h2>
<h2 class={override_for(@socket, :label_class)}>
<%= @label %>
</h2>
<% end %>
<.form
:let={f}
for={@form}
phx-change="change"
phx-submit="submit"
phx-trigger-action={@trigger_action}
phx-target={@myself}
phx-debounce={@debounce}
action={route_helpers(@socket).auth_callback_path(@socket.endpoint, :callback, @config.subject_name, @provider.provides)}
method="POST"
class={override_for(@socket, :password_authentication_form_css_class)}>
:let={form}
for={@form}
phx-submit="submit"
phx-trigger-action={@trigger_action}
phx-target={@myself}
action={
route_helpers(@socket).auth_callback_path(
@socket.endpoint,
:callback,
@config.subject_name,
@provider.provides
)
}
method="POST"
class={override_for(@socket, :form_class)}
>
<%= hidden_input(form, :action, value: "register") %>
<%= hidden_input f, :action, value: "register" %>
<PasswordAuthentication.Input.identity_field socket={@socket} config={@config} form={f} />
<PasswordAuthentication.Input.password_field socket={@socket} config={@config} form={f} />
<PasswordAuthentication.Input.identity_field socket={@socket} config={@config} form={form} />
<PasswordAuthentication.Input.password_field socket={@socket} config={@config} form={form} />
<%= if @confirm? do %>
<PasswordAuthentication.Input.password_confirmation_field socket={@socket} config={@config} form={f} />
<PasswordAuthentication.Input.password_confirmation_field
socket={@socket}
config={@config}
form={form}
/>
<% end %>
<PasswordAuthentication.Input.submit socket={@socket} config={@config} form={f} action={:register}/>
<%= if @inner_block do %>
<div class={override_for(@socket, :slot_class)}>
<%= render_slot(@inner_block) %>
</div>
<% end %>
<PasswordAuthentication.Input.submit
socket={@socket}
config={@config}
form={form}
action={:register}
/>
</.form>
</div>
"""
@ -119,20 +130,6 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.RegisterFo
@impl true
@spec handle_event(String.t(), %{required(String.t()) => String.t()}, Socket.t()) ::
{:noreply, Socket.t()}
def handle_event("change", params, socket) do
params = Map.get(params, to_string(socket.assigns.config.subject_name))
form =
socket.assigns.form
|> Form.validate(params)
socket =
socket
|> assign(:form, form)
{:noreply, socket}
end
def handle_event("submit", params, socket) do
params = Map.get(params, to_string(socket.assigns.config.subject_name))

View file

@ -1,5 +1,9 @@
defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.SignInForm do
@default_debounce 750
use AshAuthentication.Phoenix.Overrides.Overridable,
root_class: "CSS class for the root `div` element.",
label_class: "CSS class for the `h2` element.",
form_class: "CSS class for the `form` element.",
slot_class: "CSS class for the `div` surrounding the slot."
@moduledoc """
Generates a default sign in form.
@ -26,16 +30,8 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.SignInForm
Generated from the configured action name (via
`Phoenix.HTML.Form.humanize/1`) if not supplied.
Set to `false` to disable.
* `debounce` - The number of milliseconds to wait before firing a change
event to prevent too many events being fired to the server.
Defaults to `#{@default_debounce}`.
## Overrides
See `AshAuthentication.Phoenix.Overrides` for more information.
* `password_authentication_form_h2_css_class` - applied to the `h2` element used to render the label.
* `password_authentication_form_css_class` - applied to the `form` element.
#{AshAuthentication.Phoenix.Overrides.Overridable.generate_docs()}
"""
use Phoenix.LiveComponent
@ -43,14 +39,13 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.SignInForm
alias AshAuthentication.Phoenix.Components.PasswordAuthentication
alias AshPhoenix.Form
alias Phoenix.LiveView.{Rendered, Socket}
import AshAuthentication.Phoenix.Components.Helpers, only: [route_helpers: 1]
import Phoenix.HTML.Form
import AshAuthentication.Phoenix.Components.Helpers
@type props :: %{
required(:socket) => Socket.t(),
required(:config) => AshAuthentication.resource_config(),
optional(:label) => String.t() | false,
optional(:debounce) => millis :: pos_integer()
optional(:label) => String.t() | false
}
@doc false
@ -74,35 +69,55 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.SignInForm
|> assign(assigns)
|> assign(form: form, trigger_action: false)
|> assign_new(:label, fn -> humanize(action) end)
|> assign_new(:debounce, fn -> @default_debounce end)
|> assign_new(:inner_block, fn -> nil end)
{:ok, socket}
end
@doc false
@impl true
@spec render(props) :: Rendered.t() | no_return
@spec render(Socket.assigns()) :: Rendered.t() | no_return
def render(assigns) do
~H"""
<div>
<h2 class={override_for(@socket, :password_authentication_form_h2_css_class)}><%= @label %></h2>
<div class={override_for(@socket, :root_class)}>
<%= if @label do %>
<h2 class={override_for(@socket, :label_class)}><%= @label %></h2>
<% end %>
<.form :let={f}
for={@form}
phx-change="change"
phx-submit="submit"
phx-trigger-action={@trigger_action}
phx-target={@myself}
phx-debounce={@debounce}
action={route_helpers(@socket).auth_callback_path(@socket.endpoint, :callback, @config.subject_name, @provider.provides())}
method="POST"
class={override_for(@socket, :password_authentication_form_css_class)}>
<.form
:let={form}
for={@form}
phx-submit="submit"
phx-trigger-action={@trigger_action}
phx-target={@myself}
action={
route_helpers(@socket).auth_callback_path(
@socket.endpoint,
:callback,
@config.subject_name,
@provider.provides()
)
}
method="POST"
class={override_for(@socket, :form_class)}
>
<%= hidden_input(form, :action, value: "sign_in") %>
<%= hidden_input f, :action, value: "sign_in" %>
<PasswordAuthentication.Input.identity_field socket={@socket} config={@config} form={form} />
<PasswordAuthentication.Input.password_field socket={@socket} config={@config} form={form} />
<PasswordAuthentication.Input.identity_field socket={@socket} config={@config} form={f} />
<PasswordAuthentication.Input.password_field socket={@socket} config={@config} form={f} />
<PasswordAuthentication.Input.submit socket={@socket} config={@config} form={f} action={:sign_in}/>
<%= if @inner_block do %>
<div class={override_for(@socket, :slot_class)}>
<%= render_slot(@inner_block) %>
</div>
<% end %>
<PasswordAuthentication.Input.submit
socket={@socket}
config={@config}
form={form}
action={:sign_in}
/>
</.form>
</div>
"""
@ -112,17 +127,6 @@ defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.SignInForm
@impl true
@spec handle_event(String.t(), %{required(String.t()) => String.t()}, Socket.t()) ::
{:noreply, Socket.t()}
def handle_event("change", params, socket) do
params = Map.get(params, to_string(socket.assigns.config.subject_name))
form = Form.validate(socket.assigns.form, params)
socket =
socket
|> assign(:form, form)
{:noreply, socket}
end
def handle_event("submit", params, socket) do
params = Map.get(params, to_string(socket.assigns.config.subject_name))
form = Form.validate(socket.assigns.form, params)

View file

@ -1,4 +1,8 @@
defmodule AshAuthentication.Phoenix.Components.SignIn do
use AshAuthentication.Phoenix.Overrides.Overridable,
root_class: "CSS class for the root `div` element.",
provider_class: "CSS class for a `div` surrounding each provider component."
@moduledoc """
Renders sign in mark-up for an authenticated resource.
@ -23,30 +27,54 @@ defmodule AshAuthentication.Phoenix.Components.SignIn do
* `config` - The configuration man as per
`AshAuthentication.authenticated_resources/1`. Required.
## Overrides
See `AshAuthentication.Phoenix.Overrides` for more information.
* `sign_in_box_css_class` - applied to the root `div` element of this component.
* `sign_in_row_css_class` - applied to the spacer element, if enabled.
#{AshAuthentication.Phoenix.Overrides.Overridable.generate_docs()}
"""
use Phoenix.LiveComponent
alias AshAuthentication.Phoenix.Components
alias Phoenix.LiveView.Rendered
alias Phoenix.LiveView.{Rendered, Socket}
import AshAuthentication.Phoenix.Components.Helpers
@type props :: %{required(:config) => AshAuthentication.resource_config()}
@doc false
@impl true
@spec update(Socket.assigns(), Socket.t()) :: {:ok, Socket.t()}
def update(assigns, socket) do
resources =
socket
|> otp_app_from_socket()
|> AshAuthentication.authenticated_resources()
|> Enum.group_by(& &1.subject_name)
|> Enum.sort_by(&elem(&1, 0))
socket =
socket
|> assign(assigns)
|> assign(:resources, resources)
{:ok, socket}
end
@doc false
@spec render(props) :: Rendered.t() | no_return
@impl true
@spec render(Socket.assigns()) :: Rendered.t() | no_return
def render(assigns) do
~H"""
<div class={override_for(@socket, :sign_in_box_css_class)}>
<%= for provider <- @config.providers do %>
<div class={override_for(@socket, :sign_in_row_css_class)}>
<.live_component module={component_for_provider(provider)} id={provider_id(provider, @config)} provider={provider} config={@config} />
</div>
<div class={override_for(@socket, :root_class)}>
<.live_component module={Components.Banner} socket={@socket} id="sign-in-banner" />
<%= for {_subject_name, configs} <- @resources do %>
<%= for config <- configs do %>
<%= for provider <- config.providers do %>
<div class={override_for(@socket, :provider_class)}>
<.live_component
module={component_for_provider(provider)}
id={provider_id(provider, config)}
provider={provider}
config={config}
/>
</div>
<% end %>
<% end %>
<% end %>
</div>
"""
@ -60,6 +88,6 @@ defmodule AshAuthentication.Phoenix.Components.SignIn do
|> then(&Module.concat(Components, &1))
defp provider_id(provider, config) do
"sign-in-#{config.subject_name}-#{provider.provides()}"
"sign-in-#{config.subject_name}-with-#{provider.provides()}"
end
end

View file

@ -1,27 +1,4 @@
defmodule AshAuthentication.Phoenix.Overrides do
@configurables [
password_authentication_form_label_css_class: "CSS classes for generated `label` tags",
password_authentication_form_text_input_css_class:
"CSS classes for generated `input` tags of type `text`, `email` or `password`",
password_authentication_form_input_surround_css_class:
"CSS classes for the div surrounding an `label`/`input` combination.",
password_authentication_form_h2_css_class: "CSS classes for any form `h2` headers.",
password_authentication_form_submit_css_class: "CSS classes for any form submit buttons.",
password_authentication_form_css_class: "CSS classes for any `form` tags.",
password_authentication_form_error_ul_css_class: "CSS classes for `ul` tags in form errors",
password_authentication_form_error_li_css_class: "CSS classes for `li` tags in form errors",
password_authentication_box_css_class:
"CSS classes for the root `div` element in the `AshAuthentication.Phoenix.Components.PasswordAuthentication` component.",
password_authentication_box_spacer_css_class:
"CSS classes for the \"spacer\" in the `AshAuthentication.Phoenix.Components.PasswordAuthentication` component - if enabled.",
sign_in_box_css_class:
"CSS classes for the root `div` element in the `AshAuthentication.Phoenix.Components.SignIn` component.",
sign_in_row_css_class:
"CSS classes for each row in the `AshAuthentication.Phoenix.Components.SignIn` component.",
sign_in_live_css_class:
"CSS classes for the root element of the `AshAuthentication.Phoenix.SignInLive` live view."
]
@moduledoc """
Behaviour for overriding component styles and attributes in your application.
@ -32,7 +9,7 @@ defmodule AshAuthentication.Phoenix.Overrides do
You can override by setting the following in your `config.exs`:
```elixir
config :my_app, AshAuthentication.Phoenix, override_module: MyAppWeb.AuthOverrides
config :my_app, AshAuthentication.Phoenix, overrides: [MyAppWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default]
```
and defining `lib/my_app_web/auth_styles.ex` within which you can set CSS
@ -45,55 +22,63 @@ defmodule AshAuthentication.Phoenix.Overrides do
defmodule MyAppWeb.AuthOverrides do
use AshAuthentication.Phoenix.Overrides
def password_authentication_form_label_css_class, do: "my-custom-css-class"
override
end
```
## Configuration
#{Enum.map(@configurables, &" * `#{elem(&1, 0)}` - #{elem(&1, 1)}\n")}
"""
alias __MODULE__
for {name, doc} <- @configurables do
Module.put_attribute(__MODULE__, :doc, {__ENV__.line, doc})
@callback unquote({name, [], Elixir}) :: nil | String.t()
@doc """
Retrieve the override for a specific component and selector.
"""
@spec override_for(otp_app :: atom, component :: module, selector :: atom) :: any
def override_for(otp_app, component, selector)
when is_atom(otp_app) and is_atom(component) and is_atom(selector) do
otp_app
|> Application.get_env(AshAuthentication.Phoenix, [])
|> Keyword.get(:overrides, [__MODULE__.Default])
|> Enum.find_value(fn module ->
module.overrides()
|> Map.get({component, selector})
end)
end
@doc false
@spec __using__(any) :: Macro.t()
defmacro __using__(_) do
defmacro __using__(_env) do
quote do
require Overrides
@behaviour Overrides
Overrides.generate_default_implementations()
Overrides.make_overridable()
require AshAuthentication.Phoenix.Overrides
import AshAuthentication.Phoenix.Overrides, only: :macros
Module.register_attribute(__MODULE__, :override, accumulate: true)
@component nil
@before_compile AshAuthentication.Phoenix.Overrides
end
end
@doc false
@spec generate_default_implementations :: Macro.t()
defmacro generate_default_implementations do
for {name, doc} <- @configurables do
quote do
@impl true
@doc unquote(doc)
def unquote({name, [], Elixir}), do: nil
defmacro override(component, do: block) do
quote do
@component unquote(component)
unquote(block)
end
end
defmacro set(selector, value) do
quote do
@override {@component, unquote(selector), unquote(value)}
end
end
defmacro __before_compile__(env) do
overrides =
env.module
|> Module.get_attribute(:override, [])
|> Map.new(fn {component, selector, value} -> {{component, selector}, value} end)
|> Macro.escape()
quote do
def overrides do
unquote(overrides)
end
end
end
@doc false
@spec make_overridable :: Macro.t()
defmacro make_overridable do
callbacks =
@configurables
|> Enum.map(&put_elem(&1, 1, 0))
quote do
defoverridable unquote(callbacks)
end
end
end

View file

@ -1,69 +1,92 @@
defmodule AshAuthentication.Phoenix.Overrides.Default do
@moduledoc """
The default implmentation of `AshAuthentication.Phoenix.Overrides` using
[TailwindCSS](https://tailwindcss.com/).
This is the default overrides for our component UI.
These colours and styles were chosen to be reasonably generic looking.
The CSS styles are based on [TailwindCSS](https://tailwindcss.com/).
"""
use AshAuthentication.Phoenix.Overrides
alias AshAuthentication.Phoenix.{Components, SignInLive}
@doc false
@impl true
def password_authentication_form_label_css_class,
do: "block text-sm font-medium text-gray-700 mb-1"
override SignInLive do
set :root_class, "grid h-screen place-items-center"
end
@doc false
@impl true
def password_authentication_form_input_surround_css_class, do: "mt-2 mb-2"
override Components.SignIn do
set :root_class, """
flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:flex-none
lg:px-20 xl:px-24
"""
@doc false
@impl true
def password_authentication_form_text_input_css_class,
do: """
set :provider_class, "mx-auth w-full max-w-sm lg:w-96"
end
override Components.Banner do
set :root_class, "w-full flex justify-center py-2"
set :href_class, nil
set :href_url, "/"
set :image_class, nil
set :image_url, "https://ash-hq.org/images/ash-framework-light.png"
set :text_class, nil
set :text, nil
end
override Components.HorizontalRule do
set :root_class, "relative"
set :hr_outer_class, "absolute inset-0 flex items-center"
set :hr_inner_class, "w-full border-t border-gray-300"
set :text_outer_class, "relative flex justify-center text-sm"
set :text_inner_class, "px-2 bg-white text-gray-400 font-medium"
set :text, "or"
end
override Components.PasswordAuthentication do
set :root_class, "mt-4 mb-4"
set :interstitial_class, "flex justify-center text-sm font-medium"
set :toggler_class, "text-blue-500 hover:text-blue-600"
set :sign_in_toggle_text, "Need an account?"
set :register_toggle_text, "Already have an account?"
set :show_first, :sign_in
set :hide_class, "hidden"
end
override Components.PasswordAuthentication.SignInForm do
set :root_class, nil
set :label_class, "mt-2 mb-4 text-2xl tracking-tight font-bold text-gray-900"
set :form_class, nil
set :slot_class, "my-4"
end
override Components.PasswordAuthentication.RegisterForm do
set :root_class, nil
set :label_class, "mt-2 mb-4 text-2xl tracking-tight font-bold text-gray-900"
set :form_class, nil
set :slot_class, "my-4"
end
override Components.PasswordAuthentication.Input do
set :field_class, "mt-2 mb-2"
set :label_class, "block text-sm font-medium text-gray-700 mb-1"
set :input_class, """
appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md
shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-pale-500
focus:border-blue-pale-500 sm:text-sm
"""
@doc false
@impl true
def password_authentication_form_h2_css_class,
do: "mt-2 mb-2 text-2xl tracking-tight font-bold text-gray-900"
@doc false
@impl true
def password_authentication_form_submit_css_class,
do: """
w-full flex justify-center py-2 px-4 border border-transparent rounded-md
shadow-sm text-sm font-medium text-white bg-blue-500
hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2
focus:ring-blue-500 mt-2 mb-4
set :input_class_with_error, """
appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md
shadow-sm placeholder-gray-400 focus:outline-none border-red-400 sm:text-sm
"""
@doc false
@impl true
def password_authentication_box_css_class, do: "mt-4 mb-4"
set :submit_class, """
w-full flex justify-center py-2 px-4 border border-transparent rounded-md
shadow-sm text-sm font-medium text-white bg-blue-500 hover:bg-blue-600
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
mt-2 mb-4
"""
@doc false
@impl true
def password_authentication_box_spacer_css_class,
do: "w-full text-center font-semibold text-gray-400 uppercase text-lg"
@doc false
@impl true
def password_authentication_form_error_ul_css_class, do: "text-red-700 font-light"
@doc false
@impl true
def sign_in_box_css_class,
do: "flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:flex-none lg:px-20 xl:px-24"
@doc false
@impl true
def sign_in_row_css_class, do: "mx-auth w-full max-w-sm lg:w-96"
@doc false
@impl true
def sign_in_live_css_class, do: "grid place-items-center"
set :error_ul, "text-red-400 font-light my-3 italic text-sm"
set :error_li, nil
end
end

View file

@ -0,0 +1,71 @@
defmodule AshAuthentication.Phoenix.Overrides.Overridable do
@moduledoc """
Auto generates documentation and helpers for components.
"""
alias AshAuthentication.Phoenix.{Components.Helpers, Overrides}
alias Phoenix.LiveView.Socket
@doc false
@spec __using__(keyword) :: Macro.t()
defmacro __using__(opts) do
overrides =
opts
|> Enum.filter(fn
{name, value} when is_atom(name) and is_binary(value) -> true
_ -> false
end)
|> Map.new()
|> Macro.escape()
quote do
require AshAuthentication.Phoenix.Overrides
require AshAuthentication.Phoenix.Overrides.Overridable
@overrides unquote(overrides)
import AshAuthentication.Phoenix.Overrides.Overridable, only: :macros
end
end
@doc false
@spec generate_docs :: Macro.t()
defmacro generate_docs do
quote do
"""
## Overrides
This component provides the following overrides:
#{@overrides |> Enum.map(&" * `#{inspect(elem(&1, 0))}` - #{elem(&1, 1)}\n")}
See `AshAuthentication.Phoenix.Overrides` for more information.
"""
end
end
@doc false
@spec override_for(Socket.t(), atom, any) :: any
defmacro override_for(socket, selector, default \\ nil) do
overrides =
__CALLER__.module
|> Module.get_attribute(:overrides, %{})
if Map.has_key?(overrides, selector) do
quote do
override =
unquote(socket)
|> Helpers.otp_app_from_socket()
|> Overrides.override_for(__MODULE__, unquote(selector))
override || unquote(default)
end
else
IO.warn(
"Unknown override `#{inspect(selector)}` in component `#{inspect(__CALLER__.module)}"
)
quote do
unquote(default)
end
end
end
end

View file

@ -1,4 +1,7 @@
defmodule AshAuthentication.Phoenix.SignInLive do
use AshAuthentication.Phoenix.Overrides.Overridable,
root_class: "CSS class for the root `div` element."
@moduledoc """
A generic, white-label sign-in page.
@ -11,48 +14,20 @@ defmodule AshAuthentication.Phoenix.SignInLive do
appropriate UI for their providers using
`AshAuthentication.Phoenix.Components.SignIn`.
## Overrides
See `AshAuthentication.Phoenix.Overrides` for more information.
* `sign_in_live_css_class` - applied to the root `div` element of this
liveview.
#{AshAuthentication.Phoenix.Overrides.Overridable.generate_docs()}
"""
use Phoenix.LiveView
alias AshAuthentication.Phoenix.Components
alias Phoenix.LiveView.{Rendered, Socket}
import Components.Helpers
@doc false
@impl true
@spec mount(map, map, Socket.t()) :: {:ok, Socket.t()}
def mount(_params, _, socket) do
resources =
socket
|> otp_app_from_socket()
|> AshAuthentication.authenticated_resources()
|> Enum.group_by(& &1.subject_name)
|> Enum.sort_by(&elem(&1, 0))
socket =
socket
|> assign(:resources, resources)
{:ok, socket}
end
@doc false
@impl true
@spec render(Socket.assigns()) :: Rendered.t()
def render(assigns) do
~H"""
<div class={override_for(@socket, :sign_in_live_css_class)}>
<%= for {subject_name, configs} <- @resources do %>
<%= for config <- configs do %>
<.live_component module={Components.SignIn} id={"sign-in-#{subject_name}"} config={config} />
<% end %>
<% end %>
<div class={override_for(@socket, :root_class)}>
<.live_component module={Components.SignIn} id="sign-in" />
</div>
"""
end