Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions app/Http/Controllers/API/NetworkController.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,38 @@
return \App\Http\Resources\Network::make($network);
}

/**
* @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')]

Check failure on line 156 in app/Http/Controllers/API/NetworkController.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "Network groups & events" 5 times.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AaF-U-7lZR13XwsmQqil&open=AaF-U-7lZR13XwsmQqil&pullRequest=950
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),
],
]);
}

/**
* @OA\Get(
* path="/api/v2/networks/{id}/groups",
Expand Down
7 changes: 6 additions & 1 deletion app/Http/Resources/Network.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
74 changes: 74 additions & 0 deletions app/Network.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
16 changes: 16 additions & 0 deletions app/Policies/NetworkPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
7 changes: 7 additions & 0 deletions client/app/api/NetworkAPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions client/app/components/networks/NetworkNearbyGroups.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script setup>
import { useI18n } from 'vue-i18n'

// "Groups near you" on /networks/{id}: the nearest groups in the network to the
// viewer's own groups in it, from GET /api/v2/networks/{id}/viewer's
// `nearby_groups` ({from, groups: [{id, name, location, distance}]}). Renders
// nothing when the viewer has no located group in the network (null).
defineProps({
nearby: {
type: Object,
default: null,
},
})

const { t } = useI18n()
</script>

<template>
<section v-if="nearby && nearby.groups.length" class="mb-4" data-testid="network-nearby-groups">
<h2>{{ t('networks.nearby.title') }}</h2>
<p class="text-muted">{{ t('networks.nearby.intro', { group: nearby.from }) }}</p>
<ul class="list-unstyled mb-0">
<li
v-for="group in nearby.groups"
:key="group.id"
class="d-flex justify-content-between py-1"
:data-testid="`network-nearby-group-${group.id}`"
>
<span>
<NuxtLink :to="`/group/view/${group.id}`">{{ group.name }}</NuxtLink>
<span v-if="group.location" class="text-muted ms-2">{{ group.location }}</span>
</span>
<span class="text-muted">{{ t('networks.nearby.distance', { distance: group.distance }) }}</span>
</li>
</ul>
</section>
</template>
47 changes: 25 additions & 22 deletions client/app/pages/networks/[id]/index.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuth } from '~/composables/useAuth.js'
import { useSessionStore } from '~/stores/session.js'
import { useNetworksStore } from '~/stores/networks.js'
import { useGroupsStore } from '~/stores/groups.js'
import NetworkStats from '~/components/networks/NetworkStats.vue'
Expand All @@ -11,16 +9,17 @@ import NetworkGroupsModerationTable from '~/components/networks/NetworkGroupsMod
import NetworkEventsModerationTable from '~/components/networks/NetworkEventsModerationTable.vue'
import NetworkTagsManager from '~/components/networks/NetworkTagsManager.vue'
import NetworkGroupsMapList from '~/components/networks/NetworkGroupsMapList.vue'
import NetworkNearbyGroups from '~/components/networks/NetworkNearbyGroups.vue'

// /networks/{id} - resources/views/networks/show.blade.php +
// resources/js/components/NetworkPage.vue (design.md §6.2 Phase E task E1).
// Administrator/coordinator-of-this-network only (NetworkPolicy@view -
// there is no plain "Host can view" case at all, unlike the grouptags
// suite's group-page permission matrix). Since view access and manage
// access (tags CRUD, associate groups - NetworkPolicy@associateGroups) are
// governed by the exact same condition, reaching this page already implies
// "can manage" - there is no separate reduced-permission view here (unlike
// e.g. /group/view/[id] where hosts see a read-only page).
// Administrators, this network's coordinators and the volunteers/hosts of its
// groups can open it (NetworkPolicy@viewPublic); access follows
// GET /api/v2/networks/{id}/viewer, which 403s anyone else. Only
// administrators and coordinators (`can_moderate`) get the moderation
// sections, add-groups, the event export, the tags manager and the reporting
// links; everyone else sees the impact, about, coordinators, groups map and
// their nearby groups.
//
// "Network coordinators" section (avatar + name cards), matching legacy
// resources/js/components/NetworkPage.vue: GET /api/v2/networks/{id}'s
Expand Down Expand Up @@ -49,16 +48,13 @@ definePageMeta({ auth: true })

const { t } = useI18n()
const route = useRoute()
const { hasRole } = useAuth()
const sessionStore = useSessionStore()
const networksStore = useNetworksStore()
const groupsStore = useGroupsStore()

const id = computed(() => Number(route.params.id))

const isAdministrator = computed(() => hasRole('Administrator'))
const isCoordinatorHere = computed(() => (sessionStore.user?.networks || []).some((n) => n.id === id.value))
const canManage = computed(() => isAdministrator.value || isCoordinatorHere.value)
const canManage = computed(() => !!networksStore.viewer.data?.can_moderate)
const nearbyGroups = computed(() => networksStore.viewer.data?.nearby_groups ?? null)

const network = computed(() => networksStore.current.data)

Expand Down Expand Up @@ -111,18 +107,23 @@ function loadGroups() {

function loadNetwork() {
networksStore.fetchCurrent(id.value)
networksStore.fetchTags(id.value)
if (canManage.value) networksStore.fetchTags(id.value)
loadGroups()
}

onMounted(() => {
if (!isAdministrator.value && !isCoordinatorHere.value) {
navigateTo('/forbidden')
return
onMounted(async () => {
try {
await networksStore.fetchViewer(id.value)
} catch (error) {
if (error?.status === 403) {
navigateTo('/forbidden')
return
}
// Anything else (e.g. a 404) is reported by the network load below.
}

loadNetwork()
groupsStore.fetchNames().catch(() => {})
if (canManage.value) groupsStore.fetchNames().catch(() => {})
})

function retry() {
Expand Down Expand Up @@ -165,7 +166,7 @@ function retry() {
in, only its add-groups item on canAssociateGroups. develop
dropped its "View groups" item when the Groups section became an
inline map and list. -->
<BDropdown variant="primary" placement="bottom-end" :text="t('networks.general.actions')" data-testid="network-show-actions">
<BDropdown v-if="canManage" variant="primary" placement="bottom-end" :text="t('networks.general.actions')" data-testid="network-show-actions">
<BDropdownItem v-if="canManage" data-testid="network-show-add-groups" @click="showAssociateModal = true">
{{ t('networks.show.add_groups_menuitem') }}
</BDropdownItem>
Expand All @@ -179,7 +180,7 @@ function retry() {
>
{{ t('networks.general.view_reports') }}
</BDropdownItem>
<BDropdownItem :href="`/export/networks/${network.id}/events`" data-testid="network-show-export-events">
<BDropdownItem v-if="canManage" :href="`/export/networks/${network.id}/events`" data-testid="network-show-export-events">
{{ t('groups.export_event_list') }}
</BDropdownItem>
</BDropdown>
Expand Down Expand Up @@ -230,6 +231,8 @@ function retry() {
</div>
</section>

<NetworkNearbyGroups :nearby="nearbyGroups" />

<!-- Groups/events awaiting moderation, for Administrators and this
network's coordinators - the network-specific rich tables (gap
#2/#11), not a plain name-only list
Expand Down
22 changes: 22 additions & 0 deletions client/app/stores/networks.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const useNetworksStore = defineStore('networks', {
state: () => ({
list: { data: [], loading: false, error: null },
current: { data: null, loading: false, error: null },
viewer: { data: null, loading: false, error: null },
groups: { data: [], loading: false, error: null },
tags: { data: [], loading: false, error: null },
}),
Expand Down Expand Up @@ -74,6 +75,27 @@ export const useNetworksStore = defineStore('networks', {
}
},

// What the caller may do on this network's page ({can_moderate,
// nearby_groups}); rejects with a 403 error when they may not open it.
async fetchViewer(id) {
const { $api } = useNuxtApp()

this.viewer.loading = true
this.viewer.error = null
this.viewer.data = null

try {
const { data } = await $api.network.viewer(id)
this.viewer.data = data
return data
} catch (error) {
this.viewer.error = error
throw error
} finally {
this.viewer.loading = false
}
},

// Set the network logo from a completed tus upload, then reflect the new
// logo url on the loaded network so the preview updates immediately.
async uploadLogo(id, uploadKey) {
Expand Down
13 changes: 7 additions & 6 deletions client/e2e/grouptags.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -401,19 +401,20 @@ test.describe('tag visibility', () => {
await setGroupTagAssignment(page, groupId, tagName, false)
})

test('Host is redirected away from the network page entirely', async ({ page }) => {
test('Host sees the network page without tag management', async ({ page }) => {
test.slow()
// GET /api/v2/networks is a public route (routes/api.php), so resolving
// the id needs no login.
const networkId = await getNetworkId(page)

// Host coordinates no networks and isn't an Administrator -
// NetworkPolicy::view has no reduced-permission "read only" case, so
// the page itself redirects to /forbidden on mount (pages/networks/
// [id].vue) rather than hiding just the tags section.
// Host hosts a group in the network, so NetworkPolicy::viewPublic lets
// them open the page, but the coordinator-only parts (tags management,
// moderation, add groups) stay hidden.
await login(page, USERS.host)
await page.goto(`/networks/${networkId}`)
await page.waitForURL('**/forbidden**', { timeout: 10000 })
await expect(page.getByTestId('network-show-name')).toBeVisible({ timeout: 10000 })
await expect(page.getByTestId('tags-management')).toHaveCount(0)
await expect(page.getByTestId('network-groups-moderation')).toHaveCount(0)
})
})

Expand Down
5 changes: 5 additions & 0 deletions client/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1194,6 +1194,11 @@
"add_new_field": "Add new field",
"new_field_name": "New field name",
"add_field": "Add field"
},
"nearby": {
"title": "Groups near you",
"intro": "The nearest groups in this network to {group}.",
"distance": "{distance} km"
}
},
"notifications": {
Expand Down
Loading
Loading