From 28a6ab8fa20a4a922e7552dc285a69230bbd6979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pep=20Villalonga=20Amor=C3=B3s?= Date: Mon, 26 Oct 2026 18:29:06 +0100 Subject: [PATCH 1/2] feat: add missing profile form fields to user, person and organization (#260) * feat: add missing profile form fields to user, person and organization * fix: address review on user profile fields --- migrations/Version20261015093422.php | 42 ++++++ .../User/OrganizationApiResource.php | 26 ++++ src/ApiResource/User/PersonApiResource.php | 39 ++++++ src/ApiResource/User/UserApiResource.php | 41 ++++++ src/Dto/User/UserUpdationDto.php | 38 ++++++ src/Entity/Theme.php | 3 + src/Entity/User/Organization.php | 54 ++++++++ src/Entity/User/OrganizationLegalForm.php | 29 ++++ src/Entity/User/Person.php | 51 +++++++ src/Entity/User/PersonGender.php | 17 +++ src/Entity/User/TaxIdType.php | 18 +++ src/Entity/User/User.php | 122 +++++++++++++++++ src/Entity/User/UserReferralSource.php | 27 ++++ tests/Entity/UserApi/UpdateTest.php | 128 ++++++++++++++++++ 14 files changed, 635 insertions(+) create mode 100644 migrations/Version20261015093422.php create mode 100644 src/Entity/User/OrganizationLegalForm.php create mode 100644 src/Entity/User/PersonGender.php create mode 100644 src/Entity/User/TaxIdType.php create mode 100644 src/Entity/User/UserReferralSource.php create mode 100644 tests/Entity/UserApi/UpdateTest.php diff --git a/migrations/Version20261015093422.php b/migrations/Version20261015093422.php new file mode 100644 index 0000000..c3caf12 --- /dev/null +++ b/migrations/Version20261015093422.php @@ -0,0 +1,42 @@ +addSql('CREATE TABLE user_category (user_id INT NOT NULL, category_id INT NOT NULL, INDEX IDX_E6C1FDC1A76ED395 (user_id), INDEX IDX_E6C1FDC112469DE2 (category_id), PRIMARY KEY(user_id, category_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE user_category ADD CONSTRAINT FK_E6C1FDC1A76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE user_category ADD CONSTRAINT FK_E6C1FDC112469DE2 FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE CASCADE'); + $this->addSql('CREATE TABLE user_theme (user_id INT NOT NULL, theme_id INT NOT NULL, INDEX IDX_75B71C50A76ED395 (user_id), INDEX IDX_75B71C5059027487 (theme_id), PRIMARY KEY(user_id, theme_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE user_theme ADD CONSTRAINT FK_75B71C50A76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE user_theme ADD CONSTRAINT FK_75B71C5059027487 FOREIGN KEY (theme_id) REFERENCES theme (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE user ADD share_location TINYINT(1) DEFAULT 0 NOT NULL, ADD is_anonymous TINYINT(1) DEFAULT 0 NOT NULL, ADD referral_source VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE user_organization ADD tax_id_type VARCHAR(255) DEFAULT NULL, ADD legal_form VARCHAR(255) DEFAULT NULL, ADD public_entity TINYINT(1) DEFAULT 0 NOT NULL'); + $this->addSql('ALTER TABLE user_person ADD tax_id_type VARCHAR(255) DEFAULT NULL, ADD birth_year INT DEFAULT NULL, ADD gender VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE user_category DROP FOREIGN KEY FK_E6C1FDC1A76ED395'); + $this->addSql('ALTER TABLE user_category DROP FOREIGN KEY FK_E6C1FDC112469DE2'); + $this->addSql('DROP TABLE user_category'); + $this->addSql('ALTER TABLE user_theme DROP FOREIGN KEY FK_75B71C50A76ED395'); + $this->addSql('ALTER TABLE user_theme DROP FOREIGN KEY FK_75B71C5059027487'); + $this->addSql('DROP TABLE user_theme'); + $this->addSql('ALTER TABLE user_person DROP tax_id_type, DROP birth_year, DROP gender'); + $this->addSql('ALTER TABLE user DROP share_location, DROP is_anonymous, DROP referral_source'); + $this->addSql('ALTER TABLE user_organization DROP tax_id_type, DROP legal_form, DROP public_entity'); + } +} diff --git a/src/ApiResource/User/OrganizationApiResource.php b/src/ApiResource/User/OrganizationApiResource.php index 585f7ce..c233a8a 100644 --- a/src/ApiResource/User/OrganizationApiResource.php +++ b/src/ApiResource/User/OrganizationApiResource.php @@ -5,6 +5,8 @@ namespace App\ApiResource\User; use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\Metadata as API; use App\Entity\User\Organization; +use App\Entity\User\OrganizationLegalForm; +use App\Entity\User\TaxIdType; use App\State\ApiResourceStateProcessor; use App\State\ApiResourceStateProvider; use Symfony\Component\Validator\Constraints as Assert; @@ -73,4 +75,28 @@ class OrganizationApiResource securityPostDenormalize: 'is_granted("ORGANIZATION_EDIT", previous_object)', )] public string $businessName; + + /** + * The kind of document the tax ID belongs to. + */ + #[API\ApiProperty( + securityPostDenormalize: 'is_granted("ORGANIZATION_EDIT", previous_object)', + )] + public ?TaxIdType $taxIdType = null; + + /** + * Legal form under which the organization is constituted. + */ + #[API\ApiProperty( + securityPostDenormalize: 'is_granted("ORGANIZATION_EDIT", previous_object)', + )] + public ?OrganizationLegalForm $legalForm = null; + + /** + * Is the organization a public entity? e.g: city councils, public universities, etc. + */ + #[API\ApiProperty( + securityPostDenormalize: 'is_granted("ORGANIZATION_EDIT", previous_object)', + )] + public bool $publicEntity = false; } diff --git a/src/ApiResource/User/PersonApiResource.php b/src/ApiResource/User/PersonApiResource.php index 4020b04..55a850c 100644 --- a/src/ApiResource/User/PersonApiResource.php +++ b/src/ApiResource/User/PersonApiResource.php @@ -5,9 +5,12 @@ namespace App\ApiResource\User; use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\Metadata as API; use App\Entity\User\Person; +use App\Entity\User\PersonGender; +use App\Entity\User\TaxIdType; use App\State\ApiResourceStateProcessor; use App\State\ApiResourceStateProvider; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; /** * Most times a real person is behind a User account, @@ -69,4 +72,40 @@ class PersonApiResource securityPostDenormalize: 'is_granted("PERSON_EDIT", previous_object)', )] public string $lastName; + + /** + * The kind of document the tax ID belongs to. + */ + #[API\ApiProperty( + securityPostDenormalize: 'is_granted("PERSON_EDIT", previous_object)', + )] + public ?TaxIdType $taxIdType = null; + + /** + * Year of birth of the person. e.g: 1990. + */ + #[Assert\GreaterThanOrEqual(1900)] + #[Assert\Callback([self::class, 'validateBirthYear'])] + #[API\ApiProperty( + securityPostDenormalize: 'is_granted("PERSON_EDIT", previous_object)', + )] + public ?int $birthYear = null; + + /** + * Gender of the person. + */ + #[API\ApiProperty( + securityPostDenormalize: 'is_granted("PERSON_EDIT", previous_object)', + )] + public ?PersonGender $gender = null; + + /** + * `Range` only resolves relative time strings (e.g. 'now') against DateTime values, not int years. + */ + public static function validateBirthYear(?int $birthYear, ExecutionContextInterface $context): void + { + if ($birthYear !== null && $birthYear > (int) date('Y')) { + $context->buildViolation('The birth year cannot be in the future.')->addViolation(); + } + } } diff --git a/src/ApiResource/User/UserApiResource.php b/src/ApiResource/User/UserApiResource.php index 99926cc..c06ce96 100644 --- a/src/ApiResource/User/UserApiResource.php +++ b/src/ApiResource/User/UserApiResource.php @@ -8,12 +8,15 @@ use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\Metadata as API; use App\ApiResource\Accounting\AccountingApiResource; +use App\ApiResource\CategoryApiResource; +use App\ApiResource\ThemeApiResource; use App\ApiResource\TimestampedCreationApiResource; use App\ApiResource\TimestampedUpdationApiResource; use App\Dto\User\UserSignupDto; use App\Dto\User\UserUpdationDto; use App\Entity\Territory; use App\Entity\User\User; +use App\Entity\User\UserReferralSource; use App\Entity\User\UserType; use App\Filter\EncryptedSearchFilter; use App\Filter\InArrayFilter; @@ -191,4 +194,42 @@ class UserApiResource * Free-form rich text description for the User. */ public string $description; + + /** + * Has the User agreed to share their location with the platform? + * Used to generate anonymous statistics and impact indicators, never disclosed to third parties.\ + * Only available to themselves and platform administrators. + */ + #[API\ApiProperty(security: 'is_granted("USER_EDIT", object)')] + public bool $shareLocation = false; + + /** + * Does this User wish to have their personal data to remain undisclosed to 3rd parties?\ + * Only available to themselves and platform administrators. + */ + #[API\ApiProperty(security: 'is_granted("USER_EDIT", object)')] + public bool $isAnonymous = false; + + /** + * How the User got to know about the platform.\ + * Only available to themselves and platform administrators. + */ + #[API\ApiProperty(security: 'is_granted("USER_EDIT", object)')] + public ?UserReferralSource $referralSource = null; + + /** + * The Categories the User is interested in. + * + * @var CategoryApiResource[] + */ + #[API\ApiProperty(writableLink: false)] + public array $preferredCategories = []; + + /** + * The Themes the User is interested in. + * + * @var ThemeApiResource[] + */ + #[API\ApiProperty(writableLink: false)] + public array $preferredThemes = []; } diff --git a/src/Dto/User/UserUpdationDto.php b/src/Dto/User/UserUpdationDto.php index 191db84..17190af 100644 --- a/src/Dto/User/UserUpdationDto.php +++ b/src/Dto/User/UserUpdationDto.php @@ -3,9 +3,13 @@ namespace App\Dto\User; use ApiPlatform\Metadata as API; +use App\ApiResource\CategoryApiResource; +use App\ApiResource\ThemeApiResource; use App\ApiResource\User\UserApiResource; +use App\Dto\CategorizedInputDtoTrait; use App\Entity\Territory; use App\Entity\User\User; +use App\Entity\User\UserReferralSource; use App\Entity\User\UserType; use App\Mapping\Transformer\RawLinksMapTransformer; use App\Validator\NotExisting; @@ -15,6 +19,8 @@ use Symfony\Component\Validator\Constraints as Assert; final class UserUpdationDto { + use CategorizedInputDtoTrait; + /** * A unique, non white space, byte-safe string identifier for this User. */ @@ -75,4 +81,36 @@ final class UserUpdationDto * Free-form rich text description for the User. */ public string $description; + + /** + * Has the User agreed to share their location with the platform? + * Used to generate anonymous statistics and impact indicators, never disclosed to third parties. + */ + public bool $shareLocation; + + /** + * Does this User wish to have their personal data to remain undisclosed to 3rd parties? + */ + public bool $isAnonymous; + + /** + * How the User got to know about the platform. + */ + public ?UserReferralSource $referralSource; + + /** + * The Categories the User is interested in. + * + * @var CategoryApiResource[] + */ + #[API\ApiProperty(writableLink: false, openapiContext: self::CATEGORIES_OPENAPI_CONTEXT)] + public array $preferredCategories; + + /** + * The Themes the User is interested in. + * + * @var ThemeApiResource[] + */ + #[API\ApiProperty(writableLink: false, openapiContext: self::THEMES_OPENAPI_CONTEXT)] + public array $preferredThemes; } diff --git a/src/Entity/Theme.php b/src/Entity/Theme.php index bb81cf9..717d118 100644 --- a/src/Entity/Theme.php +++ b/src/Entity/Theme.php @@ -2,10 +2,13 @@ namespace App\Entity; +use App\Mapping\Provider\EntityMapProvider; use App\Repository\ThemeRepository; +use AutoMapper\Attribute\MapProvider; use Doctrine\ORM\Mapping as ORM; use Gedmo\Mapping\Annotation as Gedmo; +#[MapProvider(EntityMapProvider::class)] #[ORM\Entity(repositoryClass: ThemeRepository::class)] class Theme { diff --git a/src/Entity/User/Organization.php b/src/Entity/User/Organization.php index d680a6f..3d1a138 100644 --- a/src/Entity/User/Organization.php +++ b/src/Entity/User/Organization.php @@ -30,6 +30,24 @@ class Organization #[BlindIndex(sourceField: 'taxId', normalizer: BlindIndex::NORMALIZE_UPPERCASE)] private ?string $taxIdLookup = null; + /** + * The kind of document the tax ID belongs to. + */ + #[ORM\Column(nullable: true, enumType: TaxIdType::class)] + private ?TaxIdType $taxIdType = null; + + /** + * Legal form under which the organization is constituted. + */ + #[ORM\Column(nullable: true, enumType: OrganizationLegalForm::class)] + private ?OrganizationLegalForm $legalForm = null; + + /** + * Is the organization a public entity? + */ + #[ORM\Column(options: ['default' => false])] + private bool $publicEntity = false; + /** * Organization legal name before government, * as it appears on legal documents issued by or for this organization. @@ -110,4 +128,40 @@ class Organization return $this; } + + public function getTaxIdType(): ?TaxIdType + { + return $this->taxIdType; + } + + public function setTaxIdType(?TaxIdType $taxIdType): static + { + $this->taxIdType = $taxIdType; + + return $this; + } + + public function getLegalForm(): ?OrganizationLegalForm + { + return $this->legalForm; + } + + public function setLegalForm(?OrganizationLegalForm $legalForm): static + { + $this->legalForm = $legalForm; + + return $this; + } + + public function isPublicEntity(): bool + { + return $this->publicEntity; + } + + public function setPublicEntity(bool $publicEntity): static + { + $this->publicEntity = $publicEntity; + + return $this; + } } diff --git a/src/Entity/User/OrganizationLegalForm.php b/src/Entity/User/OrganizationLegalForm.php new file mode 100644 index 0000000..6d875af --- /dev/null +++ b/src/Entity/User/OrganizationLegalForm.php @@ -0,0 +1,29 @@ +taxIdType; + } + + public function setTaxIdType(?TaxIdType $taxIdType): static + { + $this->taxIdType = $taxIdType; + + return $this; + } + + public function getBirthYear(): ?int + { + return $this->birthYear; + } + + public function setBirthYear(?int $birthYear): static + { + $this->birthYear = $birthYear; + + return $this; + } + + public function getGender(): ?PersonGender + { + return $this->gender; + } + + public function setGender(?PersonGender $gender): static + { + $this->gender = $gender; + + return $this; + } } diff --git a/src/Entity/User/PersonGender.php b/src/Entity/User/PersonGender.php new file mode 100644 index 0000000..db04e3f --- /dev/null +++ b/src/Entity/User/PersonGender.php @@ -0,0 +1,17 @@ + false])] + private bool $shareLocation = false; + + /** + * Does this User wish to have their personal data to remain undisclosed to 3rd parties? + */ + #[ORM\Column(options: ['default' => false])] + private bool $isAnonymous = false; + + /** + * How the User got to know about the platform. + */ + #[ORM\Column(nullable: true, enumType: UserReferralSource::class)] + private ?UserReferralSource $referralSource = null; + + /** + * The Categories the User is interested in. + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Category::class)] + private Collection $preferredCategories; + + /** + * The Themes the User is interested in. + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Theme::class)] + private Collection $preferredThemes; + public function __construct() { $this->accounting = Accounting::of($this); @@ -141,6 +177,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account $this->emailConfirmed = false; $this->active = false; $this->addresses = new ArrayCollection(); + $this->preferredCategories = new ArrayCollection(); + $this->preferredThemes = new ArrayCollection(); } public function getId(): ?int @@ -431,4 +469,88 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account return $this; } + + public function isShareLocation(): bool + { + return $this->shareLocation; + } + + public function setShareLocation(bool $shareLocation): static + { + $this->shareLocation = $shareLocation; + + return $this; + } + + public function isAnonymous(): bool + { + return $this->isAnonymous; + } + + public function setIsAnonymous(bool $isAnonymous): static + { + $this->isAnonymous = $isAnonymous; + + return $this; + } + + public function getReferralSource(): ?UserReferralSource + { + return $this->referralSource; + } + + public function setReferralSource(?UserReferralSource $referralSource): static + { + $this->referralSource = $referralSource; + + return $this; + } + + /** + * @return Collection + */ + public function getPreferredCategories(): Collection + { + return $this->preferredCategories; + } + + public function addPreferredCategory(Category $category): static + { + if (!$this->preferredCategories->contains($category)) { + $this->preferredCategories->add($category); + } + + return $this; + } + + public function removePreferredCategory(Category $category): static + { + $this->preferredCategories->removeElement($category); + + return $this; + } + + /** + * @return Collection + */ + public function getPreferredThemes(): Collection + { + return $this->preferredThemes; + } + + public function addPreferredTheme(Theme $theme): static + { + if (!$this->preferredThemes->contains($theme)) { + $this->preferredThemes->add($theme); + } + + return $this; + } + + public function removePreferredTheme(Theme $theme): static + { + $this->preferredThemes->removeElement($theme); + + return $this; + } } diff --git a/src/Entity/User/UserReferralSource.php b/src/Entity/User/UserReferralSource.php new file mode 100644 index 0000000..b25d51f --- /dev/null +++ b/src/Entity/User/UserReferralSource.php @@ -0,0 +1,27 @@ + 'test']); + $theme = persist(Theme::class, ['name' => 'test']); + $uri = \sprintf('/v4/users/%d', $user->getId()); + + $this->request('PATCH', $uri, [ + 'headers' => $this->withAuthHeader($user), + 'json' => [ + 'shareLocation' => true, + 'isAnonymous' => true, + 'referralSource' => 'social_media', + 'preferredCategories' => [\sprintf('/v4/categories/%s', $category->getId())], + 'preferredThemes' => [\sprintf('/v4/themes/%s', $theme->getId())], + ], + ], Response::HTTP_OK); + + $this->assertJsonContains([ + 'shareLocation' => true, + 'isAnonymous' => true, + 'referralSource' => 'social_media', + ]); + + $data = $this->request('GET', $uri, ['headers' => $this->withAuthHeader($user)])->toArray(); + $this->assertCount(1, $data['preferredCategories']); + $this->assertCount(1, $data['preferredThemes']); + } + + public function testPrivateFieldsHiddenFromOthers(): void + { + $user = TestUser::get(); + $other = UserFactory::createOne(['handle' => 'other_user', 'email' => 'other@example.com']); + + $data = $this->request('GET', \sprintf('/v4/users/%d', $user->getId()), [ + 'headers' => $this->withAuthHeader($other), + ], Response::HTTP_OK)->toArray(); + + $this->assertArrayNotHasKey('shareLocation', $data); + $this->assertArrayNotHasKey('isAnonymous', $data); + $this->assertArrayNotHasKey('referralSource', $data); + } + + public function testUpdatePersonFields(): void + { + $user = TestUser::get(); + + $this->request('PATCH', \sprintf('/v4/users/%d/person', $user->getId()), [ + 'headers' => $this->withAuthHeader($user), + 'json' => [ + 'taxIdType' => 'passport', + 'birthYear' => 1990, + 'gender' => 'non_binary', + ], + ], Response::HTTP_OK); + + $this->assertJsonContains([ + 'taxIdType' => 'passport', + 'birthYear' => 1990, + 'gender' => 'non_binary', + ]); + } + + public function testUpdatePersonInvalidBirthYear(): void + { + $user = TestUser::get(); + + $this->request('PATCH', \sprintf('/v4/users/%d/person', $user->getId()), [ + 'headers' => $this->withAuthHeader($user), + 'json' => ['birthYear' => 1800], + ], Response::HTTP_UNPROCESSABLE_ENTITY); + + $this->request('PATCH', \sprintf('/v4/users/%d/person', $user->getId()), [ + 'headers' => $this->withAuthHeader($user), + 'json' => ['birthYear' => (int) date('Y') + 1], + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + public function testUpdateOrganizationFields(): void + { + $user = TestUser::get(); + + $this->request('PATCH', \sprintf('/v4/users/%d', $user->getId()), [ + 'headers' => $this->withAuthHeader($user), + 'json' => ['type' => UserType::Organization->value], + ], Response::HTTP_OK); + + $this->request('PATCH', \sprintf('/v4/users/%d/organization', $user->getId()), [ + 'headers' => $this->withAuthHeader($user), + 'json' => [ + 'taxId' => 'G12345678', + 'legalName' => 'Test Association', + 'taxIdType' => 'national_id', + 'legalForm' => 'association', + 'publicEntity' => true, + ], + ], Response::HTTP_OK); + + $this->assertJsonContains([ + 'taxIdType' => 'national_id', + 'legalForm' => 'association', + 'publicEntity' => true, + ]); + } +} From 037b0ae5c9f1edd99ab49a85f4b028156e3e300c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Subiabre=20Garc=C3=ADa?= Date: Mon, 26 Oct 2026 18:29:25 +0100 Subject: [PATCH 2/2] fix: hard to reach password reset (#263) * feat: add link to password reset in login * fix: improve display of known OAuth2 clients --- src/Controller/OAuthClientsController.php | 4 +++ templates/clients/clients.html.twig | 31 ++++++++++++++++------- templates/security/login.html.twig | 4 +++ translations/messages.en.yaml | 4 ++- 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/Controller/OAuthClientsController.php b/src/Controller/OAuthClientsController.php index f4e1948..85a8d0b 100644 --- a/src/Controller/OAuthClientsController.php +++ b/src/Controller/OAuthClientsController.php @@ -48,6 +48,10 @@ final class OAuthClientsController extends AbstractController private function getBaseUri(string|array $url): string { + if (empty($url)) { + return ''; + } + if (is_array($url)) { $url = $url[0]; } diff --git a/templates/clients/clients.html.twig b/templates/clients/clients.html.twig index f377ca7..403ff91 100644 --- a/templates/clients/clients.html.twig +++ b/templates/clients/clients.html.twig @@ -6,20 +6,33 @@ {% block body %}
- +
{% endblock %} diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig index 79dabc0..3d57bf2 100644 --- a/templates/security/login.html.twig +++ b/templates/security/login.html.twig @@ -26,6 +26,10 @@ + + {{ 'login.password.forgotten' | trans }} + +
diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 9e61336..1d802a5 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -10,6 +10,7 @@ login: password: label: Password placeholder: TrustNo1 + forgotten: Did you forget your password? rememberme: Remember me submit: Sign in oauth: 'You can also login via:' @@ -49,7 +50,8 @@ password_reset: clients: title: Goteo sites - description: This site is an instance of the Goteo v4 API, these clients are connected to it. + description: This site is an instance of the Goteo v4 API, these clients are connected to it. + noClients: No active clients registered footer: legal: