From f8431cb1c28abf695aef57f79a141ad45c388048 Mon Sep 17 00:00:00 2001 From: "byte[]" Date: Sun, 15 Dec 2019 21:21:14 -0500 Subject: [PATCH] admin users and roles --- lib/philomena/users.ex | 12 ++- lib/philomena/users/user.ex | 13 +++- .../admin/user/avatar_controller.ex | 24 ++++++ .../controllers/admin/user_controller.ex | 72 ++++++++++++++++++ lib/philomena_web/router.ex | 3 + .../templates/admin/user/_form.html.slime | 44 +++++++++++ .../templates/admin/user/edit.html.slime | 3 + .../templates/admin/user/index.html.slime | 75 ++++++++++++++++++ .../layout/_header_staff_links.html.slime | 2 +- .../profile/user_link/index.html.slime | 2 +- .../profile/user_link/new.html.slime | 2 +- lib/philomena_web/views/admin/user_view.ex | 76 +++++++++++++++++++ priv/repo/seeds.exs | 11 ++- priv/repo/seeds.json | 19 ++++- 14 files changed, 348 insertions(+), 10 deletions(-) create mode 100644 lib/philomena_web/controllers/admin/user/avatar_controller.ex create mode 100644 lib/philomena_web/controllers/admin/user_controller.ex create mode 100644 lib/philomena_web/templates/admin/user/_form.html.slime create mode 100644 lib/philomena_web/templates/admin/user/edit.html.slime create mode 100644 lib/philomena_web/templates/admin/user/index.html.slime create mode 100644 lib/philomena_web/views/admin/user_view.ex diff --git a/lib/philomena/users.ex b/lib/philomena/users.ex index 393f0a12..b2a869b9 100644 --- a/lib/philomena/users.ex +++ b/lib/philomena/users.ex @@ -9,6 +9,7 @@ defmodule Philomena.Users do alias Philomena.Users.Uploader alias Philomena.Users.User + alias Philomena.Roles.Role use Pow.Ecto.Context, repo: Repo, @@ -56,6 +57,7 @@ defmodule Philomena.Users do """ def create_user(attrs \\ %{}) do + roles = %User{} |> User.changeset(attrs) |> Repo.insert() @@ -74,11 +76,19 @@ defmodule Philomena.Users do """ def update_user(%User{} = user, attrs) do + roles = + Role + |> where([r], r.id in ^clean_roles(attrs["roles"])) + |> Repo.all() + user - |> User.changeset(attrs) + |> User.update_changeset(attrs, roles) |> Repo.update() end + defp clean_roles(nil), do: [] + defp clean_roles(roles), do: Enum.filter(roles, &"" != &1) + def update_spoiler_type(%User{} = user, attrs) do user |> User.spoiler_type_changeset(attrs) diff --git a/lib/philomena/users/user.ex b/lib/philomena/users/user.ex index 2e312456..e91a52db 100644 --- a/lib/philomena/users/user.ex +++ b/lib/philomena/users/user.ex @@ -36,7 +36,7 @@ defmodule Philomena.Users.User do has_many :notifications, through: [:unread_notifications, :notification] has_many :linked_tags, through: [:verified_links, :tag] has_one :commission, Commission - many_to_many :roles, Role, join_through: "users_roles" + many_to_many :roles, Role, join_through: "users_roles", on_replace: :delete belongs_to :current_filter, Filter belongs_to :deleted_by_user, User @@ -147,6 +147,15 @@ defmodule Philomena.Users.User do |> unique_constraint(:email, name: :index_users_on_email) end + def update_changeset(user, attrs, roles) do + user + |> cast(attrs, [:name, :email, :role, :secondary_role, :hide_default_role]) + |> validate_required([:name, :email, :role]) + |> validate_inclusion(:role, ["user", "assistant", "moderator", "admin"]) + |> put_assoc(:roles, roles) + |> put_slug() + end + def creation_changeset(user, attrs) do user |> pow_changeset(attrs) @@ -377,4 +386,4 @@ defmodule Philomena.Users.User do defp remove_backup_code(user, token), do: user.otp_backup_codes |> Enum.reject(&Password.verify_pass(token, &1)) -end \ No newline at end of file +end diff --git a/lib/philomena_web/controllers/admin/user/avatar_controller.ex b/lib/philomena_web/controllers/admin/user/avatar_controller.ex new file mode 100644 index 00000000..421b97d2 --- /dev/null +++ b/lib/philomena_web/controllers/admin/user/avatar_controller.ex @@ -0,0 +1,24 @@ +defmodule PhilomenaWeb.Admin.User.AvatarController do + use PhilomenaWeb, :controller + + alias Philomena.Users.User + alias Philomena.Users + + plug :verify_authorized + plug :load_resource, model: User, id_name: "user_id", id_field: "slug", persisted: true + + def delete(conn, _params) do + {:ok, _user} = Users.remove_avatar(conn.assigns.user) + + conn + |> put_flash(:info, "Successfully removed avatar.") + |> redirect(to: Routes.admin_user_path(conn, :edit, conn.assigns.user)) + end + + defp verify_authorized(conn, _opts) do + case Canada.Can.can?(conn.assigns.current_user, :index, User) do + true -> conn + _false -> PhilomenaWeb.NotAuthorizedPlug.call(conn) + end + end +end diff --git a/lib/philomena_web/controllers/admin/user_controller.ex b/lib/philomena_web/controllers/admin/user_controller.ex new file mode 100644 index 00000000..b5d6a50b --- /dev/null +++ b/lib/philomena_web/controllers/admin/user_controller.ex @@ -0,0 +1,72 @@ +defmodule PhilomenaWeb.Admin.UserController do + use PhilomenaWeb, :controller + + alias Philomena.Roles.Role + alias Philomena.Users.User + alias Philomena.Users + alias Philomena.Repo + import Ecto.Query + + plug :verify_authorized + plug :load_resource, model: User, only: [:edit, :update], id_field: "slug", preload: [:roles] + plug :load_roles when action in [:edit] + + def index(conn, %{"q" => q}) do + User + |> where([u], u.email == ^q or ilike(u.name, ^"%#{q}%")) + |> load_users(conn) + end + + def index(conn, %{"twofactor" => _twofactor}) do + User + |> where([u], u.otp_required_for_login == true) + |> load_users(conn) + end + + def index(conn, %{"staff" => _staff}) do + User + |> where([u], u.role != "user") + |> load_users(conn) + end + + def index(conn, _params) do + load_users(User, conn) + end + + defp load_users(queryable, conn) do + users = + queryable + |> order_by(desc: :id) + |> Repo.paginate(conn.assigns.scrivener) + + render(conn, "index.html", layout_class: "layout--medium", users: users) + end + + def edit(conn, _params) do + changeset = Users.change_user(conn.assigns.user) + render(conn, "edit.html", changeset: changeset) + end + + def update(conn, %{"user" => user_params}) do + case Users.update_user(conn.assigns.user, user_params) do + {:ok, _user} -> + conn + |> put_flash(:info, "User successfully updated.") + |> redirect(to: Routes.admin_user_path(conn, :index)) + + {:error, changeset} -> + render(conn, "edit.html", changeset: changeset) + end + end + + defp verify_authorized(conn, _opts) do + case Canada.Can.can?(conn.assigns.current_user, :index, User) do + true -> conn + _false -> PhilomenaWeb.NotAuthorizedPlug.call(conn) + end + end + + defp load_roles(conn, _opts) do + assign(conn, :roles, Repo.all(Role)) + end +end diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index ebb34805..e0d0315d 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -206,6 +206,9 @@ defmodule PhilomenaWeb.Router do resources "/forums", ForumController, except: [:show, :delete] resources "/badges", BadgeController, except: [:show, :delete] resources "/mod_notes", ModNoteController, except: [:show] + resources "/users", UserController, only: [:index, :edit, :update] do + resources "/avatar", User.AvatarController, only: [:delete], singleton: true + end end resources "/duplicate_reports", DuplicateReportController, only: [] do diff --git a/lib/philomena_web/templates/admin/user/_form.html.slime b/lib/philomena_web/templates/admin/user/_form.html.slime new file mode 100644 index 00000000..4d9c06d8 --- /dev/null +++ b/lib/philomena_web/templates/admin/user/_form.html.slime @@ -0,0 +1,44 @@ += form_for @changeset, @action, fn f -> + = if @changeset.action do + .alert.alert-danger + p Oops, something went wrong! Please check the errors below. + + .block + .block__header + span.block__header__title Essential user details + label.table-list__label + .table-list__label__text Name: + .table-list__label__input = text_input f, :name, class: "input" + label.table-list__label + .table-list__label__text Email: + .table-list__label__input = text_input f, :email, class: "input" + label.table-list__label + .table-list__label__text Role: + .table-list__label__input = select f, :role, ["user", "assistant", "moderator", "admin"], class: "input" + label.table-list__label + .table-list__label__text Secondary banner: + .table-list__label__input = select f, :secondary_role, [[key: "-", value: ""], "Site Developer", "System Administrator"], class: "input" + label.table-list__label + .table-list__label__text Hide staff banner: + .table-list__label__input = checkbox f, :hide_default_role, class: "checkbox" + .table-list__label + .table-list__label__text Avatar + .table-list__label__input + = link "Remove avatar", to: Routes.admin_user_avatar_path(@conn, :delete, @user), class: "button", data: [method: "delete", confirm: "Are you really, really sure?"] + + .block + .block__header + span.block__header__title General user flags + ul = collection_checkboxes f, :roles, filtered_roles(general_permissions, @roles), mapper: &checkbox_mapper/6 + + .block + .block__header.warning + span.block__header__title Special roles for assistants + ul = collection_checkboxes f, :roles, filtered_roles(assistant_permissions, @roles), mapper: &checkbox_mapper/6 + + .block + .block__header.danger + span.block__header__title Special roles for moderators + ul = collection_checkboxes f, :roles, filtered_roles(moderator_permissions, @roles), mapper: &checkbox_mapper/6 + + = submit "Save User", class: "button" diff --git a/lib/philomena_web/templates/admin/user/edit.html.slime b/lib/philomena_web/templates/admin/user/edit.html.slime new file mode 100644 index 00000000..bb1e6d02 --- /dev/null +++ b/lib/philomena_web/templates/admin/user/edit.html.slime @@ -0,0 +1,3 @@ +h1 Editing user + += render PhilomenaWeb.Admin.UserView, "_form.html", Map.put(assigns, :action, Routes.admin_user_path(@conn, :update, @user)) diff --git a/lib/philomena_web/templates/admin/user/index.html.slime b/lib/philomena_web/templates/admin/user/index.html.slime new file mode 100644 index 00000000..46cb5562 --- /dev/null +++ b/lib/philomena_web/templates/admin/user/index.html.slime @@ -0,0 +1,75 @@ +h1 Users + += form_for :user, Routes.admin_user_path(@conn, :index), [method: "get", class: "hform"], fn f -> + .field + => text_input f, :q, name: "q", class: "hform__text input", placeholder: "Search query" + = submit "Search", class: "button hform__button" + +=> link "Site staff", to: Routes.admin_user_path(@conn, :index, staff: 1) +' • +=> link "2FA users", to: Routes.admin_user_path(@conn, :index, twofactor: 1) + +- route = fn p -> Routes.admin_user_path(@conn, :index, p) end +- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @users, route: route, conn: @conn + +.block + .block__header + = pagination + + .block__content + table.table + thead + tr + th Name + th Email + th Activated + th Role + th Created + th Options + tbody + = for user <- @users do + tr + td + = link user.name, to: Routes.profile_path(@conn, :show, user) + + = cond do + - user.otp_required_for_login -> + span.banner__2fa.success 2FA + + - user.role != "user" and !user.otp_required_for_login -> + span.banner__2fa.danger 1FA + + - true -> + + td + = user.email + + td + = if user.deleted_at do + strong> Deactivated + = pretty_time user.deleted_at + - else + ' Active + + td + = String.capitalize(user.role) + + td + = pretty_time user.created_at + + td + => link "Edit", to: Routes.admin_user_path(@conn, :edit, user) + ' • + + /= if user.deleted_at do + / => link_to 'Reactivate', admin_user_activation_path(user), data: { confirm: t('are_you_sure') }, method: :create + /- else + / => link_to 'Deactivate', admin_user_activation_path(user), data: { confirm: t('are_you_sure') }, method: :delete + /' • + + => link "Ban", to: Routes.admin_user_ban_path(@conn, :new, username: user.name) + ' • + => link "Add link", to: Routes.profile_user_link_path(@conn, :new, user) + + .block__header.block__header--light + = pagination diff --git a/lib/philomena_web/templates/layout/_header_staff_links.html.slime b/lib/philomena_web/templates/layout/_header_staff_links.html.slime index b428f646..a308bb2f 100644 --- a/lib/philomena_web/templates/layout/_header_staff_links.html.slime +++ b/lib/philomena_web/templates/layout/_header_staff_links.html.slime @@ -12,7 +12,7 @@ ' Site Notices = if manages_users?(@conn) do - = link to: "#", class: "header__link" do + = link to: Routes.admin_user_path(@conn, :index), class: "header__link" do i.fa.fa-fw.fa-users> ' Users diff --git a/lib/philomena_web/templates/profile/user_link/index.html.slime b/lib/philomena_web/templates/profile/user_link/index.html.slime index 11fb5a9f..9a057306 100644 --- a/lib/philomena_web/templates/profile/user_link/index.html.slime +++ b/lib/philomena_web/templates/profile/user_link/index.html.slime @@ -1,4 +1,4 @@ -h1 Your Links +h1 User Links p a.button href=Routes.profile_user_link_path(@conn, :new, @user) ' Create a link diff --git a/lib/philomena_web/templates/profile/user_link/new.html.slime b/lib/philomena_web/templates/profile/user_link/new.html.slime index 8df60585..f132997d 100644 --- a/lib/philomena_web/templates/profile/user_link/new.html.slime +++ b/lib/philomena_web/templates/profile/user_link/new.html.slime @@ -1,2 +1,2 @@ h1 Create Link -= render PhilomenaWeb.UserLinkView, "_form.html", changeset: @changeset, action: Routes.profile_user_link_path(@conn, :create, @user), conn: @conn \ No newline at end of file += render PhilomenaWeb.Profile.UserLinkView, "_form.html", changeset: @changeset, action: Routes.profile_user_link_path(@conn, :create, @user), conn: @conn diff --git a/lib/philomena_web/views/admin/user_view.ex b/lib/philomena_web/views/admin/user_view.ex new file mode 100644 index 00000000..25c54f75 --- /dev/null +++ b/lib/philomena_web/views/admin/user_view.ex @@ -0,0 +1,76 @@ +defmodule PhilomenaWeb.Admin.UserView do + use PhilomenaWeb, :view + + def checkbox_mapper(form, field, input_opts, role, label_opts, _opts) do + input_id = "user_roles_#{role.id}" + label_opts = [for: input_id] + input_opts = + Keyword.merge(input_opts, [ + class: "checkbox", id: input_id, checked_value: to_string(role.id), hidden_input: false, + checked: Enum.member?(Enum.map(input_value(form, field), & &1.id), role.id) + ]) + + content_tag(:li, class: "table-list__label") do + content_tag(:div) do + [ + checkbox(form, field, input_opts), + " ", + content_tag(:label, description(role.name, role.resource_type), label_opts), + ] + end + end + end + + def description("moderator", "Image"), do: "Manage images" + def description("moderator", "DuplicateReport"), do: "Manage duplicates" + def description("moderator", "Comment"), do: "Manage comments" + def description("moderator", "Tag"), do: "Manage tag details" + def description("moderator", "UserLink"), do: "Manage user links" + def description("moderator", "Topic"), do: "Moderate forums" + + def description("admin", "Tag"), do: "Alias tags" + def description("batch_update", "Tag"), do: "Update tags in batches" + def description("moderator", "Tag"), do: "Manage tags" + + def description("moderator", "User"), do: "Manage users and wipe votes" + def description("admin", "Role"), do: "Manage permissions" + def description("admin", "SiteNotice"), do: "Manage site notices" + def description("admin", "Badge"), do: "Manage badges" + def description("admin", "Advert"), do: "Manage ads" + + def description(_name, _resource_type), do: "(unknown permission)" + + def filtered_roles(permission_set, roles) do + roles + |> Enum.filter(&Enum.member?(permission_set, [&1.name, &1.resource_type])) + |> Enum.map(&{&1, ""}) + end + + def general_permissions do + [ + ["batch_update", "Tag"] + ] + end + + def assistant_permissions do + [ + ["moderator", "Image"], + ["moderator", "DuplicateReport"], + ["moderator", "Comment"], + ["moderator", "Tag"], + ["moderator", "UserLink"], + ["moderator", "Topic"] + ] + end + + def moderator_permissions do + [ + ["moderator", "User"], + ["admin", "Tag"], + ["admin", "Role"], + ["admin", "SiteNotice"], + ["admin", "Badge"], + ["admin", "Advert"] + ] + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 41e38625..9c16ad2d 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -10,7 +10,7 @@ # We recommend using the bang functions (`insert!`, `update!` # and so on) as they will fail if something goes wrong. -alias Philomena.{Repo, Comments.Comment, Filters.Filter, Forums.Forum, Galleries.Gallery, Posts.Post, Images.Image, Reports.Report, Tags.Tag, Users.User} +alias Philomena.{Repo, Comments.Comment, Filters.Filter, Forums.Forum, Galleries.Gallery, Posts.Post, Images.Image, Reports.Report, Roles.Role, Tags.Tag, Users.User} alias Philomena.Tags import Ecto.Query @@ -61,7 +61,14 @@ for user_def <- resources["users"] do |> Repo.insert(on_conflict: :nothing) end +IO.puts "---- Generating roles" +for role_def <- resources["roles"] do + %Role{name: role_def["name"], resource_type: role_def["resource_type"]} + |> Role.changeset(%{}) + |> Repo.insert(on_conflict: :nothing) +end + IO.puts "---- Indexing content" Tag.reindex(Tag |> preload(^Tags.indexing_preloads())) -IO.puts "---- Done." \ No newline at end of file +IO.puts "---- Done." diff --git a/priv/repo/seeds.json b/priv/repo/seeds.json index 496d10e0..0fd74c9e 100644 --- a/priv/repo/seeds.json +++ b/priv/repo/seeds.json @@ -75,5 +75,20 @@ "semi-grimdark", "grimdark", "grotesque" - ] -} \ No newline at end of file + ], + "roles": [ + {"name": "moderator", "resource_type": "Image"}, + {"name": "moderator", "resource_type": "DuplicateReport"}, + {"name": "moderator", "resource_type": "Comment"}, + {"name": "moderator", "resource_type": "Tag"}, + {"name": "moderator", "resource_type": "UserLink"}, + {"name": "admin", "resource_type": "Tag"}, + {"name": "moderator", "resource_type": "User"}, + {"name": "admin", "resource_type": "SiteNotice"}, + {"name": "admin", "resource_type": "Badge"}, + {"name": "admin", "resource_type": "Role"}, + {"name": "batch_update", "resource_type": "Tag"}, + {"name": "moderator", "resource_type": "Topic"}, + {"name": "admin", "resource_type": "Advert"} + ] +}