+ <.header>
+ {@page_title}
+ <:subtitle>{gettext("Use this form to manage roles in your database.")}
+
+
+ <.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 />
+
+ <.input
+ field={@form[:description]}
+ type="textarea"
+ label={gettext("Description")}
+ rows="3"
+ />
+
+
+
+
+ <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
+ {gettext("Save Role")}
+
+ <.button navigate={return_path(@return_to, @role)} type="button">
+ {gettext("Cancel")}
+
+
+
+
+ """
+ end
+
+ @impl true
+ def mount(params, _session, socket) do
+ # Ensure current_user has role loaded for authorization checks
+ socket =
+ if socket.assigns[:current_user] do
+ user = socket.assigns.current_user
+
+ user_with_role =
+ case Map.get(user, :role) do
+ %Ash.NotLoaded{} -> Ash.load!(user, :role, domain: Mv.Accounts)
+ nil -> Ash.load!(user, :role, domain: Mv.Accounts)
+ role when not is_nil(role) -> user
+ end
+
+ assign(socket, :current_user, user_with_role)
+ else
+ socket
+ end
+
+ role =
+ case params["id"] do
+ nil -> nil
+ id -> Ash.get!(Mv.Authorization.Role, id, domain: Mv.Authorization)
+ end
+
+ action = if is_nil(role), do: gettext("New"), else: gettext("Edit")
+ page_title = action <> " " <> gettext("Role")
+
+ {:ok,
+ socket
+ |> assign(:return_to, return_to(params["return_to"]))
+ |> assign(:role, role)
+ |> assign(:page_title, page_title)
+ |> assign_form()}
+ end
+
+ @spec return_to(String.t() | nil) :: String.t()
+ defp return_to("show"), do: "show"
+ defp return_to(_), do: "index"
+
+ @impl true
+ def handle_event("validate", %{"role" => role_params}, socket) do
+ validated_form = AshPhoenix.Form.validate(socket.assigns.form, role_params)
+ {:noreply, assign(socket, form: validated_form)}
+ end
+
+ def handle_event("save", %{"role" => role_params}, socket) do
+ case AshPhoenix.Form.submit(socket.assigns.form, params: role_params) do
+ {:ok, role} ->
+ notify_parent({:saved, role})
+
+ redirect_path =
+ if socket.assigns.return_to == "show" do
+ ~p"/admin/roles/#{role.id}"
+ else
+ ~p"/admin/roles"
+ end
+
+ socket =
+ socket
+ |> put_flash(:info, gettext("Role saved successfully"))
+ |> push_navigate(to: redirect_path)
+
+ {:noreply, socket}
+
+ {:error, form} ->
+ {:noreply, assign(socket, form: form)}
+ end
+ end
+
+ @spec notify_parent(any()) :: any()
+ defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
+
+ @spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
+ defp assign_form(%{assigns: %{role: role}} = socket) do
+ form =
+ if role do
+ AshPhoenix.Form.for_update(role, :update_role, domain: Mv.Authorization, as: "role")
+ else
+ AshPhoenix.Form.for_create(
+ Mv.Authorization.Role,
+ :create_role,
+ domain: Mv.Authorization,
+ as: "role"
+ )
+ end
+
+ assign(socket, form: to_form(form))
+ end
+
+ defp all_permission_sets do
+ PermissionSets.all_permission_sets() |> Enum.map(&Atom.to_string/1)
+ end
+
+ defp format_permission_set_option("own_data"),
+ do: gettext("own_data - Access only to own data")
+
+ defp format_permission_set_option("read_only"),
+ do: gettext("read_only - Read access to all data")
+
+ defp format_permission_set_option("normal_user"),
+ do: gettext("normal_user - Create/Read/Update access")
+
+ defp format_permission_set_option("admin"),
+ do: gettext("admin - Unrestricted access")
+
+ defp format_permission_set_option(set), do: set
+
+ @spec return_path(String.t(), Mv.Authorization.Role.t() | nil) :: String.t()
+ defp return_path("index", _role), do: ~p"/admin/roles"
+ defp return_path("show", role) when not is_nil(role), do: ~p"/admin/roles/#{role.id}"
+ defp return_path("show", _role), do: ~p"/admin/roles"
+ defp return_path(_, role) when not is_nil(role), do: ~p"/admin/roles/#{role.id}"
+ defp return_path(_, _role), do: ~p"/admin/roles"
+end
diff --git a/lib/mv_web/live/role_live/index.ex b/lib/mv_web/live/role_live/index.ex
new file mode 100644
index 0000000..765177b
--- /dev/null
+++ b/lib/mv_web/live/role_live/index.ex
@@ -0,0 +1,93 @@
+defmodule MvWeb.RoleLive.Index do
+ @moduledoc """
+ LiveView for displaying and managing the role list.
+
+ ## 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)
+
+ ## Security
+ Only admins can access this page (enforced by authorization).
+ """
+ use MvWeb, :live_view
+
+ alias Mv.Authorization
+
+ @impl true
+ def mount(_params, _session, socket) do
+ # Ensure current_user has role loaded for authorization checks
+ socket =
+ if socket.assigns[:current_user] do
+ user = socket.assigns.current_user
+
+ # Load role if not already loaded (check for Ash.NotLoaded struct)
+ user_with_role =
+ case Map.get(user, :role) do
+ %Ash.NotLoaded{} -> Ash.load!(user, :role, domain: Mv.Accounts)
+ nil -> Ash.load!(user, :role, domain: Mv.Accounts)
+ role when not is_nil(role) -> user
+ end
+
+ assign(socket, :current_user, user_with_role)
+ else
+ socket
+ end
+
+ roles = load_roles()
+
+ {:ok,
+ socket
+ |> assign(:page_title, gettext("Listing Roles"))
+ |> assign(:roles, roles)}
+ end
+
+ @impl true
+ def handle_event("delete", %{"id" => id}, socket) do
+ {:ok, role} = Authorization.get_role(id)
+
+ case Authorization.destroy_role(role) do
+ :ok ->
+ updated_roles = Enum.reject(socket.assigns.roles, &(&1.id == id))
+
+ {:noreply,
+ socket
+ |> assign(:roles, updated_roles)
+ |> 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
+
+ defp load_roles do
+ case Authorization.list_roles() do
+ {:ok, roles} -> Enum.sort_by(roles, & &1.name)
+ {:error, _} -> []
+ end
+ end
+
+ defp format_error(%Ash.Error.Invalid{} = error) do
+ Enum.map_join(error.errors, ", ", fn e -> e.message end)
+ end
+
+ defp format_error(error) when is_binary(error), do: error
+ defp format_error(_error), do: gettext("An error occurred")
+
+ defp permission_set_badge_class("own_data"), do: "badge badge-neutral badge-sm"
+ defp permission_set_badge_class("read_only"), do: "badge badge-info badge-sm"
+ defp permission_set_badge_class("normal_user"), do: "badge badge-success badge-sm"
+ defp permission_set_badge_class("admin"), do: "badge badge-error badge-sm"
+ defp permission_set_badge_class(_), do: "badge badge-ghost badge-sm"
+end
diff --git a/lib/mv_web/live/role_live/index.html.heex b/lib/mv_web/live/role_live/index.html.heex
new file mode 100644
index 0000000..df4ed53
--- /dev/null
+++ b/lib/mv_web/live/role_live/index.html.heex
@@ -0,0 +1,91 @@
+