add default org

This commit is contained in:
Graham McIntire 2026-02-02 10:45:37 -06:00
parent 1ba55ce451
commit 949cfaf4e0
No known key found for this signature in database
5 changed files with 104 additions and 5 deletions

View file

@ -13,6 +13,7 @@ defmodule Towerops.Accounts.User do
alias Ecto.Association.NotLoaded alias Ecto.Association.NotLoaded
alias Towerops.Organizations.Membership alias Towerops.Organizations.Membership
alias Towerops.Organizations.Organization
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id @foreign_key_type :binary_id
@ -36,6 +37,7 @@ defmodule Towerops.Accounts.User do
has_many :memberships, Membership has_many :memberships, Membership
has_many :organizations, through: [:memberships, :organization] has_many :organizations, through: [:memberships, :organization]
belongs_to :default_organization, Organization
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@ -55,7 +57,7 @@ defmodule Towerops.Accounts.User do
totp_verified_at: DateTime.t() | nil, totp_verified_at: DateTime.t() | nil,
last_sudo_at: DateTime.t() | nil, last_sudo_at: DateTime.t() | nil,
memberships: NotLoaded.t() | [Membership.t()], memberships: NotLoaded.t() | [Membership.t()],
organizations: NotLoaded.t() | [Towerops.Organizations.Organization.t()], organizations: NotLoaded.t() | [Organization.t()],
inserted_at: DateTime.t(), inserted_at: DateTime.t(),
updated_at: DateTime.t() updated_at: DateTime.t()
} }
@ -107,10 +109,11 @@ defmodule Towerops.Accounts.User do
""" """
def profile_changeset(user, attrs) do def profile_changeset(user, attrs) do
user user
|> cast(attrs, [:first_name, :last_name, :timezone]) |> cast(attrs, [:first_name, :last_name, :timezone, :default_organization_id])
|> validate_length(:first_name, max: 100) |> validate_length(:first_name, max: 100)
|> validate_length(:last_name, max: 100) |> validate_length(:last_name, max: 100)
|> validate_length(:timezone, max: 100) |> validate_length(:timezone, max: 100)
|> foreign_key_constraint(:default_organization_id)
end end
@doc """ @doc """

View file

@ -287,8 +287,17 @@ defmodule ToweropsWeb.UserSettingsLive do
end end
defp assign_default_organization(socket) do defp assign_default_organization(socket) do
user = socket.assigns.current_scope.user
organizations = socket.assigns.organizations organizations = socket.assigns.organizations
default_org = List.first(organizations)
# Get user's preferred default org, or fall back to first org
default_org =
if user.default_organization_id do
Enum.find(organizations, &(&1.id == user.default_organization_id)) || List.first(organizations)
else
List.first(organizations)
end
assign(socket, :default_organization, default_org) assign(socket, :default_organization, default_org)
end end

View file

@ -251,6 +251,48 @@
</svg> </svg>
</div> </div>
</div> </div>
<div :if={@organizations != []} class="col-span-full">
<label
for="default_organization_id"
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Default Organization
</label>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Choose which organization you see by default when logging in or navigating to pages without an organization context.
</p>
<div class="mt-2 grid grid-cols-1">
<select
name={@profile_form[:default_organization_id].name}
id="default_organization_id"
class="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-1.5 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500"
>
<option value="">None (use first organization)</option>
<%= for org <- @organizations do %>
<option
value={org.id}
selected={@profile_form[:default_organization_id].value == org.id}
>
{org.name}
</option>
<% end %>
</select>
<svg
viewBox="0 0 16 16"
fill="currentColor"
data-slot="icon"
aria-hidden="true"
class="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end text-gray-400 sm:size-4"
>
<path
d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
fill-rule="evenodd"
/>
</svg>
</div>
</div>
</div> </div>
<div class="mt-8 flex"> <div class="mt-8 flex">

View file

@ -426,6 +426,7 @@ defmodule ToweropsWeb.UserAuth do
conn conn
|> assign(:current_scope, Scope.put_organization(scope, organization)) |> assign(:current_scope, Scope.put_organization(scope, organization))
|> assign(:current_membership, membership) |> assign(:current_membership, membership)
|> put_session(:current_organization_id, organization.id)
else else
conn conn
|> put_flash(:error, "You don't have access to this organization.") |> put_flash(:error, "You don't have access to this organization.")
@ -673,13 +674,45 @@ defmodule ToweropsWeb.UserAuth do
end end
end end
defp find_user_organization(org_id, _user_id) when is_binary(org_id) do defp find_user_organization(org_id, user_id) when is_binary(org_id) do
# Try session org first
org =
try do
Towerops.Organizations.get_organization!(org_id)
rescue
Ecto.NoResultsError -> nil
end
# Verify user has access to this org
if org do
membership = Towerops.Organizations.get_membership(org.id, user_id)
if membership, do: org
end
end
defp find_user_organization(_org_id, user_id) do
user = Accounts.get_user(user_id)
default_org_id = user && user.default_organization_id
# Try user's default organization first
with true <- not is_nil(default_org_id),
default_org = try_get_organization(default_org_id),
true <- not is_nil(default_org),
membership = Towerops.Organizations.get_membership(default_org.id, user_id),
true <- not is_nil(membership) do
default_org
else
_ -> fallback_to_first_org(user_id)
end
end
defp try_get_organization(org_id) do
Towerops.Organizations.get_organization!(org_id) Towerops.Organizations.get_organization!(org_id)
rescue rescue
Ecto.NoResultsError -> nil Ecto.NoResultsError -> nil
end end
defp find_user_organization(_org_id, user_id) do defp fallback_to_first_org(user_id) do
case Towerops.Organizations.list_user_organizations(user_id) do case Towerops.Organizations.list_user_organizations(user_id) do
[first_org | _] -> first_org [first_org | _] -> first_org
[] -> nil [] -> nil

View file

@ -0,0 +1,12 @@
defmodule Towerops.Repo.Migrations.AddDefaultOrganizationToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :default_organization_id,
references(:organizations, type: :binary_id, on_delete: :nilify_all)
end
create index(:users, [:default_organization_id])
end
end