From d90e7c13aca8ff9bbc2c092454107c9c97563ea4 Mon Sep 17 00:00:00 2001 From: edwh Date: Tue, 27 Oct 2026 17:03:34 +0000 Subject: [PATCH 1/3] Let group members open the network page, without the coordinator-only parts Add NetworkPolicy::viewPublic and GET /api/v2/networks/{id}/viewer, which 403s anyone who is not an administrator, a coordinator of the network or a member of one of its groups, and says whether the caller can moderate. The network resource keeps reporting_url for coordinators and administrators only. The page follows the API, hides moderation, add-groups, export, tags and reporting links from non-coordinators, and shows the five nearest groups to the viewer's own. Co-Authored-By: Claude Opus 5.5 --- .../Controllers/API/NetworkController.php | 32 +++ app/Http/Resources/Network.php | 7 +- app/Network.php | 74 +++++++ app/Policies/NetworkPolicy.php | 16 ++ client/app/api/NetworkAPI.js | 7 + .../networks/NetworkNearbyGroups.vue | 37 ++++ client/app/pages/networks/[id]/index.vue | 47 +++-- client/app/stores/networks.js | 22 ++ client/i18n/locales/en.json | 5 + client/i18n/locales/fr-BE.json | 5 + client/i18n/locales/fr.json | 5 + .../networks/NetworkNearbyGroups.spec.js | 52 +++++ client/tests/pages/networks/show.spec.js | 85 ++++++-- lang/en/networks.php | 5 + lang/fr-BE/networks.php | 5 + lang/fr/networks.php | 5 + routes/api.php | 1 + .../Networks/NetworkPageVisibilityTest.php | 195 ++++++++++++++++++ 18 files changed, 569 insertions(+), 36 deletions(-) create mode 100644 client/app/components/networks/NetworkNearbyGroups.vue create mode 100644 client/tests/components/networks/NetworkNearbyGroups.spec.js create mode 100644 tests/Feature/Networks/NetworkPageVisibilityTest.php diff --git a/app/Http/Controllers/API/NetworkController.php b/app/Http/Controllers/API/NetworkController.php index 4a022c5ba3..a15d7a19ef 100644 --- a/app/Http/Controllers/API/NetworkController.php +++ b/app/Http/Controllers/API/NetworkController.php @@ -246,6 +246,38 @@ public function getNetworkv2($id) * ) */ + /** + * @OA\Get( + * path="/api/v2/networks/{id}/viewer", + * operationId="getNetworkViewer", + * tags={"Networks"}, + * summary="Get what the caller can do on a network page", + * description="Returns whether the caller can moderate the network, and the groups nearest to the caller's own groups in it. 403 unless the caller is an administrator, a coordinator of the network, or a member of one of its groups.", + * @OA\Parameter(name="id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\Response(response=200, description="Successful operation"), + * @OA\Response(response=403, description="Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * ) + */ + + #[UserStory('As a group volunteer or host, I can open my network\'s page and see the groups nearest to mine', persona: 'Restarter', theme: 'Network groups & events')] + public function getNetworkViewerv2($id): JsonResponse + { + $network = Network::findOrFail($id); + $user = Auth::user(); + + if (! $user || ! $user->can('viewPublic', $network)) { + abort(403, 'You do not have access to this network'); + } + + return response()->json([ + 'data' => [ + 'can_moderate' => $user->can('view', $network), + 'nearby_groups' => $network->nearbyGroupsFor($user), + ], + ]); + } + #[UserStory('As a Guest, I can list groups for a network via the API', persona: 'Guest', theme: 'Network groups & events')] #[UserStory('As a ThirdParty, I can retrieve groups for a network to display on my platform', persona: 'ThirdParty', theme: 'Network groups & events')] public function getNetworkGroupsv2(Request $request, $id) diff --git a/app/Http/Resources/Network.php b/app/Http/Resources/Network.php index 036223576a..15b80b3e55 100644 --- a/app/Http/Resources/Network.php +++ b/app/Http/Resources/Network.php @@ -222,13 +222,18 @@ class Network extends JsonResource */ public function toArray(Request $request): array { + $user = \Illuminate\Support\Facades\Auth::user() + ?? auth('sanctum')->user() + ?? auth('api')->user(); + return [ 'id' => $this->id, 'name' => $this->name, 'logo' => $this->logo ? ($request->root() . '/uploads/' . $this->logo) : null, 'description' => $this->description, 'website' => $this->website, - 'reporting_url' => $this->reporting_url, + // The reporting dashboard is for the network's coordinators and administrators only. + 'reporting_url' => ($user && $user->can('view', $this->resource)) ? $this->reporting_url : null, 'shortname' => $this->shortname, 'default_language' => $this->default_language, 'stats' => $this->resource->stats(), diff --git a/app/Network.php b/app/Network.php index c0ea3840a1..155df1437f 100644 --- a/app/Network.php +++ b/app/Network.php @@ -101,6 +101,80 @@ public function groupsNotIn() return $query->get(); } + /** + * The approved, active groups in this network nearest to $from (not including it), with their distance in km. + */ + public function nearestGroups(Group $from, int $count = 5) + { + if (is_null($from->latitude) || is_null($from->longitude)) { + return collect(); + } + + return $this->groups() + ->select('groups.idgroups', 'groups.name', 'groups.location', 'groups.latitude', 'groups.longitude') + ->selectRaw( + '( 6371 * acos( cos( radians(?) ) * cos( radians( latitude ) ) * cos( radians( longitude ) - radians(?) ) + sin( radians(?) ) * sin( radians( latitude ) ) ) ) AS dist', + [$from->latitude, $from->longitude, $from->latitude] + ) + ->where('groups.idgroups', '!=', $from->idgroups) + ->where('groups.approved', true) + ->whereNull('groups.archived_at') + ->whereNotNull('groups.latitude') + ->whereNotNull('groups.longitude') + ->orderBy('dist') + ->take($count) + ->get(); + } + + /** + * The groups in the network nearest to any of the user's own located groups in it, nearest first, with each + * group listed once at its smallest distance. The user's own groups are left out. Null if they have no + * located group in the network. + */ + public function nearbyGroupsFor(User $user): ?array + { + $mine = Group::join('users_groups', 'users_groups.group', '=', 'groups.idgroups') + ->join('group_network', 'group_network.group_id', '=', 'groups.idgroups') + ->where('group_network.network_id', $this->id) + ->where('users_groups.user', $user->id) + ->whereNull('users_groups.deleted_at') + ->whereNotNull('groups.latitude') + ->whereNotNull('groups.longitude') + ->orderBy('groups.name') + ->select('groups.*') + ->get(); + + if ($mine->isEmpty()) { + return null; + } + + $mineIds = $mine->pluck('idgroups')->all(); + $nearest = []; + + foreach ($mine as $group) { + // Ask for extra so that skipping the user's other groups still leaves enough. + foreach ($this->nearestGroups($group, 5 + count($mineIds)) as $g) { + if (in_array($g->idgroups, $mineIds)) { + continue; + } + + if (! isset($nearest[$g->idgroups]) || $g->dist < $nearest[$g->idgroups]->dist) { + $nearest[$g->idgroups] = $g; + } + } + } + + return [ + 'from' => $mine->pluck('name')->implode(', '), + 'groups' => collect($nearest)->sortBy('dist')->take(5)->map(fn ($g) => [ + 'id' => $g->idgroups, + 'name' => $g->name, + 'location' => $g->location, + 'distance' => round($g->dist, 1), + ])->values()->all(), + ]; + } + public function stats() { $eEmissionRatio = \App\Helpers\LcaStats::getEmissionRatioPowered(); diff --git a/app/Policies/NetworkPolicy.php b/app/Policies/NetworkPolicy.php index 4aee713fb0..5eb05d5b60 100644 --- a/app/Policies/NetworkPolicy.php +++ b/app/Policies/NetworkPolicy.php @@ -26,6 +26,22 @@ public function view(User $user, Network $network): bool return false; } + /** + * Determine whether the user can view the network page with its coordinator-only parts hidden. That is + * anyone who can fully view it, plus the volunteers and hosts of a group in the network. + */ + public function viewPublic(User $user, Network $network): bool + { + if ($this->view($user, $network)) { + return true; + } + + return \App\UserGroups::where('user', $user->id) + ->where(fn ($q) => $q->where('status', 1)->orWhereNull('status')) + ->whereIn('group', \DB::table('group_network')->where('network_id', $network->id)->select('group_id')) + ->exists(); + } + /** * Determine whether the user can create networks. */ diff --git a/client/app/api/NetworkAPI.js b/client/app/api/NetworkAPI.js index 56c78b2b67..f5d6705fc0 100644 --- a/client/app/api/NetworkAPI.js +++ b/client/app/api/NetworkAPI.js @@ -19,6 +19,13 @@ export default class NetworkAPI extends BaseAPI { return this.$get(`/api/v2/networks/${id}`) } + // GET /api/v2/networks/{id}/viewer - what the caller may do on the network + // page: {can_moderate, nearby_groups}. Requires auth; 403 unless they are an + // administrator, a coordinator of the network or a member of one of its groups. + viewer(id) { + return this.$get(`/api/v2/networks/${id}/viewer`) + } + // includeNextEvent/includeDetails/includeStats/includeArchived/group_tag // query params (getNetworkGroupsv2). Also honours includeCounts (read // directly off the request by GroupSummary#toArray, not documented in diff --git a/client/app/components/networks/NetworkNearbyGroups.vue b/client/app/components/networks/NetworkNearbyGroups.vue new file mode 100644 index 0000000000..3c8920d6b2 --- /dev/null +++ b/client/app/components/networks/NetworkNearbyGroups.vue @@ -0,0 +1,37 @@ + + + diff --git a/client/app/pages/networks/[id]/index.vue b/client/app/pages/networks/[id]/index.vue index 257410021f..4b278f49a9 100644 --- a/client/app/pages/networks/[id]/index.vue +++ b/client/app/pages/networks/[id]/index.vue @@ -1,8 +1,6 @@