6.5 KiB · text 5af55d9
defmodule GitGudWeb.OrgLive.Members do
@moduledoc """
Admin-only management page for an org's members: list, change role,
remove, and add a new member by handle / email.
Last-admin guard: refuses to demote or remove the only admin so the
org always has at least one admin.
"""
use GitGudWeb, :live_view
alias GitGud.Accounts
alias GitGud.Organizations
alias GitGud.Organizations.Organization
@impl true
def mount(%{"handle" => handle}, _session, socket) do
org = Organizations.get_organization_by_handle!(handle)
user = socket.assigns.current_scope.user
if Organizations.org_admin?(org, user) do
{:ok,
socket
|> assign(:org, org)
|> load_members()
|> assign(:invite_form, to_form(%{"handle_or_email" => ""}, as: "invite"))
|> assign(:page_title, "Members — @#{org.handle}")}
else
{:ok,
socket
|> put_flash(:error, "Admins only.")
|> push_navigate(to: ~p"/orgs/#{org.handle}")}
end
end
defp load_members(socket) do
members = Organizations.list_members(socket.assigns.org)
assign(socket, :members, members)
end
@impl true
def handle_event("add_member", %{"invite" => %{"handle_or_email" => raw}}, socket) do
org = socket.assigns.org
case find_user(raw) do
nil ->
{:noreply, put_flash(socket, :error, "No user matched #{inspect(raw)}.")}
%Accounts.User{} = user ->
case Organizations.add_member(org, user, "member") do
{:ok, _} ->
{:noreply,
socket
|> load_members()
|> assign(:invite_form, to_form(%{"handle_or_email" => ""}, as: "invite"))
|> put_flash(:info, "Added #{display(user)} as a member.")}
_ ->
{:noreply, put_flash(socket, :error, "Could not add member.")}
end
end
end
def handle_event("set_role", %{"user_id" => uid, "role" => role}, socket)
when role in ["admin", "member"] do
org = socket.assigns.org
with {user_id, ""} <- Integer.parse(uid),
user when not is_nil(user) <- GitGud.Repo.get(Accounts.User, user_id) do
case Organizations.set_member_role(org, user, role) do
{:ok, _} ->
{:noreply, socket |> load_members() |> put_flash(:info, "Role updated.")}
{:error, :last_admin} ->
{:noreply,
put_flash(socket, :error, "Can't demote the last admin — promote someone else first.")}
{:error, :not_a_member} ->
{:noreply, put_flash(socket, :error, "Not a member of this org.")}
_ ->
{:noreply, put_flash(socket, :error, "Couldn't update role.")}
end
else
_ -> {:noreply, socket}
end
end
def handle_event("remove_member", %{"user_id" => uid}, socket) do
org = socket.assigns.org
with {user_id, ""} <- Integer.parse(uid),
user when not is_nil(user) <- GitGud.Repo.get(Accounts.User, user_id) do
case Organizations.remove_member_guarded(org, user) do
:ok ->
{:noreply, socket |> load_members() |> put_flash(:info, "Removed #{display(user)}.")}
{:error, :last_admin} ->
{:noreply,
put_flash(socket, :error, "Can't remove the last admin — promote someone else first.")}
{:error, :not_a_member} ->
{:noreply, socket}
end
else
_ -> {:noreply, socket}
end
end
defp find_user(input) do
raw = String.trim(input)
cond do
raw == "" -> nil
String.contains?(raw, "@") -> GitGud.Repo.get_by(Accounts.User, email: raw)
true -> Accounts.get_user_by_handle(raw)
end
end
defp display(%Accounts.User{} = u), do: Accounts.User.display(u)
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="space-y-6">
<header>
<p class="text-xs opacity-60">
<.link navigate={~p"/orgs/#{@org.handle}"} class="link link-hover">
{Organization.display(@org)}
</.link>
</p>
<h1 class="text-xl font-semibold">Members</h1>
</header>
<section class="space-y-2 max-w-xl">
<h2 class="font-semibold text-sm">Add a member</h2>
<.form
for={@invite_form}
id="add-member-form"
phx-submit="add_member"
class="flex items-end gap-2"
>
<.input
field={@invite_form[:handle_or_email]}
label="Handle or email"
placeholder="alice or alice@example.com"
required
/>
<button type="submit" class="btn btn-sm btn-primary">Add</button>
</.form>
<p class="text-xs opacity-70">
Adds as <span class="font-mono">member</span>. Promote to admin
afterwards via the role dropdown.
</p>
</section>
<section>
<h2 class="font-semibold text-sm mb-2">Current members</h2>
<ul class="divide-y divide-base-300">
<li :for={m <- @members} class="py-2 flex items-center gap-3">
<.avatar name={(m.user.handle || m.user.email)} size="sm" />
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2">
<span class="font-medium">{display(m.user)}</span>
<span class="text-xs opacity-60 font-mono">@{m.user.handle}</span>
</div>
<p class="text-xs opacity-60">{m.user.email}</p>
</div>
<form
phx-change="set_role"
phx-value-user_id={m.user.id}
class="flex items-center"
>
<input type="hidden" name="user_id" value={m.user.id} />
<select name="role" class="select select-xs select-bordered">
<option value="admin" selected={m.role == "admin"}>admin</option>
<option value="member" selected={m.role == "member"}>member</option>
</select>
</form>
<button
type="button"
phx-click="remove_member"
phx-value-user_id={m.user.id}
class="btn btn-ghost btn-xs text-error"
data-confirm={"Remove " <> display(m.user) <> "?"}
title="Remove"
>
<.icon name="hero-x-mark-micro" class="size-4" />
</button>
</li>
</ul>
</section>
</div>
</Layouts.app>
"""
end
end