Merge branch 'main' into feat/421_accessibility
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
commit
73382c2c3f
49 changed files with 3415 additions and 1950 deletions
|
|
@ -21,66 +21,70 @@ defmodule MvWeb.RoleLive.Form do
|
|||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>{gettext("Use this form to manage roles in your database.")}</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form class="max-w-xl" for={@form} id="role-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:name]} type="text" label={gettext("Name")} required />
|
||||
<.header>
|
||||
<:leading>
|
||||
<.button navigate={return_path(@return_to, @role)} variant="neutral">
|
||||
<.icon name="hero-arrow-left" class="size-4" />
|
||||
{gettext("Back")}
|
||||
</.button>
|
||||
</:leading>
|
||||
{@page_title}
|
||||
<:subtitle>{gettext("Use this form to manage roles in your database.")}</:subtitle>
|
||||
<:actions>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save")}
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.input
|
||||
field={@form[:description]}
|
||||
type="textarea"
|
||||
label={gettext("Description")}
|
||||
rows="3"
|
||||
/>
|
||||
<div class="mt-6 space-y-6">
|
||||
<.input field={@form[:name]} type="text" label={gettext("Name")} required />
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="role-form_permission_set_name">
|
||||
<span class="label-text font-semibold">
|
||||
{gettext("Permission Set")}
|
||||
<span class="text-red-700">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
class={[
|
||||
"select select-bordered w-full",
|
||||
@form.errors[:permission_set_name] && "select-error"
|
||||
]}
|
||||
name="role[permission_set_name]"
|
||||
id="role-form_permission_set_name"
|
||||
required
|
||||
aria-label={gettext("Permission Set")}
|
||||
>
|
||||
<option value="">{gettext("Select permission set")}</option>
|
||||
<%= for permission_set <- all_permission_sets() do %>
|
||||
<option
|
||||
value={permission_set}
|
||||
selected={@form[:permission_set_name].value == permission_set}
|
||||
>
|
||||
{format_permission_set_option(permission_set)}
|
||||
</option>
|
||||
<.input
|
||||
field={@form[:description]}
|
||||
type="textarea"
|
||||
label={gettext("Description")}
|
||||
rows="3"
|
||||
/>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="role-form_permission_set_name">
|
||||
<span class="label-text font-semibold">
|
||||
{gettext("Permission Set")}
|
||||
<span class="text-red-700">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
class={[
|
||||
"select select-bordered w-full",
|
||||
@form.errors[:permission_set_name] && "select-error"
|
||||
]}
|
||||
name="role[permission_set_name]"
|
||||
id="role-form_permission_set_name"
|
||||
required
|
||||
aria-label={gettext("Permission Set")}
|
||||
>
|
||||
<option value="">{gettext("Select permission set")}</option>
|
||||
<%= for permission_set <- all_permission_sets() do %>
|
||||
<option
|
||||
value={permission_set}
|
||||
selected={@form[:permission_set_name].value == permission_set}
|
||||
>
|
||||
{format_permission_set_option(permission_set)}
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
<%= if @form.errors[:permission_set_name] do %>
|
||||
<%= for error <- List.wrap(@form.errors[:permission_set_name]) do %>
|
||||
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
||||
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
|
||||
<.icon name="hero-exclamation-circle" class="size-5" />
|
||||
{msg}
|
||||
</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</select>
|
||||
<%= if @form.errors[:permission_set_name] do %>
|
||||
<%= for error <- List.wrap(@form.errors[:permission_set_name]) do %>
|
||||
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
||||
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
|
||||
<.icon name="hero-exclamation-circle" class="size-5" />
|
||||
{msg}
|
||||
</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save Role")}
|
||||
</.button>
|
||||
<.button navigate={return_path(@return_to, @role)} type="button">
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
|
|
@ -175,7 +179,7 @@ defmodule MvWeb.RoleLive.Form do
|
|||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, gettext("Role saved successfully."))
|
||||
|> put_flash(:success, gettext("Role saved successfully."))
|
||||
|> push_navigate(to: redirect_path)
|
||||
|
||||
{:noreply, socket}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,8 @@ defmodule MvWeb.RoleLive.Index do
|
|||
## Features
|
||||
- List all roles with name, description, permission_set_name, is_system_role
|
||||
- Create new roles
|
||||
- Navigate to role details and edit forms
|
||||
- Delete non-system roles
|
||||
|
||||
## Events
|
||||
- `delete` - Remove a role from the database (only non-system roles)
|
||||
- Navigate to role details (row click) and edit from details header
|
||||
- Delete only via Danger zone on role show page
|
||||
|
||||
## Security
|
||||
Only admins can access this page (enforced by authorization).
|
||||
|
|
@ -37,83 +34,6 @@ defmodule MvWeb.RoleLive.Index do
|
|||
|> assign(:user_counts, user_counts)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
case Authorization.get_role(id, actor: socket.assigns.current_user) do
|
||||
{:ok, role} ->
|
||||
handle_delete_role(role, id, socket)
|
||||
|
||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Role not found.")
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
error_message = format_error(error)
|
||||
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Failed to delete role: %{error}", error: error_message)
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_delete_role(role, id, socket) do
|
||||
if role.is_system_role do
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("System roles cannot be deleted.")
|
||||
)}
|
||||
else
|
||||
user_count = recalculate_user_count(role, socket.assigns.current_user)
|
||||
|
||||
if user_count > 0 do
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext(
|
||||
"Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.",
|
||||
count: user_count
|
||||
)
|
||||
)}
|
||||
else
|
||||
perform_role_deletion(role, id, socket)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp perform_role_deletion(role, id, socket) do
|
||||
case Authorization.destroy_role(role, actor: socket.assigns.current_user) do
|
||||
:ok ->
|
||||
updated_roles = Enum.reject(socket.assigns.roles, &(&1.id == id))
|
||||
updated_counts = Map.delete(socket.assigns.user_counts, id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:roles, updated_roles)
|
||||
|> assign(:user_counts, updated_counts)
|
||||
|> put_flash(:info, gettext("Role deleted successfully."))}
|
||||
|
||||
{:error, error} ->
|
||||
error_message = format_error(error)
|
||||
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Failed to delete role: %{error}", error: error_message)
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()]
|
||||
defp load_roles(actor) do
|
||||
opts = MvWeb.LiveHelpers.ash_actor_opts(actor)
|
||||
|
|
@ -154,15 +74,4 @@ defmodule MvWeb.RoleLive.Index do
|
|||
defp get_user_count(role, user_counts) do
|
||||
Map.get(user_counts, role.id, 0)
|
||||
end
|
||||
|
||||
# Recalculates user count for a specific role (used before deletion)
|
||||
@spec recalculate_user_count(Mv.Authorization.Role.t(), map() | nil) :: non_neg_integer()
|
||||
defp recalculate_user_count(role, actor) do
|
||||
opts = opts_with_actor([], actor, Mv.Accounts)
|
||||
|
||||
case Ash.count(Accounts.User |> Ash.Query.filter(role_id == ^role.id), opts) do
|
||||
{:ok, count} -> count
|
||||
_ -> 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
id="roles"
|
||||
rows={@roles}
|
||||
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
|
||||
row_tooltip={gettext("Click for role details")}
|
||||
>
|
||||
<:col :let={role} label={gettext("Name")}>
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
@ -53,46 +54,5 @@
|
|||
<:col :let={role} label={gettext("Users")}>
|
||||
<.badge variant="neutral">{get_user_count(role, @user_counts)}</.badge>
|
||||
</:col>
|
||||
|
||||
<:action :let={role}>
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/admin/roles/#{role}"}>{gettext("Show")}</.link>
|
||||
</div>
|
||||
|
||||
<%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
|
||||
<.link navigate={~p"/admin/roles/#{role}/edit"} class="btn btn-ghost btn-sm">
|
||||
<.icon name="hero-pencil" class="size-4" />
|
||||
{gettext("Edit")}
|
||||
</.link>
|
||||
<% end %>
|
||||
</:action>
|
||||
|
||||
<:action :let={role}>
|
||||
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not role.is_system_role do %>
|
||||
<.link
|
||||
phx-click={JS.push("delete", value: %{id: role.id}) |> hide("#row-#{role.id}")}
|
||||
data-confirm={gettext("Are you sure?")}
|
||||
class="btn btn-ghost btn-sm text-error"
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete")}
|
||||
</.link>
|
||||
<% else %>
|
||||
<div
|
||||
:if={role.is_system_role}
|
||||
class="tooltip tooltip-left"
|
||||
data-tip={gettext("System roles cannot be deleted")}
|
||||
>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm text-error opacity-50 cursor-not-allowed"
|
||||
disabled={true}
|
||||
aria-label={gettext("Cannot delete system role")}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete")}
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</:action>
|
||||
</.table>
|
||||
</Layouts.app>
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ defmodule MvWeb.RoleLive.Show do
|
|||
:ok ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("Role deleted successfully."))
|
||||
|> put_flash(:success, gettext("Role deleted successfully."))
|
||||
|> push_navigate(to: ~p"/admin/roles")}
|
||||
|
||||
{:error, error} ->
|
||||
|
|
@ -161,27 +161,28 @@ defmodule MvWeb.RoleLive.Show do
|
|||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
<:leading>
|
||||
<.button
|
||||
navigate={~p"/admin/roles"}
|
||||
variant="neutral"
|
||||
aria-label={gettext("Back to roles list")}
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="size-4" />
|
||||
{gettext("Back")}
|
||||
</.button>
|
||||
</:leading>
|
||||
{gettext("Role")} {@role.name}
|
||||
<:subtitle>{gettext("Role details and permissions.")}</:subtitle>
|
||||
|
||||
<:actions>
|
||||
<.button navigate={~p"/admin/roles"} aria-label={gettext("Back to roles list")}>
|
||||
<.icon name="hero-arrow-left" />
|
||||
<span class="sr-only">{gettext("Back to roles list")}</span>
|
||||
</.button>
|
||||
<%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
|
||||
<.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"}>
|
||||
<.icon name="hero-pencil-square" /> {gettext("Edit Role")}
|
||||
</.button>
|
||||
<% end %>
|
||||
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
|
||||
<.link
|
||||
phx-click={JS.push("delete", value: %{id: @role.id})}
|
||||
data-confirm={gettext("Are you sure?")}
|
||||
class="btn btn-error"
|
||||
<.button
|
||||
variant="primary"
|
||||
navigate={~p"/admin/roles/#{@role}/edit"}
|
||||
data-testid="role-show-edit-btn"
|
||||
>
|
||||
<.icon name="hero-trash" /> {gettext("Delete Role")}
|
||||
</.link>
|
||||
<.icon name="hero-pencil-square" /> {gettext("Edit role")}
|
||||
</.button>
|
||||
<% end %>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
|
@ -209,6 +210,37 @@ defmodule MvWeb.RoleLive.Show do
|
|||
</.badge>
|
||||
</:item>
|
||||
</.list>
|
||||
|
||||
<%!-- Danger zone: canonical pattern (same as member show) --%>
|
||||
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
|
||||
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||
{gettext("Danger zone")}
|
||||
</h2>
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
<p class="text-base-content/70 mb-4">
|
||||
{gettext(
|
||||
"Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
|
||||
)}
|
||||
</p>
|
||||
<.button
|
||||
variant="danger"
|
||||
phx-click={JS.push("delete", value: %{id: @role.id})}
|
||||
data-confirm={
|
||||
gettext(
|
||||
"Are you sure you want to delete the role %{name}? This action cannot be undone.",
|
||||
name: @role.name
|
||||
)
|
||||
}
|
||||
data-testid="role-delete"
|
||||
aria-label={gettext("Delete role %{name}", name: @role.name)}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete role")}
|
||||
</.button>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue