Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0f6baed
feat(panel-admin): UserResource com List/View/Edit e Role hierarchy
hefeus Jul 26, 2026
87bada0
fix(panel-admin): restringe acesso ao painel admin por role e central…
hefeus Jul 27, 2026
91d9c7b
fix(identity): não apaga address no soft delete e torna rollback seguro
hefeus Jul 27, 2026
ec9d5b3
fix(identity): normaliza espaços na lista de admins configurados
hefeus Jul 27, 2026
bc3d71e
fix(identity): garante username sem colisão no rollback de soft-deletes
hefeus Jul 27, 2026
310e496
test: atualiza AddressTest pro comportamento correto de soft/force de…
hefeus Jul 27, 2026
7fb9153
feat: Apenas staffs podem atualizar a role do usuario
hefeus Aug 15, 2026
b705e4c
test(panel-admin): corrige expectativa de troca de role por staff
hefeus Aug 23, 2026
a96d689
fix(identity): restringe ExternalIdentityResource a staff e compliance
hefeus Aug 23, 2026
3bbab7a
refactor(identity): unifica isStaff() em Role::canManageUsers()
hefeus Aug 23, 2026
71a4388
Merge branch '4.x' of https://github.com/he4rt/heartdevs.com into fea…
hefeus Sep 14, 2026
187e3e7
feat(identity,panel-admin): estende UserResource com hierarquia de ro…
hefeus Sep 14, 2026
c4ba82c
fix(identity): torna o unique index de username parcial pra soft delete
hefeus Sep 14, 2026
0bf0313
fix(identity): reconcilia usernames duplicados no rollback do índice …
hefeus Sep 17, 2026
31cec5b
fix(identity,panel-admin): restringe atribuição de papéis privilegiados
hefeus Sep 17, 2026
1d2b271
fix(panel-admin): restringe TrashedFilter a quem gerencia usuários
hefeus Sep 17, 2026
c30964b
fix(panel-admin): esconde ação de moderação de quem não pode vê-la
hefeus Sep 17, 2026
7fa6124
fix(panel-admin): valida chaves de social_links antes de salvar o perfil
hefeus Sep 17, 2026
42685ac
fix(panel-admin): garante o profile antes da hidratação do form de ed…
hefeus Sep 17, 2026
a136728
fix(identity): trunca o username antes do sufixo de duplicata no roll…
hefeus Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions app-modules/identity/database/factories/UserFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,35 @@ public function definition(): array

public function superAdmin(): static
{
return $this->afterCreating(function (User $user): void {
Role::findOrCreate(UserRole::SuperAdmin->value, UserRole::GUARD);
return $this->withRole(UserRole::SuperAdmin);
}

public function staff(): static
{
return $this->withRole(UserRole::Staff);
}

public function compliance(): static
{
return $this->withRole(UserRole::Compliance);
}

public function recruiter(): static
{
return $this->withRole(UserRole::Recruiter);
}

public function squadCaptain(): static
{
return $this->withRole(UserRole::SquadCaptain);
}

private function withRole(UserRole $role): static
{
return $this->afterCreating(function (User $user) use ($role): void {
Role::findOrCreate($role->value, UserRole::GUARD);

$user->assignRole(UserRole::SuperAdmin);
$user->assignRole($role);
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('users', static function (Blueprint $table): void {
$table->timestampTz('deleted_at')->nullable();
});
}

public function down(): void
{
Schema::table('users', static function (Blueprint $table): void {
$table->dropColumn('deleted_at');
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
/**
* Uma conta soft-deletada não pode travar o `username` pra sempre — sem
* isso, `MergeAccountsAction` (e qualquer novo cadastro) esbarra em
* "duplicate key" ao tentar reaproveitar o username de um usuário
* apenas soft-deletado.
*/
public function up(): void
{
DB::statement('ALTER TABLE users DROP CONSTRAINT users_username_unique');
DB::statement('CREATE UNIQUE INDEX users_username_unique ON users (username) WHERE deleted_at IS NULL');
}

public function down(): void
{
DB::statement(<<<'SQL'
WITH ranked AS (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY username
ORDER BY (deleted_at IS NULL) DESC, created_at ASC
) AS rn
FROM users
)
UPDATE users
SET username = LEFT(users.username, 214) || '_dup_' || users.id
FROM ranked
WHERE users.id = ranked.id AND ranked.rn > 1
SQL);

DB::statement('DROP INDEX users_username_unique');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
DB::statement('ALTER TABLE users ADD CONSTRAINT users_username_unique UNIQUE (username)');
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace He4rt\Identity\Auth\Actions;

use He4rt\Identity\Auth\DTOs\OAuthUserDTO;
use He4rt\Identity\Auth\Exceptions\AccountSoftDeletedException;
use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity;
use He4rt\Identity\User\Models\User;
use Illuminate\Database\UniqueConstraintViolationException;
Expand Down Expand Up @@ -36,17 +37,23 @@ private function findExistingUser(OAuthUserDTO $oauthUser): ?User
->where('model_type', (new User)->getMorphClass())
->first();

if ($identity?->model instanceof User) {
return $identity->model;
$user = $identity !== null
? User::query()->withTrashed()->find($identity->model_id)
: null;

if (!$user instanceof User && $oauthUser->email !== null) {
$user = User::query()->withTrashed()->where('email', $oauthUser->email)->first();
}

if ($user === null) {
return null;
}

if ($oauthUser->email !== null) {
return User::query()
->where('email', $oauthUser->email)
->first();
if ($user->trashed()) {
throw AccountSoftDeletedException::make();
}

return null;
return $user;
}

private function createUser(OAuthUserDTO $oauthUser): User
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace He4rt\Identity\Auth\Exceptions;

final class AccountSoftDeletedException extends OAuthFlowException
{
public static function make(): self
{
return new self('This account was deleted and cannot be reactivated by logging in again.');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider;
use RuntimeException;

final class OAuthFlowException extends RuntimeException
class OAuthFlowException extends RuntimeException
{
public static function providerNotSupported(string $provider): self
{
Expand All @@ -34,6 +34,11 @@ public static function tokenExchangeFailed(string $provider, string $error): sel
return new self(sprintf('Token exchange failed for "%s": %s', $provider, $error));
}

public static function accountSoftDeleted(): self
{
return new self('This account was deleted and cannot be reactivated by logging in again.');
}

public static function emailUnavailable(string $provider): self
{
return new self(sprintf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use He4rt\Identity\Auth\Actions\HandleOAuthCallbackAction;
use He4rt\Identity\Auth\DTOs\OAuthStateDTO;
use He4rt\Identity\Auth\Enums\OAuthIntent;
use He4rt\Identity\Auth\Exceptions\AccountSoftDeletedException;
use He4rt\Identity\Auth\Exceptions\OAuthFlowException;
use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider;
use Illuminate\Http\RedirectResponse;
Expand Down Expand Up @@ -64,6 +65,10 @@ public function getAuthenticate(string $provider, HandleOAuthCallbackAction $act

try {
$result = $action->execute($state, $identityProvider, $code);
} catch (AccountSoftDeletedException) {
session()->flash('error', 'Esta conta foi excluída e não pode ser reativada fazendo login novamente.');

return redirect()->to($state->returnUrl ?? '/');
} catch (OAuthFlowException $oAuthFlowException) {
Log::warning('OAuth flow failed', ['provider' => $provider, 'error' => $oAuthFlowException->getMessage()]);

Expand Down
20 changes: 20 additions & 0 deletions app-modules/identity/src/Authorization/Enums/UserRole.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,21 @@ enum UserRole: string implements HasColor, HasDescription, HasIcon, HasLabel
use StringifyEnum;

case SuperAdmin = 'super-admin';
case Staff = 'staff';
case Compliance = 'compliance';
case Recruiter = 'recruiter';
case SquadCaptain = 'squad_captain';

public const string GUARD = 'web';

public function getLabel(): string
{
return match ($this) {
self::SuperAdmin => 'Super admin',
self::Staff => 'Staff',
self::Compliance => 'Compliance',
self::Recruiter => 'Recrutador',
self::SquadCaptain => 'Capitão de squad',
};
}

Expand All @@ -41,20 +49,32 @@ public function getColor(): array
{
return match ($this) {
self::SuperAdmin => Color::Red,
self::Staff => Color::Amber,
self::Compliance => Color::Orange,
self::Recruiter => Color::Blue,
self::SquadCaptain => Color::Purple,
};
}

public function getDescription(): string
{
return match ($this) {
self::SuperAdmin => 'Acesso total ao painel admin. Passa por cima de qualquer verificação de permissão.',
self::Staff => 'Gerencia usuários: edita identidade, perfil e endereço, e pode soft-deletar.',
self::Compliance => 'Acumula as permissões de Staff e é o único papel que pode excluir um usuário permanentemente.',
self::Recruiter => 'Vê a ficha de um membro para fins de recrutamento, sem acesso a moderação.',
self::SquadCaptain => 'Vê a ficha de um membro do squad, sem acesso a moderação.',
};
}

public function getIcon(): Heroicon
{
return match ($this) {
self::SuperAdmin => Heroicon::OutlinedShieldCheck,
self::Staff => Heroicon::OutlinedIdentification,
self::Compliance => Heroicon::OutlinedScale,
self::Recruiter => Heroicon::OutlinedBriefcase,
self::SquadCaptain => Heroicon::OutlinedFlag,
};
}
}
1 change: 1 addition & 0 deletions app-modules/identity/src/IdentityServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class IdentityServiceProvider extends ServiceProvider
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
$this->loadTranslationsFrom(__DIR__.'/../lang', 'identity');

Relation::morphMap([
'user' => User::class,
Expand Down
Loading
Loading