diff --git a/config/prod.exs b/config/prod.exs
index deec65e..46ce690 100644
--- a/config/prod.exs
+++ b/config/prod.exs
@@ -8,7 +8,6 @@ config :mailgun_logger, MailgunLoggerWeb.Endpoint,
code_reloader: false,
version: Application.spec(:mailgun_logger, :vsn)
-
config :logger,
level: String.to_existing_atom(System.get_env("ML_LOG_LEVEL", "info")) || :info,
compile_time_purge_matching: [[application: :remote_ip]]
diff --git a/config/test.exs b/config/test.exs
index 09d1fdc..7718fd3 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -7,8 +7,7 @@ config :mailgun_logger, MailgunLoggerWeb.Endpoint,
server: false
# Quantum cron schedule
-config :mailgun_logger, MailgunLogger.Scheduler,
- jobs: []
+config :mailgun_logger, MailgunLogger.Scheduler, jobs: []
config :logger, level: :warning
config :mailgun_logger, MailgunLogger.Repo, pool: Ecto.Adapters.SQL.Sandbox
diff --git a/lib/mailgun_logger/roles/roles.ex b/lib/mailgun_logger/roles/roles.ex
index 8a873bb..ce24c0a 100644
--- a/lib/mailgun_logger/roles/roles.ex
+++ b/lib/mailgun_logger/roles/roles.ex
@@ -7,13 +7,13 @@ defmodule MailgunLogger.Roles do
@superuser_role "superuser"
@admin_role "admin"
+ @member_role "member"
#########################################################
@default_actions ~w()
-
- @admin_actions ~w(do_stuff) ++ @default_actions
-
+ @member_actions ~w(view_events) ++ @default_actions
+ @admin_actions ~w(do_stuff manage_roles) ++ @member_actions
@superuser_actions ~w() ++ @admin_actions
#########################################################
@@ -55,6 +55,11 @@ defmodule MailgunLogger.Roles do
Enum.any?(roles, &can?(&1.name, action))
end
+ for action <- @member_actions do
+ action = String.to_atom(action)
+ def can?(@member_role, unquote(action)), do: true
+ end
+
for action <- @admin_actions do
action = String.to_atom(action)
def can?(@admin_role, unquote(action)), do: true
@@ -69,12 +74,14 @@ defmodule MailgunLogger.Roles do
def is?(%User{roles: roles}, :superuser), do: is(roles, "superuser")
def is?(%User{roles: roles}, :admin), do: is(roles, "admin")
+ def is?(%User{roles: roles}, :member), do: is(roles, "member")
def is?(_, _), do: raise("Roles.is/2 requires roles to be preloaded")
defp is(roles, role) when is_binary(role), do: Enum.map(roles, & &1.name) |> Enum.member?(role)
def abilities(%User{roles: []}), do: []
def abilities(%User{roles: roles}), do: hd(roles) |> abilities()
+ def abilities(%Role{name: "member"}), do: @member_actions
def abilities(%Role{name: "admin"}), do: @admin_actions
def abilities(%Role{name: "superuser"}), do: @superuser_actions
diff --git a/lib/mailgun_logger/seeder.ex b/lib/mailgun_logger/seeder.ex
index 6fe0588..d2d25f4 100644
--- a/lib/mailgun_logger/seeder.ex
+++ b/lib/mailgun_logger/seeder.ex
@@ -6,7 +6,7 @@ defmodule MailgunLogger.Seeder do
# alias MailgunLogger.UserRole
- @roles [%Role{name: "superuser"}, %Role{name: "admin"}]
+ @roles [%Role{name: "superuser"}, %Role{name: "admin"}, %Role{name: "member"}]
def run do
Enum.each(@roles, &insert_if_new(&1))
diff --git a/lib/mailgun_logger/users/users.ex b/lib/mailgun_logger/users/users.ex
index ae6902e..91b94dc 100644
--- a/lib/mailgun_logger/users/users.ex
+++ b/lib/mailgun_logger/users/users.ex
@@ -4,7 +4,6 @@ defmodule MailgunLogger.Users do
alias MailgunLogger.Repo
alias MailgunLogger.User
-
@type ecto_user() :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
@type maybe_user() :: User.t() | nil
@@ -151,6 +150,14 @@ defmodule MailgunLogger.Users do
|> Repo.insert()
end
+ @spec create_user_with_roles(map, [MailgunLogger.Role.t()]) :: ecto_user()
+ def create_user_with_roles(params, roles) do
+ %User{}
+ |> User.changeset(params)
+ |> Ecto.Changeset.put_assoc(:roles, roles)
+ |> Repo.insert()
+ end
+
@spec update_user(User.t(), map) :: ecto_user()
def update_user(user, params) do
user
@@ -158,6 +165,14 @@ defmodule MailgunLogger.Users do
|> Repo.update()
end
+ @spec update_user_with_roles(User.t(), map, [MailgunLogger.Role.t()]) :: ecto_user()
+ def update_user_with_roles(user, params, roles) do
+ user
+ |> User.update_changeset(params)
+ |> Ecto.Changeset.put_assoc(:roles, roles)
+ |> Repo.update()
+ end
+
@spec delete_user(User.t()) :: ecto_user()
def delete_user(%User{} = user) do
Repo.delete(user)
diff --git a/lib/mailgun_logger_web/components/core_components.ex b/lib/mailgun_logger_web/components/core_components.ex
index 938c0b1..5d8d0f0 100644
--- a/lib/mailgun_logger_web/components/core_components.ex
+++ b/lib/mailgun_logger_web/components/core_components.ex
@@ -79,7 +79,7 @@ defmodule MailgunLoggerWeb.CoreComponents do
- <%= render_slot(@inner_block) %>
+ {render_slot(@inner_block)}
@@ -122,9 +122,9 @@ defmodule MailgunLoggerWeb.CoreComponents do
<.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" />
<.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" />
- <%= @title %>
+ {@title}
- <%= msg %>
+ {msg}
<.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" />
@@ -186,9 +186,9 @@ defmodule MailgunLoggerWeb.CoreComponents do
~H"""
<.form :let={f} for={@for} as={@as} {@rest}>
- <%= render_slot(@inner_block, f) %>
+ {render_slot(@inner_block, f)}
- <%= render_slot(action, f) %>
+ {render_slot(action, f)}
@@ -220,7 +220,7 @@ defmodule MailgunLoggerWeb.CoreComponents do
]}
{@rest}
>
- <%= render_slot(@inner_block) %>
+ {render_slot(@inner_block)}
"""
end
@@ -291,9 +291,9 @@ defmodule MailgunLoggerWeb.CoreComponents do
checked={@checked}
{@rest}
/>
- <%= @label %>
+ {@label}
- <.error :for={msg <- @errors}><%= msg %>
+ <.error :for={msg <- @errors}>{msg}
"""
end
@@ -301,7 +301,7 @@ defmodule MailgunLoggerWeb.CoreComponents do
def input(%{type: "select"} = assigns) do
~H"""
- <.label for={@id}><%= @label %>
+ <.label for={@id}>{@label}
- <%= @prompt %>
- <%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
+ {@prompt}
+ {Phoenix.HTML.Form.options_for_select(@options, @value)}
- <.error :for={msg <- @errors}><%= msg %>
+ <.error :for={msg <- @errors}>{msg}
"""
end
@@ -320,14 +320,14 @@ defmodule MailgunLoggerWeb.CoreComponents do
def input(%{type: "textarea"} = assigns) do
~H"""
- <.label for={@id}><%= @label %>
+ <.label for={@id}>{@label}
- <.error :for={msg <- @errors}><%= msg %>
+ <.error :for={msg <- @errors}>{msg}
"""
end
@@ -336,7 +336,7 @@ defmodule MailgunLoggerWeb.CoreComponents do
def input(assigns) do
~H"""
- <.label for={@id}><%= @label %>
+ <.label for={@id}>{@label}
- <.error :for={msg <- @errors}><%= msg %>
+ <.error :for={msg <- @errors}>{msg}
"""
end
@@ -360,7 +360,7 @@ defmodule MailgunLoggerWeb.CoreComponents do
def label(assigns) do
~H"""
- <%= render_slot(@inner_block) %>
+ {render_slot(@inner_block)}
"""
end
@@ -373,7 +373,7 @@ defmodule MailgunLoggerWeb.CoreComponents do
def error(assigns) do
~H"""
- <%= render_slot(@inner_block) %>
+ {render_slot(@inner_block)}
"""
end
@@ -392,13 +392,13 @@ defmodule MailgunLoggerWeb.CoreComponents do
"""
end
@@ -440,8 +440,8 @@ defmodule MailgunLoggerWeb.CoreComponents do
- <%= col[:label] %>
- <%= gettext("Actions") %>
+ {col[:label]}
+ {gettext("Actions")}
- <%= render_slot(col, @row_item.(row)) %>
+ {render_slot(col, @row_item.(row))}
@@ -469,7 +469,7 @@ defmodule MailgunLoggerWeb.CoreComponents do
:for={action <- @action}
class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
>
- <%= render_slot(action, @row_item.(row)) %>
+ {render_slot(action, @row_item.(row))}
@@ -499,8 +499,8 @@ defmodule MailgunLoggerWeb.CoreComponents do
-
<%= item.title %>
- <%= render_slot(item) %>
+ {item.title}
+ {render_slot(item)}
@@ -525,7 +525,7 @@ defmodule MailgunLoggerWeb.CoreComponents do
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
>
<.icon name="hero-arrow-left-solid" class="h-3 w-3" />
- <%= render_slot(@inner_block) %>
+ {render_slot(@inner_block)}
"""
diff --git a/lib/mailgun_logger_web/controllers/setup_controller.ex b/lib/mailgun_logger_web/controllers/setup_controller.ex
index 0c71a58..2c549e9 100644
--- a/lib/mailgun_logger_web/controllers/setup_controller.ex
+++ b/lib/mailgun_logger_web/controllers/setup_controller.ex
@@ -15,7 +15,7 @@ defmodule MailgunLoggerWeb.SetupController do
end
end
- def create_root(conn, %{"user" => params}) do
+ def create_root(conn, %{"user" => params}) do
# Only allow create_root if there are no users in the database yet,
# otherwise it's possible for any unauthenticated request to this
# endpoint to make an admin user.
diff --git a/lib/mailgun_logger_web/controllers/user_controller.ex b/lib/mailgun_logger_web/controllers/user_controller.ex
index 1128c69..fb32908 100644
--- a/lib/mailgun_logger_web/controllers/user_controller.ex
+++ b/lib/mailgun_logger_web/controllers/user_controller.ex
@@ -1,6 +1,7 @@
defmodule MailgunLoggerWeb.UserController do
use MailgunLoggerWeb, :controller
+ alias MailgunLogger.Roles
alias MailgunLogger.Users
alias MailgunLogger.User
@@ -10,43 +11,105 @@ defmodule MailgunLoggerWeb.UserController do
end
def new(conn, _) do
+ current_user = conn.assigns[:current_user]
changeset = User.changeset(%User{})
- render(conn, :new, changeset: changeset)
+
+ render(conn, :new,
+ changeset: changeset,
+ all_roles: Roles.list_roles(),
+ selected_role_ids: [],
+ show_roles?: Roles.can?(current_user, :manage_roles)
+ )
end
def create(conn, %{"user" => params}) do
- case Users.create_user(params) do
- {:ok, _} -> redirect(conn, to: Routes.user_path(conn, :index))
- {:error, changeset} -> render(conn, :new, changeset: changeset)
+ current_user = conn.assigns[:current_user]
+ roles = resolve_roles(current_user, params, [])
+
+ case Users.create_user_with_roles(params, roles) do
+ {:ok, _user} ->
+ conn
+ |> put_flash(:info, "User created successfully.")
+ |> redirect(to: Routes.user_path(conn, :index))
+
+ {:error, changeset} ->
+ render(conn, :new,
+ changeset: changeset,
+ all_roles: Roles.list_roles(),
+ selected_role_ids: Enum.map(roles, & &1.id),
+ show_roles?: Roles.can?(current_user, :manage_roles)
+ )
end
end
def edit(conn, %{"id" => id}) do
+ current_user = conn.assigns[:current_user]
user = Users.get_user!(id)
- changeset = User.changeset(user)
- render(conn, :edit, changeset: changeset, user: user)
+ changeset = User.update_changeset(user)
+
+ render(conn, :edit,
+ changeset: changeset,
+ user: user,
+ all_roles: Roles.list_roles(),
+ selected_role_ids: Enum.map(user.roles, & &1.id),
+ show_roles?: Roles.can?(current_user, :manage_roles)
+ )
end
def update(conn, %{"id" => id, "user" => params}) do
+ current_user = conn.assigns[:current_user]
user = Users.get_user!(id)
+ roles = resolve_roles(current_user, params, user.roles)
- case Users.update_user(user, params) do
- {:ok, _} ->
- redirect(conn, to: Routes.user_path(conn, :index))
+ if Roles.can?(current_user, :manage_roles) && current_user.id == user.id &&
+ self_downgrade?(current_user, roles) do
+ conn
+ |> put_flash(:error, "You cannot remove your own role.")
+ |> render(:edit,
+ changeset: User.update_changeset(user, params),
+ user: user,
+ all_roles: Roles.list_roles(),
+ selected_role_ids: Enum.map(roles, & &1.id),
+ show_roles?: true
+ )
+ else
+ case Users.update_user_with_roles(user, params, roles) do
+ {:ok, _updated} ->
+ conn
+ |> put_flash(:info, "User updated successfully.")
+ |> redirect(to: Routes.user_path(conn, :index))
- {:error, changeset} ->
- render(conn, :edit, changeset: changeset, user: user)
+ {:error, changeset} ->
+ render(conn, :edit,
+ changeset: changeset,
+ user: user,
+ all_roles: Roles.list_roles(),
+ selected_role_ids: Enum.map(roles, & &1.id),
+ show_roles?: Roles.can?(current_user, :manage_roles)
+ )
+ end
end
end
def delete(conn, %{"id" => id}) do
- {:ok, _} =
- id
- |> Users.get_user!()
- |> Users.delete_user()
+ {:ok, _} = id |> Users.get_user!() |> Users.delete_user()
conn
|> put_flash(:info, "user deleted successfully.")
|> redirect(to: Routes.user_path(conn, :index))
end
+
+ defp resolve_roles(current_user, params, existing_roles) do
+ if Roles.can?(current_user, :manage_roles) do
+ params |> Map.get("role_ids", []) |> Enum.map(&String.to_integer/1) |> Roles.get_roles_by_id()
+ else
+ existing_roles
+ end
+ end
+
+ defp self_downgrade?(%User{roles: current_roles}, new_roles) do
+ current_ids = MapSet.new(current_roles, & &1.id)
+ new_ids = MapSet.new(new_roles, & &1.id)
+ not MapSet.subset?(current_ids, new_ids)
+ end
end
diff --git a/lib/mailgun_logger_web/plugs/require_permission.ex b/lib/mailgun_logger_web/plugs/require_permission.ex
new file mode 100644
index 0000000..7e87039
--- /dev/null
+++ b/lib/mailgun_logger_web/plugs/require_permission.ex
@@ -0,0 +1,26 @@
+defmodule MailgunLoggerWeb.Plugs.RequirePermission do
+ import Plug.Conn
+ import Phoenix.Controller
+
+ alias MailgunLogger.Roles
+ alias MailgunLoggerWeb.Router.Helpers, as: Routes
+
+ @moduledoc """
+ Enforces that the authenticated user has the required permission.
+ This plug expects MailgunLoggerWeb.Plugs.Auth to have assigned :current_user.
+ """
+ def init(permission), do: permission
+
+ def call(conn, permission) do
+ user = conn.assigns[:current_user]
+
+ if user && Roles.can?(user, permission) do
+ conn
+ else
+ conn
+ |> put_flash(:error, "You do not have permission to access this page.")
+ |> redirect(to: Routes.event_path(conn, :index))
+ |> halt()
+ end
+ end
+end
diff --git a/lib/mailgun_logger_web/router.ex b/lib/mailgun_logger_web/router.ex
index 7297982..6a72d05 100644
--- a/lib/mailgun_logger_web/router.ex
+++ b/lib/mailgun_logger_web/router.ex
@@ -23,6 +23,14 @@ defmodule MailgunLoggerWeb.Router do
plug(MailgunLoggerWeb.Plugs.Auth)
end
+ pipeline :require_events_access do
+ plug(MailgunLoggerWeb.Plugs.RequirePermission, :view_events)
+ end
+
+ pipeline :require_admin_access do
+ plug(MailgunLoggerWeb.Plugs.RequirePermission, :do_stuff)
+ end
+
# Always except in prod
if Application.compile_env(:mailgun_logger, :env) == :dev do
forward("/sent_emails", Bamboo.SentEmailViewerPlug)
@@ -73,18 +81,28 @@ defmodule MailgunLoggerWeb.Router do
pipe_through([:browser, :auth])
get("/logout", AuthController, :logout)
+ get("/profile", ProfileController, :edit)
+ put("/profile", ProfileController, :update)
+ end
+
+ scope "/", MailgunLoggerWeb do
+ pipe_through([:browser, :auth, :require_events_access])
+
+ get("/", PageController, :index)
+ get("/non-affiliation", PageController, :non_affiliation)
resources("/events", EventController, only: [:index, :show])
get("/events/:id/stored_message", EventController, :stored_message)
+ end
+
+ scope "/", MailgunLoggerWeb do
+ pipe_through([:browser, :auth, :require_admin_access])
+
resources("/accounts", AccountController, except: [:show])
- get("/profile", ProfileController, :edit)
- put("/profile", ProfileController, :update)
resources("/users", UserController, except: [:show])
- get("/", PageController, :index)
post("/trigger-run", PageController, :trigger_run)
get("/stats", PageController, :stats)
get("/graphs", PageController, :graphs)
- get("/non-affiliation", PageController, :non_affiliation)
end
end
diff --git a/lib/mailgun_logger_web/templates/account/edit.html.heex b/lib/mailgun_logger_web/templates/account/edit.html.heex
index 18fabb4..2b807d6 100644
--- a/lib/mailgun_logger_web/templates/account/edit.html.heex
+++ b/lib/mailgun_logger_web/templates/account/edit.html.heex
@@ -1,4 +1,15 @@
Edit account
-<%= render "form.html", action: Routes.account_path(@conn, :update, @account), changeset: @changeset, flash: @flash %>
+{render("form.html",
+ action: Routes.account_path(@conn, :update, @account),
+ changeset: @changeset,
+ flash: @flash
+)}
-<.link href={Routes.account_path(@conn, :delete, @account)} method="delete" data-confirm="Are you sure?" class="btn btn-danger btn-sm">delete
+<.link
+ href={Routes.account_path(@conn, :delete, @account)}
+ method="delete"
+ data-confirm="Are you sure?"
+ class="btn btn-danger btn-sm"
+>
+ delete
+
diff --git a/lib/mailgun_logger_web/templates/account/form.html.heex b/lib/mailgun_logger_web/templates/account/form.html.heex
index ba990bf..2c214dd 100644
--- a/lib/mailgun_logger_web/templates/account/form.html.heex
+++ b/lib/mailgun_logger_web/templates/account/form.html.heex
@@ -3,22 +3,32 @@
Oops, something went wrong! Please check the errors below.
- These settings can be found on your Mailgun dashboard. An account covers a sending domain / API key combo. If you have multiple domains, add them on seperate accounts.
+
+ These settings can be found on your Mailgun dashboard. An account covers a sending domain / API key combo. If you have multiple domains, add them on seperate accounts.
+
- <%= Phoenix.Flash.get(@flash, :info) %>
+ {Phoenix.Flash.get(@flash, :info)}
<.input field={f[:domain]} label="Sending domain" />
-
+
<.input field={f[:api_key]} label="API key" />
-
+
<.input field={f[:is_active]} type="checkbox" label="Is active" />
<.input field={f[:is_eu]} type="checkbox" label="Is EU" />
- <%= submit "submit", class: "btn btn-primary"%>
+ {submit("submit", class: "btn btn-primary")}
diff --git a/lib/mailgun_logger_web/templates/account/index.html.heex b/lib/mailgun_logger_web/templates/account/index.html.heex
index 8ee45ba..2fb607f 100644
--- a/lib/mailgun_logger_web/templates/account/index.html.heex
+++ b/lib/mailgun_logger_web/templates/account/index.html.heex
@@ -12,16 +12,16 @@
<%= for account <- @accounts do %>
-
- <%= account.domain %>
- <%= account.api_key %>
- EU
-
- active
- inactive
-
- <.link href={Routes.account_path(@conn, :edit, account)}>edit
-
+
+ {account.domain}
+ {account.api_key}
+ EU
+
+ active
+ inactive
+
+ <.link href={Routes.account_path(@conn, :edit, account)}>edit
+
<% end %>
diff --git a/lib/mailgun_logger_web/templates/account/new.html.heex b/lib/mailgun_logger_web/templates/account/new.html.heex
index 013d40f..2cbf46f 100644
--- a/lib/mailgun_logger_web/templates/account/new.html.heex
+++ b/lib/mailgun_logger_web/templates/account/new.html.heex
@@ -1,2 +1,2 @@
New account
-<%= render "form.html", action: ~p"/accounts", changeset: @changeset, flash: @flash %>
+{render("form.html", action: ~p"/accounts", changeset: @changeset, flash: @flash)}
diff --git a/lib/mailgun_logger_web/templates/auth/new.html.heex b/lib/mailgun_logger_web/templates/auth/new.html.heex
index ed0af1d..f70f1cd 100644
--- a/lib/mailgun_logger_web/templates/auth/new.html.heex
+++ b/lib/mailgun_logger_web/templates/auth/new.html.heex
@@ -1,24 +1,26 @@
-
<%= gettext("Inloggen") %>
+
{gettext("Inloggen")}
- <%= Phoenix.Flash.get(@flash, :info) %>
+ {Phoenix.Flash.get(@flash, :info)}
- <%= Phoenix.Flash.get(@flash, :error) %>
+ {Phoenix.Flash.get(@flash, :error)}
<.form :let={f} for={@conn} action={Routes.auth_path(@conn, :create)} as={:user} novalidate>
<.input field={f[:email]} type="email" label={gettext("Email")} required autofocus />
<.input field={f[:password]} type="password" label={gettext("Password")} required />
- <%= submit gettext("Inloggen"), class: "btn btn-primary" %>
+ {submit(gettext("Inloggen"), class: "btn btn-primary")}
-
+
- <.link href={Routes.password_reset_path(@conn, :request_new)}><%= gettext("Forgot password?") %>
+ <.link href={Routes.password_reset_path(@conn, :request_new)}>
+ {gettext("Forgot password?")}
+
diff --git a/lib/mailgun_logger_web/templates/event/index.html.heex b/lib/mailgun_logger_web/templates/event/index.html.heex
index de31390..cd7c3b7 100644
--- a/lib/mailgun_logger_web/templates/event/index.html.heex
+++ b/lib/mailgun_logger_web/templates/event/index.html.heex
@@ -1,7 +1,7 @@
@@ -26,15 +26,21 @@
<%= for event <- @events do %>
-
<%= event_name(event.event) %>
-
<%= event_type(event.log_level) %>
-
<%= send_recv(event.method) %>
-
<%= event.timestamp %>
-
<%= event.message_from %>
-
<%= event.recipient %>
-
<%= event.message_subject %>
+
{event_name(event.event)}
+
{event_type(event.log_level)}
+
{send_recv(event.method)}
+
{event.timestamp}
+
{event.message_from}
+
{event.recipient}
+
{event.message_subject}
- msg
+
+ msg
+
<.link href={Routes.event_path(@conn, :show, event.id)}>detail
@@ -42,7 +48,12 @@