mirror of
https://github.com/team-alembic/ash_authentication_phoenix.git
synced 2024-09-19 13:03:50 +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{
|
||||
ignore_modules: [~r/^Inspect\./, ~r/.Plug$/],
|
||||
ignore_modules: [~r/^Inspect\./, ~r/.Plug$/, AshAuthentication.Phoenix.Overrides],
|
||||
ignore_paths: [],
|
||||
min_module_doc_coverage: 40,
|
||||
min_module_spec_coverage: 0,
|
||||
|
|
|
@ -1,5 +1,19 @@
|
|||
# Used by "mix format"
|
||||
[
|
||||
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
|
||||
|
||||
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
|
||||
by adding `ash_authentication_phoenix` to your list of dependencies in `mix.exs`:
|
||||
The package can be installed by adding `ash_authentication_phoenix` to your list
|
||||
of dependencies in `mix.exs`:
|
||||
|
||||
```elixir
|
||||
def deps do
|
||||
|
@ -15,7 +18,59 @@ def deps do
|
|||
end
|
||||
```
|
||||
|
||||
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
|
||||
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
|
||||
be found at <https://hexdocs.pm/ash_authentication_phoenix>.
|
||||
## Usage
|
||||
|
||||
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",
|
||||
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,
|
||||
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
|
||||
|
||||
use DevWeb, :controller
|
||||
# use AshPhoenix.Authentication.Controller
|
||||
alias Plug.Conn
|
||||
use AshAuthentication.Phoenix.Controller
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def success(conn, user, _token) do
|
||||
conn
|
||||
# |> store_in_session(user)
|
||||
|> store_in_session(user)
|
||||
|> assign(:current_user, user)
|
||||
|> put_status(200)
|
||||
|> render("success.html")
|
||||
|
|
|
@ -2,9 +2,10 @@ defmodule DevWeb.PageController do
|
|||
@moduledoc false
|
||||
|
||||
use DevWeb, :controller
|
||||
alias Plug.Conn
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec index(Conn.t(), %{required(String.t()) => String.t()}) :: Conn.t()
|
||||
def index(conn, _params) do
|
||||
render(conn, "index.html")
|
||||
end
|
||||
|
|
|
@ -2,7 +2,7 @@ defmodule DevWeb.Router do
|
|||
@moduledoc false
|
||||
|
||||
use DevWeb, :router
|
||||
# use AshPhoenix.Authentication.Router
|
||||
use AshAuthentication.Phoenix.Router
|
||||
|
||||
pipeline :browser do
|
||||
plug :accepts, ["html"]
|
||||
|
@ -11,7 +11,7 @@ defmodule DevWeb.Router do
|
|||
plug :put_root_layout, {DevWeb.LayoutView, :root}
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
# plug :load_from_session
|
||||
plug :load_from_session
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
|
@ -29,10 +29,10 @@ defmodule DevWeb.Router do
|
|||
# pipe_through :api
|
||||
# end
|
||||
|
||||
# scope "/" do
|
||||
# pipe_through :browser
|
||||
# auth_routes DevWeb.AuthController, "/auth"
|
||||
# sign_in_route "/sign-in"
|
||||
# sign_out_route DevWeb.AuthController, "/sign-out"
|
||||
# end
|
||||
scope "/", DevWeb do
|
||||
pipe_through :browser
|
||||
auth_routes(AuthController, "/auth")
|
||||
sign_in_route("/sign-in")
|
||||
sign_out_route(AuthController, "/sign-out")
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,18 +1,32 @@
|
|||
defmodule AshAuthenticationPhoenix do
|
||||
defmodule AshAuthentication.Phoenix do
|
||||
@moduledoc """
|
||||
Documentation for `AshAuthenticationPhoenix`.
|
||||
"""
|
||||
Welcome to `AshAuthentication.Pheonix`.
|
||||
|
||||
@doc """
|
||||
Hello world.
|
||||
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.
|
||||
|
||||
## Examples
|
||||
## Where to start.
|
||||
|
||||
iex> AshAuthenticationPhoenix.hello()
|
||||
:world
|
||||
Presuming that you already have [Phoenix](https://phoenixframework.org/),
|
||||
[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
|
||||
|
|
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
|
||||
|
||||
@version "0.1.0"
|
||||
|
@ -15,9 +16,35 @@ defmodule AshAuthenticationPhoenix.MixProject do
|
|||
package: package(),
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
dialyzer: [
|
||||
plt_add_apps: [:mix, :ex_unit],
|
||||
plt_add_apps: [:mix, :ex_unit, :ash_authentication],
|
||||
plt_core_path: "priv/plts",
|
||||
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
|
||||
|
@ -31,7 +58,8 @@ defmodule AshAuthenticationPhoenix.MixProject do
|
|||
links: %{
|
||||
"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
|
||||
|
||||
|
@ -53,8 +81,7 @@ defmodule AshAuthenticationPhoenix.MixProject do
|
|||
# Run "mix help deps" to learn about dependencies.
|
||||
defp deps do
|
||||
[
|
||||
{:ash_authentication,
|
||||
github: "team-alembic/ash_authentication", optional: true, branch: "feat/identity"},
|
||||
{:ash_authentication, github: "team-alembic/ash_authentication", optional: true},
|
||||
{:ash_phoenix, "~> 1.1"},
|
||||
{:ash, "~> 2.2"},
|
||||
{:jason, "~> 1.0"},
|
||||
|
@ -83,8 +110,7 @@ defmodule AshAuthenticationPhoenix.MixProject do
|
|||
"dialyzer",
|
||||
"hex.audit",
|
||||
"test"
|
||||
],
|
||||
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"]
|
||||
]
|
||||
]
|
||||
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_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"},
|
||||
"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"},
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
defmodule AshAuthenticationPhoenixTest do
|
||||
defmodule AshAuthentication.PhoenixTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case
|
||||
doctest AshAuthenticationPhoenix
|
||||
|
||||
test "greets the world" do
|
||||
assert AshAuthenticationPhoenix.hello() == :world
|
||||
end
|
||||
doctest AshAuthentication.Phoenix
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue