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..4c3f6b028 --- /dev/null +++ b/app-modules/identity/src/User/Exceptions/UsernameException.php @@ -0,0 +1,99 @@ + $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, + translationKey: 'panel-app::profile.validation.username_already_taken', + translationParams: ['username' => $username], + ); + } + + public static function invalidFormat(string $reason, ?string $reasonKey = null): self + { + 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 + { + $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, + 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, + 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, + 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/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..f94217fa7 --- /dev/null +++ b/app-modules/identity/src/User/ValueObjects/UsernameValidator.php @@ -0,0 +1,102 @@ + + */ + 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', + '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', + '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', + 'panel-app::profile.validation.username_reason_edges', + ); + } + + if (preg_match('/[._-]{2,}/', $normalized)) { + 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)) { + 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..4d75bea94 --- /dev/null +++ b/app-modules/identity/tests/Feature/User/UpdateUsernameTest.php @@ -0,0 +1,182 @@ +set('he4rt.username_cooldown_days', 7); + 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', + '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'); + + try { + $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 + } finally { + 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/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..baac7f7bf 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,12 +68,29 @@ '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).', ], 'validation' => [ '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' => [ @@ -82,11 +100,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 +114,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..3c2b69bcc 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,12 +68,29 @@ '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).', ], 'validation' => [ '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' => [ @@ -82,11 +100,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 +114,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-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..1a41e4e7a 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,77 @@ 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([ + 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->getLocalizedMessage()); + } + }, + ]), + 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) { + $errorMessage = $usernameException->getLocalizedMessage(); + + Notification::make() + ->danger() + ->title(__('panel-app::profile.notifications.username_error')) + ->body($errorMessage) + ->send(); + + $this->addError('mountedActionsData.0.username', $errorMessage); + $this->addError('data.username', $errorMessage); + $this->addError('username', $errorMessage); + + $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..79548cd8b 100644 --- a/app-modules/panel-app/tests/Feature/ProfilePageTest.php +++ b/app-modules/panel-app/tests/Feature/ProfilePageTest.php @@ -345,3 +345,56 @@ 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', +]); + +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'), + ]) + ); +}); 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