Invite people to an org instead of adding them outright

c68e1e7 · Gabriel Morell · 2026-09-10 15:22

6 files +510 -16
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