mirror of
https://github.com/wanderer-industries/wanderer
synced 2026-01-28 01:26:09 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
078e5fc19e | ||
|
|
3877e121c3 | ||
|
|
dcb2a0cdb2 | ||
|
|
f5294eee84 | ||
|
|
a5c87b6fa4 | ||
|
|
eae275f515 | ||
|
|
68ae6706dd | ||
|
|
a34b30af15 | ||
|
|
38b49266ed | ||
|
|
049884bb4c | ||
|
|
3c75b2b59f | ||
|
|
4ad5d191a3 | ||
|
|
2499c24cc1 |
36
CHANGELOG.md
36
CHANGELOG.md
@@ -2,6 +2,42 @@
|
||||
|
||||
<!-- changelog -->
|
||||
|
||||
## [v1.90.4](https://github.com/wanderer-industries/wanderer/compare/v1.90.3...v1.90.4) (2025-12-12)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed map scopes & signatures clean up behaviour
|
||||
|
||||
## [v1.90.3](https://github.com/wanderer-industries/wanderer/compare/v1.90.2...v1.90.3) (2025-12-11)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: added pagination for long ACL lists
|
||||
|
||||
## [v1.90.2](https://github.com/wanderer-industries/wanderer/compare/v1.90.1...v1.90.2) (2025-12-10)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: added system position updates to SSE
|
||||
|
||||
## [v1.90.1](https://github.com/wanderer-industries/wanderer/compare/v1.90.0...v1.90.1) (2025-12-08)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed connections and signatures remove issues, added comprehensive audit log for auto removed connections and signatures
|
||||
|
||||
## [v1.90.0](https://github.com/wanderer-industries/wanderer/compare/v1.89.6...v1.90.0) (2025-12-06)
|
||||
|
||||
|
||||
|
||||
@@ -98,8 +98,8 @@ defmodule WandererApp.ExternalEvents.JsonApiFormatter do
|
||||
"id" => payload["system_id"] || payload[:system_id],
|
||||
"attributes" => %{
|
||||
"locked" => payload["locked"] || payload[:locked],
|
||||
"x" => payload["x"] || payload[:x],
|
||||
"y" => payload["y"] || payload[:y],
|
||||
"position_x" => payload["position_x"] || payload[:position_x],
|
||||
"position_y" => payload["position_y"] || payload[:position_y],
|
||||
"updated_at" => event.timestamp
|
||||
},
|
||||
"relationships" => %{
|
||||
|
||||
@@ -829,7 +829,8 @@ defmodule WandererApp.Map.Server.CharactersImpl do
|
||||
)
|
||||
|> case do
|
||||
true ->
|
||||
# Add new location system
|
||||
# Connection is valid (at least one system matches scopes)
|
||||
# Add BOTH systems including border systems - filtering already done by is_connection_valid
|
||||
case SystemsImpl.maybe_add_system(map_id, location, old_location, map_opts) do
|
||||
:ok ->
|
||||
:ok
|
||||
|
||||
@@ -349,6 +349,27 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
|
||||
solar_system_source: solar_system_source_id,
|
||||
solar_system_target: solar_system_target_id
|
||||
} ->
|
||||
# Emit telemetry for connection auto-deletion
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :map, :connection_cleanup, :delete],
|
||||
%{system_time: System.system_time()},
|
||||
%{
|
||||
map_id: map_id,
|
||||
solar_system_source_id: solar_system_source_id,
|
||||
solar_system_target_id: solar_system_target_id,
|
||||
reason: :auto_cleanup
|
||||
}
|
||||
)
|
||||
|
||||
# Log auto-deletion for audit trail (no user/character context for auto-cleanup)
|
||||
WandererApp.User.ActivityTracker.track_map_event(:map_connection_removed, %{
|
||||
character_id: nil,
|
||||
user_id: nil,
|
||||
map_id: map_id,
|
||||
solar_system_source_id: solar_system_source_id,
|
||||
solar_system_target_id: solar_system_target_id
|
||||
})
|
||||
|
||||
delete_connection(map_id, %{
|
||||
solar_system_source_id: solar_system_source_id,
|
||||
solar_system_target_id: solar_system_target_id
|
||||
@@ -723,15 +744,33 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
|
||||
when is_list(scopes) and from_solar_system_id != to_solar_system_id do
|
||||
with {:ok, from_system_static_info} <- get_system_static_info(from_solar_system_id),
|
||||
{:ok, to_system_static_info} <- get_system_static_info(to_solar_system_id) do
|
||||
# Connection is valid if:
|
||||
# 1. Neither system is prohibited
|
||||
# 2. At least one system matches one of the selected scopes
|
||||
not is_prohibited_system_class?(from_system_static_info.system_class) and
|
||||
not is_prohibited_system_class?(to_system_static_info.system_class) and
|
||||
not (@prohibited_systems |> Enum.member?(from_solar_system_id)) and
|
||||
not (@prohibited_systems |> Enum.member?(to_solar_system_id)) and
|
||||
(system_matches_any_scope?(from_system_static_info.system_class, scopes) or
|
||||
system_matches_any_scope?(to_system_static_info.system_class, scopes))
|
||||
# First check: neither system is prohibited
|
||||
not_prohibited =
|
||||
not is_prohibited_system_class?(from_system_static_info.system_class) and
|
||||
not is_prohibited_system_class?(to_system_static_info.system_class) and
|
||||
not (@prohibited_systems |> Enum.member?(from_solar_system_id)) and
|
||||
not (@prohibited_systems |> Enum.member?(to_solar_system_id))
|
||||
|
||||
if not_prohibited do
|
||||
from_is_wormhole = from_system_static_info.system_class in @wh_space
|
||||
to_is_wormhole = to_system_static_info.system_class in @wh_space
|
||||
wormholes_enabled = :wormholes in scopes
|
||||
|
||||
# Wormhole border behavior: if wormholes scope is enabled AND at least one
|
||||
# system is a wormhole, allow the connection (adds border k-space systems)
|
||||
# Otherwise: BOTH systems must match the configured scopes
|
||||
if wormholes_enabled and (from_is_wormhole or to_is_wormhole) do
|
||||
# At least one system matches (wormhole matches :wormholes, or other matches its scope)
|
||||
system_matches_any_scope?(from_system_static_info.system_class, scopes) or
|
||||
system_matches_any_scope?(to_system_static_info.system_class, scopes)
|
||||
else
|
||||
# Non-wormhole movement: both systems must match scopes
|
||||
system_matches_any_scope?(from_system_static_info.system_class, scopes) and
|
||||
system_matches_any_scope?(to_system_static_info.system_class, scopes)
|
||||
end
|
||||
else
|
||||
false
|
||||
end
|
||||
else
|
||||
_ -> false
|
||||
end
|
||||
|
||||
@@ -383,6 +383,16 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|
||||
|> Enum.each(fn connection ->
|
||||
try do
|
||||
Logger.debug(fn -> "Removing connection from map: #{inspect(connection)}" end)
|
||||
|
||||
# Audit logging for cascade deletion (no user/character context)
|
||||
WandererApp.User.ActivityTracker.track_map_event(:map_connection_removed, %{
|
||||
character_id: nil,
|
||||
user_id: nil,
|
||||
map_id: map_id,
|
||||
solar_system_source_id: connection.solar_system_source,
|
||||
solar_system_target_id: connection.solar_system_target
|
||||
})
|
||||
|
||||
:ok = WandererApp.MapConnectionRepo.destroy(map_id, connection)
|
||||
:ok = WandererApp.Map.remove_connection(map_id, connection)
|
||||
Impl.broadcast!(map_id, :remove_connections, [connection])
|
||||
@@ -393,55 +403,76 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|
||||
end)
|
||||
end
|
||||
|
||||
# When destination systems are deleted, unlink signatures instead of destroying them.
|
||||
# This preserves the user's scan data while removing the stale link.
|
||||
defp cleanup_linked_signatures(map_id, removed_solar_system_ids) do
|
||||
removed_solar_system_ids
|
||||
|> Enum.map(fn solar_system_id ->
|
||||
WandererApp.Api.MapSystemSignature.by_linked_system_id!(solar_system_id)
|
||||
end)
|
||||
|> List.flatten()
|
||||
|> Enum.uniq_by(& &1.system_id)
|
||||
|> Enum.each(fn s ->
|
||||
try do
|
||||
{:ok, %{eve_id: eve_id, system: system}} = s |> Ash.load([:system])
|
||||
# Group signatures by their source system for efficient broadcasting
|
||||
signatures_by_system =
|
||||
removed_solar_system_ids
|
||||
|> Enum.flat_map(fn solar_system_id ->
|
||||
WandererApp.Api.MapSystemSignature.by_linked_system_id!(solar_system_id)
|
||||
end)
|
||||
|> Enum.uniq_by(& &1.id)
|
||||
|> Enum.group_by(fn sig -> sig.system_id end)
|
||||
|
||||
# Use Ash.destroy (not destroy!) to handle already-deleted signatures gracefully
|
||||
case Ash.destroy(s) do
|
||||
:ok ->
|
||||
# Handle case where parent system was already deleted
|
||||
case system do
|
||||
nil ->
|
||||
Logger.debug(fn ->
|
||||
"[cleanup_linked_signatures] signature #{eve_id} destroyed (parent system already deleted)"
|
||||
end)
|
||||
signatures_by_system
|
||||
|> Enum.each(fn {_system_id, signatures} ->
|
||||
signatures
|
||||
|> Enum.each(fn sig ->
|
||||
try do
|
||||
{:ok, %{eve_id: eve_id, system: system}} = sig |> Ash.load([:system])
|
||||
|
||||
%{solar_system_id: solar_system_id} ->
|
||||
Logger.debug(fn ->
|
||||
"[cleanup_linked_signatures] for system #{solar_system_id}: #{inspect(eve_id)}"
|
||||
end)
|
||||
# Clear the linked_system_id instead of destroying the signature
|
||||
case WandererApp.Api.MapSystemSignature.update_linked_system(sig, %{
|
||||
linked_system_id: nil
|
||||
}) do
|
||||
{:ok, _updated_sig} ->
|
||||
case system do
|
||||
nil ->
|
||||
Logger.debug(fn ->
|
||||
"[cleanup_linked_signatures] signature #{eve_id} unlinked (parent system already deleted)"
|
||||
end)
|
||||
|
||||
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
|
||||
end
|
||||
%{solar_system_id: solar_system_id} ->
|
||||
Logger.debug(fn ->
|
||||
"[cleanup_linked_signatures] unlinked signature #{eve_id} in system #{solar_system_id}"
|
||||
end)
|
||||
|
||||
{:error, %Ash.Error.Invalid{errors: errors}} ->
|
||||
# Check if this is a StaleRecord error (signature already deleted)
|
||||
if Enum.any?(errors, &match?(%Ash.Error.Changes.StaleRecord{}, &1)) do
|
||||
Logger.debug(fn ->
|
||||
"[cleanup_linked_signatures] signature #{eve_id} already deleted (StaleRecord)"
|
||||
end)
|
||||
else
|
||||
# Audit logging for cascade unlink (no user/character context)
|
||||
WandererApp.User.ActivityTracker.track_map_event(:signatures_unlinked, %{
|
||||
character_id: nil,
|
||||
user_id: nil,
|
||||
map_id: map_id,
|
||||
solar_system_id: solar_system_id,
|
||||
signatures: [eve_id]
|
||||
})
|
||||
end
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error(
|
||||
"[cleanup_linked_signatures] Failed to destroy signature #{eve_id}: #{inspect(errors)}"
|
||||
"[cleanup_linked_signatures] Failed to unlink signature #{sig.eve_id}: #{inspect(error)}"
|
||||
)
|
||||
end
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error(
|
||||
"[cleanup_linked_signatures] Failed to destroy signature: #{inspect(error)}"
|
||||
)
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Failed to cleanup linked signature: #{inspect(e)}")
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Failed to cleanup linked signature: #{inspect(e)}")
|
||||
end)
|
||||
|
||||
# Broadcast once per source system after all its signatures are processed
|
||||
case List.first(signatures) do
|
||||
%{system: %{solar_system_id: solar_system_id}} ->
|
||||
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
|
||||
|
||||
_ ->
|
||||
# Try to get the system info if not preloaded
|
||||
case List.first(signatures) |> Ash.load([:system]) do
|
||||
{:ok, %{system: %{solar_system_id: solar_system_id}}} ->
|
||||
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -466,8 +497,32 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|
||||
end)
|
||||
end
|
||||
|
||||
def maybe_add_system(map_id, location, old_location, map_opts)
|
||||
def maybe_add_system(map_id, location, old_location, map_opts, scopes \\ nil)
|
||||
|
||||
def maybe_add_system(map_id, location, old_location, map_opts, scopes)
|
||||
when not is_nil(location) do
|
||||
alias WandererApp.Map.Server.ConnectionsImpl
|
||||
|
||||
# Check if the system matches the map's configured scopes before adding
|
||||
should_add =
|
||||
case scopes do
|
||||
nil -> true
|
||||
[] -> true
|
||||
scopes when is_list(scopes) ->
|
||||
ConnectionsImpl.can_add_location(scopes, location.solar_system_id)
|
||||
end
|
||||
|
||||
if should_add do
|
||||
do_add_system_from_location(map_id, location, old_location, map_opts)
|
||||
else
|
||||
# System filtered out by scope settings - this is expected behavior
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
def maybe_add_system(_map_id, _location, _old_location, _map_opts, _scopes), do: :ok
|
||||
|
||||
defp do_add_system_from_location(map_id, location, old_location, map_opts) do
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :map, :system_addition, :start],
|
||||
%{system_time: System.system_time()},
|
||||
@@ -546,12 +601,14 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|
||||
|> case do
|
||||
{:ok, solar_system_info} ->
|
||||
# Use upsert instead of create - handles race conditions gracefully
|
||||
# visible: true ensures previously-deleted systems become visible again
|
||||
WandererApp.MapSystemRepo.upsert(%{
|
||||
map_id: map_id,
|
||||
solar_system_id: location.solar_system_id,
|
||||
name: solar_system_info.solar_system_name,
|
||||
position_x: position.x,
|
||||
position_y: position.y
|
||||
position_y: position.y,
|
||||
visible: true
|
||||
})
|
||||
|> case do
|
||||
{:ok, system} ->
|
||||
@@ -673,8 +730,6 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|
||||
end
|
||||
end
|
||||
|
||||
def maybe_add_system(_map_id, _location, _old_location, _map_opts), do: :ok
|
||||
|
||||
defp do_add_system(
|
||||
map_id,
|
||||
%{
|
||||
@@ -1008,12 +1063,16 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|
||||
# ADDITIVE: Also broadcast to external event system (webhooks/WebSocket)
|
||||
# This may fail if the relay is not available (e.g., in tests), which is fine
|
||||
WandererApp.ExternalEvents.broadcast(map_id, :system_metadata_changed, %{
|
||||
system_id: updated_system.id,
|
||||
solar_system_id: updated_system.solar_system_id,
|
||||
name: updated_system.name,
|
||||
temporary_name: updated_system.temporary_name,
|
||||
labels: updated_system.labels,
|
||||
description: updated_system.description,
|
||||
status: updated_system.status
|
||||
status: updated_system.status,
|
||||
locked: updated_system.locked,
|
||||
position_x: updated_system.position_x,
|
||||
position_y: updated_system.position_y
|
||||
})
|
||||
|
||||
:ok
|
||||
|
||||
@@ -455,7 +455,9 @@ defmodule WandererAppWeb.AccessListMemberAPIController do
|
||||
end)
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning("Failed to invalidate map_characters cache for ACL #{acl_id}: #{inspect(error)}")
|
||||
Logger.warning(
|
||||
"Failed to invalidate map_characters cache for ACL #{acl_id}: #{inspect(error)}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -42,12 +42,18 @@ defmodule WandererAppWeb.AuthController do
|
||||
|
||||
WandererApp.Character.update_character(character.id, character_update)
|
||||
|
||||
# Update corporation/alliance data from ESI to ensure access control is current
|
||||
update_character_affiliation(character)
|
||||
|
||||
{:ok, character}
|
||||
|
||||
{:error, _error} ->
|
||||
{:ok, character} = WandererApp.Api.Character.create(character_data)
|
||||
:telemetry.execute([:wanderer_app, :user, :character, :registered], %{count: 1})
|
||||
|
||||
# Fetch initial corporation/alliance data for new characters
|
||||
update_character_affiliation(character)
|
||||
|
||||
{:ok, character}
|
||||
end
|
||||
|
||||
@@ -113,4 +119,102 @@ defmodule WandererAppWeb.AuthController do
|
||||
end
|
||||
|
||||
def maybe_update_character_user_id(_character, _user_id), do: :ok
|
||||
|
||||
# Updates character's corporation and alliance data from ESI.
|
||||
# This ensures ACL-based access control uses current corporation membership,
|
||||
# even for characters not actively being tracked on any map.
|
||||
defp update_character_affiliation(%{id: character_id, eve_id: eve_id} = character) do
|
||||
# Run async to not block the SSO callback
|
||||
Task.start(fn ->
|
||||
character_eve_id = eve_id |> String.to_integer()
|
||||
|
||||
case WandererApp.Esi.post_characters_affiliation([character_eve_id]) do
|
||||
{:ok, [affiliation_info]} when is_map(affiliation_info) ->
|
||||
new_corporation_id = Map.get(affiliation_info, "corporation_id")
|
||||
new_alliance_id = Map.get(affiliation_info, "alliance_id")
|
||||
|
||||
# Check if corporation changed
|
||||
corporation_changed = character.corporation_id != new_corporation_id
|
||||
alliance_changed = character.alliance_id != new_alliance_id
|
||||
|
||||
if corporation_changed or alliance_changed do
|
||||
update_affiliation_data(character_id, character, new_corporation_id, new_alliance_id)
|
||||
end
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning(
|
||||
"[AuthController] Failed to fetch affiliation for character #{character_id}: #{inspect(error)}"
|
||||
)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp update_character_affiliation(_character), do: :ok
|
||||
|
||||
defp update_affiliation_data(character_id, character, corporation_id, alliance_id) do
|
||||
# Fetch corporation info
|
||||
corporation_update =
|
||||
case WandererApp.Esi.get_corporation_info(corporation_id) do
|
||||
{:ok, %{"name" => corp_name, "ticker" => corp_ticker}} ->
|
||||
%{
|
||||
corporation_id: corporation_id,
|
||||
corporation_name: corp_name,
|
||||
corporation_ticker: corp_ticker
|
||||
}
|
||||
|
||||
_ ->
|
||||
%{corporation_id: corporation_id}
|
||||
end
|
||||
|
||||
# Fetch alliance info if present
|
||||
alliance_update =
|
||||
case alliance_id do
|
||||
nil ->
|
||||
%{alliance_id: nil, alliance_name: nil, alliance_ticker: nil}
|
||||
|
||||
_ ->
|
||||
case WandererApp.Esi.get_alliance_info(alliance_id) do
|
||||
{:ok, %{"name" => alliance_name, "ticker" => alliance_ticker}} ->
|
||||
%{
|
||||
alliance_id: alliance_id,
|
||||
alliance_name: alliance_name,
|
||||
alliance_ticker: alliance_ticker
|
||||
}
|
||||
|
||||
_ ->
|
||||
%{alliance_id: alliance_id}
|
||||
end
|
||||
end
|
||||
|
||||
full_update = Map.merge(corporation_update, alliance_update)
|
||||
|
||||
# Update database
|
||||
case character.corporation_id != corporation_id do
|
||||
true ->
|
||||
{:ok, _} = WandererApp.Api.Character.update_corporation(character, corporation_update)
|
||||
|
||||
false ->
|
||||
:ok
|
||||
end
|
||||
|
||||
case character.alliance_id != alliance_id do
|
||||
true ->
|
||||
{:ok, _} = WandererApp.Api.Character.update_alliance(character, alliance_update)
|
||||
|
||||
false ->
|
||||
:ok
|
||||
end
|
||||
|
||||
# Update cache
|
||||
WandererApp.Character.update_character(character_id, full_update)
|
||||
|
||||
Logger.info(
|
||||
"[AuthController] Updated affiliation for character #{character_id}: " <>
|
||||
"corp #{character.corporation_id} -> #{corporation_id}, " <>
|
||||
"alliance #{character.alliance_id} -> #{alliance_id}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,7 @@ defmodule WandererAppWeb.MapAuditAPIController do
|
||||
|
||||
require Logger
|
||||
|
||||
alias WandererAppWeb.UserActivityItem
|
||||
alias WandererAppWeb.Helpers.APIUtils
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
@@ -153,10 +154,10 @@ defmodule WandererAppWeb.MapAuditAPIController do
|
||||
|
||||
result
|
||||
|> Map.put(:character, WandererAppWeb.MapEventHandler.map_ui_character_stat(character))
|
||||
|> Map.put(:event_name, WandererAppWeb.UserActivity.get_event_name(event_type))
|
||||
|> Map.put(:event_name, WandererAppWeb.UserActivityItem.get_event_name(event_type))
|
||||
|> Map.put(
|
||||
:event_data,
|
||||
WandererAppWeb.UserActivity.get_event_data(
|
||||
WandererAppWeb.UserActivityItem.get_event_data(
|
||||
event_type,
|
||||
Jason.decode!(event_data) |> Map.drop(["character_id"])
|
||||
)
|
||||
|
||||
@@ -5,6 +5,8 @@ defmodule WandererAppWeb.AccessListsLive do
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
@members_per_page 50
|
||||
|
||||
@impl true
|
||||
def mount(_params, %{"user_id" => user_id} = _session, socket) when not is_nil(user_id) do
|
||||
{:ok, characters} = WandererApp.Api.Character.active_by_user(%{user_id: user_id})
|
||||
@@ -25,7 +27,9 @@ defmodule WandererAppWeb.AccessListsLive do
|
||||
user_id: user_id,
|
||||
access_lists: access_lists |> Enum.map(fn acl -> map_ui_acl(acl, nil) end),
|
||||
characters: characters,
|
||||
members: []
|
||||
members: [],
|
||||
members_page: 1,
|
||||
members_per_page: @members_per_page
|
||||
)}
|
||||
end
|
||||
|
||||
@@ -39,7 +43,9 @@ defmodule WandererAppWeb.AccessListsLive do
|
||||
allow_acl_creation: false,
|
||||
access_lists: [],
|
||||
characters: [],
|
||||
members: []
|
||||
members: [],
|
||||
members_page: 1,
|
||||
members_per_page: @members_per_page
|
||||
)}
|
||||
end
|
||||
|
||||
@@ -93,10 +99,8 @@ defmodule WandererAppWeb.AccessListsLive do
|
||||
|> assign(:page_title, "Access Lists - Members")
|
||||
|> assign(:selected_acl_id, acl_id)
|
||||
|> assign(:access_list, access_list)
|
||||
|> assign(
|
||||
:members,
|
||||
members
|
||||
)
|
||||
|> assign(:members, members)
|
||||
|> assign(:members_page, 1)
|
||||
else
|
||||
_ ->
|
||||
socket
|
||||
@@ -324,6 +328,20 @@ defmodule WandererAppWeb.AccessListsLive do
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("members_prev_page", _, socket) do
|
||||
new_page = max(1, socket.assigns.members_page - 1)
|
||||
{:noreply, assign(socket, :members_page, new_page)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("members_next_page", _, socket) do
|
||||
total_members = length(socket.assigns.members)
|
||||
max_page = max(1, ceil(total_members / socket.assigns.members_per_page))
|
||||
new_page = min(max_page, socket.assigns.members_page + 1)
|
||||
{:noreply, assign(socket, :members_page, new_page)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("noop", _, socket) do
|
||||
{:noreply, socket}
|
||||
@@ -719,6 +737,17 @@ defmodule WandererAppWeb.AccessListsLive do
|
||||
acl |> Map.put(:selected, acl.id == selected_id)
|
||||
end
|
||||
|
||||
defp paginated_members(members, page, per_page) do
|
||||
members
|
||||
|> Enum.sort_by(&{&1.role, &1.name}, &<=/2)
|
||||
|> Enum.drop((page - 1) * per_page)
|
||||
|> Enum.take(per_page)
|
||||
end
|
||||
|
||||
defp total_pages(members, per_page) do
|
||||
max(1, ceil(length(members) / per_page))
|
||||
end
|
||||
|
||||
# Broadcast ACL update and invalidate map_characters cache for all maps using this ACL
|
||||
# This ensures the tracking page shows updated members even when map server isn't running
|
||||
defp broadcast_acl_updated(acl_id) do
|
||||
@@ -742,7 +771,9 @@ defmodule WandererAppWeb.AccessListsLive do
|
||||
end)
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning("Failed to invalidate map_characters cache for ACL #{acl_id}: #{inspect(error)}")
|
||||
Logger.warning(
|
||||
"Failed to invalidate map_characters cache for ACL #{acl_id}: #{inspect(error)}"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -82,11 +82,14 @@
|
||||
</h3>
|
||||
|
||||
<div
|
||||
class="dropzone droppable draggable-dropzone--occupied flex flex-col gap-1 w-full rounded-none h-[calc(100vh-211px)] !overflow-y-auto"
|
||||
class={[
|
||||
"dropzone droppable draggable-dropzone--occupied flex flex-col gap-1 w-full rounded-none h-[calc(100vh-191px)] !overflow-y-auto",
|
||||
classes("!h-[calc(100vh-240px)]": length(@members) > @members_per_page)
|
||||
]}
|
||||
id="acl_members"
|
||||
>
|
||||
<div
|
||||
:for={member <- @members |> Enum.sort_by(&{&1.role, &1.name}, &<=/2)}
|
||||
:for={member <- paginated_members(@members, @members_page, @members_per_page)}
|
||||
draggable="true"
|
||||
id={member.id}
|
||||
class="draggable !p-1 h-10 cursor-move bg-black bg-opacity-25 hover:text-white"
|
||||
@@ -113,10 +116,32 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div :if={length(@members) > @members_per_page} class="flex items-center justify-between px-3 py-2 border-t border-gray-500 bg-black bg-opacity-25">
|
||||
<span class="text-sm text-gray-400">
|
||||
Page {@members_page} of {total_pages(@members, @members_per_page)} ({length(@members)} members)
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
phx-click="members_prev_page"
|
||||
disabled={@members_page <= 1}
|
||||
class={"btn btn-sm btn-ghost " <> if(@members_page <= 1, do: "btn-disabled", else: "")}
|
||||
>
|
||||
<.icon name="hero-chevron-left" class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="members_next_page"
|
||||
disabled={@members_page >= total_pages(@members, @members_per_page)}
|
||||
class={"btn btn-sm btn-ghost " <> if(@members_page >= total_pages(@members, @members_per_page), do: "btn-disabled", else: "")}
|
||||
>
|
||||
<.icon name="hero-chevron-right" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<.link
|
||||
:if={@selected_acl_id != "" and can_add_members?(@access_list, @current_user)}
|
||||
class="btn mt-2 w-full btn-neutral rounded-none"
|
||||
class="btn w-full btn-neutral rounded-none"
|
||||
patch={~p"/access-lists/#{@selected_acl_id}/add-members"}
|
||||
>
|
||||
<.icon name="hero-plus-solid" class="w-6 h-6" />
|
||||
@@ -129,6 +154,7 @@
|
||||
<.icon name="hero-plus-solid" class="w-6 h-6" />
|
||||
<h3 class="card-title text-center text-md">Add Members</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -153,10 +179,10 @@
|
||||
placeholder="Select an owner"
|
||||
options={Enum.map(@characters, fn character -> {character.label, character.id} end)}
|
||||
/>
|
||||
|
||||
|
||||
<!-- Divider between above inputs and the API key section -->
|
||||
<hr class="my-4 border-gray-600" />
|
||||
|
||||
|
||||
<!-- API Key Section with grid layout -->
|
||||
<div class="mt-2">
|
||||
<label class="block text-sm font-medium text-gray-200 mb-1">ACL API key</label>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
defmodule WandererAppWeb.UserActivity do
|
||||
defmodule WandererAppWeb.UserActivityItem do
|
||||
use WandererAppWeb, :live_component
|
||||
use LiveViewEvents
|
||||
|
||||
@@ -120,10 +120,16 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
|
||||
{:ok, signatures} =
|
||||
WandererApp.Api.MapSystemSignature.by_linked_system_id(solar_system_target_id)
|
||||
|
||||
signatures
|
||||
|> Enum.filter(fn s ->
|
||||
s.system_id == source_system.id
|
||||
end)
|
||||
filtered_signatures =
|
||||
signatures
|
||||
|> Enum.filter(fn s ->
|
||||
s.system_id == source_system.id
|
||||
end)
|
||||
|
||||
# Collect eve_ids for audit logging
|
||||
deleted_eve_ids = Enum.map(filtered_signatures, & &1.eve_id)
|
||||
|
||||
filtered_signatures
|
||||
|> Enum.each(fn s ->
|
||||
if not is_nil(s.temporary_name) && s.temporary_name == target_system.temporary_name do
|
||||
map_id
|
||||
@@ -143,6 +149,17 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
|
||||
|> WandererApp.Api.MapSystemSignature.destroy!()
|
||||
end)
|
||||
|
||||
# Audit log signatures deleted with connection
|
||||
if deleted_eve_ids != [] do
|
||||
WandererApp.User.ActivityTracker.track_map_event(:signatures_removed, %{
|
||||
character_id: main_character_id,
|
||||
user_id: current_user_id,
|
||||
map_id: map_id,
|
||||
solar_system_id: solar_system_source_id,
|
||||
signatures: deleted_eve_ids
|
||||
})
|
||||
end
|
||||
|
||||
WandererApp.Map.Server.Impl.broadcast!(
|
||||
map_id,
|
||||
:signatures_updated,
|
||||
|
||||
@@ -3,8 +3,6 @@ defmodule WandererAppWeb.MapAuditLive do
|
||||
|
||||
require Logger
|
||||
|
||||
alias WandererAppWeb.UserActivity
|
||||
|
||||
def mount(
|
||||
%{"slug" => map_slug, "period" => period, "activity" => activity} = _params,
|
||||
_session,
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
/>
|
||||
</div>
|
||||
<.live_component
|
||||
module={UserActivity}
|
||||
module={WandererAppWeb.UserActivityItem}
|
||||
id="user-activity"
|
||||
notify_to={self()}
|
||||
can_undo_types={@can_undo_types}
|
||||
|
||||
2
mix.exs
2
mix.exs
@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
|
||||
|
||||
@source_url "https://github.com/wanderer-industries/wanderer"
|
||||
|
||||
@version "1.90.0"
|
||||
@version "1.90.4"
|
||||
|
||||
def project do
|
||||
[
|
||||
|
||||
@@ -206,37 +206,55 @@ defmodule WandererApp.Map.Server.MapScopesTest do
|
||||
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, @hs_system_id) == false
|
||||
end
|
||||
|
||||
test "connection valid when at least one system matches a scope" do
|
||||
# WH to HS: valid if either :wormholes or :hi is selected
|
||||
test "wormhole border behavior: WH connections allow border k-space systems" do
|
||||
# WH to HS with [:wormholes]: valid (wormhole border behavior)
|
||||
# At least one system is WH, :wormholes is enabled -> border k-space allowed
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @hs_system_id) ==
|
||||
true
|
||||
|
||||
assert ConnectionsImpl.is_connection_valid([:hi], @wh_system_id, @hs_system_id) == true
|
||||
# WH to HS with [:hi] only: INVALID (no wormhole scope, WH doesn't match :hi)
|
||||
# Neither system matches when we require both to match (no wormhole border behavior)
|
||||
assert ConnectionsImpl.is_connection_valid([:hi], @wh_system_id, @hs_system_id) == false
|
||||
|
||||
# WH to WH: valid only if :wormholes is selected
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @c2_system_id) ==
|
||||
true
|
||||
|
||||
assert ConnectionsImpl.is_connection_valid([:hi], @wh_system_id, @c2_system_id) == false
|
||||
|
||||
# HS to LS: valid if :hi or :low is selected
|
||||
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, @ls_system_id) == true
|
||||
assert ConnectionsImpl.is_connection_valid([:low], @hs_system_id, @ls_system_id) == true
|
||||
assert ConnectionsImpl.is_connection_valid([:null], @hs_system_id, @ls_system_id) == false
|
||||
end
|
||||
|
||||
test "connection with multiple scopes allows cross-space movement" do
|
||||
# With [:wormholes, :hi], all of these should be valid:
|
||||
# - WH to WH (wormholes matches)
|
||||
# - HS to HS (hi matches)
|
||||
# - WH to HS (either matches)
|
||||
test "k-space connections require BOTH systems to match scopes" do
|
||||
# HS to LS: requires BOTH to match, so single scope is not enough
|
||||
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, @ls_system_id) == false
|
||||
assert ConnectionsImpl.is_connection_valid([:low], @hs_system_id, @ls_system_id) == false
|
||||
assert ConnectionsImpl.is_connection_valid([:null], @hs_system_id, @ls_system_id) == false
|
||||
|
||||
# HS to LS with [:hi, :low]: valid (both match)
|
||||
assert ConnectionsImpl.is_connection_valid([:hi, :low], @hs_system_id, @ls_system_id) == true
|
||||
|
||||
# HS to HS: valid with [:hi] (both match)
|
||||
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, 30_000_002) == true
|
||||
|
||||
# NS to NS: valid with [:null] (both match)
|
||||
assert ConnectionsImpl.is_connection_valid([:null], @ns_system_id, @ns_system_id) == false
|
||||
# (same system returns false)
|
||||
end
|
||||
|
||||
test "connection with multiple scopes" do
|
||||
# With [:wormholes, :hi]:
|
||||
# - WH to WH: valid (both match :wormholes)
|
||||
# - HS to HS: valid (both match :hi)
|
||||
# - WH to HS: valid (wormhole border behavior - WH is wormhole, :wormholes enabled)
|
||||
scopes = [:wormholes, :hi]
|
||||
assert ConnectionsImpl.is_connection_valid(scopes, @wh_system_id, @c2_system_id) == true
|
||||
assert ConnectionsImpl.is_connection_valid(scopes, @hs_system_id, 30_000_002) == true
|
||||
assert ConnectionsImpl.is_connection_valid(scopes, @wh_system_id, @hs_system_id) == true
|
||||
|
||||
# But LS to NS should not be valid with [:wormholes, :hi]
|
||||
# LS to NS should not be valid with [:wormholes, :hi] (neither is WH, neither matches)
|
||||
assert ConnectionsImpl.is_connection_valid(scopes, @ls_system_id, @ns_system_id) == false
|
||||
|
||||
# HS to LS should not be valid with [:wormholes, :hi] (neither is WH, only HS matches)
|
||||
assert ConnectionsImpl.is_connection_valid(scopes, @hs_system_id, @ls_system_id) == false
|
||||
end
|
||||
|
||||
test "all scopes allows any connection" do
|
||||
@@ -294,4 +312,127 @@ defmodule WandererApp.Map.Server.MapScopesTest do
|
||||
assert ConnectionsImpl.is_prohibited_system_class?(25) == false
|
||||
end
|
||||
end
|
||||
|
||||
describe "maybe_add_system/5 scope filtering" do
|
||||
alias WandererApp.Map.Server.SystemsImpl
|
||||
|
||||
test "returns :ok without filtering when scopes is nil" do
|
||||
# When scopes is nil, should not filter (backward compatibility)
|
||||
result = SystemsImpl.maybe_add_system("map_id", nil, nil, [])
|
||||
assert result == :ok
|
||||
end
|
||||
|
||||
test "returns :ok without filtering when scopes is empty list" do
|
||||
# Empty scopes should not filter (let through)
|
||||
result = SystemsImpl.maybe_add_system("map_id", nil, nil, [], [])
|
||||
assert result == :ok
|
||||
end
|
||||
|
||||
test "filters system when scopes provided and system doesn't match" do
|
||||
# When scopes is [:wormholes] and system is Hi-Sec, should filter (return :ok without adding)
|
||||
location = %{solar_system_id: @hs_system_id}
|
||||
result = SystemsImpl.maybe_add_system("map_id", location, nil, [], [:wormholes])
|
||||
# Returns :ok because system was filtered out (not an error, just skipped)
|
||||
assert result == :ok
|
||||
end
|
||||
|
||||
test "allows system through when scopes match (verified via can_add_location)" do
|
||||
# When scopes is [:wormholes] and system is WH, filtering should allow it
|
||||
# We test this via can_add_location which is what maybe_add_system uses internally
|
||||
assert ConnectionsImpl.can_add_location([:wormholes], @wh_system_id) == true
|
||||
assert ConnectionsImpl.can_add_location([:null], @ns_system_id) == true
|
||||
assert ConnectionsImpl.can_add_location([:wormholes, :null], @wh_system_id) == true
|
||||
assert ConnectionsImpl.can_add_location([:wormholes, :null], @ns_system_id) == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "border system auto-addition behavior" do
|
||||
# Tests that verify bordered systems are correctly auto-added ONLY for wormholes.
|
||||
# Key behavior:
|
||||
# - Wormhole border: WH to Hi-Sec with [:wormholes] -> BOTH added (border behavior)
|
||||
# - K-space only: Null to Hi-Sec with [:wormholes, :null] -> REJECTED (no border for k-space)
|
||||
# - K-space must match: both systems must match scopes when no wormhole involved
|
||||
|
||||
test "WORMHOLE BORDER: WH->Hi-Sec with [:wormholes] is VALID (border k-space added)" do
|
||||
# Border case: moving from WH to k-space
|
||||
# Valid because :wormholes enabled AND one system is WH
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @hs_system_id) == true
|
||||
end
|
||||
|
||||
test "WORMHOLE BORDER: Hi-Sec->WH with [:wormholes] is VALID (border k-space added)" do
|
||||
# Border case: moving from k-space to WH
|
||||
# Valid because :wormholes enabled AND one system is WH
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @wh_system_id) == true
|
||||
end
|
||||
|
||||
test "K-SPACE ONLY: Hi-Sec->Hi-Sec with [:wormholes] is REJECTED" do
|
||||
# No wormhole involved, neither matches :wormholes
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, 30_000_002) == false
|
||||
end
|
||||
|
||||
test "K-SPACE ONLY: Null->Hi-Sec with [:wormholes, :null] is REJECTED (no border for k-space)" do
|
||||
# Neither system is a wormhole, so no border behavior
|
||||
# Null matches :null, but Hi-Sec doesn't match any scope -> BOTH must match
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes, :null], @ns_system_id, @hs_system_id) ==
|
||||
false
|
||||
end
|
||||
|
||||
test "K-SPACE ONLY: Hi-Sec->Low-Sec with [:wormholes, :null] is REJECTED" do
|
||||
# Neither Hi-Sec nor Low-Sec match [:wormholes, :null], no WH involved
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes, :null], @hs_system_id, @ls_system_id) ==
|
||||
false
|
||||
end
|
||||
|
||||
test "K-SPACE ONLY: Low-Sec->Hi-Sec with [:low] is REJECTED (no border for k-space)" do
|
||||
# Low-Sec matches :low, but Hi-Sec doesn't match
|
||||
# No wormhole involved, so BOTH must match -> rejected
|
||||
assert ConnectionsImpl.is_connection_valid([:low], @ls_system_id, @hs_system_id) == false
|
||||
end
|
||||
|
||||
test "K-SPACE MATCH: Low-Sec->Low-Sec with [:low] is VALID (both match)" do
|
||||
# Both systems match :low
|
||||
assert ConnectionsImpl.is_connection_valid([:low], @ls_system_id, 30_000_101) == true
|
||||
end
|
||||
|
||||
test "K-SPACE MATCH: Null->Null with [:null] is VALID (both match)" do
|
||||
# Would need two different null-sec systems for this test
|
||||
# Using same system returns false (same system check)
|
||||
assert ConnectionsImpl.is_connection_valid([:null], @ns_system_id, @ns_system_id) == false
|
||||
end
|
||||
|
||||
test "WORMHOLE BORDER: Pochven->WH with [:wormholes, :pochven] is VALID" do
|
||||
# WH is wormhole, :wormholes enabled -> border behavior applies
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes, :pochven], @pochven_id, @wh_system_id) ==
|
||||
true
|
||||
end
|
||||
|
||||
test "WORMHOLE BORDER: WH->Pochven with [:wormholes] is VALID (border k-space)" do
|
||||
# WH is wormhole, :wormholes enabled -> border behavior, Pochven added as border
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @pochven_id) == true
|
||||
end
|
||||
|
||||
test "border systems: WH->Hi-Sec->WH path with [:wormholes] scope" do
|
||||
# Simulates a character path through k-space between WHs
|
||||
# First jump: WH to Hi-Sec - valid (wormhole border)
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @hs_system_id) == true
|
||||
# Second jump: Hi-Sec to WH - valid (wormhole border)
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @c2_system_id) == true
|
||||
end
|
||||
|
||||
test "excluded path: k-space chain with [:wormholes] scope remains excluded" do
|
||||
# If character moves within k-space (no WH involved), should be excluded
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, 30_000_002) == false
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], 30_000_002, @ls_system_id) == false
|
||||
end
|
||||
|
||||
test "excluded path: Null->Hi-Sec->Low-Sec with [:wormholes, :null] - only Null tracked" do
|
||||
# Character in Null (tracked) jumps to Hi-Sec (border - but NO wormhole!) -> REJECTED
|
||||
# This is the key case: k-space to k-space should NOT add border systems
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes, :null], @ns_system_id, @hs_system_id) ==
|
||||
false
|
||||
# Hi-Sec to Low-Sec also rejected (neither matches)
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes, :null], @hs_system_id, @ls_system_id) ==
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user