neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
⭐
Log in to mark this repository.
Invite people to an org instead of adding them outright
c68e1e7 · Gabriel Morell · 2026-09-10 15:22
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/organizations.ex
+108
−0
@@ -14,6 +14,7 @@ defmodule GitGud.Organizations do
| 14 | 14 | import Ecto.Query, warn: false |
| 15 | 15 | |
| 16 | 16 | alias GitGud.Accounts.User |
| 17 | + alias GitGud.Organizations.Invitation | |
| 17 | 18 | alias GitGud.Organizations.Membership |
| 18 | 19 | alias GitGud.Organizations.Organization |
| 19 | 20 | alias GitGud.Organizations.Team |
@@ -167,6 +168,113 @@ defmodule GitGud.Organizations do
| 167 | 168 | :ok |
| 168 | 169 | end |
| 169 | 170 | |
| 171 | + # ── invitations ────────────────────────────────────────────────────── | |
| 172 | + | |
| 173 | + @doc """ | |
| 174 | + Invite `user` to join `org`. They become a member only once they | |
| 175 | + accept. | |
| 176 | + | |
| 177 | + Refuses to invite someone who is already a member, and treats a | |
| 178 | + second invitation to someone already pending as the existing one | |
| 179 | + rather than an error. | |
| 180 | + """ | |
| 181 | + def invite_member(%Organization{} = org, %User{} = user, invited_by \\ nil, role \\ "member") do | |
| 182 | + if member?(org, user) do | |
| 183 | + {:error, :already_member} | |
| 184 | + else | |
| 185 | + attrs = %{ | |
| 186 | + organization_id: org.id, | |
| 187 | + invited_user_id: user.id, | |
| 188 | + invited_by_id: invited_by && invited_by.id, | |
| 189 | + role: role | |
| 190 | + } | |
| 191 | + | |
| 192 | + case %Invitation{} |> Invitation.changeset(attrs) |> Repo.insert() do | |
| 193 | + {:ok, invitation} -> | |
| 194 | + {:ok, Repo.preload(invitation, [:organization, :invited_user, :invited_by])} | |
| 195 | + | |
| 196 | + {:error, _cs} -> | |
| 197 | + case pending_invitation(org, user) do | |
| 198 | + nil -> {:error, :not_invitable} | |
| 199 | + existing -> {:ok, existing} | |
| 200 | + end | |
| 201 | + end | |
| 202 | + end | |
| 203 | + end | |
| 204 | + | |
| 205 | + @doc "The live invitation for `user` to `org`, if there is one." | |
| 206 | + def pending_invitation(%Organization{id: oid}, %User{id: uid}) do | |
| 207 | + from(i in Invitation, | |
| 208 | + where: i.organization_id == ^oid and i.invited_user_id == ^uid and i.state == "pending", | |
| 209 | + preload: [:organization, :invited_user, :invited_by] | |
| 210 | + ) | |
| 211 | + |> Repo.one() | |
| 212 | + end | |
| 213 | + | |
| 214 | + @doc "Invitations `user` has yet to answer, newest first." | |
| 215 | + def list_pending_invitations_for(%User{id: uid}) do | |
| 216 | + from(i in Invitation, | |
| 217 | + where: i.invited_user_id == ^uid and i.state == "pending", | |
| 218 | + order_by: [desc: i.id], | |
| 219 | + preload: [:organization, :invited_by] | |
| 220 | + ) | |
| 221 | + |> Repo.all() | |
| 222 | + end | |
| 223 | + | |
| 224 | + @doc "Outstanding invitations into `org`." | |
| 225 | + def list_pending_invitations(%Organization{id: oid}) do | |
| 226 | + from(i in Invitation, | |
| 227 | + where: i.organization_id == ^oid and i.state == "pending", | |
| 228 | + order_by: [asc: i.id], | |
| 229 | + preload: [:invited_user, :invited_by] | |
| 230 | + ) | |
| 231 | + |> Repo.all() | |
| 232 | + end | |
| 233 | + | |
| 234 | + @doc """ | |
| 235 | + Accept an invitation, creating the membership. Only the invitee can; | |
| 236 | + anyone else gets `{:error, :forbidden}`. | |
| 237 | + """ | |
| 238 | + def accept_invitation(%Invitation{} = invitation, %User{id: uid} = user) do | |
| 239 | + cond do | |
| 240 | + invitation.invited_user_id != uid -> | |
| 241 | + {:error, :forbidden} | |
| 242 | + | |
| 243 | + not Invitation.pending?(invitation) -> | |
| 244 | + {:error, :not_pending} | |
| 245 | + | |
| 246 | + true -> | |
| 247 | + org = Repo.get!(Organization, invitation.organization_id) | |
| 248 | + | |
| 249 | + Repo.transaction(fn -> | |
| 250 | + {:ok, membership} = add_member(org, user, invitation.role) | |
| 251 | + {:ok, _} = invitation |> Invitation.respond_changeset("accepted") |> Repo.update() | |
| 252 | + membership | |
| 253 | + end) | |
| 254 | + end | |
| 255 | + end | |
| 256 | + | |
| 257 | + @doc "Decline an invitation. Only the invitee can." | |
| 258 | + def decline_invitation(%Invitation{} = invitation, %User{id: uid}) do | |
| 259 | + cond do | |
| 260 | + invitation.invited_user_id != uid -> {:error, :forbidden} | |
| 261 | + not Invitation.pending?(invitation) -> {:error, :not_pending} | |
| 262 | + true -> invitation |> Invitation.respond_changeset("declined") |> Repo.update() | |
| 263 | + end | |
| 264 | + end | |
| 265 | + | |
| 266 | + @doc "Withdraw an invitation an admin sent. Leaves the row as revoked." | |
| 267 | + def revoke_invitation(%Invitation{} = invitation) do | |
| 268 | + if Invitation.pending?(invitation) do | |
| 269 | + invitation |> Invitation.respond_changeset("revoked") |> Repo.update() | |
| 270 | + else | |
| 271 | + {:error, :not_pending} | |
| 272 | + end | |
| 273 | + end | |
| 274 | + | |
| 275 | + def get_invitation(id), | |
| 276 | + do: Repo.get(Invitation, id) |> Repo.preload([:organization, :invited_user]) | |
| 277 | + | |
| 170 | 278 | def org_admin?(%Organization{id: oid}, %User{id: uid}) do |
| 171 | 279 | Repo.exists?( |
| 172 | 280 | from m in Membership, |
added
lib/git_gud/organizations/invitation.ex
+44
−0
@@ -0,0 +1,44 @@
| 1 | +defmodule GitGud.Organizations.Invitation do | |
| 2 | + use Ecto.Schema | |
| 3 | + import Ecto.Changeset | |
| 4 | + | |
| 5 | + alias GitGud.Accounts.User | |
| 6 | + alias GitGud.Organizations.Organization | |
| 7 | + | |
| 8 | + @valid_states ~w(pending accepted declined revoked) | |
| 9 | + @valid_roles ~w(admin member) | |
| 10 | + | |
| 11 | + schema "organization_invitations" do | |
| 12 | + field :role, :string, default: "member" | |
| 13 | + field :state, :string, default: "pending" | |
| 14 | + field :responded_at, :utc_datetime | |
| 15 | + | |
| 16 | + belongs_to :organization, Organization | |
| 17 | + belongs_to :invited_user, User | |
| 18 | + belongs_to :invited_by, User | |
| 19 | + | |
| 20 | + timestamps(type: :utc_datetime) | |
| 21 | + end | |
| 22 | + | |
| 23 | + def changeset(invitation, attrs) do | |
| 24 | + invitation | |
| 25 | + |> cast(attrs, [:organization_id, :invited_user_id, :invited_by_id, :role]) | |
| 26 | + |> validate_required([:organization_id, :invited_user_id]) | |
| 27 | + |> validate_inclusion(:role, @valid_roles) | |
| 28 | + |> unique_constraint([:organization_id, :invited_user_id], | |
| 29 | + name: :organization_invitations_pending_index, | |
| 30 | + message: "has already been invited" | |
| 31 | + ) | |
| 32 | + end | |
| 33 | + | |
| 34 | + @doc "Answer an invitation. Stamps `responded_at` alongside the new state." | |
| 35 | + def respond_changeset(invitation, state) when state in ["accepted", "declined", "revoked"] do | |
| 36 | + change(invitation, state: state, responded_at: DateTime.utc_now(:second)) | |
| 37 | + end | |
| 38 | + | |
| 39 | + def pending?(%__MODULE__{state: "pending"}), do: true | |
| 40 | + def pending?(%__MODULE__{}), do: false | |
| 41 | + | |
| 42 | + def valid_states, do: @valid_states | |
| 43 | + def valid_roles, do: @valid_roles | |
| 44 | +end |
modified
lib/git_gud_web/live/org_live/members.ex
+59
−16
@@ -1,7 +1,11 @@
| 1 | 1 | defmodule GitGudWeb.OrgLive.Members do |
| 2 | 2 | @moduledoc """ |
| 3 | 3 | Admin-only management page for an org's members: list, change role, |
| 4 | − remove, and add a new member by handle / email. | |
| 4 | + remove, and invite someone new by handle / email. | |
| 5 | + | |
| 6 | + Inviting rather than adding: joining an org is something you accept, | |
| 7 | + not something done to you, so an invitation sits pending until the | |
| 8 | + invitee answers it from their dashboard. | |
| 5 | 9 | |
| 6 | 10 | Last-admin guard: refuses to demote or remove the only admin so the |
| 7 | 11 | org always has at least one admin. |
@@ -34,33 +38,51 @@ defmodule GitGudWeb.OrgLive.Members do
| 34 | 38 | end |
| 35 | 39 | |
| 36 | 40 | defp load_members(socket) do |
| 37 | − members = Organizations.list_members(socket.assigns.org) | |
| 38 | − assign(socket, :members, members) | |
| 41 | + org = socket.assigns.org | |
| 42 | + | |
| 43 | + socket | |
| 44 | + |> assign(:members, Organizations.list_members(org)) | |
| 45 | + |> assign(:pending, Organizations.list_pending_invitations(org)) | |
| 39 | 46 | end |
| 40 | 47 | |
| 41 | 48 | @impl true |
| 42 | − def handle_event("add_member", %{"invite" => %{"handle_or_email" => raw}}, socket) do | |
| 49 | + def handle_event("invite_member", %{"invite" => %{"handle_or_email" => raw}}, socket) do | |
| 43 | 50 | org = socket.assigns.org |
| 51 | + inviter = socket.assigns.current_scope.user | |
| 44 | 52 | |
| 45 | 53 | case find_user(raw) do |
| 46 | 54 | nil -> |
| 47 | 55 | {:noreply, put_flash(socket, :error, "No user matched #{inspect(raw)}.")} |
| 48 | 56 | |
| 49 | 57 | %Accounts.User{} = user -> |
| 50 | − case Organizations.add_member(org, user, "member") do | |
| 51 | − {:ok, _} -> | |
| 58 | + case Organizations.invite_member(org, user, inviter) do | |
| 59 | + {:ok, _invitation} -> | |
| 52 | 60 | {:noreply, |
| 53 | 61 | socket |
| 54 | 62 | |> load_members() |
| 55 | 63 | |> assign(:invite_form, to_form(%{"handle_or_email" => ""}, as: "invite")) |
| 56 | − |> put_flash(:info, "Added #{display(user)} as a member.")} | |
| 64 | + |> put_flash(:info, "Invited #{display(user)}. They'll see it on their dashboard.")} | |
| 57 | 65 | |
| 58 | − _ -> | |
| 59 | − {:noreply, put_flash(socket, :error, "Could not add member.")} | |
| 66 | + {:error, :already_member} -> | |
| 67 | + {:noreply, put_flash(socket, :error, "#{display(user)} is already a member.")} | |
| 68 | + | |
| 69 | + {:error, _} -> | |
| 70 | + {:noreply, put_flash(socket, :error, "Could not send that invitation.")} | |
| 60 | 71 | end |
| 61 | 72 | end |
| 62 | 73 | end |
| 63 | 74 | |
| 75 | + def handle_event("revoke_invitation", %{"id" => id}, socket) do | |
| 76 | + case Enum.find(socket.assigns.pending, &(&1.id == String.to_integer(id))) do | |
| 77 | + nil -> | |
| 78 | + {:noreply, socket} | |
| 79 | + | |
| 80 | + invitation -> | |
| 81 | + {:ok, _} = Organizations.revoke_invitation(invitation) | |
| 82 | + {:noreply, socket |> load_members() |> put_flash(:info, "Invitation withdrawn.")} | |
| 83 | + end | |
| 84 | + end | |
| 85 | + | |
| 64 | 86 | def handle_event("set_role", %{"user_id" => uid, "role" => role}, socket) |
| 65 | 87 | when role in ["admin", "member"] do |
| 66 | 88 | org = socket.assigns.org |
@@ -134,11 +156,11 @@ defmodule GitGudWeb.OrgLive.Members do
| 134 | 156 | </header> |
| 135 | 157 | |
| 136 | 158 | <section class="space-y-2 max-w-xl"> |
| 137 | − <h2 class="font-semibold text-sm">Add a member</h2> | |
| 159 | + <h2 class="font-semibold text-sm">Invite a member</h2> | |
| 138 | 160 | <.form |
| 139 | 161 | for={@invite_form} |
| 140 | − id="add-member-form" | |
| 141 | − phx-submit="add_member" | |
| 162 | + id="invite-member-form" | |
| 163 | + phx-submit="invite_member" | |
| 142 | 164 | class="flex items-end gap-2" |
| 143 | 165 | > |
| 144 | 166 | <.input |
@@ -147,19 +169,40 @@ defmodule GitGudWeb.OrgLive.Members do
| 147 | 169 | placeholder="alice or alice@example.com" |
| 148 | 170 | required |
| 149 | 171 | /> |
| 150 | − <button type="submit" class="btn btn-sm btn-primary">Add</button> | |
| 172 | + <button type="submit" class="btn btn-sm btn-primary">Invite</button> | |
| 151 | 173 | </.form> |
| 152 | 174 | <p class="text-xs opacity-70"> |
| 153 | − Adds as <span class="font-mono">member</span>. Promote to admin | |
| 154 | − afterwards via the role dropdown. | |
| 175 | + They join as <span class="font-mono">member</span> once they accept, and | |
| 176 | + can be promoted afterwards. Nobody is added to an org without agreeing. | |
| 155 | 177 | </p> |
| 156 | 178 | </section> |
| 157 | 179 | |
| 180 | + <section :if={@pending != []}> | |
| 181 | + <h2 class="font-semibold text-sm mb-2">Pending invitations</h2> | |
| 182 | + <ul class="divide-y divide-base-300"> | |
| 183 | + <li :for={inv <- @pending} class="py-2 flex items-center gap-3"> | |
| 184 | + <.avatar name={inv.invited_user.handle || inv.invited_user.email} size="sm" /> | |
| 185 | + <div class="flex-1 min-w-0"> | |
| 186 | + <span class="font-medium">{display(inv.invited_user)}</span> | |
| 187 | + <span class="badge badge-xs badge-ghost ml-1">awaiting reply</span> | |
| 188 | + </div> | |
| 189 | + <button | |
| 190 | + type="button" | |
| 191 | + phx-click="revoke_invitation" | |
| 192 | + phx-value-id={inv.id} | |
| 193 | + class="btn btn-xs btn-ghost" | |
| 194 | + > | |
| 195 | + Withdraw | |
| 196 | + </button> | |
| 197 | + </li> | |
| 198 | + </ul> | |
| 199 | + </section> | |
| 200 | + | |
| 158 | 201 | <section> |
| 159 | 202 | <h2 class="font-semibold text-sm mb-2">Current members</h2> |
| 160 | 203 | <ul class="divide-y divide-base-300"> |
| 161 | 204 | <li :for={m <- @members} class="py-2 flex items-center gap-3"> |
| 162 | − <.avatar name={(m.user.handle || m.user.email)} size="sm" /> | |
| 205 | + <.avatar name={m.user.handle || m.user.email} size="sm" /> | |
| 163 | 206 | <div class="flex-1 min-w-0"> |
| 164 | 207 | <div class="flex items-baseline gap-2"> |
| 165 | 208 | <span class="font-medium">{display(m.user)}</span> |
modified
lib/git_gud_web/live/page_live/home.ex
+79
−0
@@ -41,6 +41,7 @@ defmodule GitGudWeb.PageLive.Home do
| 41 | 41 | |> assign(:accessible_ids, Repositories.relevant_repo_ids_for_user(user)) |
| 42 | 42 | |> assign(:maintained_ids, Repositories.maintained_repo_ids_for_user(user)) |
| 43 | 43 | |> assign(:orgs, Organizations.list_organizations_for(user)) |
| 44 | + |> assign(:org_invitations, Organizations.list_pending_invitations_for(user)) | |
| 44 | 45 | |> assign(:pins, Profiles.list_pins(user)) |
| 45 | 46 | |> load_work() |
| 46 | 47 | |> load_repos() |
@@ -95,6 +96,40 @@ defmodule GitGudWeb.PageLive.Home do
| 95 | 96 | ) |
| 96 | 97 | end |
| 97 | 98 | |
| 99 | + @impl true | |
| 100 | + def handle_event("accept_org_invitation", %{"id" => id}, socket) do | |
| 101 | + respond(socket, id, &Organizations.accept_invitation/2, "Joined the organization.") | |
| 102 | + end | |
| 103 | + | |
| 104 | + def handle_event("decline_org_invitation", %{"id" => id}, socket) do | |
| 105 | + respond(socket, id, &Organizations.decline_invitation/2, "Invitation declined.") | |
| 106 | + end | |
| 107 | + | |
| 108 | + # Both answers do the same bookkeeping; only the context call and the | |
| 109 | + # confirmation differ. The invitation is looked up in the assigns, so | |
| 110 | + # a viewer can only answer one that was actually addressed to them. | |
| 111 | + defp respond(socket, id, fun, message) do | |
| 112 | + user = socket.assigns.user | |
| 113 | + | |
| 114 | + case Enum.find(socket.assigns.org_invitations, &(&1.id == String.to_integer(id))) do | |
| 115 | + nil -> | |
| 116 | + {:noreply, socket} | |
| 117 | + | |
| 118 | + invitation -> | |
| 119 | + case fun.(invitation, user) do | |
| 120 | + {:ok, _} -> | |
| 121 | + {:noreply, | |
| 122 | + socket | |
| 123 | + |> assign(:org_invitations, Organizations.list_pending_invitations_for(user)) | |
| 124 | + |> assign(:orgs, Organizations.list_organizations_for(user)) | |
| 125 | + |> put_flash(:info, message)} | |
| 126 | + | |
| 127 | + {:error, _} -> | |
| 128 | + {:noreply, put_flash(socket, :error, "Could not answer that invitation.")} | |
| 129 | + end | |
| 130 | + end | |
| 131 | + end | |
| 132 | + | |
| 98 | 133 | @impl true |
| 99 | 134 | def handle_info({:ci_run_changed, _run_id}, socket) do |
| 100 | 135 | {:noreply, load_ci(socket)} |
@@ -109,6 +144,9 @@ defmodule GitGudWeb.PageLive.Home do
| 109 | 144 | defp review_hint([]), do: "Open in repos you maintain, opened by someone else" |
| 110 | 145 | defp review_hint(_requested), do: "Requested of you, plus repos you maintain" |
| 111 | 146 | |
| 147 | + defp inviter_name(%{invited_by: %{handle: h}}) when is_binary(h), do: h | |
| 148 | + defp inviter_name(_), do: "An admin" | |
| 149 | + | |
| 112 | 150 | defp signed_in?(%{user: %{}}), do: true |
| 113 | 151 | defp signed_in?(_), do: false |
| 114 | 152 |
@@ -154,6 +192,7 @@ defmodule GitGudWeb.PageLive.Home do
| 154 | 192 | recent_runs={@recent_runs} |
| 155 | 193 | pins={@pins} |
| 156 | 194 | orgs={@orgs} |
| 195 | + org_invitations={@org_invitations} | |
| 157 | 196 | /> |
| 158 | 197 | <% else %> |
| 159 | 198 | <.landing current_scope={@current_scope} /> |
@@ -174,6 +213,7 @@ defmodule GitGudWeb.PageLive.Home do
| 174 | 213 | attr :recent_runs, :list, required: true |
| 175 | 214 | attr :pins, :list, required: true |
| 176 | 215 | attr :orgs, :list, required: true |
| 216 | + attr :org_invitations, :list, required: true | |
| 177 | 217 | |
| 178 | 218 | defp dashboard(assigns) do |
| 179 | 219 | ~H""" |
@@ -195,6 +235,45 @@ defmodule GitGudWeb.PageLive.Home do
| 195 | 235 | </div> |
| 196 | 236 | </header> |
| 197 | 237 | |
| 238 | + <section | |
| 239 | + :if={@org_invitations != []} | |
| 240 | + class="rounded-lg border border-primary/40 bg-primary/5 p-4" | |
| 241 | + > | |
| 242 | + <h2 class="font-semibold mb-2">Organization invitations</h2> | |
| 243 | + <ul class="space-y-2"> | |
| 244 | + <li | |
| 245 | + :for={inv <- @org_invitations} | |
| 246 | + class="flex items-center gap-3 flex-wrap text-sm" | |
| 247 | + > | |
| 248 | + <.avatar name={inv.organization.handle} size="sm" alt={inv.organization.handle} /> | |
| 249 | + <span class="flex-1 min-w-0"> | |
| 250 | + <span class="font-medium">{inviter_name(inv)}</span> | |
| 251 | + has invited you to join | |
| 252 | + <.link navigate={~p"/orgs/#{inv.organization.handle}"} class="link link-hover"> | |
| 253 | + {Organization.display(inv.organization)} | |
| 254 | + </.link> | |
| 255 | + as <span class="font-mono">{inv.role}</span>. | |
| 256 | + </span> | |
| 257 | + <button | |
| 258 | + type="button" | |
| 259 | + phx-click="accept_org_invitation" | |
| 260 | + phx-value-id={inv.id} | |
| 261 | + class="btn btn-xs btn-primary" | |
| 262 | + > | |
| 263 | + Accept | |
| 264 | + </button> | |
| 265 | + <button | |
| 266 | + type="button" | |
| 267 | + phx-click="decline_org_invitation" | |
| 268 | + phx-value-id={inv.id} | |
| 269 | + class="btn btn-xs btn-ghost" | |
| 270 | + > | |
| 271 | + Decline | |
| 272 | + </button> | |
| 273 | + </li> | |
| 274 | + </ul> | |
| 275 | + </section> | |
| 276 | + | |
| 198 | 277 | <div class="grid lg:grid-cols-2 gap-4"> |
| 199 | 278 | <.panel |
| 200 | 279 | title="Needs your review" |
added
priv/repo/migrations/20260910040000_create_organization_invitations.exs
+33
−0
@@ -0,0 +1,33 @@
| 1 | +defmodule GitGud.Repo.Migrations.CreateOrganizationInvitations do | |
| 2 | + use Ecto.Migration | |
| 3 | + | |
| 4 | + # Joining an org becomes something you accept rather than something | |
| 5 | + # done to you. An admin creates an invitation; membership is only | |
| 6 | + # inserted once the invitee says yes. | |
| 7 | + # | |
| 8 | + # Declined rows are kept rather than deleted so the same person can't | |
| 9 | + # be re-invited into an endless loop without the admin noticing, and | |
| 10 | + # so the org's members page can show that an invitation was answered. | |
| 11 | + def change do | |
| 12 | + create table(:organization_invitations) do | |
| 13 | + add :organization_id, references(:organizations, on_delete: :delete_all), null: false | |
| 14 | + add :invited_user_id, references(:users, on_delete: :delete_all), null: false | |
| 15 | + add :invited_by_id, references(:users, on_delete: :nilify_all) | |
| 16 | + add :role, :string, null: false, default: "member" | |
| 17 | + add :state, :string, null: false, default: "pending" | |
| 18 | + add :responded_at, :utc_datetime | |
| 19 | + | |
| 20 | + timestamps(type: :utc_datetime) | |
| 21 | + end | |
| 22 | + | |
| 23 | + # One live invitation per person per org. Answered ones are kept, | |
| 24 | + # so the constraint only covers pending. | |
| 25 | + create unique_index(:organization_invitations, [:organization_id, :invited_user_id], | |
| 26 | + where: "state = 'pending'", | |
| 27 | + name: :organization_invitations_pending_index | |
| 28 | + ) | |
| 29 | + | |
| 30 | + # "What am I being asked to join?" — the dashboard's query. | |
| 31 | + create index(:organization_invitations, [:invited_user_id, :state]) | |
| 32 | + end | |
| 33 | +end |
added
test/git_gud_web/live/org_invitation_test.exs
+187
−0
@@ -0,0 +1,187 @@
| 1 | +defmodule GitGudWeb.OrgInvitationTest do | |
| 2 | + @moduledoc """ | |
| 3 | + Joining an org is something you accept, not something done to you: | |
| 4 | + an admin invites, the invitee answers from their dashboard. | |
| 5 | + """ | |
| 6 | + | |
| 7 | + use GitGudWeb.ConnCase, async: false | |
| 8 | + | |
| 9 | + import Phoenix.LiveViewTest | |
| 10 | + import GitGud.AccountsFixtures | |
| 11 | + | |
| 12 | + alias GitGud.Organizations | |
| 13 | + | |
| 14 | + defp org_with_admin(handle) do | |
| 15 | + admin = user_fixture() | |
| 16 | + {:ok, org} = Organizations.create_organization(admin, %{"handle" => handle}) | |
| 17 | + {admin, org} | |
| 18 | + end | |
| 19 | + | |
| 20 | + defp members_path(org), do: ~p"/orgs/#{org.handle}/settings/members" | |
| 21 | + | |
| 22 | + defp invite!(lv, who) do | |
| 23 | + lv | |
| 24 | + |> form("#invite-member-form", %{"invite" => %{"handle_or_email" => who.handle}}) | |
| 25 | + |> render_submit() | |
| 26 | + end | |
| 27 | + | |
| 28 | + describe "inviting" do | |
| 29 | + test "an admin invites rather than adding outright", %{conn: conn} do | |
| 30 | + {admin, org} = org_with_admin("inv-basic") | |
| 31 | + invitee = user_fixture() | |
| 32 | + | |
| 33 | + {:ok, lv, _html} = live(log_in_user(conn, admin), members_path(org)) | |
| 34 | + html = invite!(lv, invitee) | |
| 35 | + | |
| 36 | + assert html =~ "Pending invitations" | |
| 37 | + assert html =~ "awaiting reply" | |
| 38 | + | |
| 39 | + # Crucially, not a member yet. | |
| 40 | + refute Organizations.member?(org, invitee) | |
| 41 | + assert [inv] = Organizations.list_pending_invitations(org) | |
| 42 | + assert inv.invited_user_id == invitee.id | |
| 43 | + assert inv.invited_by_id == admin.id | |
| 44 | + end | |
| 45 | + | |
| 46 | + test "inviting an existing member is refused", %{conn: conn} do | |
| 47 | + {admin, org} = org_with_admin("inv-existing") | |
| 48 | + member = user_fixture() | |
| 49 | + {:ok, _} = Organizations.add_member(org, member, "member") | |
| 50 | + | |
| 51 | + {:ok, lv, _html} = live(log_in_user(conn, admin), members_path(org)) | |
| 52 | + html = invite!(lv, member) | |
| 53 | + | |
| 54 | + assert html =~ "already a member" | |
| 55 | + assert Organizations.list_pending_invitations(org) == [] | |
| 56 | + end | |
| 57 | + | |
| 58 | + test "inviting twice keeps one pending invitation", %{conn: _conn} do | |
| 59 | + {admin, org} = org_with_admin("inv-twice") | |
| 60 | + invitee = user_fixture() | |
| 61 | + | |
| 62 | + {:ok, first} = Organizations.invite_member(org, invitee, admin) | |
| 63 | + {:ok, second} = Organizations.invite_member(org, invitee, admin) | |
| 64 | + | |
| 65 | + assert first.id == second.id | |
| 66 | + assert length(Organizations.list_pending_invitations(org)) == 1 | |
| 67 | + end | |
| 68 | + | |
| 69 | + test "an admin can withdraw an invitation", %{conn: conn} do | |
| 70 | + {admin, org} = org_with_admin("inv-withdraw") | |
| 71 | + invitee = user_fixture() | |
| 72 | + | |
| 73 | + {:ok, lv, _html} = live(log_in_user(conn, admin), members_path(org)) | |
| 74 | + _ = invite!(lv, invitee) | |
| 75 | + | |
| 76 | + [inv] = Organizations.list_pending_invitations(org) | |
| 77 | + html = render_hook(lv, "revoke_invitation", %{"id" => to_string(inv.id)}) | |
| 78 | + | |
| 79 | + refute html =~ "awaiting reply" | |
| 80 | + assert Organizations.list_pending_invitations(org) == [] | |
| 81 | + refute Organizations.member?(org, invitee) | |
| 82 | + end | |
| 83 | + | |
| 84 | + test "a non-admin can't reach the members page at all", %{conn: conn} do | |
| 85 | + {_admin, org} = org_with_admin("inv-nonadmin") | |
| 86 | + outsider = user_fixture() | |
| 87 | + | |
| 88 | + assert {:error, {:live_redirect, _}} = | |
| 89 | + live(log_in_user(conn, outsider), members_path(org)) | |
| 90 | + end | |
| 91 | + end | |
| 92 | + | |
| 93 | + describe "the dashboard panel" do | |
| 94 | + test "names who invited you and to which org", %{conn: conn} do | |
| 95 | + {admin, org} = org_with_admin("inv-dash") | |
| 96 | + invitee = user_fixture() | |
| 97 | + {:ok, _} = Organizations.invite_member(org, invitee, admin) | |
| 98 | + | |
| 99 | + {:ok, _lv, html} = live(log_in_user(conn, invitee), ~p"/") | |
| 100 | + | |
| 101 | + assert html =~ "Organization invitations" | |
| 102 | + assert html =~ admin.handle | |
| 103 | + assert html =~ "has invited you to join" | |
| 104 | + assert html =~ "inv-dash" | |
| 105 | + end | |
| 106 | + | |
| 107 | + test "accepting makes you a member", %{conn: conn} do | |
| 108 | + {admin, org} = org_with_admin("inv-accept") | |
| 109 | + invitee = user_fixture() | |
| 110 | + {:ok, inv} = Organizations.invite_member(org, invitee, admin) | |
| 111 | + | |
| 112 | + {:ok, lv, _html} = live(log_in_user(conn, invitee), ~p"/") | |
| 113 | + html = render_hook(lv, "accept_org_invitation", %{"id" => to_string(inv.id)}) | |
| 114 | + | |
| 115 | + assert Organizations.member?(org, invitee) | |
| 116 | + refute html =~ "has invited you to join" | |
| 117 | + # The org now shows in their own list. | |
| 118 | + assert html =~ "inv-accept" | |
| 119 | + end | |
| 120 | + | |
| 121 | + test "declining does not", %{conn: conn} do | |
| 122 | + {admin, org} = org_with_admin("inv-decline") | |
| 123 | + invitee = user_fixture() | |
| 124 | + {:ok, inv} = Organizations.invite_member(org, invitee, admin) | |
| 125 | + | |
| 126 | + {:ok, lv, _html} = live(log_in_user(conn, invitee), ~p"/") | |
| 127 | + html = render_hook(lv, "decline_org_invitation", %{"id" => to_string(inv.id)}) | |
| 128 | + | |
| 129 | + refute Organizations.member?(org, invitee) | |
| 130 | + refute html =~ "has invited you to join" | |
| 131 | + assert Organizations.list_pending_invitations(org) == [] | |
| 132 | + end | |
| 133 | + | |
| 134 | + test "an answered invitation can't be answered again", %{conn: _conn} do | |
| 135 | + {admin, org} = org_with_admin("inv-again") | |
| 136 | + invitee = user_fixture() | |
| 137 | + {:ok, inv} = Organizations.invite_member(org, invitee, admin) | |
| 138 | + | |
| 139 | + {:ok, _} = Organizations.accept_invitation(inv, invitee) | |
| 140 | + reloaded = Organizations.get_invitation(inv.id) | |
| 141 | + | |
| 142 | + assert {:error, :not_pending} = Organizations.decline_invitation(reloaded, invitee) | |
| 143 | + end | |
| 144 | + | |
| 145 | + test "only the invitee can answer their invitation", %{conn: _conn} do | |
| 146 | + {admin, org} = org_with_admin("inv-forbidden") | |
| 147 | + invitee = user_fixture() | |
| 148 | + bystander = user_fixture() | |
| 149 | + {:ok, inv} = Organizations.invite_member(org, invitee, admin) | |
| 150 | + | |
| 151 | + assert {:error, :forbidden} = Organizations.accept_invitation(inv, bystander) | |
| 152 | + assert {:error, :forbidden} = Organizations.decline_invitation(inv, bystander) | |
| 153 | + refute Organizations.member?(org, bystander) | |
| 154 | + end | |
| 155 | + | |
| 156 | + test "someone else's invitation isn't answerable from your dashboard", %{conn: conn} do | |
| 157 | + {admin, org} = org_with_admin("inv-notmine") | |
| 158 | + invitee = user_fixture() | |
| 159 | + {:ok, inv} = Organizations.invite_member(org, invitee, admin) | |
| 160 | + | |
| 161 | + bystander = user_fixture() | |
| 162 | + {:ok, lv, html} = live(log_in_user(conn, bystander), ~p"/") | |
| 163 | + | |
| 164 | + refute html =~ "Organization invitations" | |
| 165 | + render_hook(lv, "accept_org_invitation", %{"id" => to_string(inv.id)}) | |
| 166 | + | |
| 167 | + refute Organizations.member?(org, bystander) | |
| 168 | + assert Organizations.member?(org, invitee) == false | |
| 169 | + end | |
| 170 | + | |
| 171 | + test "no panel when you have no invitations", %{conn: conn} do | |
| 172 | + {:ok, _lv, html} = live(log_in_user(conn, user_fixture()), ~p"/") | |
| 173 | + | |
| 174 | + refute html =~ "Organization invitations" | |
| 175 | + end | |
| 176 | + | |
| 177 | + test "accepting honours the invited role", %{conn: _conn} do | |
| 178 | + {admin, org} = org_with_admin("inv-role") | |
| 179 | + invitee = user_fixture() | |
| 180 | + {:ok, inv} = Organizations.invite_member(org, invitee, admin, "admin") | |
| 181 | + | |
| 182 | + {:ok, _} = Organizations.accept_invitation(inv, invitee) | |
| 183 | + | |
| 184 | + assert Organizations.org_admin?(org, invitee) | |
| 185 | + end | |
| 186 | + end | |
| 187 | +end |
Parents: a462837