add device/org limit
This commit is contained in:
parent
d1cfbaa87c
commit
5fb92dd961
20 changed files with 920 additions and 86 deletions
|
|
@ -7,7 +7,9 @@ defmodule Towerops.Devices do
|
|||
|
||||
alias Towerops.Devices.Device, as: DeviceSchema
|
||||
alias Towerops.Devices.Event
|
||||
alias Towerops.Organizations.SubscriptionLimits
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Sites
|
||||
alias Towerops.Workers.DeviceMonitorWorker
|
||||
alias Towerops.Workers.DevicePollerWorker
|
||||
alias Towerops.Workers.DiscoveryWorker
|
||||
|
|
@ -260,27 +262,69 @@ defmodule Towerops.Devices do
|
|||
|
||||
@doc """
|
||||
Creates device.
|
||||
|
||||
## Options
|
||||
* `:bypass_limits` - When true, bypasses subscription device limits (for superusers)
|
||||
"""
|
||||
def create_device(attrs) do
|
||||
case %DeviceSchema{}
|
||||
|> DeviceSchema.changeset(attrs)
|
||||
|> Repo.insert() do
|
||||
{:ok, device} = result ->
|
||||
# Start monitoring/polling if enabled
|
||||
_ =
|
||||
if device.monitoring_enabled do
|
||||
DeviceMonitorWorker.start_monitoring(device.id)
|
||||
end
|
||||
def create_device(attrs, opts \\ []) do
|
||||
bypass_limits = Keyword.get(opts, :bypass_limits, false)
|
||||
|
||||
_ =
|
||||
if device.snmp_enabled do
|
||||
DevicePollerWorker.start_polling(device.id)
|
||||
end
|
||||
# Check device quota before insertion (unless bypassing)
|
||||
with {:ok, changeset} <- check_device_quota(attrs, bypass_limits),
|
||||
{:ok, device} <- Repo.insert(changeset) do
|
||||
# Start monitoring/polling if enabled
|
||||
_ =
|
||||
if device.monitoring_enabled do
|
||||
DeviceMonitorWorker.start_monitoring(device.id)
|
||||
end
|
||||
|
||||
result
|
||||
_ =
|
||||
if device.snmp_enabled do
|
||||
DevicePollerWorker.start_polling(device.id)
|
||||
end
|
||||
|
||||
error ->
|
||||
error
|
||||
{:ok, device}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_device_quota(attrs, bypass_limits) do
|
||||
changeset = DeviceSchema.changeset(%DeviceSchema{}, attrs)
|
||||
|
||||
if bypass_limits do
|
||||
{:ok, changeset}
|
||||
else
|
||||
do_check_quota(changeset)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_check_quota(changeset) do
|
||||
case Ecto.Changeset.fetch_change(changeset, :site_id) do
|
||||
{:ok, site_id} ->
|
||||
validate_device_quota(changeset, site_id)
|
||||
|
||||
:error ->
|
||||
# No site_id in changeset, let normal validation handle it
|
||||
{:ok, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_device_quota(changeset, site_id) do
|
||||
site = site_id |> Sites.get_site!() |> Repo.preload(:organization)
|
||||
organization = site.organization
|
||||
|
||||
case SubscriptionLimits.check_device_limit(organization) do
|
||||
{:ok, :within_limit} ->
|
||||
{:ok, changeset}
|
||||
|
||||
{:error, :at_limit, _current, max} ->
|
||||
error_changeset =
|
||||
Ecto.Changeset.add_error(
|
||||
changeset,
|
||||
:base,
|
||||
"You've reached your plan limit of #{max} devices. Upgrade to add more."
|
||||
)
|
||||
|
||||
{:error, error_changeset}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ defmodule Towerops.Organizations do
|
|||
alias Towerops.Organizations.Membership
|
||||
alias Towerops.Organizations.Organization
|
||||
alias Towerops.Organizations.Policy
|
||||
alias Towerops.Organizations.SubscriptionLimits
|
||||
alias Towerops.Repo
|
||||
|
||||
## Organizations
|
||||
|
|
@ -54,9 +55,43 @@ defmodule Towerops.Organizations do
|
|||
|
||||
@doc """
|
||||
Creates an organization and adds the creator as owner.
|
||||
|
||||
## Options
|
||||
* `:bypass_limits` - When true, bypasses free organization limit (for superusers)
|
||||
"""
|
||||
@dialyzer {:nowarn_function, create_organization: 2}
|
||||
def create_organization(attrs, user_id) do
|
||||
@dialyzer {:nowarn_function, create_organization: 3}
|
||||
def create_organization(attrs, user_id, opts \\ []) do
|
||||
bypass_limits = Keyword.get(opts, :bypass_limits, false)
|
||||
subscription_plan = Map.get(attrs, :subscription_plan) || Map.get(attrs, "subscription_plan") || "free"
|
||||
|
||||
# Check free org limit before creating (unless bypassing or creating non-free org)
|
||||
with :ok <- check_free_org_limit(bypass_limits, subscription_plan, user_id, attrs) do
|
||||
do_create_organization(attrs, user_id)
|
||||
end
|
||||
end
|
||||
|
||||
defp check_free_org_limit(bypass_limits, subscription_plan, user_id, attrs) do
|
||||
if bypass_limits or subscription_plan != "free" do
|
||||
:ok
|
||||
else
|
||||
if SubscriptionLimits.can_create_free_organization?(user_id) do
|
||||
:ok
|
||||
else
|
||||
changeset =
|
||||
%Organization{}
|
||||
|> Organization.changeset(attrs)
|
||||
|> Ecto.Changeset.add_error(
|
||||
:base,
|
||||
"You already have a free organization. Upgrade your existing organization to create additional ones."
|
||||
)
|
||||
|
||||
{:error, changeset}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp do_create_organization(attrs, user_id) do
|
||||
multi = Ecto.Multi.new()
|
||||
multi = Ecto.Multi.insert(multi, :organization, Organization.changeset(%Organization{}, attrs))
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ defmodule Towerops.Organizations.Organization do
|
|||
schema "organizations" do
|
||||
field :name, :string
|
||||
field :slug, :string
|
||||
field :subscription_plan, :string, default: "free"
|
||||
|
||||
# device)
|
||||
field :snmp_version, :string, default: "2c"
|
||||
|
|
@ -41,6 +42,7 @@ defmodule Towerops.Organizations.Organization do
|
|||
id: Ecto.UUID.t(),
|
||||
name: String.t(),
|
||||
slug: String.t(),
|
||||
subscription_plan: String.t(),
|
||||
snmp_version: String.t() | nil,
|
||||
snmp_community: String.t() | nil,
|
||||
default_agent_token_id: Ecto.UUID.t() | nil,
|
||||
|
|
@ -55,9 +57,16 @@ defmodule Towerops.Organizations.Organization do
|
|||
@doc false
|
||||
def changeset(organization, attrs) do
|
||||
organization
|
||||
|> cast(attrs, [:name, :default_agent_token_id, :snmp_version, :snmp_community])
|
||||
|> cast(attrs, [
|
||||
:name,
|
||||
:subscription_plan,
|
||||
:default_agent_token_id,
|
||||
:snmp_version,
|
||||
:snmp_community
|
||||
])
|
||||
|> validate_required([:name])
|
||||
|> validate_length(:name, min: 2, max: 100)
|
||||
|> validate_inclusion(:subscription_plan, ["free"])
|
||||
|> validate_inclusion(:snmp_version, ["1", "2c", "3"], message: "must be 1, 2c, or 3")
|
||||
|> generate_slug()
|
||||
|> validate_required([:slug])
|
||||
|
|
|
|||
143
lib/towerops/organizations/subscription_limits.ex
Normal file
143
lib/towerops/organizations/subscription_limits.ex
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
defmodule Towerops.Organizations.SubscriptionLimits do
|
||||
@moduledoc """
|
||||
Subscription plan limits and quota enforcement.
|
||||
|
||||
Currently supports:
|
||||
- Free plan: 10 devices max, 1 owned organization per user
|
||||
- Future plans can be added here (per-device pricing, etc.)
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.Devices.Device
|
||||
alias Towerops.Organizations.Membership
|
||||
alias Towerops.Organizations.Organization
|
||||
alias Towerops.Repo
|
||||
|
||||
@free_device_limit 10
|
||||
|
||||
@doc """
|
||||
Returns the device limit for a given subscription plan.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> device_limit("free")
|
||||
10
|
||||
|
||||
iex> device_limit("paid")
|
||||
:unlimited
|
||||
"""
|
||||
def device_limit("free"), do: @free_device_limit
|
||||
def device_limit(_other), do: :unlimited
|
||||
|
||||
@doc """
|
||||
Checks if an organization is within its device quota.
|
||||
|
||||
Returns `{:ok, :within_limit}` if the organization can add more devices.
|
||||
Returns `{:error, :at_limit, current, max}` if at or over the limit.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> check_device_limit(%Organization{subscription_plan: "free"})
|
||||
{:ok, :within_limit}
|
||||
|
||||
iex> check_device_limit(%Organization{subscription_plan: "free"})
|
||||
{:error, :at_limit, 10, 10}
|
||||
"""
|
||||
def check_device_limit(%Organization{} = organization) do
|
||||
case device_limit(organization.subscription_plan) do
|
||||
:unlimited ->
|
||||
{:ok, :within_limit}
|
||||
|
||||
max_devices ->
|
||||
current = count_organization_devices(organization.id)
|
||||
|
||||
if current >= max_devices do
|
||||
{:error, :at_limit, current, max_devices}
|
||||
else
|
||||
{:ok, :within_limit}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the current device count and limit for an organization.
|
||||
|
||||
Returns `{current_count, limit}` where limit is either an integer or `:unlimited`.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> device_quota(%Organization{id: "...", subscription_plan: "free"})
|
||||
{5, 10}
|
||||
|
||||
iex> device_quota(%Organization{id: "...", subscription_plan: "paid"})
|
||||
{45, :unlimited}
|
||||
"""
|
||||
def device_quota(%Organization{} = organization) do
|
||||
current = count_organization_devices(organization.id)
|
||||
limit = device_limit(organization.subscription_plan)
|
||||
{current, limit}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a user can create a free organization.
|
||||
|
||||
Users are limited to owning 1 free organization. They can create unlimited
|
||||
paid organizations or be invited as members to unlimited organizations.
|
||||
|
||||
Returns `true` if the user can create another free organization, `false` otherwise.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> can_create_free_organization?(user_id)
|
||||
true
|
||||
|
||||
iex> can_create_free_organization?(user_id)
|
||||
false
|
||||
"""
|
||||
def can_create_free_organization?(user_id) do
|
||||
count_user_owned_free_organizations(user_id) < 1
|
||||
end
|
||||
|
||||
@doc """
|
||||
Counts how many free organizations a user owns.
|
||||
|
||||
Only counts organizations where the user has the `:owner` role.
|
||||
Memberships as admin/member/viewer do not count.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> count_user_owned_free_organizations(user_id)
|
||||
1
|
||||
|
||||
iex> count_user_owned_free_organizations(user_id)
|
||||
0
|
||||
"""
|
||||
def count_user_owned_free_organizations(user_id) do
|
||||
Repo.aggregate(
|
||||
from(o in Organization,
|
||||
join: m in Membership,
|
||||
on: m.organization_id == o.id,
|
||||
where: m.user_id == ^user_id,
|
||||
where: m.role == :owner,
|
||||
where: o.subscription_plan == "free"
|
||||
),
|
||||
:count
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Counts total devices for an organization.
|
||||
|
||||
Counts devices across all sites in the organization.
|
||||
"""
|
||||
def count_organization_devices(organization_id) do
|
||||
Repo.aggregate(
|
||||
from(d in Device,
|
||||
join: s in assoc(d, :site),
|
||||
where: s.organization_id == ^organization_id
|
||||
),
|
||||
:count
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -5,6 +5,7 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
alias Towerops.Agents
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Devices.Device, as: DeviceSchema
|
||||
alias Towerops.Organizations.SubscriptionLimits
|
||||
alias Towerops.Sites
|
||||
alias Towerops.Snmp
|
||||
alias Towerops.Workers.DiscoveryWorker
|
||||
|
|
@ -19,6 +20,16 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
cloud_pollers = Agents.list_cloud_pollers()
|
||||
all_agents = agents ++ cloud_pollers
|
||||
|
||||
# Load device quota
|
||||
{current_devices, device_limit} =
|
||||
SubscriptionLimits.device_quota(organization)
|
||||
|
||||
is_at_limit =
|
||||
case device_limit do
|
||||
:unlimited -> false
|
||||
limit -> current_devices >= limit
|
||||
end
|
||||
|
||||
# Redirect to sites page if no sites exist
|
||||
if Enum.empty?(sites) do
|
||||
{:ok,
|
||||
|
|
@ -34,7 +45,12 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
|> assign(:preselected_site_id, params["site_id"])
|
||||
|> assign(:snmp_test_result, nil)
|
||||
|> assign(:duplicate_device, nil)
|
||||
|> assign(:non_routable_ip_error, false)}
|
||||
|> assign(:non_routable_ip_error, false)
|
||||
|> assign(:device_quota, %{
|
||||
current: current_devices,
|
||||
limit: device_limit,
|
||||
at_limit: is_at_limit
|
||||
})}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -251,13 +267,27 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
snmp_enabled = socket.assigns.monitoring_mode == "snmp_and_icmp"
|
||||
device_params = Map.put(device_params, "snmp_enabled", snmp_enabled)
|
||||
|
||||
case Devices.create_device(device_params) do
|
||||
# Check if user is superuser and bypass limits if so
|
||||
is_superuser = socket.assigns.current_scope.user.is_superuser
|
||||
opts = if is_superuser, do: [bypass_limits: true], else: []
|
||||
|
||||
case Devices.create_device(device_params, opts) do
|
||||
{:ok, device} ->
|
||||
# Handle agent assignment after device creation
|
||||
handle_agent_assignment(device.id, agent_token_id)
|
||||
|
||||
flash_message = handle_device_creation(device)
|
||||
|
||||
# Reload quota after device creation
|
||||
organization = socket.assigns.organization
|
||||
{current, limit} = SubscriptionLimits.device_quota(organization)
|
||||
|
||||
is_at_limit =
|
||||
case limit do
|
||||
:unlimited -> false
|
||||
l -> current >= l
|
||||
end
|
||||
|
||||
# Reset form for next device while staying on the add page
|
||||
fresh_attrs = %{
|
||||
monitoring_enabled: true,
|
||||
|
|
@ -275,6 +305,7 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
socket
|
||||
|> put_flash(:info, flash_message)
|
||||
|> assign(:form, to_form(fresh_changeset))
|
||||
|> assign(:device_quota, %{current: current, limit: limit, at_limit: is_at_limit})
|
||||
|> assign(:snmp_test_result, nil)
|
||||
|> push_event("scroll_to_top", %{})}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,14 +19,79 @@
|
|||
</.link>
|
||||
</div>
|
||||
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{if @live_action == :new,
|
||||
do: "Add new device to monitor",
|
||||
else: "Update device details"}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{if @live_action == :new,
|
||||
do: "Add new device to monitor",
|
||||
else: "Update device details"}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<%= if @live_action == :new do %>
|
||||
<% percent =
|
||||
if @device_quota.limit != :unlimited and @device_quota.limit > 0 do
|
||||
(@device_quota.current / @device_quota.limit * 100) |> trunc()
|
||||
else
|
||||
0
|
||||
end %>
|
||||
|
||||
<% badge_class =
|
||||
cond do
|
||||
@current_scope.user.is_superuser ->
|
||||
"bg-blue-50 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
|
||||
|
||||
@device_quota.at_limit ->
|
||||
"bg-red-50 text-red-800 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800"
|
||||
|
||||
percent >= 90 ->
|
||||
"bg-orange-50 text-orange-800 border-orange-200 dark:bg-orange-900/20 dark:text-orange-400 dark:border-orange-800"
|
||||
|
||||
percent >= 75 ->
|
||||
"bg-yellow-50 text-yellow-800 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-400 dark:border-yellow-800"
|
||||
|
||||
@device_quota.limit == :unlimited ->
|
||||
"bg-blue-50 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
|
||||
|
||||
true ->
|
||||
"bg-green-50 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800"
|
||||
end %>
|
||||
|
||||
<div class={"rounded-lg px-3 py-2 text-sm font-medium border #{badge_class}"}>
|
||||
<%= cond do %>
|
||||
<% @current_scope.user.is_superuser -> %>
|
||||
{@device_quota.current} devices
|
||||
<% @device_quota.limit == :unlimited -> %>
|
||||
{@device_quota.current} devices
|
||||
<% true -> %>
|
||||
{@device_quota.current}/{@device_quota.limit} devices
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Warning banner when approaching limit -->
|
||||
<%= if @live_action == :new and not @device_quota.at_limit and @device_quota.limit != :unlimited and not @current_scope.user.is_superuser do %>
|
||||
<% remaining = @device_quota.limit - @device_quota.current %>
|
||||
<%= if remaining <= 3 do %>
|
||||
<div class="mb-6 rounded-lg bg-orange-50 dark:bg-orange-900/20 p-4 border border-orange-200 dark:border-orange-800">
|
||||
<div class="flex">
|
||||
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-orange-400" />
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-orange-800 dark:text-orange-400">
|
||||
Approaching device limit
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-orange-700 dark:text-orange-300">
|
||||
You have {remaining} {if remaining == 1, do: "slot", else: "slots"} remaining.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<.form for={@form} id="device-form" phx-change="validate" phx-submit="save">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
|
|||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Organizations.SubscriptionLimits
|
||||
alias Towerops.Sites
|
||||
alias Towerops.Snmp
|
||||
alias Towerops.Workers.DiscoveryWorker
|
||||
|
|
@ -14,13 +15,17 @@ defmodule ToweropsWeb.DeviceLive.Index do
|
|||
sites = Sites.list_organization_sites(organization.id)
|
||||
grouped_devices = group_devices_by_site(device)
|
||||
|
||||
# Load device quota
|
||||
{current, limit} = SubscriptionLimits.device_quota(organization)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Devices")
|
||||
|> assign(:device, device)
|
||||
|> assign(:grouped_devices, grouped_devices)
|
||||
|> assign(:has_sites, sites != [])
|
||||
|> assign(:reorder_mode, false)}
|
||||
|> assign(:reorder_mode, false)
|
||||
|> assign(:device_quota, %{current: current, limit: limit})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -5,51 +5,96 @@
|
|||
active_page="devices"
|
||||
timezone={@timezone}
|
||||
>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>Monitor and manage all your network devices</:subtitle>
|
||||
<:actions>
|
||||
<%= if @device != [] do %>
|
||||
<%= if @reorder_mode do %>
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="reset_order"
|
||||
data-confirm="Reset all sites and devices to alphabetical order?"
|
||||
>
|
||||
<.icon name="hero-arrow-path" class="h-4 w-4" /> Reset Order
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="toggle_reorder_mode"
|
||||
variant="primary"
|
||||
>
|
||||
<.icon name="hero-check" class="h-4 w-4" /> Done
|
||||
</.button>
|
||||
<% else %>
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="toggle_reorder_mode"
|
||||
>
|
||||
<.icon name="hero-bars-3" class="h-4 w-4" /> Reorder
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="force_rediscover_all"
|
||||
data-confirm="This will trigger SNMP discovery for all SNMP-enabled devices. Continue?"
|
||||
>
|
||||
<.icon name="hero-magnifying-glass" class="h-4 w-4" /> Force Rediscover All
|
||||
</.button>
|
||||
<% end %>
|
||||
<div class="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>Monitor and manage all your network devices</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<% percent =
|
||||
if @device_quota.limit != :unlimited and @device_quota.limit > 0 do
|
||||
(@device_quota.current / @device_quota.limit * 100) |> trunc()
|
||||
else
|
||||
0
|
||||
end %>
|
||||
|
||||
<% badge_class =
|
||||
cond do
|
||||
@current_scope.user.is_superuser ->
|
||||
"bg-blue-50 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
|
||||
|
||||
@device_quota.limit != :unlimited and @device_quota.current >= @device_quota.limit ->
|
||||
"bg-red-50 text-red-800 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800"
|
||||
|
||||
percent >= 90 ->
|
||||
"bg-orange-50 text-orange-800 border-orange-200 dark:bg-orange-900/20 dark:text-orange-400 dark:border-orange-800"
|
||||
|
||||
percent >= 75 ->
|
||||
"bg-yellow-50 text-yellow-800 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-400 dark:border-yellow-800"
|
||||
|
||||
@device_quota.limit == :unlimited ->
|
||||
"bg-blue-50 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
|
||||
|
||||
true ->
|
||||
"bg-green-50 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800"
|
||||
end %>
|
||||
|
||||
<div class={"rounded-lg px-3 py-2 text-sm font-medium border #{badge_class}"}>
|
||||
<%= cond do %>
|
||||
<% @current_scope.user.is_superuser -> %>
|
||||
{@device_quota.current} devices
|
||||
<% @device_quota.limit == :unlimited -> %>
|
||||
{@device_quota.current} devices
|
||||
<% true -> %>
|
||||
{@device_quota.current}/{@device_quota.limit} devices
|
||||
<% end %>
|
||||
<.button
|
||||
:if={@has_sites}
|
||||
navigate={~p"/devices/new"}
|
||||
variant="primary"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-5 w-5" /> New Device
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mb-6">
|
||||
<%= if @device != [] do %>
|
||||
<%= if @reorder_mode do %>
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="reset_order"
|
||||
data-confirm="Reset all sites and devices to alphabetical order?"
|
||||
>
|
||||
<.icon name="hero-arrow-path" class="h-4 w-4" /> Reset Order
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="toggle_reorder_mode"
|
||||
variant="primary"
|
||||
>
|
||||
<.icon name="hero-check" class="h-4 w-4" /> Done
|
||||
</.button>
|
||||
<% else %>
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="toggle_reorder_mode"
|
||||
>
|
||||
<.icon name="hero-bars-3" class="h-4 w-4" /> Reorder
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="force_rediscover_all"
|
||||
data-confirm="This will trigger SNMP discovery for all SNMP-enabled devices. Continue?"
|
||||
>
|
||||
<.icon name="hero-magnifying-glass" class="h-4 w-4" /> Force Rediscover All
|
||||
</.button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<.button
|
||||
:if={@has_sites}
|
||||
navigate={~p"/devices/new"}
|
||||
variant="primary"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-5 w-5" /> New Device
|
||||
</.button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-gray-200 dark:border-white/10 mb-6">
|
||||
|
|
|
|||
|
|
@ -4,15 +4,22 @@ defmodule ToweropsWeb.OrgLive.New do
|
|||
|
||||
alias Towerops.Organizations
|
||||
alias Towerops.Organizations.Organization
|
||||
alias Towerops.Organizations.SubscriptionLimits
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
changeset = Organizations.change_organization(%Organization{})
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
can_create_free = SubscriptionLimits.can_create_free_organization?(user.id)
|
||||
free_count = SubscriptionLimits.count_user_owned_free_organizations(user.id)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "New Organization")
|
||||
|> assign(:form, to_form(changeset))}
|
||||
|> assign(:form, to_form(changeset))
|
||||
|> assign(:can_create_free, can_create_free)
|
||||
|> assign(:free_org_count, free_count)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -28,8 +35,10 @@ defmodule ToweropsWeb.OrgLive.New do
|
|||
@impl true
|
||||
def handle_event("save", %{"organization" => org_params}, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
is_superuser = user.is_superuser
|
||||
opts = if is_superuser, do: [bypass_limits: true], else: []
|
||||
|
||||
case Organizations.create_organization(org_params, user.id) do
|
||||
case Organizations.create_organization(org_params, user.id, opts) do
|
||||
{:ok, organization} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|
|
|
|||
|
|
@ -9,6 +9,25 @@
|
|||
<:subtitle>Create a new organization to manage your sites and devices</:subtitle>
|
||||
</.header>
|
||||
|
||||
<%= if not @can_create_free do %>
|
||||
<div class="max-w-2xl mb-6 rounded-lg bg-orange-50 dark:bg-orange-900/20 p-4 border border-orange-200 dark:border-orange-800">
|
||||
<div class="flex">
|
||||
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-orange-400" />
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-orange-800 dark:text-orange-400">
|
||||
Free organization limit reached
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-orange-700 dark:text-orange-300">
|
||||
You already have {@free_org_count} free {if @free_org_count == 1,
|
||||
do: "organization",
|
||||
else: "organizations"}.
|
||||
To create additional organizations, you'll need to upgrade your existing organization to a paid plan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<.form for={@form} id="organization-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:name]} type="text" label="Organization Name" required />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
defmodule Towerops.Repo.Migrations.AddSubscriptionPlanToOrganizations do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:organizations) do
|
||||
add :subscription_plan, :string, default: "free", null: false
|
||||
end
|
||||
|
||||
create index(:organizations, [:subscription_plan])
|
||||
end
|
||||
end
|
||||
|
|
@ -6,6 +6,7 @@ Devices Working
|
|||
* Infrastructure improvements
|
||||
* Bug fix: poller agent assignment on devices
|
||||
* Feature: improve Ubiquiti LTU device handling
|
||||
* First pass at subscription limits
|
||||
|
||||
2025-01-26
|
||||
* Cloud poller improvements
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ defmodule Towerops.OrganizationsFixtures do
|
|||
def organization_fixture(user_id, attrs \\ %{}) do
|
||||
attrs = valid_organization_attributes(attrs)
|
||||
|
||||
{:ok, organization} = Organizations.create_organization(attrs, user_id)
|
||||
{:ok, organization} = Organizations.create_organization(attrs, user_id, bypass_limits: true)
|
||||
|
||||
organization
|
||||
end
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ defmodule Towerops.AgentsTest do
|
|||
|
||||
describe "list_organization_agent_tokens/1" do
|
||||
test "returns all agent tokens for organization", %{organization: org1, user: user} do
|
||||
{:ok, org2} = Towerops.Organizations.create_organization(%{name: "Test Org 2"}, user.id)
|
||||
{:ok, org2} = Towerops.Organizations.create_organization(%{name: "Test Org 2"}, user.id, bypass_limits: true)
|
||||
|
||||
{:ok, token1, _} = Agents.create_agent_token(org1.id, "Agent 1")
|
||||
{:ok, token2, _} = Agents.create_agent_token(org1.id, "Agent 2")
|
||||
|
|
@ -1326,8 +1326,8 @@ defmodule Towerops.AgentsTest do
|
|||
describe "cloud poller polling targets" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
{:ok, org1} = Towerops.Organizations.create_organization(%{name: "Org 1"}, user.id)
|
||||
{:ok, org2} = Towerops.Organizations.create_organization(%{name: "Org 2"}, user.id)
|
||||
{:ok, org1} = Towerops.Organizations.create_organization(%{name: "Org 1"}, user.id, bypass_limits: true)
|
||||
{:ok, org2} = Towerops.Organizations.create_organization(%{name: "Org 2"}, user.id, bypass_limits: true)
|
||||
|
||||
{:ok, site1} = Towerops.Sites.create_site(%{name: "Site 1", organization_id: org1.id})
|
||||
{:ok, site2} = Towerops.Sites.create_site(%{name: "Site 2", organization_id: org2.id})
|
||||
|
|
|
|||
|
|
@ -1031,7 +1031,8 @@ defmodule Towerops.EquipmentTest do
|
|||
site_id: site.id
|
||||
}
|
||||
|
||||
assert {:ok, %Device{} = device} = Devices.create_device(attrs)
|
||||
# Use bypass_limits since property tests may create > 10 devices
|
||||
assert {:ok, %Device{} = device} = Devices.create_device(attrs, bypass_limits: true)
|
||||
assert device.ip_address == ip_address
|
||||
assert device.status == :unknown
|
||||
end
|
||||
|
|
@ -1150,4 +1151,147 @@ defmodule Towerops.EquipmentTest do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "device creation with subscription limits" do
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
|
||||
{:ok, site} =
|
||||
Towerops.Sites.create_site(%{
|
||||
name: "Test Site",
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
%{organization: organization, site: site, user: user}
|
||||
end
|
||||
|
||||
test "blocks device creation when at 10-device limit", %{site: site} do
|
||||
# Create 10 devices (the limit)
|
||||
for i <- 1..10 do
|
||||
{:ok, _device} =
|
||||
Devices.create_device(%{
|
||||
name: "Device #{i}",
|
||||
ip_address: "192.168.1.#{i}",
|
||||
site_id: site.id,
|
||||
monitoring_enabled: false,
|
||||
snmp_enabled: false
|
||||
})
|
||||
end
|
||||
|
||||
# 11th device should be blocked
|
||||
assert {:error, changeset} =
|
||||
Devices.create_device(%{
|
||||
name: "Device 11",
|
||||
ip_address: "192.168.1.11",
|
||||
site_id: site.id,
|
||||
monitoring_enabled: false,
|
||||
snmp_enabled: false
|
||||
})
|
||||
|
||||
assert %{base: ["You've reached your plan limit of 10 devices. Upgrade to add more."]} =
|
||||
errors_on(changeset)
|
||||
end
|
||||
|
||||
test "allows device creation when under limit", %{site: site} do
|
||||
# Create 9 devices
|
||||
for i <- 1..9 do
|
||||
{:ok, _device} =
|
||||
Devices.create_device(%{
|
||||
name: "Device #{i}",
|
||||
ip_address: "192.168.1.#{i}",
|
||||
site_id: site.id,
|
||||
monitoring_enabled: false,
|
||||
snmp_enabled: false
|
||||
})
|
||||
end
|
||||
|
||||
# 10th device should succeed
|
||||
assert {:ok, _device} =
|
||||
Devices.create_device(%{
|
||||
name: "Device 10",
|
||||
ip_address: "192.168.1.10",
|
||||
site_id: site.id,
|
||||
monitoring_enabled: false,
|
||||
snmp_enabled: false
|
||||
})
|
||||
end
|
||||
|
||||
test "bypasses limit with bypass_limits option", %{site: site} do
|
||||
# Create 10 devices (at limit)
|
||||
for i <- 1..10 do
|
||||
{:ok, _device} =
|
||||
Devices.create_device(%{
|
||||
name: "Device #{i}",
|
||||
ip_address: "192.168.1.#{i}",
|
||||
site_id: site.id,
|
||||
monitoring_enabled: false,
|
||||
snmp_enabled: false
|
||||
})
|
||||
end
|
||||
|
||||
# 11th device succeeds with bypass
|
||||
assert {:ok, _device} =
|
||||
Devices.create_device(
|
||||
%{
|
||||
name: "Device 11",
|
||||
ip_address: "192.168.1.11",
|
||||
site_id: site.id,
|
||||
monitoring_enabled: false,
|
||||
snmp_enabled: false
|
||||
},
|
||||
bypass_limits: true
|
||||
)
|
||||
end
|
||||
|
||||
test "counts devices across multiple sites in same organization", %{
|
||||
organization: organization,
|
||||
site: site1
|
||||
} do
|
||||
{:ok, site2} =
|
||||
Towerops.Sites.create_site(%{
|
||||
name: "Site 2",
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
# Create 6 devices in site1
|
||||
for i <- 1..6 do
|
||||
{:ok, _device} =
|
||||
Devices.create_device(%{
|
||||
name: "Site1 Device #{i}",
|
||||
ip_address: "192.168.1.#{i}",
|
||||
site_id: site1.id,
|
||||
monitoring_enabled: false,
|
||||
snmp_enabled: false
|
||||
})
|
||||
end
|
||||
|
||||
# Create 4 devices in site2 (total = 10)
|
||||
for i <- 1..4 do
|
||||
{:ok, _device} =
|
||||
Devices.create_device(%{
|
||||
name: "Site2 Device #{i}",
|
||||
ip_address: "192.168.2.#{i}",
|
||||
site_id: site2.id,
|
||||
monitoring_enabled: false,
|
||||
snmp_enabled: false
|
||||
})
|
||||
end
|
||||
|
||||
# 11th device in either site should be blocked
|
||||
assert {:error, changeset} =
|
||||
Devices.create_device(%{
|
||||
name: "Site1 Device 7",
|
||||
ip_address: "192.168.1.7",
|
||||
site_id: site1.id,
|
||||
monitoring_enabled: false,
|
||||
snmp_enabled: false
|
||||
})
|
||||
|
||||
assert %{base: ["You've reached your plan limit of 10 devices. Upgrade to add more."]} =
|
||||
errors_on(changeset)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
213
test/towerops/organizations/subscription_limits_test.exs
Normal file
213
test/towerops/organizations/subscription_limits_test.exs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
defmodule Towerops.Organizations.SubscriptionLimitsTest do
|
||||
use Towerops.DataCase
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Organizations
|
||||
alias Towerops.Organizations.SubscriptionLimits
|
||||
alias Towerops.Sites
|
||||
|
||||
describe "device_limit/1" do
|
||||
test "returns 10 for free plan" do
|
||||
assert SubscriptionLimits.device_limit("free") == 10
|
||||
end
|
||||
|
||||
test "returns unlimited for any non-free plan" do
|
||||
# Future: when other plans are added, they will return :unlimited
|
||||
assert SubscriptionLimits.device_limit("paid") == :unlimited
|
||||
assert SubscriptionLimits.device_limit("premium") == :unlimited
|
||||
assert SubscriptionLimits.device_limit("enterprise") == :unlimited
|
||||
end
|
||||
end
|
||||
|
||||
describe "check_device_limit/1" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
{:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
{:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: organization.id})
|
||||
%{organization: organization, site: site}
|
||||
end
|
||||
|
||||
test "returns ok when organization has no devices", %{organization: organization} do
|
||||
assert {:ok, :within_limit} = SubscriptionLimits.check_device_limit(organization)
|
||||
end
|
||||
|
||||
test "returns ok when organization has devices under limit", %{
|
||||
organization: organization,
|
||||
site: site
|
||||
} do
|
||||
# Create 5 devices
|
||||
for i <- 1..5 do
|
||||
create_device(site.id, "Device #{i}", "192.168.1.#{i}")
|
||||
end
|
||||
|
||||
organization = Repo.reload!(organization)
|
||||
assert {:ok, :within_limit} = SubscriptionLimits.check_device_limit(organization)
|
||||
end
|
||||
|
||||
test "returns error when organization is at device limit", %{
|
||||
organization: organization,
|
||||
site: site
|
||||
} do
|
||||
# Create 10 devices (the limit)
|
||||
for i <- 1..10 do
|
||||
create_device(site.id, "Device #{i}", "192.168.1.#{i}")
|
||||
end
|
||||
|
||||
organization = Repo.reload!(organization)
|
||||
assert {:error, :at_limit, 10, 10} = SubscriptionLimits.check_device_limit(organization)
|
||||
end
|
||||
|
||||
test "returns error when organization is over device limit", %{
|
||||
organization: organization,
|
||||
site: site
|
||||
} do
|
||||
# Create 12 devices (over the limit)
|
||||
for i <- 1..12 do
|
||||
create_device(site.id, "Device #{i}", "192.168.1.#{i}")
|
||||
end
|
||||
|
||||
organization = Repo.reload!(organization)
|
||||
assert {:error, :at_limit, 12, 10} = SubscriptionLimits.check_device_limit(organization)
|
||||
end
|
||||
end
|
||||
|
||||
describe "device_quota/1" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
{:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
{:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: organization.id})
|
||||
%{organization: organization, site: site}
|
||||
end
|
||||
|
||||
test "returns {0, 10} for free org with no devices", %{organization: organization} do
|
||||
assert {0, 10} = SubscriptionLimits.device_quota(organization)
|
||||
end
|
||||
|
||||
test "returns {5, 10} for free org with 5 devices", %{organization: organization, site: site} do
|
||||
for i <- 1..5 do
|
||||
create_device(site.id, "Device #{i}", "192.168.1.#{i}")
|
||||
end
|
||||
|
||||
organization = Repo.reload!(organization)
|
||||
assert {5, 10} = SubscriptionLimits.device_quota(organization)
|
||||
end
|
||||
end
|
||||
|
||||
describe "can_create_free_organization?/1" do
|
||||
test "returns true when user has no free organizations" do
|
||||
user = user_fixture()
|
||||
assert SubscriptionLimits.can_create_free_organization?(user.id) == true
|
||||
end
|
||||
|
||||
test "returns false when user owns one free organization" do
|
||||
user = user_fixture()
|
||||
{:ok, _org} = Organizations.create_organization(%{name: "Free Org"}, user.id)
|
||||
|
||||
assert SubscriptionLimits.can_create_free_organization?(user.id) == false
|
||||
end
|
||||
|
||||
test "returns true when user is member but not owner of free organization" do
|
||||
owner = user_fixture()
|
||||
member = user_fixture()
|
||||
{:ok, organization} = Organizations.create_organization(%{name: "Free Org"}, owner.id)
|
||||
|
||||
# Add member to organization
|
||||
{:ok, _membership} =
|
||||
Organizations.create_membership(%{
|
||||
user_id: member.id,
|
||||
organization_id: organization.id,
|
||||
role: :member
|
||||
})
|
||||
|
||||
# Member can still create their own free org
|
||||
assert SubscriptionLimits.can_create_free_organization?(member.id) == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "count_user_owned_free_organizations/1" do
|
||||
test "returns 0 when user has no organizations" do
|
||||
user = user_fixture()
|
||||
assert SubscriptionLimits.count_user_owned_free_organizations(user.id) == 0
|
||||
end
|
||||
|
||||
test "returns 1 when user owns one free organization" do
|
||||
user = user_fixture()
|
||||
{:ok, _org} = Organizations.create_organization(%{name: "Free Org"}, user.id)
|
||||
|
||||
assert SubscriptionLimits.count_user_owned_free_organizations(user.id) == 1
|
||||
end
|
||||
|
||||
test "returns 0 when user is member but not owner" do
|
||||
owner = user_fixture()
|
||||
member = user_fixture()
|
||||
{:ok, organization} = Organizations.create_organization(%{name: "Free Org"}, owner.id)
|
||||
|
||||
{:ok, _membership} =
|
||||
Organizations.create_membership(%{
|
||||
user_id: member.id,
|
||||
organization_id: organization.id,
|
||||
role: :admin
|
||||
})
|
||||
|
||||
assert SubscriptionLimits.count_user_owned_free_organizations(member.id) == 0
|
||||
end
|
||||
end
|
||||
|
||||
describe "count_organization_devices/1" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
{:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
{:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: organization.id})
|
||||
%{organization: organization, site: site}
|
||||
end
|
||||
|
||||
test "returns 0 for organization with no devices", %{organization: organization} do
|
||||
assert SubscriptionLimits.count_organization_devices(organization.id) == 0
|
||||
end
|
||||
|
||||
test "returns correct count for organization with devices", %{
|
||||
organization: organization,
|
||||
site: site
|
||||
} do
|
||||
for i <- 1..7 do
|
||||
create_device(site.id, "Device #{i}", "192.168.1.#{i}")
|
||||
end
|
||||
|
||||
assert SubscriptionLimits.count_organization_devices(organization.id) == 7
|
||||
end
|
||||
|
||||
test "counts devices across multiple sites", %{organization: organization, site: site1} do
|
||||
{:ok, site2} = Sites.create_site(%{name: "Site 2", organization_id: organization.id})
|
||||
|
||||
for i <- 1..3 do
|
||||
create_device(site1.id, "Site1 Device #{i}", "192.168.1.#{i}")
|
||||
end
|
||||
|
||||
for i <- 4..8 do
|
||||
create_device(site2.id, "Site2 Device #{i}", "192.168.2.#{i}")
|
||||
end
|
||||
|
||||
assert SubscriptionLimits.count_organization_devices(organization.id) == 8
|
||||
end
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
|
||||
defp create_device(site_id, name, ip_address) do
|
||||
{:ok, device} =
|
||||
Devices.create_device(
|
||||
%{
|
||||
name: name,
|
||||
ip_address: ip_address,
|
||||
site_id: site_id,
|
||||
monitoring_enabled: false,
|
||||
snmp_enabled: false
|
||||
},
|
||||
bypass_limits: true
|
||||
)
|
||||
|
||||
device
|
||||
end
|
||||
end
|
||||
|
|
@ -14,7 +14,8 @@ defmodule Towerops.OrganizationsTest do
|
|||
|
||||
test "list_user_organizations/1 returns all organizations for a user", %{user: user} do
|
||||
{:ok, org1} = Organizations.create_organization(%{name: "Org 1"}, user.id)
|
||||
{:ok, org2} = Organizations.create_organization(%{name: "Org 2"}, user.id)
|
||||
# Use bypass_limits to create second org for testing
|
||||
{:ok, org2} = Organizations.create_organization(%{name: "Org 2"}, user.id, bypass_limits: true)
|
||||
|
||||
orgs = Organizations.list_user_organizations(user.id)
|
||||
assert length(orgs) == 2
|
||||
|
|
@ -593,4 +594,58 @@ defmodule Towerops.OrganizationsTest do
|
|||
assert count == 0
|
||||
end
|
||||
end
|
||||
|
||||
describe "organization creation with subscription limits" do
|
||||
test "blocks creating second free organization" do
|
||||
user = user_fixture()
|
||||
{:ok, _org1} = Organizations.create_organization(%{name: "Free Org 1"}, user.id)
|
||||
|
||||
assert {:error, changeset} = Organizations.create_organization(%{name: "Free Org 2"}, user.id)
|
||||
|
||||
assert %{
|
||||
base: [
|
||||
"You already have a free organization. Upgrade your existing organization to create additional ones."
|
||||
]
|
||||
} =
|
||||
errors_on(changeset)
|
||||
end
|
||||
|
||||
test "allows creating first free organization" do
|
||||
user = user_fixture()
|
||||
|
||||
assert {:ok, org} = Organizations.create_organization(%{name: "Free Org"}, user.id)
|
||||
assert org.subscription_plan == "free"
|
||||
end
|
||||
|
||||
test "bypasses limit with bypass_limits option" do
|
||||
user = user_fixture()
|
||||
{:ok, _org1} = Organizations.create_organization(%{name: "Free Org 1"}, user.id)
|
||||
|
||||
# Second free org succeeds with bypass
|
||||
assert {:ok, _org2} =
|
||||
Organizations.create_organization(
|
||||
%{name: "Free Org 2"},
|
||||
user.id,
|
||||
bypass_limits: true
|
||||
)
|
||||
end
|
||||
|
||||
test "invited members can create their own free organization" do
|
||||
owner = user_fixture()
|
||||
member = user_fixture()
|
||||
|
||||
{:ok, organization} = Organizations.create_organization(%{name: "Owner's Org"}, owner.id)
|
||||
|
||||
# Add member to organization
|
||||
{:ok, _membership} =
|
||||
Organizations.create_membership(%{
|
||||
user_id: member.id,
|
||||
organization_id: organization.id,
|
||||
role: :member
|
||||
})
|
||||
|
||||
# Member can still create their own free org
|
||||
assert {:ok, _org} = Organizations.create_organization(%{name: "Member's Org"}, member.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ defmodule ToweropsWeb.AgentLiveTest do
|
|||
|
||||
test "verifies agent belongs to organization", %{conn: conn, organization: _organization, user: user} do
|
||||
# Create another organization
|
||||
{:ok, other_org} = Towerops.Organizations.create_organization(%{name: "Other Org"}, user.id)
|
||||
{:ok, other_org} = Towerops.Organizations.create_organization(%{name: "Other Org"}, user.id, bypass_limits: true)
|
||||
{:ok, agent_token, _token} = Agents.create_agent_token(other_org.id, "Other Agent")
|
||||
|
||||
# Try to edit agent from other organization - capture_log suppresses expected Router exception log
|
||||
|
|
|
|||
|
|
@ -90,9 +90,14 @@ defmodule ToweropsWeb.OrgLiveTest do
|
|||
end
|
||||
|
||||
test "allows duplicate organization names with different slugs", %{conn: conn, user: user} do
|
||||
{:ok, _organization} =
|
||||
Towerops.Organizations.create_organization(%{name: "Existing Org"}, user.id)
|
||||
# Make user a superuser to bypass free org limits
|
||||
user = Towerops.Repo.update!(Ecto.Changeset.change(user, is_superuser: true))
|
||||
|
||||
{:ok, _organization} =
|
||||
Towerops.Organizations.create_organization(%{name: "Existing Org"}, user.id, bypass_limits: true)
|
||||
|
||||
# Need to reconnect with updated user
|
||||
conn = log_in_user(conn, user)
|
||||
{:ok, view, _html} = live(conn, ~p"/orgs/new")
|
||||
|
||||
view
|
||||
|
|
|
|||
|
|
@ -1323,7 +1323,7 @@ defmodule ToweropsWeb.UserAuthTest do
|
|||
property "redirect paths are always valid for authenticated users", %{user: user} do
|
||||
check all(has_org <- boolean(), max_runs: 10) do
|
||||
if has_org do
|
||||
{:ok, _org} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
{:ok, _org} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id, bypass_limits: true)
|
||||
end
|
||||
|
||||
# Reload user to get fresh org data
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue