From bfcda5e946389e4a8852671891bdd54903b0354a Mon Sep 17 00:00:00 2001 From: Nikolas <60836654+NikolasGoncalves@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:12:09 -0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(identity,panel-app):=20permite=20alter?= =?UTF-8?q?a=C3=A7=C3=A3o=20de=20@=20(username)=20(#502)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...006_add_username_fields_to_users_table.php | 25 +++ .../Auth/Actions/EnrichUserOnFirstLogin.php | 3 +- .../src/Auth/Actions/MergeAccountsAction.php | 9 +- .../src/User/Actions/UpdateUsername.php | 68 +++++++ .../src/User/Exceptions/UsernameException.php | 39 ++++ app-modules/identity/src/User/Models/User.php | 13 ++ .../User/ValueObjects/UsernameValidator.php | 90 +++++++++ .../Auth/EnrichUserOnFirstLoginTest.php | 16 ++ .../Feature/Auth/MergeAccountsActionTest.php | 39 ++++ .../tests/Feature/User/UpdateUsernameTest.php | 176 ++++++++++++++++++ .../Actions/ImportDiscordProfileAction.php | 3 +- .../MergeDuplicateDiscordUserAction.php | 2 +- .../MergeDuplicateDiscordProfilesCommand.php | 4 +- .../Feature/ETL/ImportDiscordProfileTest.php | 25 +++ .../ETL/MergeDuplicateDiscordProfilesTest.php | 13 ++ app-modules/panel-app/lang/en/profile.php | 15 ++ app-modules/panel-app/lang/pt_BR/profile.php | 15 ++ .../components/profile-media-header.blade.php | 26 ++- .../components/profile-preview-card.blade.php | 12 +- .../username-admin-warning.blade.php | 13 ++ .../views/components/username-rules.blade.php | 28 +++ .../panel-app/src/Pages/ProfilePage.php | 79 ++++++++ .../tests/Feature/ProfilePageTest.php | 37 ++++ config/he4rt.php | 2 + 24 files changed, 743 insertions(+), 9 deletions(-) create mode 100644 app-modules/identity/database/migrations/2026_09_02_195006_add_username_fields_to_users_table.php create mode 100644 app-modules/identity/src/User/Actions/UpdateUsername.php create mode 100644 app-modules/identity/src/User/Exceptions/UsernameException.php create mode 100644 app-modules/identity/src/User/ValueObjects/UsernameValidator.php create mode 100644 app-modules/identity/tests/Feature/User/UpdateUsernameTest.php create mode 100644 app-modules/panel-app/resources/views/components/username-admin-warning.blade.php create mode 100644 app-modules/panel-app/resources/views/components/username-rules.blade.php diff --git a/app-modules/identity/database/migrations/2026_09_02_195006_add_username_fields_to_users_table.php b/app-modules/identity/database/migrations/2026_09_02_195006_add_username_fields_to_users_table.php new file mode 100644 index 000000000..4d325aade --- /dev/null +++ b/app-modules/identity/database/migrations/2026_09_02_195006_add_username_fields_to_users_table.php @@ -0,0 +1,25 @@ +timestampTz('username_manually_set_at')->nullable()->after('username'); + $table->timestampTz('username_updated_at')->nullable()->after('username_manually_set_at'); + }); + } + + public function down(): void + { + Schema::table('users', static function (Blueprint $table): void { + $table->dropColumn(['username_manually_set_at', 'username_updated_at']); + }); + } +}; diff --git a/app-modules/identity/src/Auth/Actions/EnrichUserOnFirstLogin.php b/app-modules/identity/src/Auth/Actions/EnrichUserOnFirstLogin.php index dd53302d5..877eaaaec 100644 --- a/app-modules/identity/src/Auth/Actions/EnrichUserOnFirstLogin.php +++ b/app-modules/identity/src/Auth/Actions/EnrichUserOnFirstLogin.php @@ -27,7 +27,8 @@ public function execute(User $user, OAuthUserDTO $oauthUser): User $updates['name'] = $oauthUser->name; } - $canUpdateUsername = $oauthUser->username !== $user->username + $canUpdateUsername = $user->username_manually_set_at === null + && $oauthUser->username !== $user->username && !User::query() ->where('username', $oauthUser->username) ->where('id', '!=', $user->id) diff --git a/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php b/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php index f270932e8..125cfe8f1 100644 --- a/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php +++ b/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php @@ -51,7 +51,8 @@ private function enrichOldUser(User $source, User $target): void $updates['name'] = $source->name; } - $canUpdateUsername = $source->username !== $target->username + $canUpdateUsername = $target->username_manually_set_at === null + && $source->username !== $target->username && !User::query() ->where('username', $source->username) ->where('id', '!=', $target->id) @@ -59,12 +60,16 @@ private function enrichOldUser(User $source, User $target): void if ($canUpdateUsername) { $updates['username'] = $source->username; + if ($source->username_manually_set_at !== null) { + $updates['username_manually_set_at'] = $source->username_manually_set_at; + $updates['username_updated_at'] = $source->username_updated_at; + } } try { DB::transaction(fn () => $target->update($updates)); } catch (UniqueConstraintViolationException) { - unset($updates['username']); + unset($updates['username'], $updates['username_manually_set_at'], $updates['username_updated_at']); $target->update($updates); } } diff --git a/app-modules/identity/src/User/Actions/UpdateUsername.php b/app-modules/identity/src/User/Actions/UpdateUsername.php new file mode 100644 index 000000000..437ff6656 --- /dev/null +++ b/app-modules/identity/src/User/Actions/UpdateUsername.php @@ -0,0 +1,68 @@ +username)) { + throw UsernameException::sameAsCurrent(); + } + + if ($user->username_updated_at !== null && !$user->isAdmin()) { + $cooldownDays = (int) config('he4rt.username_cooldown_days', 7); + $availableAt = $user->username_updated_at->copy()->addDays($cooldownDays); + + if (now()->lessThan($availableAt)) { + throw UsernameException::cooldownActive($availableAt); + } + } + + $isTaken = User::query() + ->whereRaw('LOWER(username) = ?', [$normalized]) + ->where('id', '!=', $user->id) + ->exists(); + + if ($isTaken) { + throw UsernameException::alreadyTaken($normalized); + } + + try { + DB::transaction(function () use ($user, $normalized): void { + $user->update([ + 'username' => $normalized, + 'username_updated_at' => now(), + 'username_manually_set_at' => $user->username_manually_set_at ?? now(), + ]); + }); + } catch (UniqueConstraintViolationException) { + throw UsernameException::alreadyTaken($normalized); + } + + return $user->refresh(); + } + + /** + * Alias for handle + * + * @throws UsernameException + */ + public function execute(User $user, string $newUsername): User + { + return $this->handle($user, $newUsername); + } +} diff --git a/app-modules/identity/src/User/Exceptions/UsernameException.php b/app-modules/identity/src/User/Exceptions/UsernameException.php new file mode 100644 index 000000000..9cf15bc3a --- /dev/null +++ b/app-modules/identity/src/User/Exceptions/UsernameException.php @@ -0,0 +1,39 @@ +timezone($timezone)->format('d/m/Y H:i'); + + return new self(sprintf('Você só poderá alterar seu @ novamente a partir de %s.', $formattedDate), 422); + } + + public static function sameAsCurrent(): self + { + return new self('O novo @ deve ser diferente do atual.', 422); + } + + public static function reservedUsername(string $username): self + { + return new self(sprintf('O @%s está reservado para o sistema e não pode ser utilizado.', $username), 422); + } +} diff --git a/app-modules/identity/src/User/Models/User.php b/app-modules/identity/src/User/Models/User.php index da61c76ec..f028aaa4e 100644 --- a/app-modules/identity/src/User/Models/User.php +++ b/app-modules/identity/src/User/Models/User.php @@ -44,6 +44,8 @@ * @property CarbonInterface|null $suspended_until * @property CarbonInterface|null $banned_at * @property CarbonInterface|null $first_login_at + * @property CarbonInterface|null $username_manually_set_at + * @property CarbonInterface|null $username_updated_at * @property string|null $remember_token * @property CarbonInterface|null $created_at * @property CarbonInterface|null $updated_at @@ -71,6 +73,15 @@ public function isSuperAdmin(): bool return $this->hasRole(UserRole::SuperAdmin); } + public function isAdmin(): bool + { + $admins = array_filter(explode(',', (string) config('he4rt.admins', ''))); + + return $this->isSuperAdmin() + || in_array($this->username, $admins, strict: true) + || in_array($this->id, $admins, strict: true); + } + /** * @return MorphMany */ @@ -154,6 +165,8 @@ protected function casts(): array 'suspended_until' => 'datetime', 'banned_at' => 'datetime', 'first_login_at' => 'datetime', + 'username_manually_set_at' => 'datetime', + 'username_updated_at' => 'datetime', ]; } diff --git a/app-modules/identity/src/User/ValueObjects/UsernameValidator.php b/app-modules/identity/src/User/ValueObjects/UsernameValidator.php new file mode 100644 index 000000000..ce833429e --- /dev/null +++ b/app-modules/identity/src/User/ValueObjects/UsernameValidator.php @@ -0,0 +1,90 @@ + + */ + public const array RESERVED_USERNAMES = [ + 'admin', + 'administrator', + 'system', + 'root', + 'he4rt', + 'heart', + 'he4rtdevs', + 'mod', + 'moderator', + 'staff', + 'support', + 'help', + 'api', + 'bot', + 'null', + 'undefined', + 'anonymous', + 'everyone', + 'here', + ]; + + /** + * @throws UsernameException + */ + public static function normalizeAndValidate(string $username, ?User $user = null): string + { + $normalized = mb_strtolower(mb_trim($username)); + $length = mb_strlen($normalized); + + if ($length < 2 || $length > 32) { + throw UsernameException::invalidFormat('o tamanho deve ter entre 2 e 32 caracteres'); + } + + if (!preg_match('/^[a-z0-9._-]+$/', $normalized)) { + throw UsernameException::invalidFormat('apenas letras, números, sublinhado (_), hífen (-) e ponto (.) são permitidos'); + } + + if (!preg_match('/^[a-z0-9]/', $normalized) || !preg_match('/[a-z0-9]$/', $normalized)) { + throw UsernameException::invalidFormat('não pode começar ou terminar com caracteres especiais'); + } + + if (preg_match('/[._-]{2,}/', $normalized)) { + throw UsernameException::invalidFormat('não pode conter caracteres especiais consecutivos'); + } + + if (in_array($normalized, self::RESERVED_USERNAMES, strict: true)) { + throw UsernameException::reservedUsername($normalized); + } + + $adminsConfig = (string) config('he4rt.admins', ''); + $adminEntries = array_filter(array_map(trim(...), explode(',', $adminsConfig))); + $adminNames = array_map(strtolower(...), $adminEntries); + + if (in_array($normalized, $adminNames, strict: true)) { + $isOwnAdminUsername = $user instanceof User && ( + mb_strtolower($user->username) === $normalized + || in_array(mb_strtolower($user->id), $adminNames, strict: true) + ); + + if (!$isOwnAdminUsername) { + throw UsernameException::reservedUsername($normalized); + } + } + + return $normalized; + } + + /** + * @throws UsernameException + */ + public static function validate(string $username, ?User $user = null): string + { + return self::normalizeAndValidate($username, $user); + } +} diff --git a/app-modules/identity/tests/Feature/Auth/EnrichUserOnFirstLoginTest.php b/app-modules/identity/tests/Feature/Auth/EnrichUserOnFirstLoginTest.php index 9f5bec7a9..fe5f96637 100644 --- a/app-modules/identity/tests/Feature/Auth/EnrichUserOnFirstLoginTest.php +++ b/app-modules/identity/tests/Feature/Auth/EnrichUserOnFirstLoginTest.php @@ -113,3 +113,19 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self expect($result->email)->toBe('existing@example.com') ->and($result->first_login_at)->not->toBeNull(); }); + +test('does not overwrite username when username_manually_set_at is present', function (): void { + $user = User::factory()->create([ + 'username' => 'custom-username', + 'first_login_at' => null, + 'username_manually_set_at' => now()->subDay(), + ]); + + $action = new EnrichUserOnFirstLogin(); + $result = $action->execute( + $user, + makeOAuthUserForEnrich(username: 'oauth-username'), + ); + + expect($result->username)->toBe('custom-username'); +}); diff --git a/app-modules/identity/tests/Feature/Auth/MergeAccountsActionTest.php b/app-modules/identity/tests/Feature/Auth/MergeAccountsActionTest.php index 29d138baf..609c64ab9 100644 --- a/app-modules/identity/tests/Feature/Auth/MergeAccountsActionTest.php +++ b/app-modules/identity/tests/Feature/Auth/MergeAccountsActionTest.php @@ -159,3 +159,42 @@ $oldUser->refresh(); expect($oldUser->username)->toBe('old-user'); }); + +test('does not overwrite old user username when old user has username_manually_set_at', function (): void { + $oldUser = User::factory()->create([ + 'username' => 'manual-old-username', + 'first_login_at' => null, + 'username_manually_set_at' => now()->subMonth(), + ]); + $currentUser = User::factory()->create([ + 'username' => 'current-username', + 'name' => 'Current Name', + ]); + + $action = new MergeAccountsAction(); + $action->execute($currentUser, $oldUser); + + expect($oldUser->refresh()->username)->toBe('manual-old-username'); +}); + +test('copies username_manually_set_at when currentUser has manual username', function (): void { + $manualTimestamp = now()->subDays(10); + $oldUser = User::factory()->create([ + 'username' => 'legacy-user', + 'first_login_at' => null, + 'username_manually_set_at' => null, + ]); + $currentUser = User::factory()->create([ + 'username' => 'manual-current-username', + 'name' => 'Manual Current', + 'username_manually_set_at' => $manualTimestamp, + 'username_updated_at' => $manualTimestamp, + ]); + + $action = new MergeAccountsAction(); + $action->execute($currentUser, $oldUser); + + $oldUser->refresh(); + expect($oldUser->username)->toBe('manual-current-username') + ->and($oldUser->username_manually_set_at->toIso8601String())->toBe($manualTimestamp->toIso8601String()); +}); diff --git a/app-modules/identity/tests/Feature/User/UpdateUsernameTest.php b/app-modules/identity/tests/Feature/User/UpdateUsernameTest.php new file mode 100644 index 000000000..d3b60c1c7 --- /dev/null +++ b/app-modules/identity/tests/Feature/User/UpdateUsernameTest.php @@ -0,0 +1,176 @@ +set('he4rt.username_cooldown_days', 7); + config()->set('app.display_timezone', 'America/Sao_Paulo'); +}); + +test('updates username with lowercase normalization and sets timestamps', function (): void { + $user = User::factory()->create([ + 'username' => 'originaluser', + 'username_manually_set_at' => null, + 'username_updated_at' => null, + ]); + + $action = resolve(UpdateUsername::class); + $updated = $action->handle($user, 'NewHandle_99'); + + expect($updated->username)->toBe('newhandle_99') + ->and($updated->username_manually_set_at)->not->toBeNull() + ->and($updated->username_updated_at)->not->toBeNull(); +}); + +test('preserves username_manually_set_at on subsequent updates after cooldown', function (): void { + $initialManualDate = now()->subDays(10); + $user = User::factory()->create([ + 'username' => 'firsthandle', + 'username_manually_set_at' => $initialManualDate, + 'username_updated_at' => $initialManualDate, + ]); + + $action = resolve(UpdateUsername::class); + $updated = $action->handle($user, 'secondhandle'); + + expect($updated->username)->toBe('secondhandle') + ->and($updated->username_manually_set_at->toIso8601String())->toBe($initialManualDate->toIso8601String()) + ->and($updated->username_updated_at->greaterThan($initialManualDate))->toBeTrue(); +}); + +test('throws exception when cooldown is still active', function (): void { + Date::setTestNow('2026-09-08 12:00:00'); + + $user = User::factory()->create([ + 'username' => 'currentuser', + 'username_manually_set_at' => now()->subDays(3), + 'username_updated_at' => now()->subDays(3), + ]); + + $action = resolve(UpdateUsername::class); + + expect(fn () => $action->handle($user, 'newhandle')) + ->toThrow(UsernameException::class, '12/09/2026 09:00'); // 2026-09-08 12:00 UTC - 3 days + 7 days = 2026-09-12 12:00 UTC = 09:00 America/Sao_Paulo + + Date::setTestNow(); +}); + +test('allows update after 7 days cooldown has elapsed', function (): void { + $user = User::factory()->create([ + 'username' => 'currentuser', + 'username_manually_set_at' => now()->subDays(8), + 'username_updated_at' => now()->subDays(8), + ]); + + $action = resolve(UpdateUsername::class); + $updated = $action->handle($user, 'newhandle'); + + expect($updated->username)->toBe('newhandle'); +}); + +test('throws exception when new username is same as current username', function (): void { + $user = User::factory()->create([ + 'username' => 'myhandle', + 'username_manually_set_at' => null, + 'username_updated_at' => null, + ]); + + $action = resolve(UpdateUsername::class); + + expect(fn () => $action->handle($user, 'MYHANDLE')) + ->toThrow(UsernameException::class, 'O novo @ deve ser diferente do atual.'); + + expect($user->fresh()->username_updated_at)->toBeNull(); +}); + +test('throws exception when username is already taken case-insensitively', function (): void { + User::factory()->create(['username' => 'takenhandle']); + $user = User::factory()->create(['username' => 'myhandle']); + + $action = resolve(UpdateUsername::class); + + expect(fn () => $action->handle($user, 'TAKENHANDLE')) + ->toThrow(UsernameException::class, 'O @takenhandle já está em uso por outro membro.'); +}); + +test('rejects invalid username formats', function (string $invalidUsername): void { + $user = User::factory()->create(['username' => 'validuser']); + $action = resolve(UpdateUsername::class); + + expect(fn () => $action->handle($user, $invalidUsername)) + ->toThrow(UsernameException::class); +})->with([ + 'single char' => 'a', + 'too long' => str_repeat('a', 33), + 'starts with dot' => '.username', + 'ends with dot' => 'username.', + 'starts with dash' => '-username', + 'ends with dash' => 'username-', + 'starts with underscore' => '_username', + 'ends with underscore' => 'username_', + 'consecutive dots' => 'user..name', + 'consecutive dashes' => 'user--name', + 'consecutive underscores' => 'user__name', + 'consecutive mixed' => 'user.-name', + 'invalid chars space' => 'user name', + 'invalid chars at symbol' => 'user@name', + 'invalid chars exclamation' => 'user!name', +]); + +test('rejects reserved system usernames', function (string $reservedName): void { + $user = User::factory()->create(['username' => 'normaluser']); + $action = resolve(UpdateUsername::class); + + expect(fn () => $action->handle($user, $reservedName)) + ->toThrow(UsernameException::class, "O @{$reservedName} está reservado para o sistema e não pode ser utilizado."); +})->with([ + 'admin', + 'root', + 'he4rt', + 'system', + 'support', + 'api', +]); + +test('prevents ordinary users from claiming admin username from config', function (): void { + config()->set('he4rt.admins', 'adminmaster,he4rtfounder'); + + $user = User::factory()->create(['username' => 'regularuser']); + $action = resolve(UpdateUsername::class); + + expect(fn () => $action->handle($user, 'adminmaster')) + ->toThrow(UsernameException::class, 'O @adminmaster está reservado para o sistema e não pode ser utilizado.'); +}); + +test('allows admin user to change their username', function (): void { + config()->set('he4rt.admins', 'danielhe4rt'); + + $admin = User::factory()->superAdmin()->create(['username' => 'danielhe4rt']); + expect($admin->isAdmin())->toBeTrue(); + + $action = resolve(UpdateUsername::class); + $updated = $action->handle($admin, 'daniel.reis'); + + expect($updated->username)->toBe('daniel.reis'); +}); + +test('admin is exempt from 7 days cooldown and can change username repeatedly', function (): void { + config()->set('he4rt.admins', 'danielhe4rt'); + + $admin = User::factory()->superAdmin()->create([ + 'username' => 'danielhe4rt', + 'username_updated_at' => now()->subMinutes(5), + ]); + + expect($admin->isAdmin())->toBeTrue(); + + $action = resolve(UpdateUsername::class); + $updated = $action->handle($admin, 'daniel.reis'); + + expect($updated->username)->toBe('daniel.reis'); +}); diff --git a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordProfileAction.php b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordProfileAction.php index 19d23cff1..73f3d2178 100644 --- a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordProfileAction.php +++ b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordProfileAction.php @@ -79,7 +79,8 @@ private function syncUserAttributes(User $user, DiscordProfileDTO $dto): User { $changes = []; - $usernameChanged = $user->username !== $dto->username + $usernameChanged = $user->username_manually_set_at === null + && $user->username !== $dto->username && !User::query() ->where('username', $dto->username) ->where('id', '!=', $user->id) diff --git a/app-modules/integration-discord/src/ETL/Actions/MergeDuplicateDiscordUserAction.php b/app-modules/integration-discord/src/ETL/Actions/MergeDuplicateDiscordUserAction.php index b59271ac3..26769425d 100644 --- a/app-modules/integration-discord/src/ETL/Actions/MergeDuplicateDiscordUserAction.php +++ b/app-modules/integration-discord/src/ETL/Actions/MergeDuplicateDiscordUserAction.php @@ -33,7 +33,7 @@ public function handle(User $oldUser, User $newUser, string $targetUsername): ar $newUser->delete(); - if ($oldUser->username !== $targetUsername) { + if ($oldUser->username_manually_set_at === null && $oldUser->username !== $targetUsername) { $taken = User::query() ->where('username', $targetUsername) ->where('id', '!=', $oldUser->id) diff --git a/app-modules/integration-discord/src/ETL/Console/MergeDuplicateDiscordProfilesCommand.php b/app-modules/integration-discord/src/ETL/Console/MergeDuplicateDiscordProfilesCommand.php index 60a882ba1..165a7a375 100644 --- a/app-modules/integration-discord/src/ETL/Console/MergeDuplicateDiscordProfilesCommand.php +++ b/app-modules/integration-discord/src/ETL/Console/MergeDuplicateDiscordProfilesCommand.php @@ -44,7 +44,9 @@ public function handle(MergeDuplicateDiscordUserAction $merge): int private function runFromPairsFile(MergeDuplicateDiscordUserAction $merge, string $path): int { - $absolute = str_starts_with($path, '/') ? $path : base_path($path); + $absolute = is_file($path) + ? $path + : (str_starts_with($path, '/') || preg_match('/^[A-Za-z]:[\\\\\/]/', $path) ? $path : base_path($path)); if (!is_file($absolute)) { error('Arquivo de pares nao encontrado: '.$absolute); diff --git a/app-modules/integration-discord/tests/Feature/ETL/ImportDiscordProfileTest.php b/app-modules/integration-discord/tests/Feature/ETL/ImportDiscordProfileTest.php index faee8af63..887d08c8d 100644 --- a/app-modules/integration-discord/tests/Feature/ETL/ImportDiscordProfileTest.php +++ b/app-modules/integration-discord/tests/Feature/ETL/ImportDiscordProfileTest.php @@ -422,3 +422,28 @@ function discordProfile(array $overrides = []): array expect((string) $identity->model_id)->toBe((string) $portalUser->id); }); + +test('it does not overwrite username when user has username_manually_set_at', function (): void { + $action = resolve(ImportDiscordProfileAction::class); + + $user = User::factory()->create([ + 'username' => 'manual_custom_handle', + 'username_manually_set_at' => now()->subDays(5), + ]); + + ExternalIdentity::factory()->create([ + 'provider' => IdentityProvider::Discord, + 'external_account_id' => '999999', + 'model_type' => (new User)->getMorphClass(), + 'model_id' => $user->id, + ]); + + $action->handle( + DiscordProfileDTO::fromDump(discordProfile([ + 'user' => ['id' => '999999', 'username' => 'new_discord_handle'], + 'connected_accounts' => [], + ])), + ); + + expect($user->fresh()->username)->toBe('manual_custom_handle'); +}); diff --git a/app-modules/integration-discord/tests/Feature/ETL/MergeDuplicateDiscordProfilesTest.php b/app-modules/integration-discord/tests/Feature/ETL/MergeDuplicateDiscordProfilesTest.php index fbce00adf..dda376cf4 100644 --- a/app-modules/integration-discord/tests/Feature/ETL/MergeDuplicateDiscordProfilesTest.php +++ b/app-modules/integration-discord/tests/Feature/ETL/MergeDuplicateDiscordProfilesTest.php @@ -216,3 +216,16 @@ function makeOrphan(string $username, ?string $createdAt = '2025-08-10 00:00:00' expect($remaining)->toBe(1); }); + +test('does not overwrite oldUser username when username_manually_set_at is set', function (): void { + $orphan = User::factory()->create([ + 'username' => 'custom-set-name', + 'username_manually_set_at' => now()->subMonth(), + 'created_at' => '2025-08-10 00:00:00', + ]); + [$newUser] = makeImportedDup('49615312957476864', '_tats', ['legacy_username' => 'custom-set-name']); + + Artisan::call('discord:merge-duplicate-profiles', ['--from-date' => '2026-05-01']); + + expect(User::query()->find($orphan->id)->username)->toBe('custom-set-name'); +}); diff --git a/app-modules/panel-app/lang/en/profile.php b/app-modules/panel-app/lang/en/profile.php index 5b63b02b2..5f99df23e 100644 --- a/app-modules/panel-app/lang/en/profile.php +++ b/app-modules/panel-app/lang/en/profile.php @@ -47,6 +47,7 @@ 'willing_to_relocate' => 'Willing to relocate', 'has_disability' => 'Person with a disability', 'employment_types' => 'Employment type', + 'username' => 'Username (@)', ], 'placeholders' => [ @@ -67,6 +68,16 @@ 'has_disability' => 'Sensitive information — used only for affirmative-action roles.', 'expected_salary' => 'Monthly amount in BRL. Private, used only in proposals.', 'skills' => 'Pick your skills and set your level and years of experience for each.', + 'username_modal_description' => 'Choose a new unique handle for your community profile.', + 'username_cooldown_notice' => 'You will only be able to change your @ again after 7 days.', + 'username_rules_title' => 'Rules for your @', + 'username_rule_length' => 'Between 2 and 32 characters long (automatically converted to lowercase).', + 'username_rule_characters' => 'Allowed: letters (a-z), numbers (0-9), dots (.), hyphens (-), and underscores (_).', + 'username_rule_format' => 'Must start and end with a letter or number (no symbols at edges).', + 'username_rule_consecutive' => 'No consecutive special characters allowed (e.g. .. or --).', + 'username_rule_cooldown' => 'Regular members can change once every 7 days (admins are exempt).', + 'admin_warning_title' => 'Administrator Notice', + 'admin_warning_body' => 'If your production admin permissions rely on HE4RT_ADMINS_USERNAMES on the server, remember to update the environment variable after changing your handle.', ], 'validation' => [ @@ -82,11 +93,13 @@ 'add_skill' => 'Add skill', 'change_avatar' => 'Change photo', 'change_cover' => 'Change cover', + 'change_username' => 'Change @', 'adjust_avatar' => 'Adjust photo framing', 'adjust_cover' => 'Adjust framing', 'save_framing' => 'Save framing', 'save_avatar' => 'Save photo', 'save_cover' => 'Save cover', + 'save_username' => 'Save @', ], 'notifications' => [ @@ -94,6 +107,8 @@ 'avatar_updated' => 'Photo updated successfully!', 'cover_updated' => 'Cover updated successfully!', 'framing_updated' => 'Framing saved!', + 'username_updated' => 'Username updated successfully!', + 'username_error' => 'Could not update @', 'no_profile' => 'Profile not found for this tenant.', ], diff --git a/app-modules/panel-app/lang/pt_BR/profile.php b/app-modules/panel-app/lang/pt_BR/profile.php index d021f7918..720739eb4 100644 --- a/app-modules/panel-app/lang/pt_BR/profile.php +++ b/app-modules/panel-app/lang/pt_BR/profile.php @@ -47,6 +47,7 @@ 'willing_to_relocate' => 'Disposto a mudar de cidade', 'has_disability' => 'Pessoa com deficiência (PcD)', 'employment_types' => 'Tipo de contratação', + 'username' => 'Nome de usuário (@)', ], 'placeholders' => [ @@ -67,6 +68,16 @@ 'expected_salary' => 'Valor mensal em R$. Informação privada, usada apenas em propostas.', 'skills' => 'Selecione suas skills e informe o nível e os anos de experiência em cada uma.', 'city' => 'Se sua cidade não estiver na listagem, pesquise.', + 'username_modal_description' => 'Escolha um novo identificador único para seu perfil na comunidade.', + 'username_cooldown_notice' => 'Você só poderá alterar seu @ novamente após 7 dias.', + 'username_rules_title' => 'Regras para o @', + 'username_rule_length' => 'Entre 2 e 32 caracteres (convertido automaticamente para minúsculo).', + 'username_rule_characters' => 'Permitido letras (a-z), números (0-9), ponto (.), hífen (-) e sublinhado (_).', + 'username_rule_format' => 'Deve começar e terminar com letra ou número (sem símbolos nas extremidades).', + 'username_rule_consecutive' => 'Proibido símbolos especiais consecutivos (ex: .. ou --).', + 'username_rule_cooldown' => 'Usuários comuns podem alterar apenas a cada 7 dias (administradores têm alteração livre).', + 'admin_warning_title' => 'Aviso para Administradores', + 'admin_warning_body' => 'Se suas permissões administrativas em produção dependerem de HE4RT_ADMINS_USERNAMES no servidor, lembre-se de atualizar a variável de ambiente após a alteração.', ], 'validation' => [ @@ -82,11 +93,13 @@ 'add_skill' => 'Adicionar skill', 'change_avatar' => 'Alterar foto', 'change_cover' => 'Alterar capa', + 'change_username' => 'Alterar @', 'adjust_avatar' => 'Ajustar enquadramento da foto', 'adjust_cover' => 'Ajustar enquadramento', 'save_framing' => 'Salvar enquadramento', 'save_avatar' => 'Salvar foto', 'save_cover' => 'Salvar capa', + 'save_username' => 'Salvar @', ], 'notifications' => [ @@ -94,6 +107,8 @@ 'avatar_updated' => 'Foto atualizada com sucesso!', 'cover_updated' => 'Capa atualizada com sucesso!', 'framing_updated' => 'Enquadramento salvo!', + 'username_updated' => 'Nome de usuário atualizado com sucesso!', + 'username_error' => 'Não foi possível alterar o @', 'no_profile' => 'Perfil não encontrado para este tenant.', ], diff --git a/app-modules/panel-app/resources/views/components/profile-media-header.blade.php b/app-modules/panel-app/resources/views/components/profile-media-header.blade.php index db7d3bbde..7eed19b8e 100644 --- a/app-modules/panel-app/resources/views/components/profile-media-header.blade.php +++ b/app-modules/panel-app/resources/views/components/profile-media-header.blade.php @@ -120,8 +120,30 @@ class="absolute -top-1 -right-1 z-20 rounded-full bg-red-500 p-1 text-white shad - {{-- Nickname + Birthdate --}} -
+ {{-- Username + Nickname + Birthdate --}} +
+
+
+ + +
+
+ {{ '@' }}{{ auth()->user()->username }} + +
+
-

{{ '@' }}{{ $username }}

+
+

{{ '@' }}{{ $username }}

+ +
@if ($location)

diff --git a/app-modules/panel-app/resources/views/components/username-admin-warning.blade.php b/app-modules/panel-app/resources/views/components/username-admin-warning.blade.php new file mode 100644 index 000000000..aec2b2d69 --- /dev/null +++ b/app-modules/panel-app/resources/views/components/username-admin-warning.blade.php @@ -0,0 +1,13 @@ +

+
+ +
+
+

+ {{ __('panel-app::profile.hints.admin_warning_title') }} +

+

+ {{ __('panel-app::profile.hints.admin_warning_body') }} +

+
+
diff --git a/app-modules/panel-app/resources/views/components/username-rules.blade.php b/app-modules/panel-app/resources/views/components/username-rules.blade.php new file mode 100644 index 000000000..7f503a0f2 --- /dev/null +++ b/app-modules/panel-app/resources/views/components/username-rules.blade.php @@ -0,0 +1,28 @@ +
+
+ + {{ __('panel-app::profile.hints.username_rules_title') }} +
+
    +
  • + + {{ __('panel-app::profile.hints.username_rule_length') }} +
  • +
  • + + {{ __('panel-app::profile.hints.username_rule_characters') }} +
  • +
  • + + {{ __('panel-app::profile.hints.username_rule_format') }} +
  • +
  • + + {{ __('panel-app::profile.hints.username_rule_consecutive') }} +
  • +
  • + + {{ __('panel-app::profile.hints.username_rule_cooldown') }} +
  • +
+
diff --git a/app-modules/panel-app/src/Pages/ProfilePage.php b/app-modules/panel-app/src/Pages/ProfilePage.php index 9ccdc29d5..5a3587fac 100644 --- a/app-modules/panel-app/src/Pages/ProfilePage.php +++ b/app-modules/panel-app/src/Pages/ProfilePage.php @@ -7,9 +7,11 @@ use App\Geo\Support\GeoLocation; use App\Support\UploadLimit; use BackedEnum; +use Closure; use Filament\Actions\Action; use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\FileUpload; +use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; @@ -28,8 +30,11 @@ use Filament\Schemas\Schema; use Filament\Support\Enums\Width; use He4rt\Gamification\Character\Models\Character; +use He4rt\Identity\User\Actions\UpdateUsername; use He4rt\Identity\User\Enums\ProfileImage; +use He4rt\Identity\User\Exceptions\UsernameException; use He4rt\Identity\User\Models\User; +use He4rt\Identity\User\ValueObjects\UsernameValidator; use He4rt\PanelApp\Rules\UnconvertedImageSize; use He4rt\Profile\Actions\SyncProfileSkills; use He4rt\Profile\Actions\ToggleAvailability; @@ -44,6 +49,7 @@ use He4rt\Profile\Models\Profile; use He4rt\Profile\Models\Skill; use Illuminate\Http\UploadedFile; +use Illuminate\Support\HtmlString; use Illuminate\Support\Str; use Livewire\Attributes\Computed; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; @@ -505,6 +511,79 @@ public function adjustCoverAction(): Action return $this->imageFramingAction('adjustCover', ProfileImage::Cover); } + public function editUsernameAction(): Action + { + return Action::make('editUsername') + ->label(__('panel-app::profile.actions.change_username')) + ->modalHeading(__('panel-app::profile.actions.change_username')) + ->modalDescription(__('panel-app::profile.hints.username_modal_description')) + ->modalSubmitActionLabel(__('panel-app::profile.actions.save_username')) + ->modalSubmitAction(fn (Action $action) => $action->color('primary')) + ->modalWidth(Width::Medium) + ->schema([ + Placeholder::make('admin_warning') + ->hiddenLabel() + ->visible(fn (): bool => auth()->user()?->isAdmin() ?? false) + ->content(new HtmlString(view('panel-app::components.username-admin-warning')->render())), + TextInput::make('username') + ->label(__('panel-app::profile.fields.username')) + ->prefix('@') + ->default(fn (): string => auth()->user()->username) + ->required() + ->rules([ + fn (): Closure => function (string $attribute, mixed $value, Closure $fail): void { + if (!is_string($value)) { + $fail(__('panel-app::profile.hints.username_rules_title')); + + return; + } + + /** @var User|null $user */ + $user = auth()->user(); + + try { + UsernameValidator::validate($value, $user); + } catch (UsernameException $usernameException) { + $fail($usernameException->getMessage()); + } + }, + ]), + Placeholder::make('username_rules') + ->hiddenLabel() + ->content(new HtmlString(view('panel-app::components.username-rules')->render())), + ]) + ->action(function (array $data, Action $action): void { + /** @var User $user */ + $user = auth()->user(); + + $rawUsername = $data['username'] ?? ''; + $newUsername = is_string($rawUsername) ? $rawUsername : ''; + + try { + $updated = resolve(UpdateUsername::class)->handle($user, $newUsername); + auth()->setUser($updated); + filament()->auth()->setUser($updated); + } catch (UsernameException $usernameException) { + Notification::make() + ->danger() + ->title(__('panel-app::profile.notifications.username_error')) + ->body($usernameException->getMessage()) + ->send(); + + $this->addError('mountedActionsData.0.username', $usernameException->getMessage()); + $this->addError('data.username', $usernameException->getMessage()); + $this->addError('username', $usernameException->getMessage()); + + $action->halt(); + } + + Notification::make() + ->success() + ->title(__('panel-app::profile.notifications.username_updated')) + ->send(); + }); + } + public function getRecord(): Profile { return Profile::query() diff --git a/app-modules/panel-app/tests/Feature/ProfilePageTest.php b/app-modules/panel-app/tests/Feature/ProfilePageTest.php index 0014d8e38..ef99420a2 100644 --- a/app-modules/panel-app/tests/Feature/ProfilePageTest.php +++ b/app-modules/panel-app/tests/Feature/ProfilePageTest.php @@ -345,3 +345,40 @@ expect($media)->not->toBeNull() ->and($media?->collection_name)->toBe('cover'); }); + +test('profile page allows updating username through editUsername action modal', function (): void { + livewire(ProfilePage::class) + ->callAction('editUsername', [ + 'username' => 'new_cool_handle', + ]) + ->assertHasNoActionErrors() + ->assertNotified(__('panel-app::profile.notifications.username_updated')) + ->assertSee('@new_cool_handle'); + + expect($this->user->fresh()->username)->toBe('new_cool_handle'); +}); + +test('profile page halts action and sends danger notification on username error', function (): void { + livewire(ProfilePage::class) + ->callAction('editUsername', [ + 'username' => $this->user->username, + ]) + ->assertActionHalted('editUsername') + ->assertNotified() + ->assertHasErrors(['mountedActionsData.0.username']); +}); + +test('profile page shows validation error when username has invalid format', function (string $invalidUsername): void { + livewire(ProfilePage::class) + ->callAction('editUsername', [ + 'username' => $invalidUsername, + ]) + ->assertHasActionErrors(['username']); +})->with([ + 'contains special characters' => 'teste!', + 'starts with special character' => '_teste', + 'ends with special character' => 'teste-', + 'consecutive special characters' => 'teste..teste', + 'too short' => 'a', + 'reserved word' => 'admin', +]); diff --git a/config/he4rt.php b/config/he4rt.php index 8edd028ed..91c76ce4a 100644 --- a/config/he4rt.php +++ b/config/he4rt.php @@ -10,6 +10,8 @@ 'minimum_level_for_retro' => env('HE4RT_SEASON_MIN_LEVEL', 3), ], 'server_key' => env('HE4RT_BOT_SECRET', 'he4rt'), + 'admins' => env('HE4RT_ADMINS_USERNAMES', ''), + 'username_cooldown_days' => (int) env('HE4RT_USERNAME_COOLDOWN_DAYS', 7), /* * Quando a He4rt começou. Data única para qualquer lugar que precise contar a From ad619f9f119120f83e0f6ab5bf5ba1cac26152ef Mon Sep 17 00:00:00 2001 From: Nikolas <60836654+NikolasGoncalves@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:09:40 -0300 Subject: [PATCH 2/3] fix(identity,panel-app): localiza excecoes de username e garante limpeza de data nos testes --- .../src/User/Exceptions/UsernameException.php | 72 +++++++++++++++++-- .../User/ValueObjects/UsernameValidator.php | 20 ++++-- .../tests/Feature/User/UpdateUsernameTest.php | 26 ++++--- .../MergeDuplicateDiscordProfilesCommand.php | 4 +- app-modules/panel-app/lang/en/profile.php | 9 +++ app-modules/panel-app/lang/pt_BR/profile.php | 9 +++ .../panel-app/src/Pages/ProfilePage.php | 12 ++-- .../tests/Feature/ProfilePageTest.php | 16 +++++ 8 files changed, 140 insertions(+), 28 deletions(-) diff --git a/app-modules/identity/src/User/Exceptions/UsernameException.php b/app-modules/identity/src/User/Exceptions/UsernameException.php index 9cf15bc3a..4c3f6b028 100644 --- a/app-modules/identity/src/User/Exceptions/UsernameException.php +++ b/app-modules/identity/src/User/Exceptions/UsernameException.php @@ -6,17 +6,41 @@ use Carbon\CarbonInterface; use Exception; +use Throwable; final class UsernameException extends Exception { + /** + * @param array $translationParams + */ + public function __construct( + string $message = '', + int $code = 422, + ?Throwable $previous = null, + public readonly ?string $translationKey = null, + public readonly array $translationParams = [], + ) { + parent::__construct($message, $code, $previous); + } + public static function alreadyTaken(string $username): self { - return new self(sprintf('O @%s já está em uso por outro membro.', $username), 422); + return new self( + sprintf('O @%s já está em uso por outro membro.', $username), + 422, + translationKey: 'panel-app::profile.validation.username_already_taken', + translationParams: ['username' => $username], + ); } - public static function invalidFormat(string $reason): self + public static function invalidFormat(string $reason, ?string $reasonKey = null): self { - return new self(sprintf('Formato de @ inválido: %s.', $reason), 422); + return new self( + sprintf('Formato de @ inválido: %s.', $reason), + 422, + translationKey: 'panel-app::profile.validation.username_invalid_format', + translationParams: ['reason' => $reason, 'reason_key' => $reasonKey], + ); } public static function cooldownActive(CarbonInterface $availableAt): self @@ -24,16 +48,52 @@ public static function cooldownActive(CarbonInterface $availableAt): self $timezone = (string) config('app.display_timezone', 'America/Sao_Paulo'); $formattedDate = $availableAt->timezone($timezone)->format('d/m/Y H:i'); - return new self(sprintf('Você só poderá alterar seu @ novamente a partir de %s.', $formattedDate), 422); + return new self( + sprintf('Você só poderá alterar seu @ novamente a partir de %s.', $formattedDate), + 422, + translationKey: 'panel-app::profile.validation.username_cooldown_active', + translationParams: ['date' => $formattedDate], + ); } public static function sameAsCurrent(): self { - return new self('O novo @ deve ser diferente do atual.', 422); + return new self( + 'O novo @ deve ser diferente do atual.', + 422, + translationKey: 'panel-app::profile.validation.username_same_as_current', + ); } public static function reservedUsername(string $username): self { - return new self(sprintf('O @%s está reservado para o sistema e não pode ser utilizado.', $username), 422); + return new self( + sprintf('O @%s está reservado para o sistema e não pode ser utilizado.', $username), + 422, + translationKey: 'panel-app::profile.validation.username_reserved', + translationParams: ['username' => $username], + ); + } + + public function getLocalizedMessage(): string + { + if ($this->translationKey !== null) { + $params = $this->translationParams; + if (isset($params['reason_key']) && is_string($params['reason_key'])) { + $translatedReason = __($params['reason_key']); + if ($translatedReason !== $params['reason_key']) { + $params['reason'] = $translatedReason; + } + + unset($params['reason_key']); + } + + $translated = __($this->translationKey, $params); + if ($translated !== $this->translationKey) { + return $translated; + } + } + + return $this->getMessage(); } } diff --git a/app-modules/identity/src/User/ValueObjects/UsernameValidator.php b/app-modules/identity/src/User/ValueObjects/UsernameValidator.php index ce833429e..f94217fa7 100644 --- a/app-modules/identity/src/User/ValueObjects/UsernameValidator.php +++ b/app-modules/identity/src/User/ValueObjects/UsernameValidator.php @@ -43,19 +43,31 @@ public static function normalizeAndValidate(string $username, ?User $user = null $length = mb_strlen($normalized); if ($length < 2 || $length > 32) { - throw UsernameException::invalidFormat('o tamanho deve ter entre 2 e 32 caracteres'); + throw UsernameException::invalidFormat( + 'o tamanho deve ter entre 2 e 32 caracteres', + 'panel-app::profile.validation.username_reason_length', + ); } if (!preg_match('/^[a-z0-9._-]+$/', $normalized)) { - throw UsernameException::invalidFormat('apenas letras, números, sublinhado (_), hífen (-) e ponto (.) são permitidos'); + throw UsernameException::invalidFormat( + 'apenas letras, números, sublinhado (_), hífen (-) e ponto (.) são permitidos', + 'panel-app::profile.validation.username_reason_characters', + ); } if (!preg_match('/^[a-z0-9]/', $normalized) || !preg_match('/[a-z0-9]$/', $normalized)) { - throw UsernameException::invalidFormat('não pode começar ou terminar com caracteres especiais'); + throw UsernameException::invalidFormat( + 'não pode começar ou terminar com caracteres especiais', + 'panel-app::profile.validation.username_reason_edges', + ); } if (preg_match('/[._-]{2,}/', $normalized)) { - throw UsernameException::invalidFormat('não pode conter caracteres especiais consecutivos'); + throw UsernameException::invalidFormat( + 'não pode conter caracteres especiais consecutivos', + 'panel-app::profile.validation.username_reason_consecutive', + ); } if (in_array($normalized, self::RESERVED_USERNAMES, strict: true)) { diff --git a/app-modules/identity/tests/Feature/User/UpdateUsernameTest.php b/app-modules/identity/tests/Feature/User/UpdateUsernameTest.php index d3b60c1c7..4d75bea94 100644 --- a/app-modules/identity/tests/Feature/User/UpdateUsernameTest.php +++ b/app-modules/identity/tests/Feature/User/UpdateUsernameTest.php @@ -12,6 +12,10 @@ config()->set('app.display_timezone', 'America/Sao_Paulo'); }); +afterEach(function (): void { + Date::setTestNow(); +}); + test('updates username with lowercase normalization and sets timestamps', function (): void { $user = User::factory()->create([ 'username' => 'originaluser', @@ -46,18 +50,20 @@ test('throws exception when cooldown is still active', function (): void { Date::setTestNow('2026-09-08 12:00:00'); - $user = User::factory()->create([ - 'username' => 'currentuser', - 'username_manually_set_at' => now()->subDays(3), - 'username_updated_at' => now()->subDays(3), - ]); - - $action = resolve(UpdateUsername::class); + try { + $user = User::factory()->create([ + 'username' => 'currentuser', + 'username_manually_set_at' => now()->subDays(3), + 'username_updated_at' => now()->subDays(3), + ]); - expect(fn () => $action->handle($user, 'newhandle')) - ->toThrow(UsernameException::class, '12/09/2026 09:00'); // 2026-09-08 12:00 UTC - 3 days + 7 days = 2026-09-12 12:00 UTC = 09:00 America/Sao_Paulo + $action = resolve(UpdateUsername::class); - Date::setTestNow(); + expect(fn () => $action->handle($user, 'newhandle')) + ->toThrow(UsernameException::class, '12/09/2026 09:00'); // 2026-09-08 12:00 UTC - 3 days + 7 days = 2026-09-12 12:00 UTC = 09:00 America/Sao_Paulo + } finally { + Date::setTestNow(); + } }); test('allows update after 7 days cooldown has elapsed', function (): void { diff --git a/app-modules/integration-discord/src/ETL/Console/MergeDuplicateDiscordProfilesCommand.php b/app-modules/integration-discord/src/ETL/Console/MergeDuplicateDiscordProfilesCommand.php index 165a7a375..60a882ba1 100644 --- a/app-modules/integration-discord/src/ETL/Console/MergeDuplicateDiscordProfilesCommand.php +++ b/app-modules/integration-discord/src/ETL/Console/MergeDuplicateDiscordProfilesCommand.php @@ -44,9 +44,7 @@ public function handle(MergeDuplicateDiscordUserAction $merge): int private function runFromPairsFile(MergeDuplicateDiscordUserAction $merge, string $path): int { - $absolute = is_file($path) - ? $path - : (str_starts_with($path, '/') || preg_match('/^[A-Za-z]:[\\\\\/]/', $path) ? $path : base_path($path)); + $absolute = str_starts_with($path, '/') ? $path : base_path($path); if (!is_file($absolute)) { error('Arquivo de pares nao encontrado: '.$absolute); diff --git a/app-modules/panel-app/lang/en/profile.php b/app-modules/panel-app/lang/en/profile.php index 5f99df23e..ab4d08f27 100644 --- a/app-modules/panel-app/lang/en/profile.php +++ b/app-modules/panel-app/lang/en/profile.php @@ -84,6 +84,15 @@ 'image_dimensions' => 'After cropping, the image must be at least :min_width × :min_height px. The recommended size is :width × :height px.', 'image_mimetypes' => 'Unsupported format. Upload a :formats image.', 'image_unconverted_max_size' => 'A GIF can be at most :gif_mb MB. It is served exactly as it arrives, with no compression, so the file weighs on every profile visit.', + 'username_already_taken' => 'The handle @:username is already in use by another member.', + 'username_invalid_format' => 'Invalid handle format: :reason.', + 'username_cooldown_active' => 'You will only be able to change your @ again starting on :date.', + 'username_same_as_current' => 'The new @ must be different from the current one.', + 'username_reserved' => 'The handle @:username is reserved for the system and cannot be used.', + 'username_reason_length' => 'length must be between 2 and 32 characters', + 'username_reason_characters' => 'only letters, numbers, underscores (_), hyphens (-), and dots (.) are allowed', + 'username_reason_edges' => 'cannot start or end with special characters', + 'username_reason_consecutive' => 'cannot contain consecutive special characters', ], 'actions' => [ diff --git a/app-modules/panel-app/lang/pt_BR/profile.php b/app-modules/panel-app/lang/pt_BR/profile.php index 720739eb4..690a609a9 100644 --- a/app-modules/panel-app/lang/pt_BR/profile.php +++ b/app-modules/panel-app/lang/pt_BR/profile.php @@ -84,6 +84,15 @@ 'image_dimensions' => 'A imagem, depois do recorte, precisa ter no mínimo :min_width × :min_height px. O recomendado é :width × :height px.', 'image_mimetypes' => 'Formato não suportado. Envie uma imagem :formats.', 'image_unconverted_max_size' => 'GIF pode ter no máximo :gif_mb MB. Como ele é exibido do jeito que chega, sem compressão, o arquivo pesa em cada visita ao perfil.', + 'username_already_taken' => 'O @:username já está em uso por outro membro.', + 'username_invalid_format' => 'Formato de @ inválido: :reason.', + 'username_cooldown_active' => 'Você só poderá alterar seu @ novamente a partir de :date.', + 'username_same_as_current' => 'O novo @ deve ser diferente do atual.', + 'username_reserved' => 'O @:username está reservado para o sistema e não pode ser utilizado.', + 'username_reason_length' => 'o tamanho deve ter entre 2 e 32 caracteres', + 'username_reason_characters' => 'apenas letras, números, sublinhado (_), hífen (-) e ponto (.) são permitidos', + 'username_reason_edges' => 'não pode começar ou terminar com caracteres especiais', + 'username_reason_consecutive' => 'não pode conter caracteres especiais consecutivos', ], 'actions' => [ diff --git a/app-modules/panel-app/src/Pages/ProfilePage.php b/app-modules/panel-app/src/Pages/ProfilePage.php index 5a3587fac..ab1059068 100644 --- a/app-modules/panel-app/src/Pages/ProfilePage.php +++ b/app-modules/panel-app/src/Pages/ProfilePage.php @@ -544,7 +544,7 @@ public function editUsernameAction(): Action try { UsernameValidator::validate($value, $user); } catch (UsernameException $usernameException) { - $fail($usernameException->getMessage()); + $fail($usernameException->getLocalizedMessage()); } }, ]), @@ -564,15 +564,17 @@ public function editUsernameAction(): Action auth()->setUser($updated); filament()->auth()->setUser($updated); } catch (UsernameException $usernameException) { + $errorMessage = $usernameException->getLocalizedMessage(); + Notification::make() ->danger() ->title(__('panel-app::profile.notifications.username_error')) - ->body($usernameException->getMessage()) + ->body($errorMessage) ->send(); - $this->addError('mountedActionsData.0.username', $usernameException->getMessage()); - $this->addError('data.username', $usernameException->getMessage()); - $this->addError('username', $usernameException->getMessage()); + $this->addError('mountedActionsData.0.username', $errorMessage); + $this->addError('data.username', $errorMessage); + $this->addError('username', $errorMessage); $action->halt(); } diff --git a/app-modules/panel-app/tests/Feature/ProfilePageTest.php b/app-modules/panel-app/tests/Feature/ProfilePageTest.php index ef99420a2..79548cd8b 100644 --- a/app-modules/panel-app/tests/Feature/ProfilePageTest.php +++ b/app-modules/panel-app/tests/Feature/ProfilePageTest.php @@ -382,3 +382,19 @@ 'too short' => 'a', 'reserved word' => 'admin', ]); + +test('profile page localizes username error in english', function (): void { + app()->setLocale('en'); + + $component = livewire(ProfilePage::class) + ->callAction('editUsername', [ + 'username' => 'invalid!', + ]) + ->assertHasActionErrors(['username']); + + expect($component->errors()->all())->toContain( + __('panel-app::profile.validation.username_invalid_format', [ + 'reason' => __('panel-app::profile.validation.username_reason_characters'), + ]) + ); +}); From f2d46db98a4680630948e342c5636161693dee42 Mon Sep 17 00:00:00 2001 From: Nikolas <60836654+NikolasGoncalves@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:48:37 -0300 Subject: [PATCH 3/3] =?UTF-8?q?refactor(panel-app):=20remove=20aviso=20obs?= =?UTF-8?q?oleto=20para=20administradores=20na=20altera=C3=A7=C3=A3o=20de?= =?UTF-8?q?=20username?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app-modules/panel-app/lang/en/profile.php | 2 -- app-modules/panel-app/lang/pt_BR/profile.php | 2 -- .../components/username-admin-warning.blade.php | 13 ------------- app-modules/panel-app/src/Pages/ProfilePage.php | 4 ---- 4 files changed, 21 deletions(-) delete mode 100644 app-modules/panel-app/resources/views/components/username-admin-warning.blade.php diff --git a/app-modules/panel-app/lang/en/profile.php b/app-modules/panel-app/lang/en/profile.php index ab4d08f27..baac7f7bf 100644 --- a/app-modules/panel-app/lang/en/profile.php +++ b/app-modules/panel-app/lang/en/profile.php @@ -76,8 +76,6 @@ 'username_rule_format' => 'Must start and end with a letter or number (no symbols at edges).', 'username_rule_consecutive' => 'No consecutive special characters allowed (e.g. .. or --).', 'username_rule_cooldown' => 'Regular members can change once every 7 days (admins are exempt).', - 'admin_warning_title' => 'Administrator Notice', - 'admin_warning_body' => 'If your production admin permissions rely on HE4RT_ADMINS_USERNAMES on the server, remember to update the environment variable after changing your handle.', ], 'validation' => [ diff --git a/app-modules/panel-app/lang/pt_BR/profile.php b/app-modules/panel-app/lang/pt_BR/profile.php index 690a609a9..3c2b69bcc 100644 --- a/app-modules/panel-app/lang/pt_BR/profile.php +++ b/app-modules/panel-app/lang/pt_BR/profile.php @@ -76,8 +76,6 @@ 'username_rule_format' => 'Deve começar e terminar com letra ou número (sem símbolos nas extremidades).', 'username_rule_consecutive' => 'Proibido símbolos especiais consecutivos (ex: .. ou --).', 'username_rule_cooldown' => 'Usuários comuns podem alterar apenas a cada 7 dias (administradores têm alteração livre).', - 'admin_warning_title' => 'Aviso para Administradores', - 'admin_warning_body' => 'Se suas permissões administrativas em produção dependerem de HE4RT_ADMINS_USERNAMES no servidor, lembre-se de atualizar a variável de ambiente após a alteração.', ], 'validation' => [ diff --git a/app-modules/panel-app/resources/views/components/username-admin-warning.blade.php b/app-modules/panel-app/resources/views/components/username-admin-warning.blade.php deleted file mode 100644 index aec2b2d69..000000000 --- a/app-modules/panel-app/resources/views/components/username-admin-warning.blade.php +++ /dev/null @@ -1,13 +0,0 @@ -
-
- -
-
-

- {{ __('panel-app::profile.hints.admin_warning_title') }} -

-

- {{ __('panel-app::profile.hints.admin_warning_body') }} -

-
-
diff --git a/app-modules/panel-app/src/Pages/ProfilePage.php b/app-modules/panel-app/src/Pages/ProfilePage.php index ab1059068..1a41e4e7a 100644 --- a/app-modules/panel-app/src/Pages/ProfilePage.php +++ b/app-modules/panel-app/src/Pages/ProfilePage.php @@ -521,10 +521,6 @@ public function editUsernameAction(): Action ->modalSubmitAction(fn (Action $action) => $action->color('primary')) ->modalWidth(Width::Medium) ->schema([ - Placeholder::make('admin_warning') - ->hiddenLabel() - ->visible(fn (): bool => auth()->user()?->isAdmin() ?? false) - ->content(new HtmlString(view('panel-app::components.username-admin-warning')->render())), TextInput::make('username') ->label(__('panel-app::profile.fields.username')) ->prefix('@')