Add github login

This commit is contained in:
Chris McCord 2021-09-08 10:58:32 -04:00
parent f9edbf76ba
commit e28abc0a0a
21 changed files with 899 additions and 149 deletions

View file

@ -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
View file

@ -0,0 +1 @@
/Users/chris/oss/phoenix_live_view/assets/js/phoenix_live_view

View file

@ -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",

View 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

View 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

View 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
View 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

View 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

View 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

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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: &quot;text-gray-500&quot;, undefined: &quot;text-gray-400 group-hover:text-gray-500&quot;"
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: &quot;text-gray-500&quot;, undefined: &quot;text-gray-400 group-hover:text-gray-500&quot;"
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&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=facearea&amp;facepad=3&amp;w=256&amp;h=256&amp;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&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=facearea&amp;facepad=3&amp;w=256&amp;h=256&amp;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: &quot;text-gray-500&quot;, undefined: &quot;text-gray-400 group-hover:text-gray-500&quot;"
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: &quot;text-gray-500&quot;, undefined: &quot;text-gray-400 group-hover:text-gray-500&quot;"
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>

View file

@ -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

View file

@ -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"},

View 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

View 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

View 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

View file

@ -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

View 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

View 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