Add default organization feature

Features:
- Users can set a default organization in org settings
- First organization created is automatically set as default
- Default org selector only shown if user has 2+ organizations
- Migration auto-sets first org as default for existing users

Database changes:
- Added is_default boolean field to organization_memberships
- Added partial index for efficient default org lookups
- Migration sets oldest org as default for each existing user

UI changes:
- Organization settings page shows default org toggle
- Green badge displayed when org is already default
- Button to set org as default if it's not current default

Backend:
- Organizations.set_default_organization/2 manages defaults
- Organizations.get_default_membership/1 retrieves default
- Auto-set first org as default in create_organization/3
This commit is contained in:
Graham McIntire 2026-02-04 16:09:43 -06:00
parent 7a1317d0d0
commit fc66109f19
No known key found for this signature in database
5 changed files with 162 additions and 15 deletions

View file

@ -93,6 +93,13 @@ defmodule Towerops.Organizations do
end
defp do_create_organization(attrs, user_id) do
# Check if user has any existing organizations
has_existing_orgs =
Repo.exists?(
from m in Membership,
where: m.user_id == ^user_id
)
multi =
Ecto.Multi.new()
|> Ecto.Multi.insert(:organization, Organization.changeset(%Organization{}, attrs))
@ -100,27 +107,16 @@ defmodule Towerops.Organizations do
Membership.changeset(%Membership{}, %{
organization_id: organization.id,
user_id: user_id,
role: :owner
role: :owner,
# Set as default if this is the user's first organization
is_default: !has_existing_orgs
})
end)
|> Ecto.Multi.run(:set_default, fn _repo, %{organization: organization} ->
# Set this as the user's default organization if they don't have one
user = Towerops.Accounts.get_user!(user_id)
if user.default_organization_id == nil do
user
|> Ecto.Changeset.change(default_organization_id: organization.id)
|> Repo.update()
else
{:ok, user}
end
end)
case Repo.transaction(multi) do
{:ok, %{organization: organization}} -> {:ok, organization}
{:error, :organization, changeset, _} -> {:error, changeset}
{:error, :membership, changeset, _} -> {:error, changeset}
{:error, :set_default, changeset, _} -> {:error, changeset}
end
end
@ -273,6 +269,52 @@ defmodule Towerops.Organizations do
Repo.delete(membership)
end
@doc """
Gets the user's default organization membership.
"""
def get_default_membership(user_id) do
Repo.one(
from m in Membership,
where: m.user_id == ^user_id and m.is_default == true,
preload: [:organization]
)
end
@doc """
Sets an organization as the user's default.
Clears any existing default and sets the new one.
"""
def set_default_organization(user_id, organization_id) do
# Verify user has access to this organization
if user_has_access?(user_id, organization_id) do
multi =
Ecto.Multi.new()
|> Ecto.Multi.update_all(:clear_old_default, clear_default_query(user_id), set: [is_default: false])
|> Ecto.Multi.update_all(
:set_new_default,
set_default_query(user_id, organization_id),
set: [is_default: true]
)
case Repo.transaction(multi) do
{:ok, _} -> {:ok, get_organization!(organization_id)}
{:error, _, changeset, _} -> {:error, changeset}
end
else
{:error, :not_found}
end
end
defp clear_default_query(user_id) do
from m in Membership,
where: m.user_id == ^user_id and m.is_default == true
end
defp set_default_query(user_id, organization_id) do
from m in Membership,
where: m.user_id == ^user_id and m.organization_id == ^organization_id
end
## Invitations
@doc """

View file

@ -16,6 +16,7 @@ defmodule Towerops.Organizations.Membership do
@foreign_key_type :binary_id
schema "organization_memberships" do
field :role, Ecto.Enum, values: [:owner, :admin, :member, :viewer]
field :is_default, :boolean, default: false
belongs_to :organization, Organization
belongs_to :user, User
@ -26,6 +27,7 @@ defmodule Towerops.Organizations.Membership do
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
role: :owner | :admin | :member | :viewer,
is_default: boolean(),
organization_id: Ecto.UUID.t(),
organization: NotLoaded.t() | Organization.t(),
user_id: Ecto.UUID.t(),
@ -37,7 +39,7 @@ defmodule Towerops.Organizations.Membership do
@doc false
def changeset(membership, attrs) do
membership
|> cast(attrs, [:role, :organization_id, :user_id])
|> cast(attrs, [:role, :organization_id, :user_id, :is_default])
|> validate_required([:role, :organization_id, :user_id])
|> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:user_id)

View file

@ -15,6 +15,12 @@ defmodule ToweropsWeb.Org.SettingsLive do
# Get assignment breakdown to show impact
assignment_breakdown = Agents.Stats.get_device_assignment_breakdown(organization.id)
# Get user's membership for this org to check if it's default
membership = Organizations.get_membership(organization.id, user.id)
# Count user's total organizations
user_orgs_count = user.id |> Organizations.list_user_organizations() |> length()
# Log organization data access
AuditLogger.log_org_data_accessed(nil, user.id, organization.id, "view_settings")
@ -23,6 +29,8 @@ defmodule ToweropsWeb.Org.SettingsLive do
{:ok,
socket
|> assign(:organization, organization)
|> assign(:membership, membership)
|> assign(:user_orgs_count, user_orgs_count)
|> assign(:available_agents, available_agents)
|> assign(:assignment_breakdown, assignment_breakdown)
|> assign(:form, to_form(changeset))}
@ -68,4 +76,29 @@ defmodule ToweropsWeb.Org.SettingsLive do
{:noreply, put_flash(socket, :info, "Applied default agent to #{count} device records across all sites")}
end
@impl true
def handle_event("toggle_default_org", _params, socket) do
user = socket.assigns.current_scope.user
organization = socket.assigns.organization
membership = socket.assigns.membership
if membership.is_default do
{:noreply, put_flash(socket, :error, "This is already your default organization")}
else
case Organizations.set_default_organization(user.id, organization.id) do
{:ok, _} ->
# Refresh membership to reflect change
updated_membership = Organizations.get_membership(organization.id, user.id)
{:noreply,
socket
|> put_flash(:info, "#{organization.name} is now your default organization")
|> assign(:membership, updated_membership)}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to set default organization")}
end
end
end
end

View file

@ -48,6 +48,47 @@
/>
</div>
</div>
<%= if @user_orgs_count > 1 do %>
<!-- Default Organization Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Default Organization
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Set this organization as your default. When you log in, you'll be directed to your default organization.
</p>
</div>
<div class="md:col-span-2">
<%= if @membership.is_default do %>
<div class="rounded-md bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 p-4">
<div class="flex items-center">
<.icon
name="hero-check-circle"
class="h-5 w-5 text-green-600 dark:text-green-400"
/>
<p class="ml-3 text-sm text-green-800 dark:text-green-200">
This is your default organization
</p>
</div>
</div>
<% else %>
<button
type="button"
phx-click="toggle_default_org"
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400"
>
<.icon name="hero-star" class="h-4 w-4 inline" /> Set as Default Organization
</button>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Click to make this your default organization
</p>
<% end %>
</div>
</div>
<% end %>
<!-- Use Sites Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">

View file

@ -0,0 +1,29 @@
defmodule Towerops.Repo.Migrations.AddIsDefaultToOrganizationMemberships do
use Ecto.Migration
def change do
alter table(:organization_memberships) do
add :is_default, :boolean, default: false, null: false
end
create index(:organization_memberships, [:user_id, :is_default],
where: "is_default = true",
name: :organization_memberships_user_id_default_index
)
# Set the first organization for each user as their default
execute(
"""
UPDATE organization_memberships
SET is_default = true
WHERE id IN (
SELECT DISTINCT ON (user_id) id
FROM organization_memberships
ORDER BY user_id, inserted_at ASC
)
""",
# Rollback: clear all defaults
"UPDATE organization_memberships SET is_default = false"
)
end
end