diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index f47205a43..60e15027e 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -35,6 +35,17 @@ public function render($request, Throwable $exception) 422); } + // AuthenticationException / AuthorizationException don't implement + // getStatusCode(), so without these the generic branch below rendered + // them as 500 instead of the correct 401/403 for JSON API requests. + if ($exception instanceof \Illuminate\Auth\AuthenticationException) { + return response()->json(['message' => $exception->getMessage()], 401); + } + + if ($exception instanceof \Illuminate\Auth\Access\AuthorizationException) { + return response()->json(['message' => $exception->getMessage()], 403); + } + return response()->json( ['message' => $exception->getMessage()], method_exists($exception, 'getStatusCode') ? $exception->getStatusCode() : 500); diff --git a/app/Http/Controllers/API/UserController.php b/app/Http/Controllers/API/UserController.php index 769aeb51f..1bb8e103b 100644 --- a/app/Http/Controllers/API/UserController.php +++ b/app/Http/Controllers/API/UserController.php @@ -2,12 +2,16 @@ namespace App\Http\Controllers\API; -use Illuminate\Http\JsonResponse; +use App\Helpers\Fixometer; use App\Http\Controllers\Controller; +use App\Http\Resources\UserAdmin; +use App\Role; use App\User; use Auth; -use Illuminate\Http\Request; use Cache; +use DB; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; class UserController extends Controller { @@ -134,4 +138,95 @@ public function notifications(Request $request, int $id): JsonResponse 'discourse' => $discourseNotifications ], 200); } + + /** + * @OA\Get( + * path="/api/v2/users", + * operationId="listUsersv2", + * tags={"Users"}, + * summary="List users with optional filtering and sorting", + * description="Administrator only. Paginated.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="name", in="query", required=false, @OA\Schema(type="string")), + * @OA\Parameter(name="email", in="query", required=false, @OA\Schema(type="string")), + * @OA\Parameter(name="location", in="query", required=false, @OA\Schema(type="string")), + * @OA\Parameter(name="country", in="query", required=false, @OA\Schema(type="string")), + * @OA\Parameter(name="role", in="query", required=false, @OA\Schema(type="integer")), + * @OA\Parameter(name="sort", in="query", required=false, @OA\Schema(type="string", enum={"name","email","role","location","country","created_at","updated_at"})), + * @OA\Parameter(name="sortdir", in="query", required=false, @OA\Schema(type="string", enum={"asc","desc"})), + * @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/UserAdmin")), + * @OA\Property(property="meta", type="object") + * ) + * ), + * @OA\Response(response=401, description="Unauthenticated"), + * @OA\Response(response=403, description="Forbidden") + * ) + */ + public function listUsersv2(Request $request): JsonResponse + { + if (!Fixometer::hasRole(Auth::user(), 'Administrator')) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $query = User::query() + ->leftJoin('roles', 'roles.idroles', '=', 'users.role') + ->select('users.*', 'roles.role as role_name') + ->withCount('groups'); + + if ($name = $request->input('name')) { + $query->where('users.name', 'like', '%' . $name . '%'); + } + if ($email = $request->input('email')) { + $query->where('users.email', 'like', '%' . $email . '%'); + } + if ($location = $request->input('location')) { + $query->where('users.location', 'like', '%' . $location . '%'); + } + if ($country = $request->input('country')) { + $query->where('users.country_code', '=', $country); + } + if (($role = $request->input('role')) !== null && $role !== '') { + $query->where('users.role', '=', (int) $role); + } + + $sortMap = [ + 'name' => 'users.name', + 'email' => 'users.email', + 'role' => 'users.role', + 'location' => 'users.location', + 'country' => 'users.country_code', + 'created_at' => 'users.created_at', + 'updated_at' => 'users.updated_at', + ]; + $sort = $request->input('sort'); + if ($sort && isset($sortMap[$sort])) { + $dir = strtolower($request->input('sortdir', 'asc')); + if (!in_array($dir, ['asc', 'desc'], true)) { + $dir = 'asc'; + } + $query->orderBy($sortMap[$sort], $dir); + } else { + $query->orderBy('users.id', 'asc'); + } + + $perPage = (int) (env('PAGINATE') ?: 30); + $paginator = $query->paginate($perPage); + + return response()->json([ + 'data' => UserAdmin::collection($paginator->getCollection())->toArray($request), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'from' => $paginator->firstItem(), + 'to' => $paginator->lastItem(), + ], + ]); + } } diff --git a/app/Http/Resources/UserAdmin.php b/app/Http/Resources/UserAdmin.php new file mode 100644 index 000000000..d77553632 --- /dev/null +++ b/app/Http/Resources/UserAdmin.php @@ -0,0 +1,60 @@ +resource, 'lastLogin') ? $this->resource->lastLogin() : null; + + return [ + 'id' => (int) $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'role' => (int) $this->role, + 'role_name' => $this->role_name ?? (string) $this->getRoleName(), + 'location' => $this->location, + 'country' => $this->country_code, + 'country_name' => $this->country_code ? Fixometer::getCountryFromCountryCode($this->country_code) : null, + 'groups_count' => (int) ($this->groups_count ?? $this->groups()->count()), + 'created_at' => optional($this->created_at)?->toIso8601String(), + 'last_login_at' => optional($lastLogin)?->toIso8601String(), + ]; + } + + private function getRoleName(): string + { + $roleNames = [ + \App\Role::ROOT => 'Root', + \App\Role::ADMINISTRATOR => 'Administrator', + \App\Role::NETWORK_COORDINATOR => 'NetworkCoordinator', + \App\Role::HOST => 'Host', + \App\Role::RESTARTER => 'Restarter', + ]; + + return $roleNames[$this->role] ?? ''; + } +} diff --git a/lang/en/users.php b/lang/en/users.php new file mode 100644 index 000000000..024c1424b --- /dev/null +++ b/lang/en/users.php @@ -0,0 +1,23 @@ + 'Users', + 'reveal_filters' => 'Reveal filters', + 'create_new' => 'Create new user', + 'name' => 'Name', + 'email' => 'Email address', + 'role' => 'Role', + 'role_any' => 'Any role', + 'location' => 'Location', + 'country' => 'Country', + 'groups' => 'Groups', + 'joined' => 'Joined', + 'last_login' => 'Last login', + 'placeholder_name' => 'Search by name', + 'placeholder_email' => 'Search by email address', + 'placeholder_location' => 'E.g. Paris, London, Brussels', + 'search' => 'Search all users', + 'empty' => 'No users match the current filters.', + 'never' => 'Never', + 'showing' => 'Showing :from to :to of :total results', +]; diff --git a/lang/fr-BE/users.php b/lang/fr-BE/users.php new file mode 100644 index 000000000..77c435885 --- /dev/null +++ b/lang/fr-BE/users.php @@ -0,0 +1,23 @@ + 'Utilisateurs', + 'reveal_filters' => 'Afficher les filtres', + 'create_new' => 'Créer un nouvel utilisateur', + 'name' => 'Nom', + 'email' => 'Adresse e-mail', + 'role' => 'Rôle', + 'role_any' => 'Tous les rôles', + 'location' => 'Ville', + 'country' => 'Pays', + 'groups' => 'Groupes', + 'joined' => 'Inscrit', + 'last_login' => 'Dernière connexion', + 'placeholder_name' => 'Rechercher par nom', + 'placeholder_email' => 'Rechercher par adresse e-mail', + 'placeholder_location' => 'Par ex. Paris, Londres, Bruxelles', + 'search' => 'Rechercher tous les utilisateurs', + 'empty' => 'Aucun utilisateur ne correspond aux filtres actuels.', + 'never' => 'Jamais', + 'showing' => 'Affichage de :from à :to sur :total résultats', +]; diff --git a/lang/fr/users.php b/lang/fr/users.php new file mode 100644 index 000000000..77c435885 --- /dev/null +++ b/lang/fr/users.php @@ -0,0 +1,23 @@ + 'Utilisateurs', + 'reveal_filters' => 'Afficher les filtres', + 'create_new' => 'Créer un nouvel utilisateur', + 'name' => 'Nom', + 'email' => 'Adresse e-mail', + 'role' => 'Rôle', + 'role_any' => 'Tous les rôles', + 'location' => 'Ville', + 'country' => 'Pays', + 'groups' => 'Groupes', + 'joined' => 'Inscrit', + 'last_login' => 'Dernière connexion', + 'placeholder_name' => 'Rechercher par nom', + 'placeholder_email' => 'Rechercher par adresse e-mail', + 'placeholder_location' => 'Par ex. Paris, Londres, Bruxelles', + 'search' => 'Rechercher tous les utilisateurs', + 'empty' => 'Aucun utilisateur ne correspond aux filtres actuels.', + 'never' => 'Jamais', + 'showing' => 'Affichage de :from à :to sur :total résultats', +]; diff --git a/resources/js/app.js b/resources/js/app.js index 4cc54ab73..6f67115dd 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -42,6 +42,7 @@ import EventsRequiringModeration from './components/EventsRequiringModeration.vu import EventPage from './components/EventPage.vue' import FixometerPage from './components/FixometerPage.vue' import GroupsPage from './components/GroupsPage.vue' +import UsersPage from './components/UsersPage.vue' import GroupPage from './components/GroupPage.vue' import GroupAddEditPage from './components/GroupAddEditPage.vue' import GroupEventsPage from './components/GroupEventsPage.vue' @@ -402,6 +403,7 @@ function initializeJQuery() { 'eventpage': EventPage, 'fixometerpage': FixometerPage, 'groupspage': GroupsPage, + 'userspage': UsersPage, 'grouppage': GroupPage, 'groupaddeditpage': GroupAddEditPage, 'groupeventspage': GroupEventsPage, diff --git a/resources/js/components/UsersPage.vue b/resources/js/components/UsersPage.vue new file mode 100644 index 000000000..6ecbd8143 --- /dev/null +++ b/resources/js/components/UsersPage.vue @@ -0,0 +1,245 @@ + + + diff --git a/resources/views/user/all.blade.php b/resources/views/user/all.blade.php index 50223f5f1..402073296 100644 --- a/resources/views/user/all.blade.php +++ b/resources/views/user/all.blade.php @@ -1,239 +1,44 @@ @extends('layouts.app') @section('content') -
-
- @if (\Session::has('success')) -
- {!! \Session::get('success') !!} -
- @endif - - @if (\Session::has('danger')) -
- {!! \Session::get('danger') !!} -
- @endif - - -
-
-
-

