mirror of
https://github.com/team-alembic/ash_authentication_phoenix.git
synced 2024-09-19 21:13:52 +12:00
feat: Add support for PasswordAuthentication.
This commit is contained in:
parent
c3bbac966b
commit
05ab4f438b
23 changed files with 1533 additions and 47 deletions
|
@ -1,5 +1,5 @@
|
||||||
%Doctor.Config{
|
%Doctor.Config{
|
||||||
ignore_modules: [~r/^Inspect\./, ~r/.Plug$/],
|
ignore_modules: [~r/^Inspect\./, ~r/.Plug$/, AshAuthentication.Phoenix.Overrides],
|
||||||
ignore_paths: [],
|
ignore_paths: [],
|
||||||
min_module_doc_coverage: 40,
|
min_module_doc_coverage: 40,
|
||||||
min_module_spec_coverage: 0,
|
min_module_spec_coverage: 0,
|
||||||
|
|
|
@ -1,5 +1,19 @@
|
||||||
# Used by "mix format"
|
# Used by "mix format"
|
||||||
[
|
[
|
||||||
import_deps: [:ash, :ash_authentication, :phoenix],
|
import_deps: [:ash, :ash_authentication, :phoenix],
|
||||||
inputs: ["{mix,.formatter}.exs", "{dev,config,lib,test}/**/*.{ex,exs}"]
|
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
|
||||||
|
]
|
||||||
|
]
|
||||||
]
|
]
|
||||||
|
|
69
README.md
69
README.md
|
@ -1,11 +1,14 @@
|
||||||
# AshAuthenticationPhoenix
|
# AshAuthentication.Phoenix
|
||||||
|
|
||||||
**TODO: Add description**
|
The `ash_authentication_phoenix` package extends
|
||||||
|
[`ash_authentication`](https://github.com/team-alembic/ash_authentication) by
|
||||||
|
adding router helpers, plugs and behaviours that makes adding authentication to
|
||||||
|
an existing Ash-based Phoenix application dead easy.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
|
The package can be installed by adding `ash_authentication_phoenix` to your list
|
||||||
by adding `ash_authentication_phoenix` to your list of dependencies in `mix.exs`:
|
of dependencies in `mix.exs`:
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
def deps do
|
def deps do
|
||||||
|
@ -15,7 +18,59 @@ def deps do
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
|
## Usage
|
||||||
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
|
|
||||||
be found at <https://hexdocs.pm/ash_authentication_phoenix>.
|
This package assumes that you have [Phoenix](https://phoenixframework.org/),
|
||||||
|
[Ash](https://ash-hq.org/) and
|
||||||
|
[AshAuthentication](https://github.com/team-alembic/ash_authentication)
|
||||||
|
installed and configured. See their individual documentation for details.
|
||||||
|
|
||||||
|
This package is designed so that you can choose the level of customisation
|
||||||
|
required. At the easiest level of configuration, you can just add the routes
|
||||||
|
into your router:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmodule MyAppWeb.Router do
|
||||||
|
use MyAppWeb, :router
|
||||||
|
use AshAuthentication.Phoenix.Router
|
||||||
|
|
||||||
|
pipeline :browser do
|
||||||
|
# ...
|
||||||
|
plug(:load_from_session)
|
||||||
|
end
|
||||||
|
|
||||||
|
scope "/" do
|
||||||
|
pipe_through :browser
|
||||||
|
sign_in_route
|
||||||
|
sign_out_route MyAppWeb.AuthController
|
||||||
|
auth_routes MyAppWeb.AuthController
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
This will give you a generic sign-in/registration page and store the
|
||||||
|
authenticated user in the Phoenix session.
|
||||||
|
|
||||||
|
### Customisation
|
||||||
|
|
||||||
|
There are several methods of customisation available depending on the level of
|
||||||
|
control you would like:
|
||||||
|
|
||||||
|
1. Use the [generic sign-in liveview](https://hexdocs.pm/ash_authentication_phoenix/AshAuthentication.Phoenix.SignInLive.html).
|
||||||
|
2. Apply [overrides](https://hexdocs.pm/ash_authentication_phoenix/AshAuthentication.Phoenix.Overrides.html)
|
||||||
|
to set your own CSS classes for all components.
|
||||||
|
3. Build your own sign-in pages using the pre-defined components.
|
||||||
|
4. Build your own sign-in pages using the generated `auth` routes.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Documentation for the latest release will be [available on
|
||||||
|
hexdocs](https://hexdocs.pm/ash_authentication_phoenix) and for the [`main`
|
||||||
|
branch](https://team-alembic.github.io/ash_authentication_phoenix).
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
* To contribute updates, fixes or new features please fork and open a pull-request against `main`.
|
||||||
|
* Please use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) - this allows us to dynamically generate the changelog.
|
||||||
|
* Feel free to ask any questions on out [GitHub discussions page](https://github.com/team-alembic/ash_authentication_phoenix/discussions).
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,7 @@ config :ash_authentication_phoenix, DevWeb.Endpoint,
|
||||||
secret_key_base: "5PmCh9zQTJuCjlXm2EeF+hoYLkFxgH/3bzLE8D0Tzg5XLw6ZIMGipHFbr0z19dlC",
|
secret_key_base: "5PmCh9zQTJuCjlXm2EeF+hoYLkFxgH/3bzLE8D0Tzg5XLw6ZIMGipHFbr0z19dlC",
|
||||||
server: true
|
server: true
|
||||||
|
|
||||||
config :ash_authentication_phoenix, ash_apis: [Example.Accounts]
|
config :ash_authentication_phoenix, ash_apis: [Example.Accounts], namespace: Dev
|
||||||
|
|
||||||
config :ash_authentication, AshAuthentication.JsonWebToken,
|
config :ash_authentication, AshAuthentication.JsonWebToken,
|
||||||
signing_secret: "All I wanna do is to thank you, even though I don't know who you are."
|
signing_secret: "All I wanna do is to thank you, even though I don't know who you are."
|
||||||
|
|
|
@ -2,14 +2,13 @@ defmodule DevWeb.AuthController do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use DevWeb, :controller
|
use DevWeb, :controller
|
||||||
# use AshPhoenix.Authentication.Controller
|
use AshAuthentication.Phoenix.Controller
|
||||||
alias Plug.Conn
|
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
@impl true
|
@impl true
|
||||||
def success(conn, user, _token) do
|
def success(conn, user, _token) do
|
||||||
conn
|
conn
|
||||||
# |> store_in_session(user)
|
|> store_in_session(user)
|
||||||
|> assign(:current_user, user)
|
|> assign(:current_user, user)
|
||||||
|> put_status(200)
|
|> put_status(200)
|
||||||
|> render("success.html")
|
|> render("success.html")
|
||||||
|
|
|
@ -2,9 +2,10 @@ defmodule DevWeb.PageController do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use DevWeb, :controller
|
use DevWeb, :controller
|
||||||
|
alias Plug.Conn
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
@impl true
|
@spec index(Conn.t(), %{required(String.t()) => String.t()}) :: Conn.t()
|
||||||
def index(conn, _params) do
|
def index(conn, _params) do
|
||||||
render(conn, "index.html")
|
render(conn, "index.html")
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,7 +2,7 @@ defmodule DevWeb.Router do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use DevWeb, :router
|
use DevWeb, :router
|
||||||
# use AshPhoenix.Authentication.Router
|
use AshAuthentication.Phoenix.Router
|
||||||
|
|
||||||
pipeline :browser do
|
pipeline :browser do
|
||||||
plug :accepts, ["html"]
|
plug :accepts, ["html"]
|
||||||
|
@ -11,7 +11,7 @@ defmodule DevWeb.Router do
|
||||||
plug :put_root_layout, {DevWeb.LayoutView, :root}
|
plug :put_root_layout, {DevWeb.LayoutView, :root}
|
||||||
plug :protect_from_forgery
|
plug :protect_from_forgery
|
||||||
plug :put_secure_browser_headers
|
plug :put_secure_browser_headers
|
||||||
# plug :load_from_session
|
plug :load_from_session
|
||||||
end
|
end
|
||||||
|
|
||||||
pipeline :api do
|
pipeline :api do
|
||||||
|
@ -29,10 +29,10 @@ defmodule DevWeb.Router do
|
||||||
# pipe_through :api
|
# pipe_through :api
|
||||||
# end
|
# end
|
||||||
|
|
||||||
# scope "/" do
|
scope "/", DevWeb do
|
||||||
# pipe_through :browser
|
pipe_through :browser
|
||||||
# auth_routes DevWeb.AuthController, "/auth"
|
auth_routes(AuthController, "/auth")
|
||||||
# sign_in_route "/sign-in"
|
sign_in_route("/sign-in")
|
||||||
# sign_out_route DevWeb.AuthController, "/sign-out"
|
sign_out_route(AuthController, "/sign-out")
|
||||||
# end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,18 +1,32 @@
|
||||||
defmodule AshAuthenticationPhoenix do
|
defmodule AshAuthentication.Phoenix do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Documentation for `AshAuthenticationPhoenix`.
|
Welcome to `AshAuthentication.Pheonix`.
|
||||||
"""
|
|
||||||
|
|
||||||
@doc """
|
The `ash_authentication_phoenix` package extends
|
||||||
Hello world.
|
[`ash_authentication`](https://github.com/team-alembic/ash_authentication) by
|
||||||
|
adding router helpers, plugs and behaviours that makes adding authentication
|
||||||
|
to an existing Ash-based Phoenix application dead easy.
|
||||||
|
|
||||||
## Examples
|
## Where to start.
|
||||||
|
|
||||||
iex> AshAuthenticationPhoenix.hello()
|
Presuming that you already have [Phoenix](https://phoenixframework.org/),
|
||||||
:world
|
[Ash](https://ash-hq.org/) and
|
||||||
|
[AshAuthentication](https://github.com/team-alembic/ash_authentication)
|
||||||
|
installed and configured, start by adding plugs and routes to your router
|
||||||
|
using `AshAuthentication.Phoenix.Router` and customising your sign-in page as
|
||||||
|
needed.
|
||||||
|
|
||||||
|
### Customisation
|
||||||
|
|
||||||
|
There are several methods of customisation available depending on the level of
|
||||||
|
control you would like:
|
||||||
|
|
||||||
|
1. Use the generic sign-in liveview -
|
||||||
|
`AshAuthentication.Phoenix.SignInLive`.
|
||||||
|
2. Apply overrides using `AshAuthentication.Phoenix.Overrides` to set your
|
||||||
|
own CSS classes for all components.
|
||||||
|
3. Build your own sign-in pages using the pre-defined components.
|
||||||
|
4. Build your own sign-in pages using the generated `auth` routes.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def hello do
|
|
||||||
:world
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
43
lib/ash_authentication_phoenix/components/helpers.ex
Normal file
43
lib/ash_authentication_phoenix/components/helpers.ex
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
defmodule AshAuthentication.Phoenix.Components.Helpers do
|
||||||
|
@moduledoc """
|
||||||
|
Helpers which are commonly needed inside the various components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Phoenix.LiveView.Socket
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
The LiveView `Socket` contains a reference to the Phoenix endpoint, and from
|
||||||
|
there we can extract the `otp_app` of the current request.
|
||||||
|
"""
|
||||||
|
@spec otp_app_from_socket(Socket.t()) :: atom
|
||||||
|
def otp_app_from_socket(socket) do
|
||||||
|
:otp_app
|
||||||
|
|> socket.endpoint.config()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
The LiveView `Socket` contains a refererence to the Phoenix router, and from
|
||||||
|
there we can generate the name of the route helpers module.
|
||||||
|
"""
|
||||||
|
@spec route_helpers(Socket.t()) :: module
|
||||||
|
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
|
|
@ -0,0 +1,78 @@
|
||||||
|
defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication do
|
||||||
|
@default_debounce 750
|
||||||
|
@moduledoc """
|
||||||
|
Generates sign in and registration forms for a resource.
|
||||||
|
|
||||||
|
## Component heirarchy
|
||||||
|
|
||||||
|
This is the top-most provider-specific component, nested below `AshAuthentication.Phoenix.Components.SignIn`.
|
||||||
|
|
||||||
|
Children:
|
||||||
|
|
||||||
|
* `AshAuthentication.Phoenix.Components.PasswordAuthentication.SignInForm`
|
||||||
|
* `AshAuthentication.Phoenix.Components.PasswordAuthentication.RegisterForm`
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
* `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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec render(props) :: Rendered.t() | no_return
|
||||||
|
def render(assigns) do
|
||||||
|
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)
|
||||||
|
|
||||||
|
~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>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,267 @@
|
||||||
|
defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.Input do
|
||||||
|
@moduledoc """
|
||||||
|
Function components for dealing with form input during password authentication.
|
||||||
|
|
||||||
|
## Component heirarchy
|
||||||
|
|
||||||
|
These function components are consumed by
|
||||||
|
`AshAuthentication.Phoenix.Components.PasswordAuthentication.SignInForm` and
|
||||||
|
`AshAuthentication.Phoenix.Components.PasswordAuthentication.RegisterForm`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Phoenix.Component
|
||||||
|
alias AshAuthentication.PasswordAuthentication.Info
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
* `socket` - Phoenix LiveView socket.
|
||||||
|
This is needed to be able to retrieve the correct CSS configuration.
|
||||||
|
Required.
|
||||||
|
* `config` - The configuration map as per
|
||||||
|
`AshAuthentication.authenticated_resources/1`.
|
||||||
|
Required.
|
||||||
|
* `form` - An `AshPhoenix.Form`.
|
||||||
|
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(),
|
||||||
|
required(:config) => AshAuthentication.resource_config(),
|
||||||
|
required(:form) => AshPhoenix.Form.t(),
|
||||||
|
optional(:input_type) => :text | :email
|
||||||
|
}) :: Rendered.t() | no_return
|
||||||
|
def identity_field(assigns) do
|
||||||
|
identity_field = Info.identity_field!(assigns.config.resource)
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:identity_field, identity_field)
|
||||||
|
|> assign_new(:input_type, fn ->
|
||||||
|
identity_field
|
||||||
|
|> to_string()
|
||||||
|
|> String.starts_with?("email")
|
||||||
|
|> then(fn
|
||||||
|
true -> :email
|
||||||
|
_ -> :text
|
||||||
|
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) %>
|
||||||
|
<.error socket={@socket} form={@form} field={@identity_field} />
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Generate a form field for the configured password entry field.
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
* `socket` - Phoenix LiveView socket.
|
||||||
|
This is needed to be able to retrieve the correct CSS configuration.
|
||||||
|
Required.
|
||||||
|
* `config` - The configuration map as per
|
||||||
|
`AshAuthentication.authenticated_resources/1`.
|
||||||
|
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(),
|
||||||
|
required(:config) => AshAuthentication.resource_config(),
|
||||||
|
required(:form) => AshPhoenix.Form.t()
|
||||||
|
}) :: Rendered.t() | no_return
|
||||||
|
def password_field(assigns) do
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:password_field, Info.password_field!(assigns.config.resource))
|
||||||
|
|
||||||
|
~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) %>
|
||||||
|
<.error socket={@socket} form={@form} field={@password_field} />
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Generate a form field for the configured password confirmation entry field.
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
* `socket` - Phoenix LiveView socket.
|
||||||
|
This is needed to be able to retrieve the correct CSS configuration.
|
||||||
|
Required.
|
||||||
|
* `config` - The configuration map as per
|
||||||
|
`AshAuthentication.authenticated_resources/1`.
|
||||||
|
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(),
|
||||||
|
required(:config) => AshAuthentication.resource_config(),
|
||||||
|
required(:form) => AshPhoenix.Form.t()
|
||||||
|
}) :: Rendered.t() | no_return
|
||||||
|
def password_confirmation_field(assigns) do
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(
|
||||||
|
:password_confirmation_field,
|
||||||
|
Info.password_confirmation_field!(assigns.config.resource)
|
||||||
|
)
|
||||||
|
|
||||||
|
~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) %>
|
||||||
|
<.error socket={@socket} form={@form} field={@password_confirmation_field} />
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Generate an form submit button.
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
* `socket` - Phoenix LiveView socket.
|
||||||
|
This is needed to be able to retrieve the correct CSS configuration.
|
||||||
|
Required.
|
||||||
|
* `config` - The configuration map as per
|
||||||
|
`AshAuthentication.authenticated_resources/1`.
|
||||||
|
Required.
|
||||||
|
* `form` - An `AshPhoenix.Form`.
|
||||||
|
Required.
|
||||||
|
* `action` - Either `:sign_in` or `:register`.
|
||||||
|
Required.
|
||||||
|
* `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(),
|
||||||
|
required(:config) => AshAuthentication.resource_config(),
|
||||||
|
required(:form) => AshPhoenix.Form.t(),
|
||||||
|
required(:action) => :sign_in | :register,
|
||||||
|
optional(:label) => String.t()
|
||||||
|
}) :: Rendered.t() | no_return
|
||||||
|
def submit(assigns) do
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign_new(:label, fn ->
|
||||||
|
case assigns.action do
|
||||||
|
:sign_in ->
|
||||||
|
assigns.config.resource
|
||||||
|
|> Info.sign_in_action_name!()
|
||||||
|
|
||||||
|
:register ->
|
||||||
|
assigns.config.resource
|
||||||
|
|> Info.register_action_name!()
|
||||||
|
end
|
||||||
|
|> humanize()
|
||||||
|
end)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<%= submit @label, class: override_for(@socket, :password_authentication_form_submit_css_class) %>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Generate a list of errors for a field (if there are any).
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
* `socket` - Phoenix LiveView socket.
|
||||||
|
This is needed to be able to retrieve the correct CSS configuration.
|
||||||
|
Required.
|
||||||
|
* `form` - An `AshPhoenix.Form`.
|
||||||
|
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(),
|
||||||
|
required(:form) => Form.t(),
|
||||||
|
required(:field) => atom,
|
||||||
|
optional(:field_label) => String.Chars.t(),
|
||||||
|
optional(:errors) => [{atom, String.t()}]
|
||||||
|
}) :: Rendered.t() | no_return
|
||||||
|
def error(assigns) do
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign_new(:errors, fn ->
|
||||||
|
assigns.form
|
||||||
|
|> Form.errors()
|
||||||
|
|> Keyword.get_values(assigns.field)
|
||||||
|
end)
|
||||||
|
|> assign_new(:field_label, fn -> humanize(assigns.field) end)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<%= if Enum.any?(@errors) do %>
|
||||||
|
<ul class={override_for(@socket, :password_authentication_form_error_ul_css_class)}>
|
||||||
|
<%= for error <- @errors do %>
|
||||||
|
<li class={override_for(@socket, :password_authentication_form_error_li_css_class)} phx-feedback-for={input_name(@form, @field)}>
|
||||||
|
<%= @field_label %> <%= error %>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% end %>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,148 @@
|
||||||
|
defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.RegisterForm do
|
||||||
|
@default_debounce 750
|
||||||
|
|
||||||
|
@moduledoc """
|
||||||
|
Generates a default registration form.
|
||||||
|
|
||||||
|
## Component heirarchy
|
||||||
|
|
||||||
|
This is a child of `AshAuthentication.Phoenix.Components.PasswordAuthentication`.
|
||||||
|
|
||||||
|
Children:
|
||||||
|
|
||||||
|
* `AshAuthentication.Phoenix.Components.PasswordAuthentication.Input.identity_field/1`
|
||||||
|
* `AshAuthentication.Phoenix.Components.PasswordAuthentication.Input.password_field/1`
|
||||||
|
* `AshAuthentication.Phoenix.Components.PasswordAuthentication.Input.password_confirmation_field/1`
|
||||||
|
* `AshAuthentication.Phoenix.Components.PasswordAuthentication.Input.submit/1`
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
* `config` - The configuration map as per
|
||||||
|
`AshAuthentication.authenticated_resources/1`.
|
||||||
|
Required.
|
||||||
|
* `label` - The text to show in the submit label.
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Phoenix.LiveComponent
|
||||||
|
alias AshAuthentication.PasswordAuthentication.Info
|
||||||
|
alias AshAuthentication.Phoenix.Components.PasswordAuthentication
|
||||||
|
alias AshPhoenix.Form
|
||||||
|
alias Phoenix.LiveView.{Rendered, Socket}
|
||||||
|
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()}
|
||||||
|
def update(assigns, socket) do
|
||||||
|
config = assigns.config
|
||||||
|
action = Info.register_action_name!(config.resource)
|
||||||
|
confirm? = Info.confirmation_required?(config.resource)
|
||||||
|
|
||||||
|
form =
|
||||||
|
config.resource
|
||||||
|
|> Form.for_action(action,
|
||||||
|
api: config.api,
|
||||||
|
as: to_string(config.subject_name),
|
||||||
|
id:
|
||||||
|
"#{AshAuthentication.PasswordAuthentication.provides()}_#{config.subject_name}_#{action}"
|
||||||
|
)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(assigns)
|
||||||
|
|> assign(form: form, trigger_action: false, confirm?: confirm?)
|
||||||
|
|> assign_new(:label, fn -> humanize(action) end)
|
||||||
|
|> assign_new(:debounce, fn -> @default_debounce end)
|
||||||
|
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
@spec render(props) :: Rendered.t() | no_return
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div>
|
||||||
|
<%= if @label do %>
|
||||||
|
<h2 class={override_for(@socket, :password_authentication_form_h2_css_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)}>
|
||||||
|
|
||||||
|
<%= 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} />
|
||||||
|
|
||||||
|
<%= if @confirm? do %>
|
||||||
|
<PasswordAuthentication.Input.password_confirmation_field socket={@socket} config={@config} form={f} />
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<PasswordAuthentication.Input.submit socket={@socket} config={@config} form={f} action={:register}/>
|
||||||
|
</.form>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@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))
|
||||||
|
|
||||||
|
form = Form.validate(socket.assigns.form, params)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:form, form)
|
||||||
|
|> assign(:trigger_action, form.valid?)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,137 @@
|
||||||
|
defmodule AshAuthentication.Phoenix.Components.PasswordAuthentication.SignInForm do
|
||||||
|
@default_debounce 750
|
||||||
|
|
||||||
|
@moduledoc """
|
||||||
|
Generates a default sign in form.
|
||||||
|
|
||||||
|
## Component heirarchy
|
||||||
|
|
||||||
|
This is a child of `AshAuthentication.Phoenix.Components.PasswordAuthentication`.
|
||||||
|
|
||||||
|
Children:
|
||||||
|
|
||||||
|
* `AshAuthentication.Phoenix.Components.PasswordAuthentication.Input.identity_field/1`
|
||||||
|
* `AshAuthentication.Phoenix.Components.PasswordAuthentication.Input.password_field/1`
|
||||||
|
* `AshAuthentication.Phoenix.Components.PasswordAuthentication.Input.submit/1`
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
* `socket` - Phoenix LiveView socket. This is needed to be able to retrieve
|
||||||
|
the correct CSS configuration.
|
||||||
|
Required.
|
||||||
|
* `config` - The configuration map as per
|
||||||
|
`AshAuthentication.authenticated_resources/1`.
|
||||||
|
Required.
|
||||||
|
* `label` - The text to show in the submit label.
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Phoenix.LiveComponent
|
||||||
|
alias AshAuthentication.PasswordAuthentication.Info
|
||||||
|
alias AshAuthentication.Phoenix.Components.PasswordAuthentication
|
||||||
|
alias AshPhoenix.Form
|
||||||
|
alias Phoenix.LiveView.{Rendered, Socket}
|
||||||
|
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()}
|
||||||
|
def update(assigns, socket) do
|
||||||
|
config = assigns.config
|
||||||
|
action = Info.sign_in_action_name!(config.resource)
|
||||||
|
|
||||||
|
form =
|
||||||
|
config.resource
|
||||||
|
|> Form.for_action(action,
|
||||||
|
api: config.api,
|
||||||
|
as: to_string(config.subject_name),
|
||||||
|
id:
|
||||||
|
"#{AshAuthentication.PasswordAuthentication.provides()}_#{config.subject_name}_#{action}"
|
||||||
|
)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(assigns)
|
||||||
|
|> assign(form: form, trigger_action: false)
|
||||||
|
|> assign_new(:label, fn -> humanize(action) end)
|
||||||
|
|> assign_new(:debounce, fn -> @default_debounce end)
|
||||||
|
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
@spec render(props) :: Rendered.t() | no_return
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div>
|
||||||
|
<h2 class={override_for(@socket, :password_authentication_form_h2_css_class)}><%= @label %></h2>
|
||||||
|
|
||||||
|
<.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)}>
|
||||||
|
|
||||||
|
<%= hidden_input f, :action, value: "sign_in" %>
|
||||||
|
|
||||||
|
<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}/>
|
||||||
|
</.form>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@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)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:form, form)
|
||||||
|
|> assign(:trigger_action, form.valid?)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
65
lib/ash_authentication_phoenix/components/sign_in.ex
Normal file
65
lib/ash_authentication_phoenix/components/sign_in.ex
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
defmodule AshAuthentication.Phoenix.Components.SignIn do
|
||||||
|
@moduledoc """
|
||||||
|
Renders sign in mark-up for an authenticated resource.
|
||||||
|
|
||||||
|
This means that it will render sign-in UI for all of the authentication
|
||||||
|
providers for a resource.
|
||||||
|
|
||||||
|
For each provider configured on the resource a component name is inferred
|
||||||
|
(e.g. `AshAuthentication.PasswordAuthentication` becomes
|
||||||
|
`AshAuthentication.Phoenix.Components.PasswordAuthentication`) and is rendered
|
||||||
|
into the output.
|
||||||
|
|
||||||
|
## Component heirarchy
|
||||||
|
|
||||||
|
This is the top-most authentication component.
|
||||||
|
|
||||||
|
Children:
|
||||||
|
|
||||||
|
* `AshAuthentication.Phoenix.Components.PasswordAuthentication`.
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
* `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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Phoenix.LiveComponent
|
||||||
|
alias AshAuthentication.Phoenix.Components
|
||||||
|
alias Phoenix.LiveView.Rendered
|
||||||
|
import AshAuthentication.Phoenix.Components.Helpers
|
||||||
|
|
||||||
|
@type props :: %{required(:config) => AshAuthentication.resource_config()}
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec render(props) :: 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>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp component_for_provider(provider),
|
||||||
|
do:
|
||||||
|
provider
|
||||||
|
|> Module.split()
|
||||||
|
|> List.last()
|
||||||
|
|> then(&Module.concat(Components, &1))
|
||||||
|
|
||||||
|
defp provider_id(provider, config) do
|
||||||
|
"sign-in-#{config.subject_name}-#{provider.provides()}"
|
||||||
|
end
|
||||||
|
end
|
251
lib/ash_authentication_phoenix/controller.ex
Normal file
251
lib/ash_authentication_phoenix/controller.ex
Normal file
|
@ -0,0 +1,251 @@
|
||||||
|
defmodule AshAuthentication.Phoenix.Controller do
|
||||||
|
@moduledoc """
|
||||||
|
The authentication controller generator.
|
||||||
|
|
||||||
|
Since authentication often requires explicit HTTP requests to do things like
|
||||||
|
set cookies or return Authorization headers, use this module to create an
|
||||||
|
`AuthController` in your Phoenix application.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Handling the registration or authentication of a normal web-based user.
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmodule MyAppWeb.AuthController do
|
||||||
|
use MyAppWeb, :controller
|
||||||
|
use AshAuthentication.Phoenix.Controller
|
||||||
|
|
||||||
|
def success(conn, user, _token) do
|
||||||
|
conn
|
||||||
|
|> store_in_session(user)
|
||||||
|
|> assign(:current_user, user)
|
||||||
|
|> redirect(to: Routes.page_path(conn, :index))
|
||||||
|
end
|
||||||
|
|
||||||
|
def failure(conn) do
|
||||||
|
conn
|
||||||
|
|> put_status(401)
|
||||||
|
|> render("failure.html")
|
||||||
|
end
|
||||||
|
|
||||||
|
def sign_out(conn) do
|
||||||
|
conn
|
||||||
|
|> clear_session()
|
||||||
|
|> render("sign_out.html")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Handling registration or authentication of an API user.
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmodule MyAppWeb.ApiAuthController do
|
||||||
|
use MyAppWeb, :controller
|
||||||
|
use AshAuthentication.Phoenix.Controller
|
||||||
|
alias AshAuthentication.TokenRevocation
|
||||||
|
|
||||||
|
def success(conn, _user, token) do
|
||||||
|
conn
|
||||||
|
|> put_status(200)
|
||||||
|
|> json(%{
|
||||||
|
authentication: %{
|
||||||
|
status: :success,
|
||||||
|
bearer: token}
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
def failure(conn) do
|
||||||
|
conn
|
||||||
|
|> put_status(401)
|
||||||
|
|> json(%{
|
||||||
|
authentication: %{
|
||||||
|
status: :failed
|
||||||
|
}
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
def sign_out(conn) do
|
||||||
|
conn
|
||||||
|
|> revoke_bearer_tokens()
|
||||||
|
|> json(%{
|
||||||
|
status: :ok
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
@typedoc false
|
||||||
|
@type routes :: %{
|
||||||
|
required({String.t(), String.t()}) => %{
|
||||||
|
required(:provider) => module,
|
||||||
|
optional(atom) => any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alias Plug.Conn
|
||||||
|
@doc false
|
||||||
|
@callback request(Conn.t(), %{required(String.t()) => String.t()}) :: Conn.t()
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@callback callback(Conn.t(), %{required(String.t()) => String.t()}) :: Conn.t()
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Called when authentication (or registration, depending on the provider) has been successful.
|
||||||
|
"""
|
||||||
|
@callback success(Conn.t(), actor :: Ash.Resource.record(), token :: String.t()) :: Conn.t()
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Called when authentication fails.
|
||||||
|
"""
|
||||||
|
@callback failure(Conn.t(), nil | Ash.Changeset.t() | Ash.Error.t()) :: Conn.t()
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Called when a request to sign out is received.
|
||||||
|
"""
|
||||||
|
@callback sign_out(Conn.t(), map) :: Conn.t()
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec __using__(any) :: Macro.t()
|
||||||
|
defmacro __using__(_opts) do
|
||||||
|
quote do
|
||||||
|
@behaviour AshAuthentication.Phoenix.Controller
|
||||||
|
import Phoenix.Controller
|
||||||
|
import Plug.Conn
|
||||||
|
import AshAuthentication.Phoenix.Plug
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
@spec request(Conn.t(), map) :: Conn.t()
|
||||||
|
def request(conn, params),
|
||||||
|
do:
|
||||||
|
AshAuthentication.Phoenix.Controller.request(
|
||||||
|
conn,
|
||||||
|
params,
|
||||||
|
__MODULE__
|
||||||
|
)
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
@spec callback(Conn.t(), map) :: Conn.t()
|
||||||
|
def callback(conn, params),
|
||||||
|
do:
|
||||||
|
AshAuthentication.Phoenix.Controller.callback(
|
||||||
|
conn,
|
||||||
|
params,
|
||||||
|
__MODULE__
|
||||||
|
)
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
@spec success(Conn.t(), Ash.Resource.record(), nil | AshAuthentication.Jwt.token()) ::
|
||||||
|
Conn.t()
|
||||||
|
def success(conn, actor, _token) do
|
||||||
|
conn
|
||||||
|
|> store_in_session(actor)
|
||||||
|
|> put_status(200)
|
||||||
|
|> render("success.html")
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
@spec failure(Conn.t(), nil | Ash.Changeset.t() | Ash.Error.t()) :: Conn.t()
|
||||||
|
def failure(conn, _) do
|
||||||
|
conn
|
||||||
|
|> put_status(401)
|
||||||
|
|> render("failure.html")
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
@spec sign_out(Conn.t(), map) :: Conn.t()
|
||||||
|
def sign_out(conn, _params) do
|
||||||
|
conn
|
||||||
|
|> clear_session()
|
||||||
|
|> render("sign_out.html")
|
||||||
|
end
|
||||||
|
|
||||||
|
defoverridable success: 3, failure: 2, sign_out: 2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec request(Conn.t(), %{required(String.t()) => String.t()}, module) :: Conn.t()
|
||||||
|
def request(conn, params, return_to) do
|
||||||
|
handle(conn, params, :request, return_to)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec callback(Conn.t(), %{required(String.t()) => String.t()}, module) :: Conn.t()
|
||||||
|
def callback(conn, params, return_to) do
|
||||||
|
handle(conn, params, :callback, return_to)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle(
|
||||||
|
conn,
|
||||||
|
%{"subject_name" => subject_name, "provider" => provider} = _params,
|
||||||
|
phase,
|
||||||
|
return_to
|
||||||
|
) do
|
||||||
|
routes = generate_routes(conn)
|
||||||
|
|
||||||
|
case Map.get(routes, {subject_name, provider}) do
|
||||||
|
config when is_map(config) ->
|
||||||
|
conn = Conn.put_private(conn, :authenticator, config)
|
||||||
|
|
||||||
|
case phase do
|
||||||
|
:request -> config.provider.request_plug(conn, [])
|
||||||
|
:callback -> config.provider.callback_plug(conn, [])
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
conn
|
||||||
|
end
|
||||||
|
|> case do
|
||||||
|
%{state: :sent} ->
|
||||||
|
conn
|
||||||
|
|
||||||
|
%{private: %{authentication_result: {:success, actor}}} ->
|
||||||
|
return_to.success(conn, actor, Map.get(actor.__metadata__, :token))
|
||||||
|
|
||||||
|
%{private: %{authentication_result: {:failure, reason}}} ->
|
||||||
|
return_to.failure(conn, reason)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
return_to.failure(conn, nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle(conn, _, _, return_to) do
|
||||||
|
return_to.failure(conn, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Doing this on every request is probably a really bad idea, but if I do it at
|
||||||
|
# compile time I need to ask for the OTP app all over the place and it reduces
|
||||||
|
# the developer experience sharply.
|
||||||
|
#
|
||||||
|
# Maybe we should just shove them in ETS?
|
||||||
|
defp generate_routes(conn) do
|
||||||
|
:otp_app
|
||||||
|
|> conn.private.phoenix_endpoint.config()
|
||||||
|
|> AshAuthentication.authenticated_resources()
|
||||||
|
|> Stream.flat_map(fn config ->
|
||||||
|
subject_name =
|
||||||
|
config.subject_name
|
||||||
|
|> to_string()
|
||||||
|
|
||||||
|
config
|
||||||
|
|> Map.get(:providers, [])
|
||||||
|
|> Stream.map(fn provider ->
|
||||||
|
config =
|
||||||
|
config
|
||||||
|
|> Map.delete(:providers)
|
||||||
|
|> Map.put(:provider, provider)
|
||||||
|
|
||||||
|
{{subject_name, provider.provides()}, config}
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|> Map.new()
|
||||||
|
end
|
||||||
|
end
|
99
lib/ash_authentication_phoenix/overrides.ex
Normal file
99
lib/ash_authentication_phoenix/overrides.ex
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
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.
|
||||||
|
|
||||||
|
The default implementation is `AshAuthentication.Phoenix.Overrides.Default`
|
||||||
|
which uses [TailwindCSS](https://tailwindcss.com/) to generate a fairly
|
||||||
|
generic looking user interface.
|
||||||
|
|
||||||
|
You can override by setting the following in your `config.exs`:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
config :my_app, AshAuthentication.Phoenix, override_module: MyAppWeb.AuthOverrides
|
||||||
|
```
|
||||||
|
|
||||||
|
and defining `lib/my_app_web/auth_styles.ex` within which you can set CSS
|
||||||
|
classes for any values you want.
|
||||||
|
|
||||||
|
The `use` macro defines overridable versions of all callbacks which return
|
||||||
|
`nil`, so you only need to define the functions that you care about.
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmodule MyAppWeb.AuthOverrides do
|
||||||
|
use AshAuthentication.Phoenix.Overrides
|
||||||
|
|
||||||
|
def password_authentication_form_label_css_class, do: "my-custom-css-class"
|
||||||
|
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()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec __using__(any) :: Macro.t()
|
||||||
|
defmacro __using__(_) do
|
||||||
|
quote do
|
||||||
|
require Overrides
|
||||||
|
@behaviour Overrides
|
||||||
|
|
||||||
|
Overrides.generate_default_implementations()
|
||||||
|
Overrides.make_overridable()
|
||||||
|
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
|
||||||
|
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
|
69
lib/ash_authentication_phoenix/overrides/default.ex
Normal file
69
lib/ash_authentication_phoenix/overrides/default.ex
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
defmodule AshAuthentication.Phoenix.Overrides.Default do
|
||||||
|
@moduledoc """
|
||||||
|
The default implmentation of `AshAuthentication.Phoenix.Overrides` using
|
||||||
|
[TailwindCSS](https://tailwindcss.com/).
|
||||||
|
|
||||||
|
These colours and styles were chosen to be reasonably generic looking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use AshAuthentication.Phoenix.Overrides
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
def password_authentication_form_label_css_class,
|
||||||
|
do: "block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
def password_authentication_form_input_surround_css_class, do: "mt-2 mb-2"
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
def password_authentication_form_text_input_css_class,
|
||||||
|
do: """
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
def password_authentication_box_css_class, do: "mt-4 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"
|
||||||
|
end
|
52
lib/ash_authentication_phoenix/plug.ex
Normal file
52
lib/ash_authentication_phoenix/plug.ex
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
defmodule AshAuthentication.Phoenix.Plug do
|
||||||
|
@moduledoc """
|
||||||
|
Helper plugs mixed in to your router.
|
||||||
|
|
||||||
|
When you `use AshAuthentication.Phoenix.Router` this module is included, so
|
||||||
|
that you can use these plugs in your pipelines.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias AshAuthentication.Plug.Helpers
|
||||||
|
alias Plug.Conn
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Attempt to retrieve all actors from the connections' session.
|
||||||
|
|
||||||
|
A wrapper around `AshAuthentication.Plug.Helpers.retrieve_from_session/2`
|
||||||
|
with the `otp_app` already present.
|
||||||
|
"""
|
||||||
|
@spec load_from_session(Conn.t(), any) :: Conn.t()
|
||||||
|
def load_from_session(conn, _opts) do
|
||||||
|
:otp_app
|
||||||
|
|> conn.private.phoenix_endpoint.config()
|
||||||
|
|> then(&Helpers.retrieve_from_session(conn, &1))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Attempt to retrieve actors from the `Authorization` header(s).
|
||||||
|
|
||||||
|
A wrapper around `AshAuthentication.Plug.Helpers.retrieve_from_bearer/2` with the `otp_app` already present.
|
||||||
|
"""
|
||||||
|
@spec load_from_bearer(Conn.t(), any) :: Conn.t()
|
||||||
|
def load_from_bearer(conn, _opts) do
|
||||||
|
otp_app = conn.private.phoenix_endpoint.config(:otp_app)
|
||||||
|
Helpers.retrieve_from_bearer(conn, otp_app)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Revoke all token(s) in the `Authorization` header(s).
|
||||||
|
|
||||||
|
A wrapper around `AshAuthentication.Plug.Helpers.revoke_bearer_tokens/2` with the `otp_app` already present.
|
||||||
|
"""
|
||||||
|
@spec revoke_bearer_tokens(Conn.t(), any) :: Conn.t()
|
||||||
|
def revoke_bearer_tokens(conn, _opts) do
|
||||||
|
otp_app = conn.private.phoenix_endpoint.config(:otp_app)
|
||||||
|
Helpers.revoke_bearer_tokens(conn, otp_app)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Store the actor in the connections' session.
|
||||||
|
"""
|
||||||
|
@spec store_in_session(Conn.t(), Ash.Resource.record()) :: Conn.t()
|
||||||
|
defdelegate store_in_session(conn, actor), to: AshAuthentication.Plug.Helpers
|
||||||
|
end
|
112
lib/ash_authentication_phoenix/router.ex
Normal file
112
lib/ash_authentication_phoenix/router.ex
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
defmodule AshAuthentication.Phoenix.Router do
|
||||||
|
@moduledoc """
|
||||||
|
Phoenix route generation for AshAuthentication.
|
||||||
|
|
||||||
|
Using this module imports the macros in this module and the plug functions
|
||||||
|
from `AshAuthentication.Phoenix.Plug`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Adding authentication to your live-view router is very simple:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmodule MyAppWeb.Router do
|
||||||
|
use MyAppWeb, :router
|
||||||
|
use AshAuthentication.Phoenix.Router
|
||||||
|
|
||||||
|
pipeline :browser do
|
||||||
|
# ...
|
||||||
|
plug(:load_from_session)
|
||||||
|
end
|
||||||
|
|
||||||
|
pipeline :api do
|
||||||
|
# ...
|
||||||
|
plug(:load_from_bearer)
|
||||||
|
end
|
||||||
|
|
||||||
|
scope "/", MyAppWeb do
|
||||||
|
pipe_through :browser
|
||||||
|
sign_in_route
|
||||||
|
sign_out_route AuthController
|
||||||
|
auth_routes AuthController
|
||||||
|
end
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec __using__(any) :: Macro.t()
|
||||||
|
defmacro __using__(_opts) do
|
||||||
|
quote do
|
||||||
|
import AshAuthentication.Phoenix.Router
|
||||||
|
import AshAuthentication.Phoenix.Plug
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Generates the routes needed for the various subjects and providers
|
||||||
|
authenticating with AshAuthentication.
|
||||||
|
|
||||||
|
This is required if you wish to use authentication.
|
||||||
|
"""
|
||||||
|
defmacro auth_routes(auth_controller, path \\ "auth", opts \\ []) do
|
||||||
|
opts =
|
||||||
|
opts
|
||||||
|
|> Keyword.put_new(:as, :auth)
|
||||||
|
|
||||||
|
quote do
|
||||||
|
scope unquote(path), unquote(opts) do
|
||||||
|
match(:*, "/:subject_name/:provider", unquote(auth_controller), :request, as: :request)
|
||||||
|
|
||||||
|
match(:*, "/:subject_name/:provider/callback", unquote(auth_controller), :callback,
|
||||||
|
as: :callback
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Generates a generic, white-label sign-in page using LiveView and the
|
||||||
|
components in `AshAuthentication.Phoenix.Components`.
|
||||||
|
|
||||||
|
This is completely optional.
|
||||||
|
"""
|
||||||
|
defmacro sign_in_route(
|
||||||
|
path \\ "/sign-in",
|
||||||
|
live_view \\ AshAuthentication.Phoenix.SignInLive,
|
||||||
|
opts \\ []
|
||||||
|
) do
|
||||||
|
{as, opts} = Keyword.pop(opts, :as, :auth)
|
||||||
|
|
||||||
|
opts =
|
||||||
|
opts
|
||||||
|
|> Keyword.put_new(:alias, false)
|
||||||
|
|
||||||
|
quote do
|
||||||
|
scope unquote(path), unquote(opts) do
|
||||||
|
import Phoenix.LiveView.Router, only: [live: 4, live_session: 2]
|
||||||
|
|
||||||
|
live_session :sign_in do
|
||||||
|
live("/", unquote(live_view), :sign_in, as: unquote(as))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Generates a sign-out route which points to the `sign_out` action in your auth
|
||||||
|
controller.
|
||||||
|
|
||||||
|
This is optional, but you probably want it.
|
||||||
|
"""
|
||||||
|
defmacro sign_out_route(auth_controller, path \\ "/sign-out", opts \\ []) do
|
||||||
|
{as, opts} = Keyword.pop(opts, :as, :auth)
|
||||||
|
|
||||||
|
quote do
|
||||||
|
scope unquote(path), unquote(opts) do
|
||||||
|
get("/", unquote(auth_controller), :sign_out, as: unquote(as))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
59
lib/ash_authentication_phoenix/sign_in_live.ex
Normal file
59
lib/ash_authentication_phoenix/sign_in_live.ex
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
defmodule AshAuthentication.Phoenix.SignInLive do
|
||||||
|
@moduledoc """
|
||||||
|
A generic, white-label sign-in page.
|
||||||
|
|
||||||
|
This live-view can be rendered into your app by using the
|
||||||
|
`AshAuthentication.Phoenix.Router.sign_in_route/3` macro in your router (or by
|
||||||
|
using `Phoenix.LiveView.Controller.live_render/3` directly in your markup).
|
||||||
|
|
||||||
|
This live-view finds all Ash resources with an authentication configuration
|
||||||
|
(via `AshAuthentication.authenticated_resources/1`) and renders the
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
40
mix.exs
40
mix.exs
|
@ -1,4 +1,5 @@
|
||||||
defmodule AshAuthenticationPhoenix.MixProject do
|
defmodule AshAuthentication.Phoenix.MixProject do
|
||||||
|
@moduledoc false
|
||||||
use Mix.Project
|
use Mix.Project
|
||||||
|
|
||||||
@version "0.1.0"
|
@version "0.1.0"
|
||||||
|
@ -15,9 +16,35 @@ defmodule AshAuthenticationPhoenix.MixProject do
|
||||||
package: package(),
|
package: package(),
|
||||||
elixirc_paths: elixirc_paths(Mix.env()),
|
elixirc_paths: elixirc_paths(Mix.env()),
|
||||||
dialyzer: [
|
dialyzer: [
|
||||||
plt_add_apps: [:mix, :ex_unit],
|
plt_add_apps: [:mix, :ex_unit, :ash_authentication],
|
||||||
plt_core_path: "priv/plts",
|
plt_core_path: "priv/plts",
|
||||||
plt_file: {:no_warn, "priv/plts/dialyzer.plt"}
|
plt_file: {:no_warn, "priv/plts/dialyzer.plt"}
|
||||||
|
],
|
||||||
|
docs: [
|
||||||
|
main: "readme",
|
||||||
|
extras: ["README.md"],
|
||||||
|
formatters: ["html"],
|
||||||
|
filter_modules: ~r/^Elixir.AshAuthentication.Phoenix/,
|
||||||
|
source_url_pattern:
|
||||||
|
"https://github.com/team-alembic/ash_authentication_phoenix/blob/main/%{path}#L%{line}",
|
||||||
|
groups_for_modules: [
|
||||||
|
"Routing and Controller": [
|
||||||
|
AshAuthentication.Phoenix.Controller,
|
||||||
|
AshAuthentication.Phoenix.Plug,
|
||||||
|
AshAuthentication.Phoenix.Router
|
||||||
|
],
|
||||||
|
Customisation: [
|
||||||
|
AshAuthentication.Phoenix.SignInLive,
|
||||||
|
AshAuthentication.Phoenix.Overrides,
|
||||||
|
AshAuthentication.Phoenix.Overrides.Default,
|
||||||
|
AshAuthentication.Phoenix.Components.SignIn,
|
||||||
|
AshAuthentication.Phoenix.Components.PasswordAuthentication,
|
||||||
|
AshAuthentication.Phoenix.Components.PasswordAuthentication.SignInForm,
|
||||||
|
AshAuthentication.Phoenix.Components.PasswordAuthentication.RegisterForm,
|
||||||
|
AshAuthentication.Phoenix.Components.PasswordAuthentication.Input,
|
||||||
|
AshAuthentication.Phoenix.Components.Helpers
|
||||||
|
]
|
||||||
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
@ -31,7 +58,8 @@ defmodule AshAuthenticationPhoenix.MixProject do
|
||||||
links: %{
|
links: %{
|
||||||
"Source" => "https://github.com/team-alembic/ash_authentication_phoenix"
|
"Source" => "https://github.com/team-alembic/ash_authentication_phoenix"
|
||||||
},
|
},
|
||||||
source_url: "https://github.com/team-alembic/ash_authentication_phoenix"
|
source_url: "https://github.com/team-alembic/ash_authentication_phoenix",
|
||||||
|
files: ~w(lib .formatter.exs mix.exs README* LICENSE* CHANGELOG*)
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -53,8 +81,7 @@ defmodule AshAuthenticationPhoenix.MixProject do
|
||||||
# Run "mix help deps" to learn about dependencies.
|
# Run "mix help deps" to learn about dependencies.
|
||||||
defp deps do
|
defp deps do
|
||||||
[
|
[
|
||||||
{:ash_authentication,
|
{:ash_authentication, github: "team-alembic/ash_authentication", optional: true},
|
||||||
github: "team-alembic/ash_authentication", optional: true, branch: "feat/identity"},
|
|
||||||
{:ash_phoenix, "~> 1.1"},
|
{:ash_phoenix, "~> 1.1"},
|
||||||
{:ash, "~> 2.2"},
|
{:ash, "~> 2.2"},
|
||||||
{:jason, "~> 1.0"},
|
{:jason, "~> 1.0"},
|
||||||
|
@ -83,8 +110,7 @@ defmodule AshAuthenticationPhoenix.MixProject do
|
||||||
"dialyzer",
|
"dialyzer",
|
||||||
"hex.audit",
|
"hex.audit",
|
||||||
"test"
|
"test"
|
||||||
],
|
]
|
||||||
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"]
|
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
2
mix.lock
2
mix.lock
|
@ -1,6 +1,6 @@
|
||||||
%{
|
%{
|
||||||
"ash": {:hex, :ash, "2.2.0", "4fdc0fef5afb3f5045b1ca4e1ccb139b9f703cbc7c21dc645e32ac9582b11f63", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:spark, "~> 0.1 and >= 0.1.28", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "48eca587e7076fe4f8547e919c0712f081ce85e66c316f6f51dd2535ad046013"},
|
"ash": {:hex, :ash, "2.2.0", "4fdc0fef5afb3f5045b1ca4e1ccb139b9f703cbc7c21dc645e32ac9582b11f63", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:spark, "~> 0.1 and >= 0.1.28", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "48eca587e7076fe4f8547e919c0712f081ce85e66c316f6f51dd2535ad046013"},
|
||||||
"ash_authentication": {:git, "https://github.com/team-alembic/ash_authentication.git", "335f4b97d8f0bbd13c117e82ded621bf2884669f", [branch: "feat/identity"]},
|
"ash_authentication": {:git, "https://github.com/team-alembic/ash_authentication.git", "809a0faa55fc29fe08c6586fb53643605b09d2a3", []},
|
||||||
"ash_phoenix": {:hex, :ash_phoenix, "1.1.0", "7e8da0d463d181f5ee7f029722ea54519947d08d2c154b29b8b88e06d02fabdf", [:mix], [{:ash, "~> 2.0", [hex: :ash, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.15", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "0f8cf588d845fbd827ff522b3102170e4e90a1a2de63743b1be5e37b535a75ec"},
|
"ash_phoenix": {:hex, :ash_phoenix, "1.1.0", "7e8da0d463d181f5ee7f029722ea54519947d08d2c154b29b8b88e06d02fabdf", [:mix], [{:ash, "~> 2.0", [hex: :ash, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.15", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "0f8cf588d845fbd827ff522b3102170e4e90a1a2de63743b1be5e37b535a75ec"},
|
||||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.0.1", "9be815469e6bfefec40fa74658ecbbe6897acfb57614df1416eeccd4903f602c", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "486bb95efb645d1efc6794c1ddd776a186a9a713abf06f45708a6ce324fb96cf"},
|
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.0.1", "9be815469e6bfefec40fa74658ecbbe6897acfb57614df1416eeccd4903f602c", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "486bb95efb645d1efc6794c1ddd776a186a9a713abf06f45708a6ce324fb96cf"},
|
||||||
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
|
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
defmodule AshAuthenticationPhoenixTest do
|
defmodule AshAuthentication.PhoenixTest do
|
||||||
|
@moduledoc false
|
||||||
use ExUnit.Case
|
use ExUnit.Case
|
||||||
doctest AshAuthenticationPhoenix
|
doctest AshAuthentication.Phoenix
|
||||||
|
|
||||||
test "greets the world" do
|
|
||||||
assert AshAuthenticationPhoenix.hello() == :world
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in a new issue