From 0508f60214230cced99f6223be2fa04ef799ffe3 Mon Sep 17 00:00:00 2001 From: sebastian marcet Date: Wed, 15 Apr 2026 18:51:35 -0300 Subject: [PATCH 1/3] fix(ui): unverified user (#122) * fix(ui): unverified user * fix(auth): null check before calling getId() in verifyEmail Split combined null/inactive check into separate guards to prevent "Call to a member function getId() on null" when no user is found for the verification token. --- app/Services/Auth/UserService.php | 7 ++++++- resources/js/login/login.js | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/Services/Auth/UserService.php b/app/Services/Auth/UserService.php index 4910a878..f4df159d 100644 --- a/app/Services/Auth/UserService.php +++ b/app/Services/Auth/UserService.php @@ -225,7 +225,12 @@ public function verifyEmail(string $token): User return $this->tx_service->transaction(function () use ($token) { $user = $this->user_repository->getByVerificationEmailToken($token); - if (is_null($user) || !$user->isActive()) { + if (is_null($user)) { + Log::warning("UserService::verifyEmail no user found for token"); + throw new EntityNotFoundException(); + } + + if (!$user->isActive()) { Log::warning(sprintf("UserService::verifyEmail user with id %s is not active", $user->getId())); throw new EntityNotFoundException(); } diff --git a/resources/js/login/login.js b/resources/js/login/login.js index c55a4b91..a5f0ea06 100644 --- a/resources/js/login/login.js +++ b/resources/js/login/login.js @@ -617,7 +617,7 @@ class LoginPage extends React.Component { }, function () { //Once the state is updated, it's now possible to trigger emitOtpAction. //No need to wait for the component to update. - if (!response.has_password_set) { + if (!response.has_password_set && response.is_verified !== false) { this.emitOtpAction(); } }); From 5076ed2e1f846f8797b6a58d58ed83c7ec5942b5 Mon Sep 17 00:00:00 2001 From: smarcet Date: Tue, 23 Jun 2026 13:53:12 -0300 Subject: [PATCH 2/3] fix: allow CORS on .well-known/openid-configuration endpoint --- config/cors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/cors.php b/config/cors.php index 0a699749..8a9a2596 100644 --- a/config/cors.php +++ b/config/cors.php @@ -15,7 +15,7 @@ | */ - 'paths' => ['api/*', 'oauth2/*'], + 'paths' => ['api/*', 'oauth2/*', '.well-known/openid-configuration'], 'allowed_methods' => [ 'POST', From ea7c515b31b48de9aa1a66518b9af7c04929a9eb Mon Sep 17 00:00:00 2001 From: sebastian marcet Date: Wed, 22 Jul 2026 10:27:37 -0300 Subject: [PATCH 3/3] Feature/add expands fields relations to get user by id v2 (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add fields/relations passthrough to GET /api/v1|v2/users/{id} Wires SerializerUtils::getExpand()/getFields()/getRelations() into both by-ID user endpoints (matching the ParametrizedGetAll pattern already used by list endpoints), and makes PrivateUserSerializer treat `groups` as a gated relation (mirroring ApiScopeGroupSerializer) instead of appending it unconditionally. Unblocks ftn-docsnsklz PR #76 (SDS ftn-attendee-native-realtime-comms.md, D38): attendee-networking-api's IDP user_updated consumer needs to fetch exactly fields=public_profile_allow_chat_with_me,first_name,last_name,pic without pulling the rest of the private profile. v1 get() also migrated from manual try/catch to the shared processRequest() wrapper for consistent error handling with getV2(). Also fixes an unrelated pre-existing bug in UserLoginTurnstileTest where $testEmail/$testPassword being typed as non-nullable string caused a TypeError before the test's own markTestSkipped() logic could run when TEST_USER_EMAIL/TEST_USER_PASSWORD are unset. 9 new tests added to OAuth2UserApiTest; full suite green (180 tests, 0 failures, 7 legitimate skips). * docs(plan): mark spec as VERIFIED * chore: untrack plan file — plan docs are working artifacts, not committed * fix: document v1 expand param and add v1 default-shape regression test Address deep-review findings on PR #148: - OA\Get annotation for GET /api/v1/users/{id} was missing the `expand` parameter even though get() already passed SerializerUtils::getExpand() into serialize() -- the capability was live but undocumented. - v1 lacked a default-shape regression test symmetric with testGetUserByIdV2WithNoParamsReturnsSameShapeAsBefore, so the fields/relations passthrough's backward compatibility on v1 was only exercised incidentally. --- .../Api/OAuth2/OAuth2UserApiController.php | 102 ++++++- app/ModelSerializers/Auth/UserSerializer.php | 20 +- tests/OAuth2UserApiTest.php | 282 ++++++++++++++++++ 3 files changed, 385 insertions(+), 19 deletions(-) diff --git a/app/Http/Controllers/Api/OAuth2/OAuth2UserApiController.php b/app/Http/Controllers/Api/OAuth2/OAuth2UserApiController.php index 20356e1d..850103e4 100644 --- a/app/Http/Controllers/Api/OAuth2/OAuth2UserApiController.php +++ b/app/Http/Controllers/Api/OAuth2/OAuth2UserApiController.php @@ -19,6 +19,7 @@ use App\Http\Exceptions\HTTP403ForbiddenException; use App\Http\Utils\HTMLCleaner; use App\ModelSerializers\SerializerRegistry; +use App\ModelSerializers\SerializerUtils; use Auth\Repositories\IUserRepository; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request as LaravelRequest; @@ -315,24 +316,83 @@ public function userInfo() * @param $id * @return \Illuminate\Http\JsonResponse|mixed */ + #[OA\Get( + path: '/api/v1/users/{id}', + summary: 'Get a user by ID', + operationId: 'getUserById', + tags: ['Users'], + security: [ + [ + 'OAuth2UserSecurity' => [ + IUserScopes::ReadAll, + ] + ], + ], + parameters: [ + new OA\Parameter( + name: 'id', + description: 'User ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ), + new OA\Parameter( + name: 'expand', + description: 'Expand relations: groups', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'fields', + description: 'Comma-separated list of scalar fields to return, e.g. first_name,last_name,pic,public_profile_allow_chat_with_me', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'relations', + description: 'Comma-separated list of relations to include (supported: groups)', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: HttpResponse::HTTP_OK, + description: 'OK', + content: new OA\JsonContent(ref: '#/components/schemas/User') + ), + new OA\Response( + response: HttpResponse::HTTP_NOT_FOUND, + description: 'Not Found' + ), + new OA\Response( + response: HttpResponse::HTTP_PRECONDITION_FAILED, + description: 'Validation Failed' + ), + new OA\Response( + response: HttpResponse::HTTP_INTERNAL_SERVER_ERROR, + description: 'Server Error' + ), + ] + )] public function get($id) { - try { + return $this->processRequest(function () use ($id) { $user = $this->repository->getById(intval($id)); if (is_null($user)) { throw new EntityNotFoundException(); } - return $this->ok(SerializerRegistry::getInstance()->getSerializer($user, SerializerRegistry::SerializerType_Private)->serialize()); - } catch (ValidationException $ex1) { - Log::warning($ex1); - return $this->error412($ex1->getMessages()); - } catch (EntityNotFoundException $ex2) { - Log::warning($ex2); - return $this->error404(['message' => $ex2->getMessage()]); - } catch (Exception $ex) { - Log::error($ex); - return $this->error500($ex); - } + return $this->ok(SerializerRegistry::getInstance() + ->getSerializer($user, SerializerRegistry::SerializerType_Private) + ->serialize( + SerializerUtils::getExpand(), + SerializerUtils::getFields(), + SerializerUtils::getRelations() + )); + }); } /** @@ -368,6 +428,20 @@ public function get($id) required: false, schema: new OA\Schema(type: 'string') ), + new OA\Parameter( + name: 'fields', + description: 'Comma-separated list of scalar fields to return, e.g. first_name,last_name,pic,public_profile_allow_chat_with_me', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'relations', + description: 'Comma-separated list of relations to include (supported: groups)', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string') + ), ], responses: [ new OA\Response( @@ -399,7 +473,9 @@ public function getV2($id) return $this->ok(SerializerRegistry::getInstance() ->getSerializer($user, SerializerRegistry::SerializerType_Private) ->serialize( - Request::input("expand", '') + SerializerUtils::getExpand(), + SerializerUtils::getFields(), + SerializerUtils::getRelations() )); }); } diff --git a/app/ModelSerializers/Auth/UserSerializer.php b/app/ModelSerializers/Auth/UserSerializer.php index 9c29d113..dd4ef935 100644 --- a/app/ModelSerializers/Auth/UserSerializer.php +++ b/app/ModelSerializers/Auth/UserSerializer.php @@ -46,6 +46,10 @@ final class PublicUserSerializer extends BaseUserSerializer final class PrivateUserSerializer extends BaseUserSerializer { + protected static $allowed_relations = [ + 'groups', + ]; + protected static $array_mappings = [ 'Email' => 'email:json_string', 'Identifier' => 'identifier:json_string', @@ -96,15 +100,19 @@ public function serialize($expand = null, array $fields = [], array $relations = $user = $this->object; if (!$user instanceof User) return []; + if (!count($relations)) $relations = $this->getAllowedRelations(); + $values = parent::serialize($expand, $fields, $relations, $params); - $groups = []; - foreach ($user->getGroups() as $group) { - if (!$group instanceof Group) continue; - $groups[] = $group->getSlug(); - } + if (in_array('groups', $relations)) { + $groups = []; + foreach ($user->getGroups() as $group) { + if (!$group instanceof Group) continue; + $groups[] = $group->getSlug(); + } - $values['groups'] = $groups; + $values['groups'] = $groups; + } if (!empty($expand)) { $exp_expand = explode(',', $expand); diff --git a/tests/OAuth2UserApiTest.php b/tests/OAuth2UserApiTest.php index a0633ba1..0f24ac88 100644 --- a/tests/OAuth2UserApiTest.php +++ b/tests/OAuth2UserApiTest.php @@ -95,6 +95,128 @@ public function testGetUserByIdV1(){ $this->assertNotNull($user); } + public function testGetUserByIdV1WithFieldsPassthrough(){ + $repo = EntityManager::getRepository(User::class); + $user = $repo->getAll()[0]; + + $params = [ + 'id' => $user->getId(), + 'fields' => 'public_profile_allow_chat_with_me,first_name,last_name,pic' + ]; + + $response = $this->action( + "GET", + "Api\OAuth2\OAuth2UserApiController@get", + $params, + [], + [], + [], + array("HTTP_Authorization" => " Bearer " .$this->access_token)); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + $payload = json_decode($content, true); + $this->assertNotNull($payload); + // no `relations` override sent -> the default relation ('groups') still applies. + $this->assertEqualsCanonicalizing( + ['public_profile_allow_chat_with_me', 'first_name', 'last_name', 'pic', 'groups'], + array_keys($payload) + ); + } + + public function testGetUserByIdV1WithFieldsAndRelationsNone(){ + $repo = EntityManager::getRepository(User::class); + $user = $repo->getAll()[0]; + + $params = [ + 'id' => $user->getId(), + 'fields' => 'public_profile_allow_chat_with_me,first_name,last_name,pic', + // 'none' is an arbitrary non-matching relation name used only to prove the override + // suppresses the default -- there is no dedicated empty-relations syntax for this endpoint. + 'relations' => 'none' + ]; + + $response = $this->action( + "GET", + "Api\OAuth2\OAuth2UserApiController@get", + $params, + [], + [], + [], + array("HTTP_Authorization" => " Bearer " .$this->access_token)); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + $payload = json_decode($content, true); + $this->assertNotNull($payload); + $this->assertEqualsCanonicalizing( + ['public_profile_allow_chat_with_me', 'first_name', 'last_name', 'pic'], + array_keys($payload) + ); + } + + public function testGetUserByIdV1NotFoundStillReturns404(){ + $params = [ + 'id' => PHP_INT_MAX, + ]; + + $response = $this->action( + "GET", + "Api\OAuth2\OAuth2UserApiController@get", + $params, + [], + [], + [], + array("HTTP_Authorization" => " Bearer " .$this->access_token)); + + $this->assertResponseStatus(404); + $payload = json_decode($response->getContent(), true); + $this->assertArrayHasKey('message', $payload); + } + + public function testGetUserByIdV1WithNoParamsReturnsSameShapeAsBefore(){ + $repo = EntityManager::getRepository(User::class); + $user = $repo->getAll()[0]; + + // No 'fields', 'relations', or 'expand' at all -- proves the fields/relations + // passthrough is purely additive and does not change the v1 response for a + // caller that sends none of the new params. + $params = [ + 'id' => $user->getId(), + ]; + + $response = $this->action( + "GET", + "Api\OAuth2\OAuth2UserApiController@get", + $params, + [], + [], + [], + array("HTTP_Authorization" => " Bearer " .$this->access_token)); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + $payload = json_decode($content, true); + $this->assertNotNull($payload); + // Full private field set, unchanged from before this PR -- id/timestamps/PII/groups all present. + $this->assertEqualsCanonicalizing( + [ + 'active', 'address1', 'address2', 'bio', 'birthday', 'city', 'company', + 'country_iso_code', 'created_at', 'email', 'email_verified', 'first_name', + 'gender', 'gender_specify', 'github_user', 'groups', 'id', 'identifier', + 'irc', 'job_title', 'language', 'last_login_date', 'last_name', + 'linked_in_profile', 'phone_number', 'pic', 'post_code', + 'public_profile_allow_chat_with_me', 'public_profile_show_bio', + 'public_profile_show_email', 'public_profile_show_fullname', + 'public_profile_show_photo', 'public_profile_show_social_media_info', + 'public_profile_show_telephone_number', 'second_email', 'spam_type', + 'state', 'statement_of_interest', 'third_email', 'twitter_name', + 'updated_at', 'wechat_user', + ], + array_keys($payload) + ); + } + public function testGetUserByIdV2(){ $repo = EntityManager::getRepository(User::class); $user = $repo->getAll()[0]; @@ -119,6 +241,166 @@ public function testGetUserByIdV2(){ $this->assertNotNull($user); } + public function testGetUserByIdV2WithNoParamsReturnsSameShapeAsBefore(){ + $repo = EntityManager::getRepository(User::class); + $user = $repo->getAll()[0]; + + // No 'fields', 'relations', or 'expand' at all -- proves the fields/relations + // passthrough is purely additive and does not change the response for a caller + // that sends none of the new params. + $params = [ + 'id' => $user->getId(), + ]; + + $response = $this->action( + "GET", + "Api\OAuth2\OAuth2UserApiController@getV2", + $params, + [], + [], + [], + array("HTTP_Authorization" => " Bearer " .$this->access_token_service_app_type)); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + $payload = json_decode($content, true); + $this->assertNotNull($payload); + // Full private field set, unchanged from before this plan -- id/timestamps/PII/groups all present. + $this->assertEqualsCanonicalizing( + [ + 'active', 'address1', 'address2', 'bio', 'birthday', 'city', 'company', + 'country_iso_code', 'created_at', 'email', 'email_verified', 'first_name', + 'gender', 'gender_specify', 'github_user', 'groups', 'id', 'identifier', + 'irc', 'job_title', 'language', 'last_login_date', 'last_name', + 'linked_in_profile', 'phone_number', 'pic', 'post_code', + 'public_profile_allow_chat_with_me', 'public_profile_show_bio', + 'public_profile_show_email', 'public_profile_show_fullname', + 'public_profile_show_photo', 'public_profile_show_social_media_info', + 'public_profile_show_telephone_number', 'second_email', 'spam_type', + 'state', 'statement_of_interest', 'third_email', 'twitter_name', + 'updated_at', 'wechat_user', + ], + array_keys($payload) + ); + } + + public function testGetUserByIdV2WithFieldsPassthrough(){ + $repo = EntityManager::getRepository(User::class); + $user = $repo->getAll()[0]; + + $params = [ + 'id' => $user->getId(), + 'fields' => 'public_profile_allow_chat_with_me,first_name,last_name,pic' + ]; + + $response = $this->action( + "GET", + "Api\OAuth2\OAuth2UserApiController@getV2", + $params, + [], + [], + [], + array("HTTP_Authorization" => " Bearer " .$this->access_token_service_app_type)); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + $payload = json_decode($content, true); + $this->assertNotNull($payload); + // no `relations` override sent -> the default relation ('groups') still applies. + $this->assertEqualsCanonicalizing( + ['public_profile_allow_chat_with_me', 'first_name', 'last_name', 'pic', 'groups'], + array_keys($payload) + ); + } + + public function testGetUserByIdV2WithFieldsAndRelationsNone(){ + $repo = EntityManager::getRepository(User::class); + $user = $repo->getAll()[0]; + + $params = [ + 'id' => $user->getId(), + 'fields' => 'public_profile_allow_chat_with_me,first_name,last_name,pic', + // 'none' is an arbitrary non-matching relation name used only to prove the override + // suppresses the default -- there is no dedicated empty-relations syntax for this endpoint. + 'relations' => 'none' + ]; + + $response = $this->action( + "GET", + "Api\OAuth2\OAuth2UserApiController@getV2", + $params, + [], + [], + [], + array("HTTP_Authorization" => " Bearer " .$this->access_token_service_app_type)); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + $payload = json_decode($content, true); + $this->assertNotNull($payload); + $this->assertEqualsCanonicalizing( + ['public_profile_allow_chat_with_me', 'first_name', 'last_name', 'pic'], + array_keys($payload) + ); + } + + public function testGetUserByIdV2ExpandOverridesRelationsNone(){ + $repo = EntityManager::getRepository(User::class); + $user = $repo->getAll()[0]; + + $params = [ + 'id' => $user->getId(), + 'fields' => 'public_profile_allow_chat_with_me,first_name,last_name,pic', + // same non-matching sentinel as testGetUserByIdV2WithFieldsAndRelationsNone -- proves + // `expand` still forces `groups` back in even though `relations` omitted it. + 'relations' => 'none', + 'expand' => 'groups' + ]; + + $response = $this->action( + "GET", + "Api\OAuth2\OAuth2UserApiController@getV2", + $params, + [], + [], + [], + array("HTTP_Authorization" => " Bearer " .$this->access_token_service_app_type)); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + $payload = json_decode($content, true); + $this->assertNotNull($payload); + $this->assertArrayHasKey('groups', $payload); + $this->assertIsArray($payload['groups']); + } + + public function testGetUserByIdV2RelationsGroupsWithExpand(){ + $repo = EntityManager::getRepository(User::class); + $user = $repo->getAll()[0]; + + $params = [ + 'id' => $user->getId(), + 'relations' => 'groups', + 'expand' => 'groups' + ]; + + $response = $this->action( + "GET", + "Api\OAuth2\OAuth2UserApiController@getV2", + $params, + [], + [], + [], + array("HTTP_Authorization" => " Bearer " .$this->access_token_service_app_type)); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + $payload = json_decode($content, true); + $this->assertNotNull($payload); + $this->assertArrayHasKey('groups', $payload); + $this->assertIsArray($payload['groups']); + } + public function testGetInfoCORS(){ $response = $this->action("OPTIONS", "Api\OAuth2\OAuth2UserApiController@me", [],