- Users -

-
- - Create new user -
-
+ @if (\Session::has('success')) +
+
+ {!! \Session::get('success') !!}
+ @endif - @if(isset($response)) - @php( App\Helpers\Fixometer::printResponse($response) ) - @endif - -
-
- -
- -
-
- - - - - - - - - - - - - - - @foreach($userlist as $u) - - @php( $display = true ) - - permissions, 'idpermissions'); - if(!in_array($p, $user_permissions)) { - $display = false; - break; - } - } - } - ?> - - @if ($display) - - - - - - - - - - - - - @endif - @endforeach - -
NameEmail addressRoleLocationCountryGroupsJoinedLast login
- - @if(App\Helpers\Fixometer::hasRole($user, 'Administrator')) - {{ $u->name }} - @else - {{ $u->name }} - @endif - - - - {{ Str::limit($u->email, 15) }} - - - @lang($u->role) - - @if (!empty($u->location)) - location; ?> - @else - N/A - @endif - {{ \App\Helpers\Fixometer::getCountryFromCountryCode($u->country_code) }} - @if (isset($u->groups) && $u->groups->count() > 0) - {{ $u->groups->count() }} - @else - 0 - @endif - - {{ !is_null($u->created_at) ? $u->created_at->diffForHumans(null, true) : 'Never' }} - - {{ !is_null($u->lastLogin) ? $u->lastLogin->diffForHumans(null, true) : 'Never' }} -
- -
- -
- -
- Showing {{ $userlist->firstItem() }} to {{ $userlist->lastItem() }} of {{ $userlist->total() }} results -
-
- -
-
- + @if (\Session::has('danger')) +
+
+ {!! \Session::get('danger') !!}
+ @endif + + $name) { + $countries[] = ['code' => $code, 'name' => $name]; + } + $roles = []; + foreach (App\Helpers\Fixometer::allRoles() as $r) { + $roles[] = ['id' => $r->idroles, 'name' => $r->role]; + } + $can_edit = App\Helpers\Fixometer::hasRole(Auth::user(), 'Administrator'); + ?> + +
+
@lang('partials.loading')...
+
+
+
-
-@include('includes/modals/create-user') + @include('includes/modals/create-user') @endsection diff --git a/routes/api.php b/routes/api.php index 5564ad0dc..acb9b15c8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -93,6 +93,12 @@ Route::patch('{id}', [API\EventController::class, 'updateEventv2']); }); + Route::prefix('/users')->group(function() { + Route::middleware('auth:api')->group(function() { + Route::get('', [API\UserController::class, 'listUsersv2']); + }); + }); + Route::prefix('/networks')->group(function() { Route::get('/', [API\NetworkController::class, 'getNetworksv2']); Route::get('{id}', [API\NetworkController::class, 'getNetworkv2']); diff --git a/tests/Feature/Admin/Users/ViewUsersTest.php b/tests/Feature/Admin/Users/ViewUsersTest.php index 6bba57f16..4b5ee2f48 100644 --- a/tests/Feature/Admin/Users/ViewUsersTest.php +++ b/tests/Feature/Admin/Users/ViewUsersTest.php @@ -3,135 +3,26 @@ namespace Tests\Feature; use App\User; -use Carbon\Carbon; -use DB; -use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class ViewUsersTest extends TestCase { - protected function setUp(): void + public function testAdminCanLoadUsersPage(): void { - parent::setUp(); - DB::statement('SET foreign_key_checks=0'); - User::truncate(); - DB::statement('SET foreign_key_checks=1'); - - // Given we're logged in as an admin $admin = User::factory()->administrator()->create(); $this->actingAs($admin); - } - - /** @test */ - public function an_admin_can_view_list_of_users(): void - { - // Given we have users in the database - $users = User::factory()->count(10)->create(); - // When we visit the list of users $response = $this->get('/user/all'); - - // Then the users should be in the list - $response->assertSeeText($users[0]->name); + $response->assertSuccessful(); + $response->assertSee('count(41)->create(); + $user = User::factory()->restarter()->create(); + $this->actingAs($user); - // When we visit the list of users $response = $this->get('/user/all'); - - // Then we should see the count of users - $response->assertSeeText(42); - } - - /** @test */ - public function admin_can_see_users_last_login_time(): void - { - // Given we have a user who has just logged in - $lastLogin = new \Carbon\Carbon(); - $user = User::factory()->create([ - 'updated_at' => $lastLogin, - ]); - - // When we visit the list of users - $response = $this->get('/user/all'); - - // Then we should see the last login date for that user - $response->assertSeeText($lastLogin->diffForHumans(null, true)); - } - - /** @test */ - public function admin_can_see_users_last_login_time_on_filtered_results(): void - { - // Given we have a user who has just logged in - $lastLogin = new \Carbon\Carbon(); - $user = User::factory()->create([ - 'updated_at' => $lastLogin, - ]); - - // When we visit the list of users and filter by that user - $response = $this->get('/user/all/search?name='.$user->name); - - // Then we should see the last login date for that user - $response->assertSeeText($lastLogin->diffForHumans(null, true)); - } - - /** @test */ - public function admin_can_sort_user_list_by_last_login(): void - { - // Given we have users with various login times - $dateOfMostRecentLogin = new Carbon(); - $dateOfLeastRecentLogin = new Carbon('-1 year'); - - $userWithMostRecentLogin = User::factory()->create([ - 'last_login_at' => $dateOfMostRecentLogin, - ]); - $otherUsers = User::factory()->count(42)->create([ - 'last_login_at' => new Carbon('-1 month'), - ]); - $userWithLeastRecentLogin = User::factory()->create([ - 'last_login_at' => $dateOfLeastRecentLogin, - ]); - - // Try this a few times because we might be unlucky and hit a second boundary. - $found = false; - - for ($i = 0; $i < 10; $i++) { - // When we visit the list of users and sort it by last login descending - $response = $this->get('/user/all/search?sort=last_login_at&sortdir=desc'); - $datestr = $dateOfMostRecentLogin->diffForHumans(null, true); - - if (stripos($response->getContent(), $datestr) !== false) { - // Then the first result is the most recent login - $found = true; - break; - } - } - - $this->assertTrue($found); - - // When we visit the list of users and sort it by last login descending - $response = $this->get('/user/all/search?sort=last_login_at&sortdir=asc'); - - // Then the first result is the most recent login - $found = false; - - for ($i = 0; $i < 10; $i++) { - // When we visit the list of users and sort it by last login descending - $response = $this->get('/user/all/search?sort=last_login_at&sortdir=asc'); - $datestr = $dateOfLeastRecentLogin->diffForHumans(null, true); - - if (stripos($response->getContent(), $datestr) !== false) { - // Then the first result is the most recent login - $found = true; - break; - } - } - - $this->assertTrue($found); + $response->assertRedirect('/user/forbidden'); } } diff --git a/tests/Feature/Users/APIv2UsersTest.php b/tests/Feature/Users/APIv2UsersTest.php new file mode 100644 index 000000000..29f3b2b3b --- /dev/null +++ b/tests/Feature/Users/APIv2UsersTest.php @@ -0,0 +1,125 @@ +withExceptionHandling(); + } + + public function testListUsersRequiresAuth(): void + { + $response = $this->getJson('/api/v2/users'); + $response->assertStatus(401); + } + + public function testListUsersForbiddenForNonAdmin(): void + { + $user = User::factory()->restarter()->create(['api_token' => 'r1']); + $this->actingAs($user); + + $response = $this->getJson('/api/v2/users?api_token=r1'); + $response->assertStatus(403); + } + + public function testListUsersAsAdminReturnsPaginated(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + User::factory()->host()->create(['name' => 'Aaa Host']); + User::factory()->restarter()->create(['name' => 'Bbb Restarter']); + $this->actingAs($admin); + + $response = $this->getJson('/api/v2/users?api_token=admin1'); + $response->assertSuccessful(); + + $payload = $response->json(); + $this->assertArrayHasKey('data', $payload); + $this->assertArrayHasKey('meta', $payload); + $this->assertArrayHasKey('current_page', $payload['meta']); + $this->assertArrayHasKey('total', $payload['meta']); + $this->assertIsArray($payload['data']); + + foreach ($payload['data'] as $row) { + $this->assertArrayHasKey('id', $row); + $this->assertArrayHasKey('name', $row); + $this->assertArrayHasKey('email', $row); + $this->assertArrayHasKey('role', $row); + $this->assertArrayHasKey('role_name', $row); + $this->assertArrayHasKey('location', $row); + $this->assertArrayHasKey('country', $row); + $this->assertArrayHasKey('country_name', $row); + $this->assertArrayHasKey('groups_count', $row); + $this->assertArrayHasKey('created_at', $row); + $this->assertArrayHasKey('last_login_at', $row); + } + } + + public function testListUsersFilterByName(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + User::factory()->host()->create(['name' => 'Alpha Person']); + User::factory()->host()->create(['name' => 'Bravo Person']); + $this->actingAs($admin); + + $response = $this->getJson('/api/v2/users?api_token=admin1&name=Alpha'); + $response->assertSuccessful(); + + $names = array_column($response->json('data'), 'name'); + $this->assertContains('Alpha Person', $names); + $this->assertNotContains('Bravo Person', $names); + } + + public function testListUsersFilterByEmail(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + User::factory()->host()->create(['email' => 'unique-target@example.com']); + User::factory()->host()->create(['email' => 'noise@example.com']); + $this->actingAs($admin); + + $response = $this->getJson('/api/v2/users?api_token=admin1&email=unique-target'); + $response->assertSuccessful(); + + $emails = array_column($response->json('data'), 'email'); + $this->assertContains('unique-target@example.com', $emails); + $this->assertNotContains('noise@example.com', $emails); + } + + public function testListUsersFilterByRole(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $host = User::factory()->host()->create(); + User::factory()->restarter()->create(); + $this->actingAs($admin); + + $response = $this->getJson('/api/v2/users?api_token=admin1&role=' . Role::HOST); + $response->assertSuccessful(); + + $roles = array_unique(array_column($response->json('data'), 'role')); + $this->assertEquals([Role::HOST], array_values($roles)); + } + + public function testListUsersSortByNameAsc(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + User::factory()->host()->create(['name' => 'Zeta Sortable']); + User::factory()->host()->create(['name' => 'Alpha Sortable']); + $this->actingAs($admin); + + $response = $this->getJson('/api/v2/users?api_token=admin1&name=Sortable&sort=name&sortdir=asc'); + $response->assertSuccessful(); + + $names = array_column($response->json('data'), 'name'); + $alphaIdx = array_search('Alpha Sortable', $names); + $zetaIdx = array_search('Zeta Sortable', $names); + $this->assertNotFalse($alphaIdx); + $this->assertNotFalse($zetaIdx); + $this->assertLessThan($zetaIdx, $alphaIdx); + } +} diff --git a/tests/Feature/Users/UserAdminTest.php b/tests/Feature/Users/UserAdminTest.php index 1ecddc0db..12123ac83 100644 --- a/tests/Feature/Users/UserAdminTest.php +++ b/tests/Feature/Users/UserAdminTest.php @@ -43,10 +43,11 @@ public function testUsersPage($role, $cansee): void $response = $this->get('/user/all'); if ($cansee) { - $response->assertSee('Create new user'); - $response->assertSee($admin->name); + $response->assertSuccessful(); + $response->assertSee('assertDontSee('Create new user'); + // Non-admin is redirected away from the page in the new Vue flow. + $response->assertRedirect('/user/forbidden'); } } diff --git a/tests/Integration/admin-users.test.js b/tests/Integration/admin-users.test.js new file mode 100644 index 000000000..e463d5e7b --- /dev/null +++ b/tests/Integration/admin-users.test.js @@ -0,0 +1,27 @@ +/** + * Smoke-test coverage for /user/all migrated from a server-rendered blade + * to the UsersPage Vue SPA backed by GET /api/v2/users. + */ + +const { test } = require('./fixtures') +const { expect } = require('@playwright/test') +const { login } = require('./utils') + +const ADMIN_EMAIL = 'jane@bloggs.net' +const RESTARTER_EMAIL = 'host@test.net' +const PASSWORD = 'passw0rd' + +test('Admin /user/all renders the UsersPage Vue SPA', async ({ page, baseURL }) => { + test.slow() + await login(page, baseURL, ADMIN_EMAIL, PASSWORD) + await page.goto(baseURL + '/user/all') + await expect(page.locator('[data-testid="users-table"]')).toBeVisible({ timeout: 10000 }) + await expect(page.locator('[data-testid="users-filter-submit"]')).toBeVisible() +}) + +test('Non-admin is redirected away from /user/all', async ({ page, baseURL }) => { + test.slow() + await login(page, baseURL, RESTARTER_EMAIL, PASSWORD) + await page.goto(baseURL + '/user/all') + await expect(page).toHaveURL(/\/user\/forbidden$/) +})