mirror of
https://github.com/fly-apps/live_beats.git
synced 2024-11-21 15:41:00 +00:00
Add github login
This commit is contained in:
parent
f9edbf76ba
commit
e28abc0a0a
21 changed files with 899 additions and 149 deletions
|
@ -23,7 +23,7 @@ import Alpine from "alpinejs"
|
|||
import "phoenix_html"
|
||||
// Establish Phoenix Socket and LiveView configuration.
|
||||
import {Socket} from "phoenix"
|
||||
import {LiveSocket} from "phoenix_live_view"
|
||||
import {LiveSocket} from "./phoenix_live_view"
|
||||
import topbar from "../vendor/topbar"
|
||||
|
||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
|
|
1
assets/js/phoenix_live_view
Symbolic link
1
assets/js/phoenix_live_view
Symbolic link
|
@ -0,0 +1 @@
|
|||
/Users/chris/oss/phoenix_live_view/assets/js/phoenix_live_view
|
|
@ -1,5 +1,10 @@
|
|||
import Config
|
||||
|
||||
config :live_beats, :github, %{
|
||||
client_id: "83806139172df82d4ccc",
|
||||
client_secret: System.fetch_env!("LIVE_BEATS_GITHUB_CLIENT_SECRET"),
|
||||
}
|
||||
|
||||
# Configure your database
|
||||
config :live_beats, LiveBeats.Repo,
|
||||
username: "postgres",
|
||||
|
|
89
lib/live_beats/accounts.ex
Normal file
89
lib/live_beats/accounts.ex
Normal file
|
@ -0,0 +1,89 @@
|
|||
defmodule LiveBeats.Accounts do
|
||||
import Ecto.Query
|
||||
import Ecto.Changeset
|
||||
|
||||
alias LiveBeats.Repo
|
||||
alias LiveBeats.Accounts.{User, Identity}
|
||||
|
||||
@admin_emails ["chris@chrismccord.com"]
|
||||
|
||||
def list_users(opts) do
|
||||
Repo.all(from u in User, limit: ^Keyword.fetch!(opts, :limit))
|
||||
end
|
||||
|
||||
def admin?(%User{} = user), do: user.email in @admin_emails
|
||||
|
||||
## Database getters
|
||||
|
||||
@doc """
|
||||
Gets a user by email.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_by_email("foo@example.com")
|
||||
%User{}
|
||||
|
||||
iex> get_user_by_email("unknown@example.com")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_user_by_email(email) when is_binary(email) do
|
||||
Repo.get_by(User, email: email)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single user.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the User does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user!(123)
|
||||
%User{}
|
||||
|
||||
iex> get_user!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_user!(id), do: Repo.get!(User, id)
|
||||
|
||||
## User registration
|
||||
|
||||
@doc """
|
||||
Registers a user from their GithHub information.
|
||||
"""
|
||||
def register_github_user(primary_email, info, emails, token) do
|
||||
if user = get_user_by_provider(:github, primary_email) do
|
||||
update_github_token(user, token)
|
||||
else
|
||||
info
|
||||
|> User.github_registration_changeset(primary_email, emails, token)
|
||||
|> Repo.insert()
|
||||
end
|
||||
end
|
||||
|
||||
def get_user_by_provider(provider, email) when provider in [:github] do
|
||||
query =
|
||||
from(u in User,
|
||||
join: i in assoc(u, :identities),
|
||||
where:
|
||||
i.provider == ^to_string(provider) and
|
||||
fragment("lower(?)", u.email) == ^String.downcase(email)
|
||||
)
|
||||
|
||||
Repo.one(query)
|
||||
end
|
||||
|
||||
defp update_github_token(%User{} = user, new_token) do
|
||||
identity =
|
||||
Repo.one!(from(i in Identity, where: i.user_id == ^user.id and i.provider == "github"))
|
||||
|
||||
{:ok, _} =
|
||||
identity
|
||||
|> change()
|
||||
|> put_change(:provider_token, new_token)
|
||||
|> Repo.update()
|
||||
|
||||
{:ok, Repo.preload(user, :identities, force: true)}
|
||||
end
|
||||
end
|
42
lib/live_beats/accounts/identity.ex
Normal file
42
lib/live_beats/accounts/identity.ex
Normal file
|
@ -0,0 +1,42 @@
|
|||
defmodule LiveBeats.Accounts.Identity do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias LiveBeats.Accounts.{Identity, User}
|
||||
|
||||
# providers
|
||||
@github "github"
|
||||
|
||||
@derive {Inspect, except: [:provider_token, :provider_meta]}
|
||||
schema "identities" do
|
||||
field :provider, :string
|
||||
field :provider_token, :string
|
||||
field :provider_email, :string
|
||||
field :provider_login, :string
|
||||
field :provider_name, :string, virtual: true
|
||||
field :provider_id, :string
|
||||
field :provider_meta, :map
|
||||
|
||||
belongs_to :user, User
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for github registration.
|
||||
"""
|
||||
def github_registration_changeset(info, primary_email, emails, token) do
|
||||
params = %{
|
||||
"provider_token" => token,
|
||||
"provider_id" => to_string(info["id"]),
|
||||
"provider_login" => info["login"],
|
||||
"provider_name" => info["name"] || info["login"],
|
||||
"provider_email" => primary_email,
|
||||
}
|
||||
|
||||
%Identity{provider: @github, provider_meta: %{"user" => info, "emails" => emails}}
|
||||
|> cast(params, [:provider_token, :provider_email, :provider_login, :provider_name, :provider_id])
|
||||
|> validate_required([:provider_token, :provider_email, :provider_name, :provider_id])
|
||||
|> validate_length(:provider_meta, max: 10_000)
|
||||
end
|
||||
end
|
52
lib/live_beats/accounts/user.ex
Normal file
52
lib/live_beats/accounts/user.ex
Normal file
|
@ -0,0 +1,52 @@
|
|||
defmodule LiveBeats.Accounts.User do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias LiveBeats.Accounts.{User, Identity}
|
||||
|
||||
schema "users" do
|
||||
field :email, :string
|
||||
field :name, :string
|
||||
field :username, :string
|
||||
field :confirmed_at, :naive_datetime
|
||||
field :role, :string, default: "subscriber"
|
||||
|
||||
has_many :identities, Identity
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for github registration.
|
||||
"""
|
||||
def github_registration_changeset(info, primary_email, emails, token) do
|
||||
%{"login" => username} = info
|
||||
identity_changeset = Identity.github_registration_changeset(info, primary_email, emails, token)
|
||||
if identity_changeset.valid? do
|
||||
params = %{
|
||||
"username" => username,
|
||||
"email" => primary_email,
|
||||
"name" => get_change(identity_changeset, :provider_name),
|
||||
}
|
||||
%User{}
|
||||
|> cast(params, [:email, :name, :username])
|
||||
|> validate_required([:email, :name, :username])
|
||||
|> validate_email()
|
||||
|> put_assoc(:identities, [identity_changeset])
|
||||
else
|
||||
%User{}
|
||||
|> change()
|
||||
|> Map.put(:value?, false)
|
||||
|> put_assoc(:identities, [identity_changeset])
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_email(changeset) do
|
||||
changeset
|
||||
|> validate_required([:email])
|
||||
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
|
||||
|> validate_length(:email, max: 160)
|
||||
|> unsafe_validate_unique(:email, LiveBeats.Repo)
|
||||
|> unique_constraint(:email)
|
||||
end
|
||||
end
|
129
lib/live_beats/github.ex
Normal file
129
lib/live_beats/github.ex
Normal file
|
@ -0,0 +1,129 @@
|
|||
defmodule LiveBeats.Github do
|
||||
def authorize_url() do
|
||||
state = random_string()
|
||||
"https://github.com/login/oauth/authorize?client_id=#{client_id()}&state=#{state}&scope=user:email"
|
||||
end
|
||||
|
||||
def exchange_access_token(opts) do
|
||||
code = Keyword.fetch!(opts, :code)
|
||||
state = Keyword.fetch!(opts, :state)
|
||||
|
||||
state
|
||||
|> fetch_exchange_response(code)
|
||||
|> fetch_user_info()
|
||||
|> fetch_emails()
|
||||
end
|
||||
|
||||
defp fetch_exchange_response(state, code) do
|
||||
resp =
|
||||
http(
|
||||
"github.com",
|
||||
"POST",
|
||||
"/login/oauth/access_token",
|
||||
[state: state, code: code, client_secret: secret()],
|
||||
[{"accept", "application/json"}]
|
||||
)
|
||||
|
||||
with {:ok, resp} <- resp,
|
||||
%{"access_token" => token} <- Jason.decode!(resp) do
|
||||
{:ok, token}
|
||||
else
|
||||
{:error, _reason} = err -> err
|
||||
%{} = resp -> {:error, {:bad_response, resp}}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_user_info({:error, _reason} = error), do: error
|
||||
defp fetch_user_info({:ok, token}) do
|
||||
resp =
|
||||
http(
|
||||
"api.github.com",
|
||||
"GET",
|
||||
"/user",
|
||||
[],
|
||||
[{"accept", "application/vnd.github.v3+json"}, {"Authorization", "token #{token}"}]
|
||||
)
|
||||
case resp do
|
||||
{:ok, info} -> {:ok, %{info: Jason.decode!(info), token: token}}
|
||||
{:error, _reason} = err -> err
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_emails({:error, _} = err), do: err
|
||||
defp fetch_emails({:ok, user}) do
|
||||
resp =
|
||||
http(
|
||||
"api.github.com",
|
||||
"GET",
|
||||
"/user/emails",
|
||||
[],
|
||||
[{"accept", "application/vnd.github.v3+json"}, {"Authorization", "token #{user.token}"}]
|
||||
)
|
||||
case resp do
|
||||
{:ok, info} ->
|
||||
emails = Jason.decode!(info)
|
||||
{:ok, Map.merge(user, %{primary_email: primary_email(emails), emails: emails})}
|
||||
|
||||
{:error, _reason} = err ->
|
||||
err
|
||||
end
|
||||
end
|
||||
|
||||
def random_string do
|
||||
binary = <<
|
||||
System.system_time(:nanosecond)::64,
|
||||
:erlang.phash2({node(), self()})::16,
|
||||
:erlang.unique_integer()::16
|
||||
>>
|
||||
|
||||
binary
|
||||
|> Base.url_encode64()
|
||||
|> String.replace(["/", "+"], "-")
|
||||
end
|
||||
|
||||
defp client_id, do: Application.fetch_env!(:live_beats, :github)[:client_id]
|
||||
defp secret, do: Application.fetch_env!(:live_beats, :github)[:client_secret]
|
||||
|
||||
defp http(host, method, path, query, headers, body \\ "") do
|
||||
{:ok, conn} = Mint.HTTP.connect(:https, host, 443)
|
||||
|
||||
path = path <> "?" <> URI.encode_query([{:client_id, client_id()} | query])
|
||||
|
||||
{:ok, conn, ref} =
|
||||
Mint.HTTP.request(
|
||||
conn,
|
||||
method,
|
||||
path,
|
||||
headers,
|
||||
body
|
||||
)
|
||||
|
||||
receive_resp(conn, ref, nil, nil, false)
|
||||
end
|
||||
|
||||
defp receive_resp(conn, ref, status, data, done?) do
|
||||
receive do
|
||||
message ->
|
||||
{:ok, conn, responses} = Mint.HTTP.stream(conn, message)
|
||||
|
||||
{new_status, new_data, done?} =
|
||||
Enum.reduce(responses, {status, data, done?}, fn
|
||||
{:status, ^ref, new_status}, {_old_status, data, done?} -> {new_status, data, done?}
|
||||
{:headers, ^ref, _headers}, acc -> acc
|
||||
{:data, ^ref, binary}, {status, nil, done?} -> {status, binary, done?}
|
||||
{:data, ^ref, binary}, {status, data, done?} -> {status, data <> binary, done?}
|
||||
{:done, ^ref}, {status, data, _done?} -> {status, data, true}
|
||||
end)
|
||||
|
||||
cond do
|
||||
done? and new_status == 200 -> {:ok, new_data}
|
||||
done? -> {:error, {new_status, new_data}}
|
||||
!done? -> receive_resp(conn, ref, new_status, new_data, done?)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp primary_email(emails) do
|
||||
Enum.find(emails, fn email -> email["primary"] end)["email"] || Enum.at(emails, 0)
|
||||
end
|
||||
end
|
41
lib/live_beats_web/controllers/oauth_callback_controller.ex
Normal file
41
lib/live_beats_web/controllers/oauth_callback_controller.ex
Normal file
|
@ -0,0 +1,41 @@
|
|||
defmodule LiveBeatsWeb.OAuthCallbackController do
|
||||
use LiveBeatsWeb, :controller
|
||||
require Logger
|
||||
|
||||
alias LiveBeats.Accounts
|
||||
|
||||
def new(conn, %{"provider" => "github", "code" => code, "state" => state}) do
|
||||
client = github_client(conn)
|
||||
|
||||
with {:ok, info} <- client.exchange_access_token(code: code, state: state),
|
||||
%{info: info, primary_email: primary, emails: emails, token: token} = info,
|
||||
{:ok, user} <- Accounts.register_github_user(primary, info, emails, token) do
|
||||
|
||||
conn
|
||||
|> put_flash(:info, "Welcome #{user.email}")
|
||||
|> LiveBeatsWeb.UserAuth.log_in_user(user)
|
||||
else
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
Logger.debug("failed GitHub insert #{inspect(changeset.errors)}")
|
||||
|
||||
conn
|
||||
|> put_flash(:error, "We were unable to fetch the necessary information from your GithHub account")
|
||||
|> redirect(to: "/")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.debug("failed GitHub exchange #{inspect(reason)}")
|
||||
|
||||
conn
|
||||
|> put_flash(:error, "We were unable to contact GitHub. Please try again later")
|
||||
|> redirect(to: "/")
|
||||
end
|
||||
end
|
||||
|
||||
def new(conn, %{"provider" => "github", "error" => "access_denied"}) do
|
||||
redirect(conn, to: "/")
|
||||
end
|
||||
|
||||
defp github_client(conn) do
|
||||
conn.assigns[:github_client] || LiveBeats.Github
|
||||
end
|
||||
end
|
124
lib/live_beats_web/controllers/user_auth.ex
Normal file
124
lib/live_beats_web/controllers/user_auth.ex
Normal file
|
@ -0,0 +1,124 @@
|
|||
defmodule LiveBeatsWeb.UserAuth do
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
|
||||
alias Phoenix.LiveView
|
||||
alias LiveBeats.Accounts
|
||||
alias LiveBeatsWeb.Router.Helpers, as: Routes
|
||||
|
||||
def mount_defaults(_params, session, socket) do
|
||||
case session do
|
||||
%{"user_id" => user_id} ->
|
||||
{:cont, LiveView.assign_new(socket, :current_user, fn -> Accounts.get_user!(user_id) end)}
|
||||
|
||||
%{} ->
|
||||
{:cont, LiveView.assign(socket, :current_user, nil)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Logs the user in.
|
||||
|
||||
It renews the session ID and clears the whole session
|
||||
to avoid fixation attacks. See the renew_session
|
||||
function to customize this behaviour.
|
||||
|
||||
It also sets a `:live_socket_id` key in the session,
|
||||
so LiveView sessions are identified and automatically
|
||||
disconnected on log out. The line can be safely removed
|
||||
if you are not using LiveView.
|
||||
"""
|
||||
def log_in_user(conn, user) do
|
||||
user_return_to = get_session(conn, :user_return_to)
|
||||
|
||||
conn
|
||||
|> renew_session()
|
||||
|> put_session(:user_id, user.id)
|
||||
|> put_session(:live_socket_id, "users_sessions:#{user.id}")
|
||||
|> redirect(to: user_return_to || signed_in_path(conn))
|
||||
end
|
||||
|
||||
defp renew_session(conn) do
|
||||
conn
|
||||
|> configure_session(renew: true)
|
||||
|> clear_session()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Logs the user out.
|
||||
|
||||
It clears all session data for safety. See renew_session.
|
||||
"""
|
||||
def log_out_user(conn) do
|
||||
if live_socket_id = get_session(conn, :live_socket_id) do
|
||||
LiveBeatsWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
|
||||
end
|
||||
|
||||
conn
|
||||
|> renew_session()
|
||||
|> redirect(to: "/")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Authenticates the user by looking into the session.
|
||||
"""
|
||||
def fetch_current_user(conn, _opts) do
|
||||
user_id = get_session(conn, :user_id)
|
||||
user = user_id && Accounts.get_user!(user_id)
|
||||
assign(conn, :current_user, user)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Used for routes that require the user to not be authenticated.
|
||||
"""
|
||||
def redirect_if_user_is_authenticated(conn, _opts) do
|
||||
if conn.assigns[:current_user] do
|
||||
conn
|
||||
|> redirect(to: signed_in_path(conn))
|
||||
|> halt()
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Used for routes that require the user to be authenticated.
|
||||
|
||||
If you want to enforce the user email is confirmed before
|
||||
they use the application at all, here would be a good place.
|
||||
"""
|
||||
def require_authenticated_user(conn, _opts) do
|
||||
if conn.assigns[:current_user] do
|
||||
conn
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "You must log in to access this page.")
|
||||
|> maybe_store_return_to()
|
||||
|> redirect(to: Routes.home_path(conn, :index))
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
def require_authenticated_admin(conn, _opts) do
|
||||
user = conn.assigns[:current_user]
|
||||
if user && LiveBeats.Accounts.admin?(user) do
|
||||
assign(conn, :current_admin, user)
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "You must be logged into access that page")
|
||||
|> maybe_store_return_to()
|
||||
|> redirect(to: "/")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(%{method: "GET"} = conn) do
|
||||
%{request_path: request_path, query_string: query_string} = conn
|
||||
return_to = if query_string == "", do: request_path, else: request_path <> "?" <> query_string
|
||||
put_session(conn, :user_return_to, return_to)
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(conn), do: conn
|
||||
|
||||
defp signed_in_path(_conn), do: "/"
|
||||
end
|
|
@ -17,9 +17,12 @@ defmodule LiveBeatsWeb.PlayerLive do
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-200 flex-auto dark:bg-black rounded-full overflow-hidden">
|
||||
<div class="bg-lime-500 dark:bg-lime-400 w-1/2 h-1.5" role="progressbar" aria-valuenow="1456" aria-valuemin="0" aria-valuemax="4550"></div>
|
||||
<div class="bg-gray-200 flex-auto dark:bg-black rounded-full overflow-hidden" phx-update="ignore">
|
||||
<div class="bg-lime-500 dark:bg-lime-400 h-1.5" role="progressbar"
|
||||
style="width: 0%;"
|
||||
x-data="{progress: 0}"
|
||||
x-init="setInterval(() => $el.style.width = `${progress++}%`, 1000)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 flex-row justify-between text-sm font-medium tabular-nums">
|
||||
<div><%= @time %></div>
|
||||
|
@ -76,7 +79,7 @@ defmodule LiveBeatsWeb.PlayerLive do
|
|||
|
||||
def mount(_parmas, _session, socket) do
|
||||
if connected?(socket), do: Process.send_after(self(), :tick, 1000)
|
||||
{:ok, assign(socket, time: inspect(System.system_time()), count: 0)}
|
||||
{:ok, assign(socket, time: inspect(System.system_time()), count: 0), layout: false}
|
||||
end
|
||||
|
||||
def handle_info(:tick, socket) do
|
||||
|
|
|
@ -19,87 +19,10 @@ defmodule LiveBeatsWeb.SigninLive do
|
|||
|
||||
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
<form class="space-y-6" action="#" method="POST">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700">
|
||||
Email address
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<input id="email" name="email" type="email" autocomplete="email" required class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<input id="remember-me" name="remember-me" type="checkbox" class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
|
||||
<label for="remember-me" class="ml-2 block text-sm text-gray-900">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="text-sm">
|
||||
<a href="#" class="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 bg-white text-gray-500">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<a href="#" class="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
||||
<span class="sr-only">Sign in with Facebook</span>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M20 10c0-5.523-4.477-10-10-10S0 4.477 0 10c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V10h2.54V7.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V10h2.773l-.443 2.89h-2.33v6.988C16.343 19.128 20 14.991 20 10z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a href="#" class="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
||||
<span class="sr-only">Sign in with Twitter</span>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path d="M6.29 18.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0020 3.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.073 4.073 0 01.8 7.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 010 16.407a11.616 11.616 0 006.29 1.84" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a href="#" class="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
||||
<span class="sr-only">Sign in with GitHub</span>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<a href={LiveBeats.Github.authorize_url()} class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
defmodule LiveBeatsWeb.Router do
|
||||
use LiveBeatsWeb, :router
|
||||
|
||||
import LiveBeatsWeb.UserAuth, only: [redirect_if_user_is_authenticated: 2]
|
||||
|
||||
pipeline :browser do
|
||||
plug :accepts, ["html"]
|
||||
plug :fetch_session
|
||||
|
@ -17,12 +19,19 @@ defmodule LiveBeatsWeb.Router do
|
|||
scope "/", LiveBeatsWeb do
|
||||
pipe_through :browser
|
||||
|
||||
live_session :default do
|
||||
live_session :default, on_mount: {LiveBeatsWeb.UserAuth, :mount_defaults} do
|
||||
live "/test", IndexLive
|
||||
live "/", HomeLive, :index
|
||||
live "/signin", SigninLive, :index
|
||||
end
|
||||
end
|
||||
|
||||
scope "/", LiveBeatsWeb do
|
||||
pipe_through [:browser, :redirect_if_user_is_authenticated]
|
||||
|
||||
get "/oauth/callbacks/:provider", OAuthCallbackController, :new
|
||||
end
|
||||
|
||||
# Other scopes may use custom stacks.
|
||||
# scope "/api", LiveBeatsWeb do
|
||||
# pipe_through :api
|
||||
|
|
|
@ -80,17 +80,21 @@
|
|||
My tasks
|
||||
</a>
|
||||
|
||||
<%= live_redirect to: Routes.signin_path(@conn, :index),
|
||||
class: "text-gray-600 hover:text-gray-900 hover:bg-gray-50 group flex items-center px-2 py-2 text-base leading-5 font-medium rounded-md" do %>
|
||||
<%= if @current_user do %>
|
||||
|
||||
<svg class="text-gray-400 group-hover:text-gray-500 mr-3 flex-shrink-0 h-6 w-6"
|
||||
x-state-description="undefined: "text-gray-500", undefined: "text-gray-400 group-hover:text-gray-500""
|
||||
x-description="Heroicon name: outline/clock" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Recent
|
||||
<% else %>
|
||||
<%= live_redirect to: Routes.signin_path(@conn, :index),
|
||||
class: "text-gray-600 hover:text-gray-900 hover:bg-gray-50 group flex items-center px-2 py-2 text-base leading-5 font-medium rounded-md" do %>
|
||||
|
||||
<svg class="text-gray-400 group-hover:text-gray-500 mr-3 flex-shrink-0 h-6 w-6"
|
||||
x-state-description="undefined: "text-gray-500", undefined: "text-gray-400 group-hover:text-gray-500""
|
||||
x-description="Heroicon name: outline/clock" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Sign in
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
</div>
|
||||
|
@ -145,52 +149,54 @@
|
|||
</div>
|
||||
<!-- Sidebar component, swap this element with another sidebar if you like -->
|
||||
<div class="h-0 flex-1 flex flex-col overflow-y-auto">
|
||||
<%= if @current_user do %>
|
||||
<!-- User account dropdown -->
|
||||
<div x-data="{open: false, activeIndex: 0}"
|
||||
@keydown.escape.stop="open = false"
|
||||
@click.away="open = false"
|
||||
class="px-3 mt-6 relative inline-block text-left">
|
||||
<div>
|
||||
<button type="button"
|
||||
class="group w-full bg-gray-100 rounded-md px-3.5 py-2 text-sm text-left font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-purple-500"
|
||||
id="options-menu-button"
|
||||
x-ref="button"
|
||||
@click="open = true">
|
||||
<span class="flex w-full justify-between items-center">
|
||||
<span class="flex min-w-0 items-center justify-between space-x-3">
|
||||
<img class="w-10 h-10 bg-gray-300 rounded-full flex-shrink-0"
|
||||
src="https://images.unsplash.com/photo-1502685104226-ee32379fefbe?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=3&w=256&h=256&q=80"
|
||||
alt="">
|
||||
<span class="flex-1 flex flex-col min-w-0">
|
||||
<span class="text-gray-900 text-sm font-medium truncate">Jessy Schwarz</span>
|
||||
<span class="text-gray-500 text-sm truncate">@jessyschwarz</span>
|
||||
<div x-data="{open: false, activeIndex: 0}"
|
||||
@keydown.escape.stop="open = false"
|
||||
@click.away="open = false"
|
||||
class="px-3 mt-6 relative inline-block text-left">
|
||||
<div>
|
||||
<button type="button"
|
||||
class="group w-full bg-gray-100 rounded-md px-3.5 py-2 text-sm text-left font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-purple-500"
|
||||
id="options-menu-button"
|
||||
x-ref="button"
|
||||
@click="open = true">
|
||||
<span class="flex w-full justify-between items-center">
|
||||
<span class="flex min-w-0 items-center justify-between space-x-3">
|
||||
<img class="w-10 h-10 bg-gray-300 rounded-full flex-shrink-0"
|
||||
src="https://images.unsplash.com/photo-1502685104226-ee32379fefbe?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=3&w=256&h=256&q=80"
|
||||
alt="">
|
||||
<span class="flex-1 flex flex-col min-w-0">
|
||||
<span class="text-gray-900 text-sm font-medium truncate"><%= @current_user.name %></span>
|
||||
<span class="text-gray-500 text-sm truncate">@<%= @current_user.username %></span>
|
||||
</span>
|
||||
</span>
|
||||
<svg class="flex-shrink-0 h-5 w-5 text-gray-400 group-hover:text-gray-500"
|
||||
x-description="Heroicon name: solid/selector" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
|
||||
fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<svg class="flex-shrink-0 h-5 w-5 text-gray-400 group-hover:text-gray-500"
|
||||
x-description="Heroicon name: solid/selector" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
|
||||
fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="open" x-transition:enter="transition ease-out duration-100" x-transition:enter-start="transform opacity-0 scale-95" x-transition:enter-end="transform opacity-100 scale-100" x-transition:leave="transition ease-in duration-75" x-transition:leave-start="transform opacity-100 scale-100" x-transition:leave-end="transform opacity-0 scale-95" class="z-10 mx-3 origin-top absolute right-0 left-0 mt-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-200 focus:outline-none" x-ref="menu-items" x-description="Dropdown menu, show/hide based on menu state." role="menu" aria-orientation="vertical" aria-labelledby="options-menu-button" tabindex="-1" @keydown.arrow-up.prevent="onArrowUp()" @keydown.arrow-down.prevent="onArrowDown()" @keydown.tab="open = false">
|
||||
<div class="py-1" role="none">
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700" x-state:on="Active" x-state:off="Not Active" :class="{ 'bg-gray-100 text-gray-900': activeIndex === 0, 'text-gray-700': !(activeIndex === 0) }" role="menuitem" tabindex="-1" id="options-menu-item-0" @mouseenter="activeIndex = 0" @mouseleave="activeIndex = -1" @click="open = false; focusButton()">View profile</a>
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700" :class="{ 'bg-gray-100 text-gray-900': activeIndex === 1, 'text-gray-700': !(activeIndex === 1) }" role="menuitem" tabindex="-1" id="options-menu-item-1" @mouseenter="activeIndex = 1" @mouseleave="activeIndex = -1" @click="open = false; focusButton()">Settings</a>
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700" :class="{ 'bg-gray-100 text-gray-900': activeIndex === 2, 'text-gray-700': !(activeIndex === 2) }" role="menuitem" tabindex="-1" id="options-menu-item-2" @mouseenter="activeIndex = 2" @mouseleave="activeIndex = -1" @click="open = false; focusButton()">Notifications</a>
|
||||
</button>
|
||||
</div>
|
||||
<div class="py-1" role="none">
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700" :class="{ 'bg-gray-100 text-gray-900': activeIndex === 3, 'text-gray-700': !(activeIndex === 3) }" role="menuitem" tabindex="-1" id="options-menu-item-3" @mouseenter="activeIndex = 3" @mouseleave="activeIndex = -1" @click="open = false; focusButton()">Get desktop app</a>
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700" :class="{ 'bg-gray-100 text-gray-900': activeIndex === 4, 'text-gray-700': !(activeIndex === 4) }" role="menuitem" tabindex="-1" id="options-menu-item-4" @mouseenter="activeIndex = 4" @mouseleave="activeIndex = -1" @click="open = false; focusButton()">Support</a>
|
||||
</div>
|
||||
<div class="py-1" role="none">
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700" :class="{ 'bg-gray-100 text-gray-900': activeIndex === 5, 'text-gray-700': !(activeIndex === 5) }" role="menuitem" tabindex="-1" id="options-menu-item-5" @mouseenter="activeIndex = 5" @mouseleave="activeIndex = -1" @click="open = false; focusButton()">Logout</a>
|
||||
<div x-show="open" x-transition:enter="transition ease-out duration-100" x-transition:enter-start="transform opacity-0 scale-95" x-transition:enter-end="transform opacity-100 scale-100" x-transition:leave="transition ease-in duration-75" x-transition:leave-start="transform opacity-100 scale-100" x-transition:leave-end="transform opacity-0 scale-95" class="z-10 mx-3 origin-top absolute right-0 left-0 mt-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-200 focus:outline-none" x-ref="menu-items" x-description="Dropdown menu, show/hide based on menu state." role="menu" aria-orientation="vertical" aria-labelledby="options-menu-button" tabindex="-1" @keydown.arrow-up.prevent="onArrowUp()" @keydown.arrow-down.prevent="onArrowDown()" @keydown.tab="open = false">
|
||||
<div class="py-1" role="none">
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700" x-state:on="Active" x-state:off="Not Active" :class="{ 'bg-gray-100 text-gray-900': activeIndex === 0, 'text-gray-700': !(activeIndex === 0) }" role="menuitem" tabindex="-1" id="options-menu-item-0" @mouseenter="activeIndex = 0" @mouseleave="activeIndex = -1" @click="open = false; focusButton()">View profile</a>
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700" :class="{ 'bg-gray-100 text-gray-900': activeIndex === 1, 'text-gray-700': !(activeIndex === 1) }" role="menuitem" tabindex="-1" id="options-menu-item-1" @mouseenter="activeIndex = 1" @mouseleave="activeIndex = -1" @click="open = false; focusButton()">Settings</a>
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700" :class="{ 'bg-gray-100 text-gray-900': activeIndex === 2, 'text-gray-700': !(activeIndex === 2) }" role="menuitem" tabindex="-1" id="options-menu-item-2" @mouseenter="activeIndex = 2" @mouseleave="activeIndex = -1" @click="open = false; focusButton()">Notifications</a>
|
||||
</div>
|
||||
<div class="py-1" role="none">
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700" :class="{ 'bg-gray-100 text-gray-900': activeIndex === 3, 'text-gray-700': !(activeIndex === 3) }" role="menuitem" tabindex="-1" id="options-menu-item-3" @mouseenter="activeIndex = 3" @mouseleave="activeIndex = -1" @click="open = false; focusButton()">Get desktop app</a>
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700" :class="{ 'bg-gray-100 text-gray-900': activeIndex === 4, 'text-gray-700': !(activeIndex === 4) }" role="menuitem" tabindex="-1" id="options-menu-item-4" @mouseenter="activeIndex = 4" @mouseleave="activeIndex = -1" @click="open = false; focusButton()">Support</a>
|
||||
</div>
|
||||
<div class="py-1" role="none">
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700" :class="{ 'bg-gray-100 text-gray-900': activeIndex === 5, 'text-gray-700': !(activeIndex === 5) }" role="menuitem" tabindex="-1" id="options-menu-item-5" @mouseenter="activeIndex = 5" @mouseleave="activeIndex = -1" @click="open = false; focusButton()">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<!-- Sidebar Search -->
|
||||
<div class="px-3 mt-5">
|
||||
<label for="search" class="sr-only">Search</label>
|
||||
|
@ -238,16 +244,18 @@
|
|||
My Songs
|
||||
</a>
|
||||
|
||||
<%= live_redirect to: Routes.signin_path(@conn, :index),
|
||||
class: "text-gray-700 hover:text-gray-900 hover:bg-gray-50 group flex items-center px-2 py-2 text-sm font-medium rounded-md" do %>
|
||||
<svg class="text-gray-400 group-hover:text-gray-500 mr-3 flex-shrink-0 h-6 w-6"
|
||||
x-state-description="undefined: "text-gray-500", undefined: "text-gray-400 group-hover:text-gray-500""
|
||||
x-description="Heroicon name: outline/clock" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Recent
|
||||
<%= unless @current_user do %>
|
||||
<%= live_redirect to: Routes.signin_path(@conn, :index),
|
||||
class: "text-gray-700 hover:text-gray-900 hover:bg-gray-50 group flex items-center px-2 py-2 text-sm font-medium rounded-md" do %>
|
||||
<svg class="text-gray-400 group-hover:text-gray-500 mr-3 flex-shrink-0 h-6 w-6"
|
||||
x-state-description="undefined: "text-gray-500", undefined: "text-gray-400 group-hover:text-gray-500""
|
||||
x-description="Heroicon name: outline/clock" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Sign in
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
</div>
|
||||
|
|
3
mix.exs
3
mix.exs
|
@ -48,7 +48,8 @@ defmodule LiveBeats.MixProject do
|
|||
{:telemetry_poller, "~> 1.0"},
|
||||
{:gettext, "~> 0.18"},
|
||||
{:jason, "~> 1.2"},
|
||||
{:plug_cowboy, "~> 2.5"}
|
||||
{:plug_cowboy, "~> 2.5"},
|
||||
{:mint, "~> 1.0"}
|
||||
]
|
||||
end
|
||||
|
||||
|
|
1
mix.lock
1
mix.lock
|
@ -15,6 +15,7 @@
|
|||
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
|
||||
"jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
|
||||
"mime": {:hex, :mime, "2.0.1", "0de4c81303fe07806ebc2494d5321ce8fb4df106e34dd5f9d787b637ebadc256", [:mix], [], "hexpm", "7a86b920d2aedce5fb6280ac8261ac1a739ae6c1a1ad38f5eadf910063008942"},
|
||||
"mint": {:hex, :mint, "1.3.0", "396b3301102f7b775e103da5a20494b25753aed818d6d6f0ad222a3a018c3600", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "a9aac960562e43ca69a77e5176576abfa78b8398cec5543dd4fb4ab0131d5c1e"},
|
||||
"phoenix": {:hex, :phoenix, "1.6.0-rc.0", "87dc1bb400588019a878ecf32c2d229c7d7f31a520c574860a059934663ffa70", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2a0d344d2a2f654a9300b2b09dbf9c3821762e1364e26fce12d76fcd498b92c0"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "3.0.2", "0d71bd7dfa5fad2103142206e25e16accd64f41bcbd0002af3f0da17e530968d", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d6c6e85d9bef8d52a5a66fcccd15529651f379eaccbf10500343a17f6f814f82"},
|
||||
|
|
36
priv/repo/migrations/20210905021010_create_user_auth.exs
Normal file
36
priv/repo/migrations/20210905021010_create_user_auth.exs
Normal file
|
@ -0,0 +1,36 @@
|
|||
defmodule LiveBeats.Repo.Migrations.CreateUserAuth do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
execute "CREATE EXTENSION IF NOT EXISTS citext", ""
|
||||
|
||||
create table(:users) do
|
||||
add :email, :citext, null: false
|
||||
add :username, :string, null: false
|
||||
add :name, :string
|
||||
add :role, :string, null: false
|
||||
add :confirmed_at, :naive_datetime
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create unique_index(:users, [:email])
|
||||
create unique_index(:users, [:username])
|
||||
|
||||
create table(:identities) do
|
||||
add :user_id, references(:users, on_delete: :delete_all), null: false
|
||||
add :provider, :string, null: false
|
||||
add :provider_token, :string, null: false
|
||||
add :provider_login, :string, null: false
|
||||
add :provider_email, :string, null: false
|
||||
add :provider_id, :string, null: false
|
||||
add :provider_meta, :map, default: "{}", null: false
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create index(:identities, [:user_id])
|
||||
create index(:identities, [:provider])
|
||||
create unique_index(:identities, [:user_id, :provider])
|
||||
end
|
||||
end
|
26
test/live_beats/accounts_test.exs
Normal file
26
test/live_beats/accounts_test.exs
Normal file
|
@ -0,0 +1,26 @@
|
|||
defmodule LiveBeats.AccountsTest do
|
||||
use LiveBeats.DataCase
|
||||
|
||||
alias LiveBeats.Accounts
|
||||
import LiveBeats.AccountsFixtures
|
||||
alias LiveBeats.Accounts.{User}
|
||||
|
||||
describe "get_user!/1" do
|
||||
test "raises if id is invalid" do
|
||||
assert_raise Ecto.NoResultsError, fn ->
|
||||
Accounts.get_user!(-1)
|
||||
end
|
||||
end
|
||||
|
||||
test "returns the user with the given id" do
|
||||
%{id: id} = user = user_fixture()
|
||||
assert %User{id: ^id} = Accounts.get_user!(user.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "register_github_user/1" do
|
||||
test "creates users with valid data" do
|
||||
flunk "TODO"
|
||||
end
|
||||
end
|
||||
end
|
75
test/live_beats_web/controllers/github_callbacks_test.exs
Normal file
75
test/live_beats_web/controllers/github_callbacks_test.exs
Normal file
|
@ -0,0 +1,75 @@
|
|||
defmodule LiveBeatsWeb.GithubCallbackTest do
|
||||
use LiveBeatsWeb.ConnCase, async: true
|
||||
|
||||
alias LiveBeats.Accounts
|
||||
|
||||
def exchange_access_token(opts) do
|
||||
_code = opts[:code]
|
||||
state = opts[:state]
|
||||
|
||||
case state do
|
||||
"valid" ->
|
||||
{:ok,
|
||||
%{
|
||||
info: %{"login" => "chrismccord", "name" => "Chris", "id" => 1},
|
||||
primary_email: "chris@local.test",
|
||||
emails: [%{"primary" => true, "email" => "chris@local.test"}],
|
||||
token: "1234"
|
||||
}}
|
||||
|
||||
"invalid" ->
|
||||
{:ok,
|
||||
%{
|
||||
info: %{"login" => "chrismccord"},
|
||||
primary_email: "chris@local.test",
|
||||
emails: [%{"primary" => true, "email" => "chris@local.test"}],
|
||||
token: "1234"
|
||||
}}
|
||||
|
||||
|
||||
"failed" ->
|
||||
{:error, %{reason: state}}
|
||||
end
|
||||
end
|
||||
|
||||
setup %{conn: conn} do
|
||||
conn = assign(conn, :github_client, __MODULE__)
|
||||
|
||||
{:ok, conn: conn}
|
||||
end
|
||||
|
||||
test "callback with valid token", %{conn: conn} do
|
||||
params = %{"code" => "66e1c4202275d071eced", "state" => "valid"}
|
||||
|
||||
assert Accounts.get_user_by_email("chris@local.test") == nil
|
||||
|
||||
conn = get(conn, Routes.o_auth_callback_path(conn, :new, "github", params))
|
||||
|
||||
assert redirected_to(conn, 302) == "/"
|
||||
assert %Accounts.User{} = user = Accounts.get_user_by_email("chris@local.test")
|
||||
assert user.name == "Chris"
|
||||
end
|
||||
|
||||
test "callback with invalid exchange response", %{conn: conn} do
|
||||
params = %{"code" => "66e1c4202275d071eced", "state" => "invalid"}
|
||||
assert Accounts.list_users(limit: 100) == []
|
||||
|
||||
conn = get(conn, Routes.o_auth_callback_path(conn, :new, "github", params))
|
||||
|
||||
assert get_flash(conn, :error) == "We were unable to fetch the necessary information from your GithHub account"
|
||||
assert redirected_to(conn, 302) == "/"
|
||||
assert Accounts.list_users(limit: 100) == []
|
||||
end
|
||||
|
||||
test "callback with failed token exchange", %{conn: conn} do
|
||||
params = %{"code" => "66e1c4202275d071eced", "state" => "failed"}
|
||||
|
||||
assert Accounts.list_users(limit: 100) == []
|
||||
|
||||
conn = get(conn, Routes.o_auth_callback_path(conn, :new, "github", params))
|
||||
|
||||
assert get_flash(conn, :error) == "We were unable to contact GitHub. Please try again later"
|
||||
assert redirected_to(conn, 302) == "/"
|
||||
assert Accounts.list_users(limit: 100) == []
|
||||
end
|
||||
end
|
|
@ -3,6 +3,6 @@ defmodule LiveBeatsWeb.PageControllerTest do
|
|||
|
||||
test "GET /", %{conn: conn} do
|
||||
conn = get(conn, "/")
|
||||
assert html_response(conn, 200) =~ "Welcome to Phoenix!"
|
||||
assert html_response(conn, 200) =~ "LiveBeats"
|
||||
end
|
||||
end
|
||||
|
|
125
test/live_beats_web/controllers/user_auth_test.exs
Normal file
125
test/live_beats_web/controllers/user_auth_test.exs
Normal file
|
@ -0,0 +1,125 @@
|
|||
defmodule LiveBeatsWeb.UserAuthTest do
|
||||
use LiveBeatsWeb.ConnCase, async: true
|
||||
|
||||
alias LiveBeats.Accounts
|
||||
alias LiveBeatsWeb.UserAuth
|
||||
import LiveBeats.AccountsFixtures
|
||||
|
||||
setup %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> Map.replace!(:secret_key_base, LiveBeatsWeb.Endpoint.config(:secret_key_base))
|
||||
|> init_test_session(%{})
|
||||
|
||||
%{user: user_fixture(), conn: conn}
|
||||
end
|
||||
|
||||
describe "log_in_user/3" do
|
||||
test "stores the user id in the session", %{conn: conn, user: user} do
|
||||
conn = UserAuth.log_in_user(conn, user)
|
||||
assert id = get_session(conn, :user_id)
|
||||
assert get_session(conn, :live_socket_id) == "users_sessions:#{id}"
|
||||
assert redirected_to(conn) == "/"
|
||||
assert Accounts.get_user!(id)
|
||||
end
|
||||
|
||||
test "clears everything previously stored in the session", %{conn: conn, user: user} do
|
||||
conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user)
|
||||
refute get_session(conn, :to_be_removed)
|
||||
end
|
||||
|
||||
test "redirects to the configured path", %{conn: conn, user: user} do
|
||||
conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user)
|
||||
assert redirected_to(conn) == "/hello"
|
||||
end
|
||||
end
|
||||
|
||||
describe "logout_user/1" do
|
||||
test "erases session and cookies", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> put_session(:user_id, "123")
|
||||
|> fetch_cookies()
|
||||
|> UserAuth.log_out_user()
|
||||
|
||||
refute get_session(conn, :user_id)
|
||||
assert redirected_to(conn) == "/"
|
||||
end
|
||||
|
||||
test "broadcasts to the given live_socket_id", %{conn: conn} do
|
||||
live_socket_id = "users_sessions:abcdef-token"
|
||||
LiveBeatsWeb.Endpoint.subscribe(live_socket_id)
|
||||
|
||||
conn
|
||||
|> put_session(:live_socket_id, live_socket_id)
|
||||
|> UserAuth.log_out_user()
|
||||
|
||||
assert_receive %Phoenix.Socket.Broadcast{
|
||||
event: "disconnect",
|
||||
topic: "users_sessions:abcdef-token"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_current_user/2" do
|
||||
test "authenticates user from session", %{conn: conn, user: user} do
|
||||
conn = conn |> put_session(:user_id, user.id) |> UserAuth.fetch_current_user([])
|
||||
assert conn.assigns.current_user.id == user.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "redirect_if_user_is_authenticated/2" do
|
||||
test "redirects if user is authenticated", %{conn: conn, user: user} do
|
||||
conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([])
|
||||
assert conn.halted
|
||||
assert redirected_to(conn) == "/"
|
||||
end
|
||||
|
||||
test "does not redirect if user is not authenticated", %{conn: conn} do
|
||||
conn = UserAuth.redirect_if_user_is_authenticated(conn, [])
|
||||
refute conn.halted
|
||||
refute conn.status
|
||||
end
|
||||
end
|
||||
|
||||
describe "require_authenticated_user/2" do
|
||||
test "redirects if user is not authenticated", %{conn: conn} do
|
||||
conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([])
|
||||
assert conn.halted
|
||||
assert redirected_to(conn) == Routes.home_path(conn, :index)
|
||||
assert get_flash(conn, :error) == "You must log in to access this page."
|
||||
end
|
||||
|
||||
test "stores the path to redirect to on GET", %{conn: conn} do
|
||||
halted_conn =
|
||||
%{conn | request_path: "/foo", query_string: ""}
|
||||
|> fetch_flash()
|
||||
|> UserAuth.require_authenticated_user([])
|
||||
|
||||
assert halted_conn.halted
|
||||
assert get_session(halted_conn, :user_return_to) == "/foo"
|
||||
|
||||
halted_conn =
|
||||
%{conn | request_path: "/foo", query_string: "bar=baz"}
|
||||
|> fetch_flash()
|
||||
|> UserAuth.require_authenticated_user([])
|
||||
|
||||
assert halted_conn.halted
|
||||
assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz"
|
||||
|
||||
halted_conn =
|
||||
%{conn | request_path: "/foo?bar", method: "POST"}
|
||||
|> fetch_flash()
|
||||
|> UserAuth.require_authenticated_user([])
|
||||
|
||||
assert halted_conn.halted
|
||||
refute get_session(halted_conn, :user_return_to)
|
||||
end
|
||||
|
||||
test "does not redirect if user is authenticated", %{conn: conn, user: user} do
|
||||
conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([])
|
||||
refute conn.halted
|
||||
refute conn.status
|
||||
end
|
||||
end
|
||||
end
|
60
test/support/fixtures/accounts_fixtures.ex
Normal file
60
test/support/fixtures/accounts_fixtures.ex
Normal file
|
@ -0,0 +1,60 @@
|
|||
defmodule LiveBeats.AccountsFixtures do
|
||||
@moduledoc """
|
||||
This module defines test helpers for creating
|
||||
entities via the `LiveBeats.Accounts` context.
|
||||
"""
|
||||
|
||||
def unique_user_email, do: "user#{System.unique_integer()}@example.com"
|
||||
def valid_user_password, do: "hello world!"
|
||||
|
||||
def user_fixture(attrs \\ %{}) do
|
||||
primary_email = attrs[:email] || unique_user_email()
|
||||
info = %{
|
||||
"avatar_url" => "https://avatars3.githubusercontent.com/u/576796?v=4",
|
||||
"bio" => nil,
|
||||
"blog" => "chrismccord.com",
|
||||
"company" => nil,
|
||||
"created_at" => "2010-01-21T16:12:29Z",
|
||||
"email" => nil,
|
||||
"events_url" => "https://api.github.com/users/chrismccord/events{/privacy}",
|
||||
"followers" => 100,
|
||||
"followers_url" => "https://api.github.com/users/chrismccord/followers",
|
||||
"following" => 100,
|
||||
"following_url" => "https://api.github.com/users/chrismccord/following{/other_user}",
|
||||
"gists_url" => "https://api.github.com/users/chrismccord/gists{/gist_id}",
|
||||
"gravatar_id" => "",
|
||||
"hireable" => nil,
|
||||
"html_url" => "https://github.com/chrismccord",
|
||||
"id" => 1234,
|
||||
"location" => "Charlotte, NC",
|
||||
"login" => "chrismccord",
|
||||
"name" => "Chris McCord",
|
||||
"node_id" => "slkdfjsklfjsf",
|
||||
"organizations_url" => "https://api.github.com/users/chrismccord/orgs",
|
||||
"public_gists" => 1,
|
||||
"public_repos" => 100,
|
||||
"received_events_url" => "https://api.github.com/users/chrismccord/received_events",
|
||||
"repos_url" => "https://api.github.com/users/chrismccord/repos",
|
||||
"site_admin" => false,
|
||||
"starred_url" => "https://api.github.com/users/chrismccord/starred{/owner}{/repo}",
|
||||
"subscriptions_url" => "https://api.github.com/users/chrismccord/subscriptions",
|
||||
"twitter_username" => nil,
|
||||
"type" => "User",
|
||||
"updated_at" => "2020-09-18T19:34:45Z",
|
||||
"url" => "https://api.github.com/users/chrismccord"
|
||||
}
|
||||
emails = []
|
||||
token = "token"
|
||||
|
||||
{:ok, user} =
|
||||
LiveBeats.Accounts.register_github_user(primary_email, info, emails, token)
|
||||
|
||||
user
|
||||
end
|
||||
|
||||
def extract_user_token(fun) do
|
||||
{:ok, captured} = fun.(&"[TOKEN]#{&1}[TOKEN]")
|
||||
[_, token, _] = String.split(captured.body, "[TOKEN]")
|
||||
token
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue