From cd19d84a4a54acd51a767c77f96dc09e606a48f0 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Mon, 25 May 2026 17:18:16 -0300 Subject: [PATCH 01/20] fix(identity): remove references to deleted Information and Address models in BaseSeeder TenantUserObserver already auto-creates profiles when users are attached to tenants. --- database/seeders/BaseSeeder.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/database/seeders/BaseSeeder.php b/database/seeders/BaseSeeder.php index f02b734df..5b2c828db 100644 --- a/database/seeders/BaseSeeder.php +++ b/database/seeders/BaseSeeder.php @@ -10,8 +10,6 @@ use He4rt\Gamification\Season\Models\Season; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use He4rt\Identity\Tenant\Models\Tenant; -use He4rt\Identity\User\Models\Address; -use He4rt\Identity\User\Models\Information; use He4rt\Identity\User\Models\User; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Hash; @@ -28,9 +26,6 @@ public function run(): void 'password' => Hash::make('admin'), ]); - Information::factory()->recycle($admin)->create(); - Address::factory()->recycle($admin)->create(); - $he4rt = Tenant::factory() ->for($admin, 'owner') ->afterCreating(fn (Tenant $tenant) => $tenant->members()->attach($admin)) From d35e217d76e75c85c9ea827d815c6390c468ce8c Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Mon, 25 May 2026 18:36:37 -0300 Subject: [PATCH 02/20] chore(identity): remove 3Pontos tenant and set default domain in BaseSeeder --- .../panel-app/resources/views/auth/login.blade.php | 0 app-modules/panel-app/src/Pages/LoginPage.php | 7 +++++++ database/seeders/BaseSeeder.php | 9 +-------- resources/css/filament/{user => app}/theme.css | 0 4 files changed, 8 insertions(+), 8 deletions(-) create mode 100644 app-modules/panel-app/resources/views/auth/login.blade.php create mode 100644 app-modules/panel-app/src/Pages/LoginPage.php rename resources/css/filament/{user => app}/theme.css (100%) diff --git a/app-modules/panel-app/resources/views/auth/login.blade.php b/app-modules/panel-app/resources/views/auth/login.blade.php new file mode 100644 index 000000000..e69de29bb diff --git a/app-modules/panel-app/src/Pages/LoginPage.php b/app-modules/panel-app/src/Pages/LoginPage.php new file mode 100644 index 000000000..209a63808 --- /dev/null +++ b/app-modules/panel-app/src/Pages/LoginPage.php @@ -0,0 +1,7 @@ +create([ 'name' => 'He4rt Developers', 'slug' => 'he4rt', - ]); - - Tenant::factory() - ->for($admin, 'owner') - ->afterCreating(fn (Tenant $tenant) => $tenant->members()->attach($admin)) - ->create([ - 'name' => '3 Pontos', - 'slug' => '3pontos', + 'domain' => 'he4rtdevs.test', ]); Character::factory() diff --git a/resources/css/filament/user/theme.css b/resources/css/filament/app/theme.css similarity index 100% rename from resources/css/filament/user/theme.css rename to resources/css/filament/app/theme.css From 7900307991b7d60cf1ba48d3a0796efb59e9c93d Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Mon, 25 May 2026 20:19:13 -0300 Subject: [PATCH 03/20] feat(identity): refactor OAuth flow with intent-based routing Rewrite the OAuth system to be tenant-based only with clear separation of concerns. Controller is now a thin HTTP adapter, business logic lives in dedicated Actions (HandleOAuthCallback, FindOrCreateUserByProvider, AttachProviderToUser). State DTO carries intent (login/link), provider, panel, tenant, and return URL. --- .../identity/routes/authentication-routes.php | 8 +- .../src/Auth/Actions/AttachProviderToUser.php | 39 +++++++ .../src/Auth/Actions/AuthenticateAction.php | 102 ------------------ .../Actions/FindOrCreateUserByProvider.php | 50 +++++++++ .../Actions/HandleOAuthCallbackAction.php | 71 ++++++++++++ .../identity/src/Auth/DTOs/OAuthResultDTO.php | 21 ++++ .../identity/src/Auth/DTOs/OAuthStateDTO.php | 27 +++-- .../identity/src/Auth/Enums/OAuthIntent.php | 11 ++ .../Auth/Exceptions/OAuthFlowException.php | 31 ++++++ .../Auth/Http/Controllers/OAuthController.php | 62 +++++------ .../Enums/IdentityProvider.php | 19 +--- 11 files changed, 283 insertions(+), 158 deletions(-) create mode 100644 app-modules/identity/src/Auth/Actions/AttachProviderToUser.php delete mode 100644 app-modules/identity/src/Auth/Actions/AuthenticateAction.php create mode 100644 app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php create mode 100644 app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php create mode 100644 app-modules/identity/src/Auth/DTOs/OAuthResultDTO.php create mode 100644 app-modules/identity/src/Auth/Enums/OAuthIntent.php create mode 100644 app-modules/identity/src/Auth/Exceptions/OAuthFlowException.php diff --git a/app-modules/identity/routes/authentication-routes.php b/app-modules/identity/routes/authentication-routes.php index 9d647bcca..dbffecda6 100644 --- a/app-modules/identity/routes/authentication-routes.php +++ b/app-modules/identity/routes/authentication-routes.php @@ -12,8 +12,10 @@ Route::post('/{tenant}/logout', TenantLogoutController::class)->name('tenant.logout'); Route::prefix('oauth')->group(function (): void { - Route::get('/{provider}', [OAuthController::class, 'getAuthenticate']); - Route::get('/{panel}/{provider}/redirect', [OAuthController::class, 'getRedirect'])->name('oauth.single.redirect'); - Route::get('/{tenant}/{panel}/{provider}/redirect', [OAuthController::class, 'getRedirect'])->name('oauth.tenant.redirect'); + Route::get('/{provider}', [OAuthController::class, 'getAuthenticate']) + ->name('oauth.authenticate'); + + Route::get('/{tenant}/{panel}/{provider}/redirect', [OAuthController::class, 'getRedirect']) + ->name('oauth.redirect'); }); }); diff --git a/app-modules/identity/src/Auth/Actions/AttachProviderToUser.php b/app-modules/identity/src/Auth/Actions/AttachProviderToUser.php new file mode 100644 index 000000000..f226d6f36 --- /dev/null +++ b/app-modules/identity/src/Auth/Actions/AttachProviderToUser.php @@ -0,0 +1,39 @@ +providers()->updateOrCreate( + [ + 'tenant_id' => $tenant->getKey(), + 'provider' => $oauthUser->provider, + 'external_account_id' => $oauthUser->providerId, + ], + [ + 'type' => $oauthUser->provider->getType(), + 'credentials_type' => CredentialsType::OAuth2, + 'credentials' => $access->toClientAccessManager(), + 'metadata' => array_filter([ + 'email' => $oauthUser->email, + 'avatar' => $oauthUser->avatarUrl, + 'username' => $oauthUser->username, + ]), + 'connected_at' => now(), + 'connected_by' => auth()->id(), + ] + ); + } +} diff --git a/app-modules/identity/src/Auth/Actions/AuthenticateAction.php b/app-modules/identity/src/Auth/Actions/AuthenticateAction.php deleted file mode 100644 index b68abebfb..000000000 --- a/app-modules/identity/src/Auth/Actions/AuthenticateAction.php +++ /dev/null @@ -1,102 +0,0 @@ -tenant) { - $this->authenticateTenant($state, $oauthProvider, $code); - - return; - } - - // TODO: implement admin login only. - } - - private function authenticateTenant(OAuthStateDTO $state, IdentityProvider $oauthProvider, string $code): void - { - $tenant = $this->findTenantBySlug($state->tenant); - - $clientProvider = $oauthProvider->getClient(); - $accessData = $clientProvider->auth($code); - - $user = $clientProvider->getAuthenticatedUser($accessData); - - $provider = ExternalIdentity::query() - ->where('tenant_id', $tenant->getKey()) - ->where('provider', $user->provider) - ->where('external_account_id', $user->providerId) - ->first(); - - if (!$provider) { - $provider = $this->registerNewUser($user, $tenant); - } - - Auth::logout(); - Auth::login($provider->user); - filament()->setCurrentPanel(filament()->getPanel($state->panel)); - filament()->auth()->setUser($provider->user); - } - - private function registerNewUser(OAuthUserDTO $userDTO, Tenant $tenant): ExternalIdentity - { - $user = auth()->check() ? auth()->user() : User::query() - ->where('username', $userDTO->username) - ->orWhere('email', $userDTO->email) - ->first(); - - if (!$user) { - $user = User::query()->create([ - 'id' => Uuid::uuid4()->toString(), - 'username' => $userDTO->username, - 'email' => $userDTO->email, - 'name' => $userDTO->name, - 'password' => Hash::make(Date::now()->getTimestamp().'-vai-brasil'), - 'is_donator' => false, - ]); - } - - $user->tenants()->attach($tenant); - - /** @var ExternalIdentity $provider */ - $provider = $user->providers()->updateOrCreate([ - 'tenant_id' => $tenant->getKey(), - 'provider' => IdentityProvider::from($userDTO->provider->value), - 'external_account_id' => $userDTO->providerId, - ], [ - 'type' => $userDTO->provider->getType(), - 'credentials_type' => CredentialsType::OAuth2, - 'credentials' => $userDTO->credentials->toClientAccessManager(), - 'metadata' => [ - 'email' => $userDTO->email, - 'avatar' => $userDTO->avatarUrl, - 'username' => $userDTO->username, - ], - 'connected_at' => now(), - 'connected_by' => $user->id, - ]); - - return $provider; - } - - private function findTenantBySlug(string $tenantSlug): ?Tenant - { - return Tenant::query()->where('slug', $tenantSlug)->first(); - } -} diff --git a/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php b/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php new file mode 100644 index 000000000..f582970a4 --- /dev/null +++ b/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php @@ -0,0 +1,50 @@ +findExistingUser($oauthUser, $tenant); + + if (!$user instanceof User) { + $user = User::query()->create([ + 'username' => $oauthUser->username, + 'email' => $oauthUser->email, + 'name' => $oauthUser->name, + 'is_donator' => false, + ]); + } + + if (!$user->tenants()->where('tenants.id', $tenant->getKey())->exists()) { + $user->tenants()->attach($tenant); + } + + return $user; + } + + private function findExistingUser(OAuthUserDTO $oauthUser, Tenant $tenant): ?User + { + $identity = ExternalIdentity::query() + ->where('provider', $oauthUser->provider) + ->where('external_account_id', $oauthUser->providerId) + ->where('tenant_id', $tenant->getKey()) + ->first(); + + if ($identity?->model instanceof User) { + return $identity->model; + } + + return User::query() + ->where('email', $oauthUser->email) + ->first(); + } +} diff --git a/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php b/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php new file mode 100644 index 000000000..a5892e9f7 --- /dev/null +++ b/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php @@ -0,0 +1,71 @@ +getClient(); + + if (!$client instanceof OAuthClientContract) { + throw OAuthFlowException::clientNotConfigured($provider); + } + + $access = $client->auth($code); + $oauthUser = $client->getAuthenticatedUser($access); + + $tenant = Tenant::query() + ->where('domain', $state->tenant) + ->orWhere('slug', $state->tenant) + ->firstOrFail(); + + $user = match ($state->intent) { + OAuthIntent::Login => $this->findOrCreateUser->execute($oauthUser, $tenant), + OAuthIntent::Link => $this->resolveAuthenticatedUser(), + }; + + $owner = $state->panel === 'admin' ? $tenant : $user; + $identity = $this->attachProvider->execute($owner, $tenant, $oauthUser, $access); + + $redirectUrl = $state->returnUrl ?? filament() + ->getPanel($state->panel) + ->getUrl($tenant); + + return new OAuthResultDTO( + user: $user, + tenant: $tenant, + identity: $identity, + intent: $state->intent, + redirectUrl: $redirectUrl, + ); + } + + private function resolveAuthenticatedUser(): User + { + $user = Auth::user(); + + if (!$user instanceof User) { + throw OAuthFlowException::unauthenticatedLinkAttempt(); + } + + return $user; + } +} diff --git a/app-modules/identity/src/Auth/DTOs/OAuthResultDTO.php b/app-modules/identity/src/Auth/DTOs/OAuthResultDTO.php new file mode 100644 index 000000000..ee32d0829 --- /dev/null +++ b/app-modules/identity/src/Auth/DTOs/OAuthResultDTO.php @@ -0,0 +1,21 @@ + $this->intent->value, + 'provider' => $this->provider->value, 'panel' => $this->panel, - 'tenant' => $this->tenant ?? null, + 'tenant' => $this->tenant, + 'return_url' => $this->returnUrl, ]; } } diff --git a/app-modules/identity/src/Auth/Enums/OAuthIntent.php b/app-modules/identity/src/Auth/Enums/OAuthIntent.php new file mode 100644 index 000000000..3431d31c7 --- /dev/null +++ b/app-modules/identity/src/Auth/Enums/OAuthIntent.php @@ -0,0 +1,11 @@ +value)); + } + + public static function unauthenticatedLinkAttempt(): self + { + return new self('Cannot link a provider without an authenticated user.'); + } + + public static function tenantNotFound(string $identifier): self + { + return new self(sprintf('Tenant "%s" not found.', $identifier)); + } +} diff --git a/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php b/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php index 406d75a86..61d4ce382 100644 --- a/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php +++ b/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php @@ -4,59 +4,55 @@ namespace He4rt\Identity\Auth\Http\Controllers; +use App\Contracts\OAuthClientContract; use App\Http\Controllers\Controller; -use He4rt\Identity\Auth\Actions\AuthenticateAction; +use He4rt\Identity\Auth\Actions\HandleOAuthCallbackAction; use He4rt\Identity\Auth\DTOs\OAuthStateDTO; +use He4rt\Identity\Auth\Enums\OAuthIntent; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; -use He4rt\Identity\Tenant\Models\Tenant; use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\Auth; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; final class OAuthController extends Controller { - public function getRedirect(IdentityProvider $provider): RedirectResponse + public function getRedirect(string $tenant, string $panel, string $provider): RedirectResponse { - return redirect()->to($provider->getClient()->redirectUrl()); - } - - public function getAuthenticate(IdentityProvider $provider, AuthenticateAction $action): RedirectResponse - { - $state = OAuthStateDTO::fromHashedString(request()->input('state')); + $identityProvider = IdentityProvider::tryFrom($provider); - $action->withOAuth($state, $provider, request()->input('code')); + throw_if($identityProvider === null, NotFoundHttpException::class); - if ($state->tenant === null) { - return $this->basePanelRedirectResponse($state); - } + $client = $identityProvider->getClient(); - if ($state->panel === 'event') { - return $this->eventRedirectResponse($state); - } + throw_unless($client instanceof OAuthClientContract, NotFoundHttpException::class); - $redirectUri = filament() - ->getPanel($state->panel) - ->getUrl(Tenant::query()->where('slug', $state->tenant)->firstOrFail()); + $state = new OAuthStateDTO( + intent: Auth::check() ? OAuthIntent::Link : OAuthIntent::Login, + provider: $identityProvider, + panel: $panel, + tenant: $tenant, + returnUrl: Auth::check() ? url()->previous() : null, + ); - return redirect()->to($redirectUri); + return redirect()->to($client->redirectUrl($state)); } - private function eventRedirectResponse(OAuthStateDTO $state): RedirectResponse + public function getAuthenticate(string $provider, HandleOAuthCallbackAction $action): RedirectResponse { + $identityProvider = IdentityProvider::tryFrom($provider); - $tenant = Tenant::query()->where('slug', $state->tenant)->firstOrFail(); - $baseUri = app()->isProduction() - ? $tenant->domain - : $state->tenant; + throw_if($identityProvider === null, NotFoundHttpException::class); - return redirect()->intended(route('filament.event.pages.participant-dashboard', [ - 'tenant' => $baseUri, - ])); + $state = OAuthStateDTO::fromEncryptedString(request()->input('state')); - } + $result = $action->execute($state, $identityProvider, request()->input('code')); - private function basePanelRedirectResponse(OAuthStateDTO $state): RedirectResponse - { - $panel = filament()->getPanel($state->panel); + if ($result->intent === OAuthIntent::Login) { + Auth::login($result->user); + filament()->setCurrentPanel(filament()->getPanel($state->panel)); + filament()->setTenant($result->tenant); + } - return redirect()->intended($panel->getUrl()); + return redirect()->to($result->redirectUrl); } } diff --git a/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php b/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php index f87a499aa..554feedb3 100644 --- a/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php +++ b/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php @@ -11,12 +11,10 @@ use Filament\Support\Contracts\HasIcon; use Filament\Support\Contracts\HasLabel; use He4rt\Activity\Message\Contracts\MessageActivityAdapter; -use He4rt\Identity\Auth\DTOs\OAuthStateDTO; use He4rt\IntegrationDevTo\OAuth\DevToOAuthClient; use He4rt\IntegrationDiscord\ETL\Adapters\DiscordMessageAdapter; use He4rt\IntegrationDiscord\OAuth\DiscordOAuthClient; use He4rt\IntegrationTwitch\OAuth\TwitchOAuthClient; -use LogicException; enum IdentityProvider: string implements HasColor, HasDescription, HasIcon, HasLabel { @@ -194,18 +192,11 @@ public function isEnabled(): bool public function getRedirectUri(?string $tenant = null): string { - $client = $this->getClient(); - - if (!$client instanceof OAuthClientContract) { - throw new LogicException(sprintf('Provider %s does not support OAuth authentication.', $this->name)); - } - - return $client->redirectUrl( - new OAuthStateDTO( - filament()->getCurrentPanel()->getId(), - $tenant - ) - ); + return route('oauth.redirect', [ + 'tenant' => $tenant ?? request()->getHost(), + 'panel' => filament()->getCurrentPanel()->getId(), + 'provider' => $this->value, + ]); } public function getType(): IdentityType From c8c20e634b7a54f7c1efe0b03b88398f5a60c1d6 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Mon, 25 May 2026 20:19:30 -0300 Subject: [PATCH 04/20] feat(panel-app): add Discord and GitHub social login buttons Add OAuth social login buttons to the app panel login page with Discord and GitHub providers. Add GitHub service config entry. --- .../resources/views/auth/login.blade.php | 30 +++++++++++++++++++ app-modules/panel-app/src/Pages/LoginPage.php | 19 +++++++++++- app/Providers/Filament/AppPanelProvider.php | 18 ++++++----- config/services.php | 8 ++++- 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/app-modules/panel-app/resources/views/auth/login.blade.php b/app-modules/panel-app/resources/views/auth/login.blade.php index e69de29bb..65453993b 100644 --- a/app-modules/panel-app/resources/views/auth/login.blade.php +++ b/app-modules/panel-app/resources/views/auth/login.blade.php @@ -0,0 +1,30 @@ + + + +
+
+
+
+
+ ou +
+
+ + {{ $this->content }} +
diff --git a/app-modules/panel-app/src/Pages/LoginPage.php b/app-modules/panel-app/src/Pages/LoginPage.php index 209a63808..704a713cb 100644 --- a/app-modules/panel-app/src/Pages/LoginPage.php +++ b/app-modules/panel-app/src/Pages/LoginPage.php @@ -1,7 +1,24 @@ environment(['local', 'staging'])) { + $this->form->fill([ + 'email' => 'admin@admin.com', + 'password' => 'admin', + ]); + } + } } diff --git a/app/Providers/Filament/AppPanelProvider.php b/app/Providers/Filament/AppPanelProvider.php index a3d96985e..2e323195b 100644 --- a/app/Providers/Filament/AppPanelProvider.php +++ b/app/Providers/Filament/AppPanelProvider.php @@ -5,7 +5,6 @@ namespace App\Providers\Filament; use App\Enums\FilamentPanel; -use App\Filament\Pages\Login; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -14,6 +13,7 @@ use Filament\PanelProvider; use Filament\Support\Colors\Color; use He4rt\Identity\Tenant\Models\Tenant; +use He4rt\PanelApp\Pages\LoginPage; use He4rt\PanelApp\Pages\ProfilePage; use He4rt\PanelApp\Pages\ThreadPage; use He4rt\PanelApp\Pages\TimelinePage; @@ -30,20 +30,16 @@ class AppPanelProvider extends PanelProvider public function panel(Panel $panel): Panel { - return $panel + $panel ->id($this->panelId->value) ->path($this->panelId->value) - ->login(Login::class) - ->tenant( - model: Tenant::class, - slugAttribute: 'slug' - ) + ->login(LoginPage::class) ->topbar(false) ->colors([ 'primary' => Color::Purple, 'gray' => Color::Zinc, ]) - ->viteTheme('resources/css/filament/admin/theme.css') + ->viteTheme('resources/css/filament/app/theme.css') ->sidebarCollapsibleOnDesktop() ->discoverResources(in: app_path('Filament/App/Resources'), for: 'App\Filament\App\Resources') ->discoverPages(in: app_path('Filament/App/Pages'), for: 'App\Filament\App\Pages') @@ -67,5 +63,11 @@ public function panel(Panel $panel): Panel ->authMiddleware([ Authenticate::class, ]); + + app()->isLocal() + ? $panel->tenant(model: Tenant::class, slugAttribute: 'slug') + : $panel->tenantDomain('{tenant:domain}')->tenant(model: Tenant::class, slugAttribute: 'domain'); + + return $panel; } } diff --git a/config/services.php b/config/services.php index eb64be544..6cefcecfa 100644 --- a/config/services.php +++ b/config/services.php @@ -44,7 +44,6 @@ 'twitch' => [ 'client_id' => env('TWITCH_OAUTH_CLIENT_ID'), 'client_secret' => env('TWITCH_OAUTH_CLIENT_SECRET'), - 'redirect_uri' => env('TWITCH_OAUTH_REDIRECT_URI', 'https://localhost:8000/auth/oauth/twitch'), 'scopes' => [ 'admin' => env('TWITCH_OAUTH_SCOPES_ADMIN', 'user:read:email moderator:read:followers channel:read:subscriptions bits:read moderation:read channel:read:redemptions channel:read:polls channel:read:predictions channel:read:hype_train channel:read:goals channel:read:ads channel:bot'), 'app' => env('TWITCH_OAUTH_SCOPES_APP', 'user:read:email'), @@ -62,6 +61,13 @@ 'enabled' => env('DEVTO_OAUTH_ENABLED', false), ], + 'github' => [ + 'client_id' => env('GITHUB_OAUTH_CLIENT_ID'), + 'client_secret' => env('GITHUB_OAUTH_CLIENT_SECRET'), + 'scopes' => env('GITHUB_OAUTH_SCOPES', 'read:user user:email'), + 'enabled' => env('GITHUB_OAUTH_ENABLED', true), + ], + 'openai' => [ 'api_key' => env('OPENAI_API_KEY'), ], From 2dd429e1a8f4587eae0e74f0c26fb857bc2133b2 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Mon, 25 May 2026 20:19:41 -0300 Subject: [PATCH 05/20] fix(integration): use APP_URL for OAuth callback URIs Replace hardcoded redirect_uri from config with dynamic URL built from APP_URL. Ensures correct callback domain regardless of reverse proxy. --- .../src/OAuth/DiscordOAuthClient.php | 9 +++++++-- .../src/IntegrationTwitchServiceProvider.php | 3 +-- .../src/OAuth/TwitchOAuthClient.php | 11 +++++++++-- .../src/Transport/TwitchOAuthConnector.php | 1 - 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app-modules/integration-discord/src/OAuth/DiscordOAuthClient.php b/app-modules/integration-discord/src/OAuth/DiscordOAuthClient.php index 962ba2777..701d42386 100644 --- a/app-modules/integration-discord/src/OAuth/DiscordOAuthClient.php +++ b/app-modules/integration-discord/src/OAuth/DiscordOAuthClient.php @@ -23,7 +23,7 @@ public function redirectUrl(?OAuthStateDTO $state = null): string return 'https://discord.com/oauth2/authorize?'.http_build_query([ 'client_id' => $this->connector->clientId, 'response_type' => 'code', - 'redirect_uri' => $this->connector->redirectUri, + 'redirect_uri' => $this->callbackUrl(), 'scope' => config('services.discord.scopes'), 'state' => (string) $state, ]); @@ -35,7 +35,7 @@ public function auth(string $code): OAuthAccessDTO code: $code, clientId: $this->connector->clientId, clientSecret: $this->connector->clientSecret, - redirectUri: $this->connector->redirectUri, + redirectUri: $this->callbackUrl(), )); return DiscordOAuthAccessDTO::make($response->json()); @@ -49,4 +49,9 @@ public function getAuthenticatedUser(OAuthAccessDTO $credentials): OAuthUserDTO return DiscordOAuthUser::make($credentials, $response->json()); } + + private function callbackUrl(): string + { + return mb_rtrim(config('app.url'), '/').'/auth/oauth/discord'; + } } diff --git a/app-modules/integration-twitch/src/IntegrationTwitchServiceProvider.php b/app-modules/integration-twitch/src/IntegrationTwitchServiceProvider.php index 288a9b7c2..d53955232 100644 --- a/app-modules/integration-twitch/src/IntegrationTwitchServiceProvider.php +++ b/app-modules/integration-twitch/src/IntegrationTwitchServiceProvider.php @@ -18,8 +18,7 @@ public function register(): void { $this->app->singleton(TwitchOAuthConnector::class, fn (): TwitchOAuthConnector => new TwitchOAuthConnector( clientId: config()->string('services.twitch.client_id'), - clientSecret: config()->string('services.twitch.client_secret'), - redirectUri: config()->string('services.twitch.redirect_uri'), + clientSecret: config()->string('services.twitch.client_secret') )); $this->app->singleton(TwitchAppTokenService::class); diff --git a/app-modules/integration-twitch/src/OAuth/TwitchOAuthClient.php b/app-modules/integration-twitch/src/OAuth/TwitchOAuthClient.php index 17708bd41..e634fc7ac 100644 --- a/app-modules/integration-twitch/src/OAuth/TwitchOAuthClient.php +++ b/app-modules/integration-twitch/src/OAuth/TwitchOAuthClient.php @@ -26,9 +26,11 @@ public function redirectUrl(?OAuthStateDTO $state = null): string $panel = $state->panel ?? 'app'; $scopes = config('services.twitch.scopes.'.$panel, config('services.twitch.scopes.app')); + $callbackUrl = $this->callbackUrl(); + return 'https://id.twitch.tv/oauth2/authorize?'.http_build_query([ 'client_id' => $this->oauthConnector->clientId, - 'redirect_uri' => $this->oauthConnector->redirectUri, + 'redirect_uri' => $callbackUrl, 'response_type' => 'code', 'scope' => $scopes, 'state' => (string) $state, @@ -41,7 +43,7 @@ public function auth(string $code): TwitchOAuthAccessDTO code: $code, clientId: $this->oauthConnector->clientId, clientSecret: $this->oauthConnector->getClientSecret(), - redirectUri: $this->oauthConnector->redirectUri, + redirectUri: $this->callbackUrl(), )); return TwitchOAuthAccessDTO::make($response->json()); @@ -55,4 +57,9 @@ public function getAuthenticatedUser(OAuthAccessDTO $credentials): TwitchOAuthDT return TwitchOAuthDTO::make($credentials, $response->json()); } + + private function callbackUrl(): string + { + return mb_rtrim(config('app.url'), '/').'/auth/oauth/twitch'; + } } diff --git a/app-modules/integration-twitch/src/Transport/TwitchOAuthConnector.php b/app-modules/integration-twitch/src/Transport/TwitchOAuthConnector.php index ae9adc46f..fb05174a9 100644 --- a/app-modules/integration-twitch/src/Transport/TwitchOAuthConnector.php +++ b/app-modules/integration-twitch/src/Transport/TwitchOAuthConnector.php @@ -18,7 +18,6 @@ final class TwitchOAuthConnector extends Connector public function __construct( public readonly string $clientId, private readonly string $clientSecret, - public readonly string $redirectUri, ) {} public function getClientSecret(): string From c87a9d1879a2c9e8cca593865190ce36ba73b327 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Mon, 25 May 2026 20:19:51 -0300 Subject: [PATCH 06/20] fix(panel-admin): ConnectionHub polymorphic owner for tenant providers Admin panel connections now attach to the Tenant (model_type=tenant) instead of the User. Update getTenantProviders query to filter by model_type and use connectedByUser relationship. --- app/Livewire/ConnectionHub.php | 18 ++-- .../livewire/connection-hub-admin.blade.php | 4 +- .../views/livewire/connection-hub.blade.php | 93 +++++++++---------- 3 files changed, 53 insertions(+), 62 deletions(-) diff --git a/app/Livewire/ConnectionHub.php b/app/Livewire/ConnectionHub.php index 558a395de..8ef4c7420 100644 --- a/app/Livewire/ConnectionHub.php +++ b/app/Livewire/ConnectionHub.php @@ -5,7 +5,6 @@ namespace App\Livewire; use Filament\Notifications\Notification; -use He4rt\Identity\Auth\DTOs\OAuthStateDTO; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use He4rt\Identity\Tenant\Models\Tenant; @@ -17,12 +16,12 @@ class ConnectionHub extends Component { public string $panel = 'app'; - public int $tenantId = 0; + public string $tenantId = ''; public function mount(): void { $this->panel = filament()->getCurrentPanel()?->getId() ?? 'app'; - $this->tenantId = filament()->getTenant()?->getKey() ?? 0; + $this->tenantId = filament()->getTenant()?->getKey() ?? ''; } public function render(): View @@ -48,11 +47,11 @@ public function connect(IdentityProvider $provider): void { $tenant = Tenant::query()->find($this->tenantId); - session()->put('tenant', $tenant->slug); - $state = new OAuthStateDTO(panel: $this->panel, tenant: $tenant->slug); - $redirectUri = $provider->getClient()->redirectUrl($state); - - $this->redirect($redirectUri); + $this->redirect(route('oauth.redirect', [ + 'tenant' => $tenant->domain ?? $tenant->slug, + 'panel' => $this->panel, + 'provider' => $provider->value, + ])); } public function disconnect(IdentityProvider $provider): void @@ -119,9 +118,10 @@ private function getTenantProviders(): Collection { return ExternalIdentity::query() ->where('tenant_id', $this->tenantId) + ->where('model_type', 'tenant') ->whereNotNull('connected_at') ->whereNull('disconnected_at') - ->with('user') + ->with('connectedByUser') ->get(); } } diff --git a/resources/views/livewire/connection-hub-admin.blade.php b/resources/views/livewire/connection-hub-admin.blade.php index 8ece02268..68f078748 100644 --- a/resources/views/livewire/connection-hub-admin.blade.php +++ b/resources/views/livewire/connection-hub-admin.blade.php @@ -93,9 +93,9 @@ class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-70 $connection->external_account_id }} - @if ($connection->user) + @if ($connection->connectedByUser) · {{ $connection->user->name }}· {{ $connection->connectedByUser->name }} @endif diff --git a/resources/views/livewire/connection-hub.blade.php b/resources/views/livewire/connection-hub.blade.php index 1d5094c3d..c210da731 100644 --- a/resources/views/livewire/connection-hub.blade.php +++ b/resources/views/livewire/connection-hub.blade.php @@ -6,7 +6,7 @@ /** @var string $panel */ @endphp -
+
@foreach ($supportedProviders as $provider) @php $connected = $userProviders @@ -24,60 +24,71 @@ @endphp
- {{-- Brand accent strip --}} -
- -
- {{-- Provider icon with brand tint --}} +
+ {{-- Provider icon --}}
@if ($connected)
-
-
+ class="absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-gray-900 bg-emerald-400" + >
@endif
{{-- Content --}}
-

{{ $provider->getLabel() }}

+
+ {{ $provider->getLabel() }} + + @if ($connected) + + @else + + Connect + + @endif +
@if ($connected) -
+
@if ($connected->metadata['avatar'] ?? null) @endif - {{ + {{ $connected->metadata['username'] ?? $connected->external_account_id }} · - + {{ $connected->connected_at ->timezone(config('app.display_timezone')) @@ -85,40 +96,20 @@ class="h-4 w-4 rounded-full" }}
- @else -

{{ $provider->getDescription() }}

- @endif -
- - {{-- Action --}} -
- @if ($connected) - - Disconnect - - @else - - Connect - @endif
- {{-- Expandable scopes --}} + {{-- Permissions --}} @if (!$connected && count($scopes) > 0) -
+
- @else - - Connect - + + {{ + $connected->connected_at + ->timezone(config('app.display_timezone')) + ->diffForHumans() + }} + @endif
@@ -87,17 +78,24 @@ class="h-3.5 w-3.5 rounded-full" $connected->metadata['username'] ?? $connected->external_account_id }} - · - - {{ - $connected->connected_at - ->timezone(config('app.display_timezone')) - ->diffForHumans() - }} -
@endif
+ + {{-- Action --}} + @if ($connected) + + @else + + Connect + + @endif
{{-- Permissions --}} From 379394b898932e95f9de75fc0b7c9b2b6caba422 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Mon, 25 May 2026 21:17:54 -0300 Subject: [PATCH 10/20] fix(identity): handle username collision with sequential suffix on OAuth user creation - Remove tenant_id filter from ExternalIdentity lookup (cross-tenant resolution) - Filter by model_type=user to ignore tenant-owned identities - Check username existence before INSERT to avoid PostgreSQL transaction abort - Generate sequential suffix (-2, -3, ...) when username collides - Use DB::transaction savepoint for race condition safety - Skip email lookup when OAuth email is null Closes #284 --- ...auth-user-resolution-and-merge-strategy.md | 128 ++++++++++++ .../Actions/FindOrCreateUserByProvider.php | 66 +++++-- .../Auth/FindOrCreateUserByProviderTest.php | 184 ++++++++++++++++++ 3 files changed, 366 insertions(+), 12 deletions(-) create mode 100644 app-modules/identity/docs/adr/0001-oauth-user-resolution-and-merge-strategy.md create mode 100644 app-modules/identity/tests/Feature/Auth/FindOrCreateUserByProviderTest.php diff --git a/app-modules/identity/docs/adr/0001-oauth-user-resolution-and-merge-strategy.md b/app-modules/identity/docs/adr/0001-oauth-user-resolution-and-merge-strategy.md new file mode 100644 index 000000000..3bfb74959 --- /dev/null +++ b/app-modules/identity/docs/adr/0001-oauth-user-resolution-and-merge-strategy.md @@ -0,0 +1,128 @@ +# ADR-0001: OAuth User Resolution and Account Merge Strategy + +**Status:** Accepted +**Date:** 2026-05-25 +**Deciders:** danielhe4rt + +## Context + +The platform has multiple user creation paths that lead to duplicate User records for the same physical person: + +1. **Discord ETL** — imports guild members as Users with ExternalIdentity (`model_type=user`). These users have no email and a username derived from Discord. +2. **OAuth Web Login** — creates Users when authenticating via GitHub/Discord/Twitch. Uses `FindOrCreateUserByProvider` which looks up by `(provider, external_account_id, tenant_id)` then by email. +3. **Admin Panel (tenant connections)** — creates ExternalIdentity with `model_type=tenant` for infrastructure (bot credentials, channel access). + +The collision scenarios: + +- **Unique violation**: ETL creates `danielhe4rt` (no email). Same person authenticates via GitHub (username=`danielhe4rt`) → INSERT fails on `users_username_unique`. +- **Duplicate users**: ETL creates User A (Discord). Same person authenticates via GitHub → lookup fails (different provider, no email match) → creates User B. Now two Users exist for the same person. +- **Cross-provider blindness**: `FindOrCreateUserByProvider` only searches by the current provider's `external_account_id`. It cannot correlate Discord identity with GitHub identity. + +### Domain distinction: model_type semantics + +- `model_type = user` → personal identity (the person's account in the app) +- `model_type = tenant` → infrastructure (Discord server credentials, Twitch channel OAuth, GitHub App tokens) + +These are fundamentally different domains sharing the same table. A tenant-owned ExternalIdentity does NOT represent a person's identity — it represents organizational infrastructure. + +## Decision + +### 1. User Resolution on Login (intent=Login) + +`FindOrCreateUserByProvider` resolves users with this priority: + +1. **ExternalIdentity match** — `(provider, external_account_id, model_type=user)` without tenant filter (cross-tenant) +2. **Email match** — `User.email = oauth.email` (when email is not null) +3. **Create new user** — if username collides, append sequential suffix (`-2`, `-3`, ...) + +### 2. First Login Enrichment + +New column `first_login_at` (timestamp, nullable) on `users`. When null, the user was created by ETL and never authenticated directly. + +On first real login (`first_login_at IS NULL`): + +- Update `email` from OAuth provider +- Update `name` from OAuth provider +- Attempt `username` update — skip silently if unique constraint would violate +- Set `first_login_at = now()` + +Subsequent logins: no User field updates. + +### 3. Account Merge on Link (intent=Link, ConnectionHub) + +When a logged-in user connects a provider via ConnectionHub and the `external_account_id` already belongs to a **different** User (`model_type=user`, cross-tenant lookup): + +| Step | Action | +| -------- | -------------------------------------------------------------------------------------- | +| Detect | OAuth callback finds conflicting ExternalIdentity owned by another User | +| Store | Save conflict state in session (OAuth credentials, conflicting user ID, provider data) | +| Confirm | ConnectionHub displays modal: old user's username, `created_at`, message count | +| Execute | Synchronous `DB::transaction` on user confirmation | +| Re-login | `Auth::login($oldUser)` | + +#### Merge transaction (confirmed by user): + +1. **Move ExternalIdentities** — update `model_id` on new user's identities → old user +2. **Sync tenant memberships** — `$oldUser->tenants()->syncWithoutDetaching($newUser->tenants)` +3. **Update old user info** — if `first_login_at` is null: update email, name, attempt username +4. **Set `first_login_at`** — on old user if null +5. **Delete new user** — hard delete (new user has no heavy relations) +6. **Re-login** — authenticate as old user + +#### Merge direction rationale: + +Always keep the **old** user (the one that owns the conflicting `external_account_id`). The old user carries heavy relations (100k+ messages, Character, activity history). The new user is lightweight (just created via OAuth). Transferring 2-3 records from new→old is trivial; transferring 100k messages would be catastrophic overhead. + +### 4. Conflict detection scope + +- **Cross-tenant**: `external_account_id` is globally unique per provider (Discord user ID, GitHub user ID). Conflict detection queries without `tenant_id` filter. +- **Only `model_type=user`**: tenant-owned identities are infrastructure, not personal identity. They are never considered for merge. + +### 5. Username suffix generation + +When creating a new User and `username` collides: + +```sql +SELECT username FROM users WHERE username LIKE 'danielhe4rt-%' ORDER BY username DESC LIMIT 1 +``` + +Extract the numeric suffix, increment. If none exists, use `-2`. + +## Alternatives Considered + +### A — Match by username across providers + +Use username collision as proof of identity (same username = same person). **Rejected**: security risk — anyone could register `torvalds` on GitHub and impersonate a user imported from Discord ETL. + +### B — Single ExternalIdentity per (provider, external_account_id) with ownership pivot + +Redesign ExternalIdentity to be unique per physical account with a separate ownership table. **Rejected**: over-engineering for the current problem. The `model_type` split (user vs tenant) serves a valid domain distinction and the merge strategy handles conflicts without schema redesign. + +### C — Transfer old user's data to new user (merge direction: old→new) + +Keep the new user, transfer all history from old. **Rejected**: prohibitively expensive. Users can have 100k+ messages, years of activity. Moving 2-3 lightweight records (identities + tenant pivot) from new→old is orders of magnitude cheaper. + +### D — Automatic merge without confirmation + +Merge silently when conflict is detected. **Rejected**: user should understand what's happening. They might have connected the wrong provider account accidentally. Confirmation modal shows the target account's details (username, creation date, message count) so the user can make an informed decision. + +## Consequences + +### Positive + +- Eliminates duplicate Users for the same physical person +- ETL-created users seamlessly transition to full accounts on first login +- No data loss — heavy history stays in place, lightweight data moves to it +- User has explicit control over merge (confirmation modal) +- Cross-tenant detection prevents duplicates across multi-server setups + +### Negative + +- Session-stored merge state can expire if user doesn't confirm promptly +- Sequential username suffix (`-2`) creates temporary "ugly" usernames until merge happens +- `first_login_at` adds a column that's only relevant during the ETL→OAuth transition period + +### Risks + +- Race condition: two concurrent OAuth callbacks for the same `external_account_id` could both pass the lookup and try to create. Mitigated by: `UniqueConstraintViolationException` catch with retry/fallback to existing record. +- Orphaned merge state in session if user navigates away. Acceptable: state expires naturally, no side effects. diff --git a/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php b/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php index f582970a4..9a74307ec 100644 --- a/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php +++ b/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php @@ -8,20 +8,17 @@ use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; +use Illuminate\Database\UniqueConstraintViolationException; +use Illuminate\Support\Facades\DB; final class FindOrCreateUserByProvider { public function execute(OAuthUserDTO $oauthUser, Tenant $tenant): User { - $user = $this->findExistingUser($oauthUser, $tenant); + $user = $this->findExistingUser($oauthUser); if (!$user instanceof User) { - $user = User::query()->create([ - 'username' => $oauthUser->username, - 'email' => $oauthUser->email, - 'name' => $oauthUser->name, - 'is_donator' => false, - ]); + $user = $this->createUser($oauthUser); } if (!$user->tenants()->where('tenants.id', $tenant->getKey())->exists()) { @@ -31,20 +28,65 @@ public function execute(OAuthUserDTO $oauthUser, Tenant $tenant): User return $user; } - private function findExistingUser(OAuthUserDTO $oauthUser, Tenant $tenant): ?User + private function findExistingUser(OAuthUserDTO $oauthUser): ?User { $identity = ExternalIdentity::query() ->where('provider', $oauthUser->provider) ->where('external_account_id', $oauthUser->providerId) - ->where('tenant_id', $tenant->getKey()) + ->where('model_type', (new User)->getMorphClass()) ->first(); if ($identity?->model instanceof User) { return $identity->model; } - return User::query() - ->where('email', $oauthUser->email) - ->first(); + if ($oauthUser->email !== null) { + return User::query() + ->where('email', $oauthUser->email) + ->first(); + } + + return null; + } + + private function createUser(OAuthUserDTO $oauthUser): User + { + $username = User::query()->where('username', $oauthUser->username)->exists() + ? $this->generateSuffixedUsername($oauthUser->username) + : $oauthUser->username; + + try { + return DB::transaction(fn () => User::query()->create([ + 'username' => $username, + 'email' => $oauthUser->email, + 'name' => $oauthUser->name, + 'is_donator' => false, + ])); + } catch (UniqueConstraintViolationException) { + $username = $this->generateSuffixedUsername($oauthUser->username); + + return User::query()->create([ + 'username' => $username, + 'email' => $oauthUser->email, + 'name' => $oauthUser->name, + 'is_donator' => false, + ]); + } + } + + private function generateSuffixedUsername(string $base): string + { + $prefixLength = mb_strlen($base) + 1; + + $maxSuffix = User::query() + ->where('username', 'LIKE', $base.'-%') + ->pluck('username') + ->reduce(function (?int $max, string $username) use ($prefixLength): int { + $suffix = (int) mb_substr($username, $prefixLength); + + return max($suffix, $max ?? 0); + }); + + return $base.'-'.($maxSuffix !== null ? $maxSuffix + 1 : 2); } } diff --git a/app-modules/identity/tests/Feature/Auth/FindOrCreateUserByProviderTest.php b/app-modules/identity/tests/Feature/Auth/FindOrCreateUserByProviderTest.php new file mode 100644 index 000000000..0f7ebb1aa --- /dev/null +++ b/app-modules/identity/tests/Feature/Auth/FindOrCreateUserByProviderTest.php @@ -0,0 +1,184 @@ +create(); + $tenantB = Tenant::factory()->create(); + $user = User::factory()->create(); + + ExternalIdentity::factory()->create([ + 'tenant_id' => $tenantA->id, + 'model_type' => (new User)->getMorphClass(), + 'model_id' => $user->id, + 'provider' => IdentityProvider::GitHub, + 'external_account_id' => '12345', + ]); + + $action = new FindOrCreateUserByProvider(); + $result = $action->execute( + makeOAuthUser(providerId: '12345', provider: IdentityProvider::GitHub), + $tenantB, + ); + + expect($result->id)->toBe($user->id); +}); + +test('finds existing user by email', function (): void { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['email' => 'daniel@example.com']); + + $action = new FindOrCreateUserByProvider(); + $result = $action->execute( + makeOAuthUser(providerId: 'new-id', email: 'daniel@example.com'), + $tenant, + ); + + expect($result->id)->toBe($user->id); +}); + +test('does not search by email when email is null', function (): void { + $tenant = Tenant::factory()->create(); + User::factory()->create(['email' => null, 'username' => 'someone']); + + $userCountBefore = User::query()->count(); + + $action = new FindOrCreateUserByProvider(); + $result = $action->execute( + makeOAuthUser(providerId: 'new-id', username: 'newuser', email: null), + $tenant, + ); + + expect($result->username)->toBe('newuser'); + expect(User::query()->count())->toBe($userCountBefore + 1); +}); + +test('creates new user when no match found', function (): void { + $tenant = Tenant::factory()->create(); + + $action = new FindOrCreateUserByProvider(); + $result = $action->execute( + makeOAuthUser(providerId: 'fresh-id', username: 'freshuser', name: 'Fresh User', email: 'fresh@example.com'), + $tenant, + ); + + expect($result->username)->toBe('freshuser') + ->and($result->email)->toBe('fresh@example.com') + ->and($result->name)->toBe('Fresh User'); +}); + +test('creates user with sequential suffix when username collides', function (): void { + $tenant = Tenant::factory()->create(); + User::factory()->create(['username' => 'danielhe4rt']); + + $action = new FindOrCreateUserByProvider(); + $result = $action->execute( + makeOAuthUser(providerId: 'new-id', username: 'danielhe4rt', email: 'new@example.com'), + $tenant, + ); + + expect($result->username)->toBe('danielhe4rt-2') + ->and($result->email)->toBe('new@example.com'); +}); + +test('increments suffix when previous suffixed usernames exist', function (): void { + $tenant = Tenant::factory()->create(); + User::factory()->create(['username' => 'danielhe4rt']); + User::factory()->create(['username' => 'danielhe4rt-2']); + User::factory()->create(['username' => 'danielhe4rt-3']); + + $action = new FindOrCreateUserByProvider(); + $result = $action->execute( + makeOAuthUser(providerId: 'new-id', username: 'danielhe4rt', email: 'new@example.com'), + $tenant, + ); + + expect($result->username)->toBe('danielhe4rt-4'); +}); + +test('attaches user to tenant when not already attached', function (): void { + $tenant = Tenant::factory()->create(); + + $action = new FindOrCreateUserByProvider(); + $result = $action->execute( + makeOAuthUser(providerId: 'new-id', username: 'newuser'), + $tenant, + ); + + expect($result->tenants()->where('tenants.id', $tenant->getKey())->exists())->toBeTrue(); +}); + +test('does not duplicate tenant attachment when already attached', function (): void { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + $user->tenants()->attach($tenant); + + ExternalIdentity::factory()->create([ + 'tenant_id' => $tenant->id, + 'model_type' => (new User)->getMorphClass(), + 'model_id' => $user->id, + 'provider' => IdentityProvider::GitHub, + 'external_account_id' => '12345', + ]); + + $action = new FindOrCreateUserByProvider(); + $action->execute( + makeOAuthUser(providerId: '12345', provider: IdentityProvider::GitHub), + $tenant, + ); + + expect($user->tenants()->count())->toBe(1); +}); + +test('ignores tenant-owned external identities during lookup', function (): void { + $tenant = Tenant::factory()->create(); + + ExternalIdentity::factory()->create([ + 'tenant_id' => $tenant->id, + 'model_type' => (new Tenant)->getMorphClass(), + 'model_id' => $tenant->id, + 'provider' => IdentityProvider::Discord, + 'external_account_id' => '204122995579551744', + ]); + + $action = new FindOrCreateUserByProvider(); + $result = $action->execute( + makeOAuthUser(providerId: '204122995579551744', provider: IdentityProvider::Discord, username: 'newuser'), + $tenant, + ); + + expect($result->username)->toBe('newuser'); + expect(User::query()->where('username', 'newuser')->exists())->toBeTrue(); +}); From 7c23a00d01da744bac334ce6edc47dc2c7b65c9e Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Mon, 25 May 2026 21:22:06 -0300 Subject: [PATCH 11/20] feat(identity): add first_login_at and enrich user on first OAuth login - Add `first_login_at` nullable timestamp to users table - New `EnrichUserOnFirstLogin` action: updates email, name, attempts username on first real login (when first_login_at is null) - Integrate enrichment into `FindOrCreateUserByProvider` via DI - Username update skipped silently when it would collide - Subsequent logins (first_login_at set) leave user untouched Closes #285 --- ...1934_add_first_login_at_to_users_table.php | 17 +++ .../Auth/Actions/EnrichUserOnFirstLogin.php | 49 ++++++++ .../Actions/FindOrCreateUserByProvider.php | 10 +- app-modules/identity/src/User/Models/User.php | 2 + .../Auth/EnrichUserOnFirstLoginTest.php | 115 ++++++++++++++++++ .../Auth/FindOrCreateUserByProviderTest.php | 18 +-- 6 files changed, 200 insertions(+), 11 deletions(-) create mode 100644 app-modules/identity/database/migrations/2026_05_26_001934_add_first_login_at_to_users_table.php create mode 100644 app-modules/identity/src/Auth/Actions/EnrichUserOnFirstLogin.php create mode 100644 app-modules/identity/tests/Feature/Auth/EnrichUserOnFirstLoginTest.php diff --git a/app-modules/identity/database/migrations/2026_05_26_001934_add_first_login_at_to_users_table.php b/app-modules/identity/database/migrations/2026_05_26_001934_add_first_login_at_to_users_table.php new file mode 100644 index 000000000..aac004034 --- /dev/null +++ b/app-modules/identity/database/migrations/2026_05_26_001934_add_first_login_at_to_users_table.php @@ -0,0 +1,17 @@ +timestamp('first_login_at')->nullable()->after('banned_at'); + }); + } +}; diff --git a/app-modules/identity/src/Auth/Actions/EnrichUserOnFirstLogin.php b/app-modules/identity/src/Auth/Actions/EnrichUserOnFirstLogin.php new file mode 100644 index 000000000..dd53302d5 --- /dev/null +++ b/app-modules/identity/src/Auth/Actions/EnrichUserOnFirstLogin.php @@ -0,0 +1,49 @@ +first_login_at !== null) { + return $user; + } + + $updates = ['first_login_at' => now()]; + + if ($oauthUser->email !== null) { + $updates['email'] = $oauthUser->email; + } + + if ($oauthUser->name !== '') { + $updates['name'] = $oauthUser->name; + } + + $canUpdateUsername = $oauthUser->username !== $user->username + && !User::query() + ->where('username', $oauthUser->username) + ->where('id', '!=', $user->id) + ->exists(); + + if ($canUpdateUsername) { + $updates['username'] = $oauthUser->username; + } + + try { + DB::transaction(fn () => $user->update($updates)); + } catch (UniqueConstraintViolationException) { + unset($updates['username']); + $user->update($updates); + } + + return $user->refresh(); + } +} diff --git a/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php b/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php index 9a74307ec..86e621154 100644 --- a/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php +++ b/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php @@ -11,13 +11,19 @@ use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\DB; -final class FindOrCreateUserByProvider +final readonly class FindOrCreateUserByProvider { + public function __construct( + private EnrichUserOnFirstLogin $enrichUser, + ) {} + public function execute(OAuthUserDTO $oauthUser, Tenant $tenant): User { $user = $this->findExistingUser($oauthUser); - if (!$user instanceof User) { + if ($user instanceof User) { + $user = $this->enrichUser->execute($user, $oauthUser); + } else { $user = $this->createUser($oauthUser); } diff --git a/app-modules/identity/src/User/Models/User.php b/app-modules/identity/src/User/Models/User.php index d28673bc9..48adca7fb 100644 --- a/app-modules/identity/src/User/Models/User.php +++ b/app-modules/identity/src/User/Models/User.php @@ -35,6 +35,7 @@ * @property bool $is_donator * @property Carbon|null $suspended_until * @property Carbon|null $banned_at + * @property Carbon|null $first_login_at * @property Carbon|null $created_at * @property Carbon|null $updated_at */ @@ -132,6 +133,7 @@ protected function casts(): array 'password' => 'hashed', 'suspended_until' => 'datetime', 'banned_at' => 'datetime', + 'first_login_at' => 'datetime', ]; } } diff --git a/app-modules/identity/tests/Feature/Auth/EnrichUserOnFirstLoginTest.php b/app-modules/identity/tests/Feature/Auth/EnrichUserOnFirstLoginTest.php new file mode 100644 index 000000000..31aa8029e --- /dev/null +++ b/app-modules/identity/tests/Feature/Auth/EnrichUserOnFirstLoginTest.php @@ -0,0 +1,115 @@ +create([ + 'email' => null, + 'name' => 'oldname', + 'first_login_at' => null, + ]); + + $action = new EnrichUserOnFirstLogin(); + $result = $action->execute( + $user, + makeOAuthUserForEnrich(name: 'Daniel Reis', email: 'daniel@example.com'), + ); + + expect($result->email)->toBe('daniel@example.com') + ->and($result->name)->toBe('Daniel Reis') + ->and($result->first_login_at)->not->toBeNull(); +}); + +test('updates username on first login when available', function (): void { + $user = User::factory()->create([ + 'username' => 'old-username', + 'first_login_at' => null, + ]); + + $action = new EnrichUserOnFirstLogin(); + $result = $action->execute( + $user, + makeOAuthUserForEnrich(username: 'new-username'), + ); + + expect($result->username)->toBe('new-username'); +}); + +test('skips username update when it would collide', function (): void { + User::factory()->create(['username' => 'taken-username']); + $user = User::factory()->create([ + 'username' => 'original', + 'first_login_at' => null, + ]); + + $action = new EnrichUserOnFirstLogin(); + $result = $action->execute( + $user, + makeOAuthUserForEnrich(username: 'taken-username'), + ); + + expect($result->username)->toBe('original'); +}); + +test('does not update user when first_login_at is already set', function (): void { + $user = User::factory()->create([ + 'email' => 'old@example.com', + 'name' => 'Old Name', + 'username' => 'olduser', + 'first_login_at' => now()->subMonth(), + ]); + + $action = new EnrichUserOnFirstLogin(); + $result = $action->execute( + $user, + makeOAuthUserForEnrich(username: 'newuser', name: 'New Name', email: 'new@example.com'), + ); + + expect($result->email)->toBe('old@example.com') + ->and($result->name)->toBe('Old Name') + ->and($result->username)->toBe('olduser'); +}); + +test('does not overwrite email with null', function (): void { + $user = User::factory()->create([ + 'email' => 'existing@example.com', + 'first_login_at' => null, + ]); + + $action = new EnrichUserOnFirstLogin(); + $result = $action->execute( + $user, + makeOAuthUserForEnrich(email: null), + ); + + expect($result->email)->toBe('existing@example.com') + ->and($result->first_login_at)->not->toBeNull(); +}); diff --git a/app-modules/identity/tests/Feature/Auth/FindOrCreateUserByProviderTest.php b/app-modules/identity/tests/Feature/Auth/FindOrCreateUserByProviderTest.php index 0f7ebb1aa..daa2f79d1 100644 --- a/app-modules/identity/tests/Feature/Auth/FindOrCreateUserByProviderTest.php +++ b/app-modules/identity/tests/Feature/Auth/FindOrCreateUserByProviderTest.php @@ -47,7 +47,7 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self 'external_account_id' => '12345', ]); - $action = new FindOrCreateUserByProvider(); + $action = resolve(FindOrCreateUserByProvider::class); $result = $action->execute( makeOAuthUser(providerId: '12345', provider: IdentityProvider::GitHub), $tenantB, @@ -60,7 +60,7 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self $tenant = Tenant::factory()->create(); $user = User::factory()->create(['email' => 'daniel@example.com']); - $action = new FindOrCreateUserByProvider(); + $action = resolve(FindOrCreateUserByProvider::class); $result = $action->execute( makeOAuthUser(providerId: 'new-id', email: 'daniel@example.com'), $tenant, @@ -75,7 +75,7 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self $userCountBefore = User::query()->count(); - $action = new FindOrCreateUserByProvider(); + $action = resolve(FindOrCreateUserByProvider::class); $result = $action->execute( makeOAuthUser(providerId: 'new-id', username: 'newuser', email: null), $tenant, @@ -88,7 +88,7 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self test('creates new user when no match found', function (): void { $tenant = Tenant::factory()->create(); - $action = new FindOrCreateUserByProvider(); + $action = resolve(FindOrCreateUserByProvider::class); $result = $action->execute( makeOAuthUser(providerId: 'fresh-id', username: 'freshuser', name: 'Fresh User', email: 'fresh@example.com'), $tenant, @@ -103,7 +103,7 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self $tenant = Tenant::factory()->create(); User::factory()->create(['username' => 'danielhe4rt']); - $action = new FindOrCreateUserByProvider(); + $action = resolve(FindOrCreateUserByProvider::class); $result = $action->execute( makeOAuthUser(providerId: 'new-id', username: 'danielhe4rt', email: 'new@example.com'), $tenant, @@ -119,7 +119,7 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self User::factory()->create(['username' => 'danielhe4rt-2']); User::factory()->create(['username' => 'danielhe4rt-3']); - $action = new FindOrCreateUserByProvider(); + $action = resolve(FindOrCreateUserByProvider::class); $result = $action->execute( makeOAuthUser(providerId: 'new-id', username: 'danielhe4rt', email: 'new@example.com'), $tenant, @@ -131,7 +131,7 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self test('attaches user to tenant when not already attached', function (): void { $tenant = Tenant::factory()->create(); - $action = new FindOrCreateUserByProvider(); + $action = resolve(FindOrCreateUserByProvider::class); $result = $action->execute( makeOAuthUser(providerId: 'new-id', username: 'newuser'), $tenant, @@ -153,7 +153,7 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self 'external_account_id' => '12345', ]); - $action = new FindOrCreateUserByProvider(); + $action = resolve(FindOrCreateUserByProvider::class); $action->execute( makeOAuthUser(providerId: '12345', provider: IdentityProvider::GitHub), $tenant, @@ -173,7 +173,7 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self 'external_account_id' => '204122995579551744', ]); - $action = new FindOrCreateUserByProvider(); + $action = resolve(FindOrCreateUserByProvider::class); $result = $action->execute( makeOAuthUser(providerId: '204122995579551744', provider: IdentityProvider::Discord, username: 'newuser'), $tenant, From aa0e7a19a127c76209bb88465802e14f02c01a3c Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Mon, 25 May 2026 21:25:57 -0300 Subject: [PATCH 12/20] refactor(identity): use match expression and expressive variable in FindOrCreateUserByProvider --- .../Auth/Actions/FindOrCreateUserByProvider.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php b/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php index 86e621154..1618c7097 100644 --- a/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php +++ b/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php @@ -19,15 +19,16 @@ public function __construct( public function execute(OAuthUserDTO $oauthUser, Tenant $tenant): User { - $user = $this->findExistingUser($oauthUser); + $existing = $this->findExistingUser($oauthUser); - if ($user instanceof User) { - $user = $this->enrichUser->execute($user, $oauthUser); - } else { - $user = $this->createUser($oauthUser); - } + $user = match ($existing instanceof User) { + true => $this->enrichUser->execute($existing, $oauthUser), + false => $this->createUser($oauthUser), + }; + + $alreadyBelongsToTenant = $user->tenants()->where('tenants.id', $tenant->getKey())->exists(); - if (!$user->tenants()->where('tenants.id', $tenant->getKey())->exists()) { + if (!$alreadyBelongsToTenant) { $user->tenants()->attach($tenant); } From d75a825062d2011c2ec31e03ee6bbb51f7817e61 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Mon, 25 May 2026 21:27:51 -0300 Subject: [PATCH 13/20] feat(identity): detect merge conflict on Link flow and store in session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `DetectMergeConflict` action: checks if external_account_id already belongs to another User (cross-tenant, model_type=user only) - New `MergeConflictDTO`: carries conflicting user ID, provider, credentials, and OAuth user data for session serialization - `HandleOAuthCallbackAction` now checks for conflict before attaching provider on Link intent — returns early with merge conflict in result - `OAuthResultDTO` extended with optional `mergeConflict` field - `OAuthController` saves conflict to session key `oauth_merge_pending` and redirects back to panel without creating identity Closes #286 --- .../src/Auth/Actions/DetectMergeConflict.php | 35 +++++ .../Actions/HandleOAuthCallbackAction.php | 23 ++- .../src/Auth/DTOs/MergeConflictDTO.php | 36 +++++ .../identity/src/Auth/DTOs/OAuthResultDTO.php | 8 +- .../Auth/Http/Controllers/OAuthController.php | 6 + .../Feature/Auth/DetectMergeConflictTest.php | 144 ++++++++++++++++++ 6 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 app-modules/identity/src/Auth/Actions/DetectMergeConflict.php create mode 100644 app-modules/identity/src/Auth/DTOs/MergeConflictDTO.php create mode 100644 app-modules/identity/tests/Feature/Auth/DetectMergeConflictTest.php diff --git a/app-modules/identity/src/Auth/Actions/DetectMergeConflict.php b/app-modules/identity/src/Auth/Actions/DetectMergeConflict.php new file mode 100644 index 000000000..3ab9ef102 --- /dev/null +++ b/app-modules/identity/src/Auth/Actions/DetectMergeConflict.php @@ -0,0 +1,35 @@ +where('provider', $oauthUser->provider) + ->where('external_account_id', $oauthUser->providerId) + ->where('model_type', (new User)->getMorphClass()) + ->where('model_id', '!=', $currentUser->id) + ->first(); + + if (!$existingIdentity instanceof ExternalIdentity) { + return null; + } + + return new MergeConflictDTO( + conflictingUserId: $existingIdentity->model_id, + provider: $oauthUser->provider, + credentials: $credentials, + oauthUser: $oauthUser, + ); + } +} diff --git a/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php b/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php index a5892e9f7..a76a19e76 100644 --- a/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php +++ b/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php @@ -5,6 +5,7 @@ namespace He4rt\Identity\Auth\Actions; use App\Contracts\OAuthClientContract; +use He4rt\Identity\Auth\DTOs\MergeConflictDTO; use He4rt\Identity\Auth\DTOs\OAuthResultDTO; use He4rt\Identity\Auth\DTOs\OAuthStateDTO; use He4rt\Identity\Auth\Enums\OAuthIntent; @@ -19,6 +20,7 @@ public function __construct( private FindOrCreateUserByProvider $findOrCreateUser, private AttachProviderToUser $attachProvider, + private DetectMergeConflict $detectMergeConflict, ) {} public function execute(OAuthStateDTO $state, IdentityProvider $provider, string $code): OAuthResultDTO @@ -42,13 +44,28 @@ public function execute(OAuthStateDTO $state, IdentityProvider $provider, string OAuthIntent::Link => $this->resolveAuthenticatedUser(), }; - $owner = $state->panel === 'admin' ? $tenant : $user; - $identity = $this->attachProvider->execute($owner, $tenant, $oauthUser, $access); - $redirectUrl = $state->returnUrl ?? filament() ->getPanel($state->panel) ->getUrl($tenant); + if ($state->intent === OAuthIntent::Link) { + $mergeConflict = $this->detectMergeConflict->execute($user, $oauthUser, $access); + + if ($mergeConflict instanceof MergeConflictDTO) { + return new OAuthResultDTO( + user: $user, + tenant: $tenant, + identity: null, + intent: $state->intent, + redirectUrl: $redirectUrl, + mergeConflict: $mergeConflict, + ); + } + } + + $owner = $state->panel === 'admin' ? $tenant : $user; + $identity = $this->attachProvider->execute($owner, $tenant, $oauthUser, $access); + return new OAuthResultDTO( user: $user, tenant: $tenant, diff --git a/app-modules/identity/src/Auth/DTOs/MergeConflictDTO.php b/app-modules/identity/src/Auth/DTOs/MergeConflictDTO.php new file mode 100644 index 000000000..ab771126a --- /dev/null +++ b/app-modules/identity/src/Auth/DTOs/MergeConflictDTO.php @@ -0,0 +1,36 @@ + + */ + public function toSession(): array + { + return [ + 'conflicting_user_id' => $this->conflictingUserId, + 'provider' => $this->provider->value, + 'credentials' => $this->credentials->toDatabase(), + 'oauth_user' => [ + 'provider_id' => $this->oauthUser->providerId, + 'username' => $this->oauthUser->username, + 'name' => $this->oauthUser->name, + 'email' => $this->oauthUser->email, + 'avatar_url' => $this->oauthUser->avatarUrl, + ], + ]; + } +} diff --git a/app-modules/identity/src/Auth/DTOs/OAuthResultDTO.php b/app-modules/identity/src/Auth/DTOs/OAuthResultDTO.php index ee32d0829..e1c10d217 100644 --- a/app-modules/identity/src/Auth/DTOs/OAuthResultDTO.php +++ b/app-modules/identity/src/Auth/DTOs/OAuthResultDTO.php @@ -14,8 +14,14 @@ public function __construct( public User $user, public Tenant $tenant, - public ExternalIdentity $identity, + public ?ExternalIdentity $identity, public OAuthIntent $intent, public string $redirectUrl, + public ?MergeConflictDTO $mergeConflict = null, ) {} + + public function hasMergeConflict(): bool + { + return $this->mergeConflict instanceof MergeConflictDTO; + } } diff --git a/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php b/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php index 61d4ce382..8e0503822 100644 --- a/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php +++ b/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php @@ -47,6 +47,12 @@ public function getAuthenticate(string $provider, HandleOAuthCallbackAction $act $result = $action->execute($state, $identityProvider, request()->input('code')); + if ($result->hasMergeConflict()) { + session()->put('oauth_merge_pending', $result->mergeConflict->toSession()); + + return redirect()->to($result->redirectUrl); + } + if ($result->intent === OAuthIntent::Login) { Auth::login($result->user); filament()->setCurrentPanel(filament()->getPanel($state->panel)); diff --git a/app-modules/identity/tests/Feature/Auth/DetectMergeConflictTest.php b/app-modules/identity/tests/Feature/Auth/DetectMergeConflictTest.php new file mode 100644 index 000000000..3df3620cd --- /dev/null +++ b/app-modules/identity/tests/Feature/Auth/DetectMergeConflictTest.php @@ -0,0 +1,144 @@ +create(); + $oauthUser = makeMergeOAuthUser(providerId: 'fresh-id'); + $credentials = makeMergeCredentials(); + + $action = new DetectMergeConflict(); + $result = $action->execute($currentUser, $oauthUser, $credentials); + + expect($result)->toBeNull(); +}); + +test('returns null when identity belongs to current user', function (): void { + $tenant = Tenant::factory()->create(); + $currentUser = User::factory()->create(); + + ExternalIdentity::factory()->create([ + 'tenant_id' => $tenant->id, + 'model_type' => (new User)->getMorphClass(), + 'model_id' => $currentUser->id, + 'provider' => IdentityProvider::Discord, + 'external_account_id' => '204122995579551744', + ]); + + $oauthUser = makeMergeOAuthUser(providerId: '204122995579551744'); + $credentials = makeMergeCredentials(); + + $action = new DetectMergeConflict(); + $result = $action->execute($currentUser, $oauthUser, $credentials); + + expect($result)->toBeNull(); +}); + +test('returns conflict when identity belongs to a different user', function (): void { + $tenant = Tenant::factory()->create(); + $oldUser = User::factory()->create(); + $currentUser = User::factory()->create(); + + ExternalIdentity::factory()->create([ + 'tenant_id' => $tenant->id, + 'model_type' => (new User)->getMorphClass(), + 'model_id' => $oldUser->id, + 'provider' => IdentityProvider::Discord, + 'external_account_id' => '204122995579551744', + ]); + + $oauthUser = makeMergeOAuthUser(providerId: '204122995579551744'); + $credentials = makeMergeCredentials(); + + $action = new DetectMergeConflict(); + $result = $action->execute($currentUser, $oauthUser, $credentials); + + expect($result)->not->toBeNull() + ->and($result->conflictingUserId)->toBe($oldUser->id) + ->and($result->provider)->toBe(IdentityProvider::Discord); +}); + +test('detects conflict cross-tenant', function (): void { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + $oldUser = User::factory()->create(); + $currentUser = User::factory()->create(); + + ExternalIdentity::factory()->create([ + 'tenant_id' => $tenantA->id, + 'model_type' => (new User)->getMorphClass(), + 'model_id' => $oldUser->id, + 'provider' => IdentityProvider::Discord, + 'external_account_id' => '204122995579551744', + ]); + + $oauthUser = makeMergeOAuthUser(providerId: '204122995579551744'); + $credentials = makeMergeCredentials(); + + $action = new DetectMergeConflict(); + $result = $action->execute($currentUser, $oauthUser, $credentials); + + expect($result)->not->toBeNull() + ->and($result->conflictingUserId)->toBe($oldUser->id); +}); + +test('ignores tenant-owned identities', function (): void { + $tenant = Tenant::factory()->create(); + $currentUser = User::factory()->create(); + + ExternalIdentity::factory()->create([ + 'tenant_id' => $tenant->id, + 'model_type' => (new Tenant)->getMorphClass(), + 'model_id' => $tenant->id, + 'provider' => IdentityProvider::Discord, + 'external_account_id' => '204122995579551744', + ]); + + $oauthUser = makeMergeOAuthUser(providerId: '204122995579551744'); + $credentials = makeMergeCredentials(); + + $action = new DetectMergeConflict(); + $result = $action->execute($currentUser, $oauthUser, $credentials); + + expect($result)->toBeNull(); +}); From e2e3d163adba5513b9d998bb60c3e047d24fbe04 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Mon, 25 May 2026 21:30:59 -0300 Subject: [PATCH 14/20] feat(panel-app): merge confirmation modal in ConnectionHub with MergeAccountsAction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `MergeAccountsAction`: moves ExternalIdentities and tenant memberships from current user to old user, enriches old user if first_login_at is null, deletes current user — all in DB::transaction - ConnectionHub detects `oauth_merge_pending` session on mount - Shows confirmation modal with old user's username, created_at, and message count - confirmMerge: executes merge, re-logs as old user, redirects - cancelMerge: clears session, dismisses modal Closes #287 --- .../src/Auth/Actions/MergeAccountsAction.php | 67 ++++++++++ .../Feature/Auth/MergeAccountsActionTest.php | 124 ++++++++++++++++++ app/Livewire/ConnectionHub.php | 91 +++++++++++++ .../views/livewire/connection-hub.blade.php | 45 ++++++- 4 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 app-modules/identity/src/Auth/Actions/MergeAccountsAction.php create mode 100644 app-modules/identity/tests/Feature/Auth/MergeAccountsActionTest.php diff --git a/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php b/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php new file mode 100644 index 000000000..74582a5a3 --- /dev/null +++ b/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php @@ -0,0 +1,67 @@ +where('model_type', (new User)->getMorphClass()) + ->where('model_id', $currentUser->id) + ->update(['model_id' => $oldUser->id]); + + $oldUser->tenants()->syncWithoutDetaching( + $currentUser->tenants()->pluck('tenants.id') + ); + + $currentUser->delete(); + + $this->enrichOldUser($currentUser, $oldUser); + }); + } + + private function enrichOldUser(User $source, User $target): void + { + $isFirstLogin = $target->first_login_at === null; + + if (!$isFirstLogin) { + return; + } + + $updates = ['first_login_at' => now()]; + + if ($source->email !== null && $target->email === null) { + $updates['email'] = $source->email; + } + + if ($source->name !== $source->username && $target->name === $target->username) { + $updates['name'] = $source->name; + } + + $canUpdateUsername = $source->username !== $target->username + && !User::query() + ->where('username', $source->username) + ->where('id', '!=', $target->id) + ->exists(); + + if ($canUpdateUsername) { + $updates['username'] = $source->username; + } + + try { + DB::transaction(fn () => $target->update($updates)); + } catch (UniqueConstraintViolationException) { + unset($updates['username']); + $target->update($updates); + } + } +} diff --git a/app-modules/identity/tests/Feature/Auth/MergeAccountsActionTest.php b/app-modules/identity/tests/Feature/Auth/MergeAccountsActionTest.php new file mode 100644 index 000000000..614a1c6b7 --- /dev/null +++ b/app-modules/identity/tests/Feature/Auth/MergeAccountsActionTest.php @@ -0,0 +1,124 @@ +create(); + $oldUser = User::factory()->create(['first_login_at' => now()]); + $currentUser = User::factory()->create(); + + $identity = ExternalIdentity::factory()->create([ + 'tenant_id' => $tenant->id, + 'model_type' => (new User)->getMorphClass(), + 'model_id' => $currentUser->id, + 'provider' => IdentityProvider::GitHub, + 'external_account_id' => 'github-123', + ]); + + $action = new MergeAccountsAction(); + $action->execute($currentUser, $oldUser); + + $identity->refresh(); + expect($identity->model_id)->toBe($oldUser->id); +}); + +test('syncs tenant memberships from current to old user', function (): void { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + $oldUser = User::factory()->create(['first_login_at' => now()]); + $currentUser = User::factory()->create(); + + $oldUser->tenants()->attach($tenantA); + $currentUser->tenants()->attach([$tenantA->id, $tenantB->id]); + + $action = new MergeAccountsAction(); + $action->execute($currentUser, $oldUser); + + expect($oldUser->tenants()->pluck('tenants.id')->sort()->values()->toArray()) + ->toBe(collect([$tenantA->id, $tenantB->id])->sort()->values()->all()); +}); + +test('deletes current user after merge', function (): void { + $oldUser = User::factory()->create(['first_login_at' => now()]); + $currentUser = User::factory()->create(); + $currentUserId = $currentUser->id; + + $action = new MergeAccountsAction(); + $action->execute($currentUser, $oldUser); + + expect(User::query()->find($currentUserId))->toBeNull(); +}); + +test('enriches old user when first_login_at is null', function (): void { + $oldUser = User::factory()->create([ + 'username' => 'old-etl-user', + 'name' => 'old-etl-user', + 'email' => null, + 'first_login_at' => null, + ]); + $currentUser = User::factory()->create([ + 'username' => 'new-oauth-user', + 'name' => 'Daniel Reis', + 'email' => 'daniel@example.com', + ]); + + $action = new MergeAccountsAction(); + $action->execute($currentUser, $oldUser); + + $oldUser->refresh(); + expect($oldUser->email)->toBe('daniel@example.com') + ->and($oldUser->name)->toBe('Daniel Reis') + ->and($oldUser->first_login_at)->not->toBeNull(); +}); + +test('does not enrich old user when first_login_at is already set', function (): void { + $oldUser = User::factory()->create([ + 'username' => 'active-user', + 'name' => 'Active User', + 'email' => 'active@example.com', + 'first_login_at' => now()->subMonth(), + ]); + $currentUser = User::factory()->create([ + 'username' => 'new-user', + 'name' => 'New Name', + 'email' => 'new@example.com', + ]); + + $action = new MergeAccountsAction(); + $action->execute($currentUser, $oldUser); + + $oldUser->refresh(); + expect($oldUser->email)->toBe('active@example.com') + ->and($oldUser->name)->toBe('Active User') + ->and($oldUser->username)->toBe('active-user'); +}); + +test('skips username update on old user when it would collide', function (): void { + User::factory()->create(['username' => 'blocked-name']); + $oldUser = User::factory()->create([ + 'username' => 'old-user', + 'name' => 'old-user', + 'first_login_at' => null, + ]); + $currentUser = User::factory()->create([ + 'username' => 'blocked-name-2', + 'name' => 'Current Name', + 'email' => 'new@example.com', + ]); + + // Simulate currentUser having the blocked username by setting it in-memory + // after creation (the merge reads $source->username from the object) + $currentUser->username = 'blocked-name'; + + $action = new MergeAccountsAction(); + $action->execute($currentUser, $oldUser); + + $oldUser->refresh(); + expect($oldUser->username)->toBe('old-user'); +}); diff --git a/app/Livewire/ConnectionHub.php b/app/Livewire/ConnectionHub.php index 8ef4c7420..8db7805af 100644 --- a/app/Livewire/ConnectionHub.php +++ b/app/Livewire/ConnectionHub.php @@ -5,11 +5,14 @@ namespace App\Livewire; use Filament\Notifications\Notification; +use He4rt\Identity\Auth\Actions\MergeAccountsAction; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use He4rt\Identity\Tenant\Models\Tenant; +use He4rt\Identity\User\Models\User; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\Auth; use Livewire\Component; class ConnectionHub extends Component @@ -18,10 +21,16 @@ class ConnectionHub extends Component public string $tenantId = ''; + public bool $showMergeModal = false; + + /** @var array|null */ + public ?array $mergeData = null; + public function mount(): void { $this->panel = filament()->getCurrentPanel()?->getId() ?? 'app'; $this->tenantId = filament()->getTenant()?->getKey() ?? ''; + $this->checkPendingMerge(); } public function render(): View @@ -40,6 +49,7 @@ public function render(): View 'userProviders' => $this->getUserProviders(), 'supportedProviders' => $supportedProviders, 'panel' => $this->panel, + 'mergeTarget' => $this->getMergeTarget(), ]); } @@ -54,6 +64,46 @@ public function connect(IdentityProvider $provider): void ])); } + public function confirmMerge(MergeAccountsAction $action): void + { + if ($this->mergeData === null) { + return; + } + + $oldUser = User::query()->find($this->mergeData['conflicting_user_id']); + + if (!$oldUser instanceof User) { + $this->cancelMerge(); + + return; + } + + /** @var User $currentUser */ + $currentUser = auth()->user(); + + $action->execute($currentUser, $oldUser); + + session()->forget('oauth_merge_pending'); + $this->showMergeModal = false; + $this->mergeData = null; + + Auth::login($oldUser); + + Notification::make() + ->title('Contas unificadas com sucesso') + ->success() + ->send(); + + $this->redirect(request()->url()); + } + + public function cancelMerge(): void + { + session()->forget('oauth_merge_pending'); + $this->showMergeModal = false; + $this->mergeData = null; + } + public function disconnect(IdentityProvider $provider): void { $identity = auth()->user() @@ -107,6 +157,47 @@ public function disconnectById(string $identityId): void ->send(); } + private function checkPendingMerge(): void + { + $pending = session()->get('oauth_merge_pending'); + + if ($pending === null) { + return; + } + + $this->mergeData = $pending; + $this->showMergeModal = true; + } + + /** + * @return array|null + */ + private function getMergeTarget(): ?array + { + if ($this->mergeData === null) { + return null; + } + + $user = User::query()->find($this->mergeData['conflicting_user_id']); + + if (!$user instanceof User) { + return null; + } + + $messagesCount = ExternalIdentity::query() + ->where('model_type', (new User)->getMorphClass()) + ->where('model_id', $user->id) + ->withCount('messages') + ->get() + ->sum('messages_count'); + + return [ + 'username' => $user->username, + 'created_at' => $user->created_at?->format('d/m/Y'), + 'messages_count' => $messagesCount, + ]; + } + /** @return Collection */ private function getUserProviders(): Collection { diff --git a/resources/views/livewire/connection-hub.blade.php b/resources/views/livewire/connection-hub.blade.php index 15f1abdbb..f77655673 100644 --- a/resources/views/livewire/connection-hub.blade.php +++ b/resources/views/livewire/connection-hub.blade.php @@ -4,6 +4,7 @@ /** @var \He4rt\Identity\ExternalIdentity\Enums\IdentityProvider[] $supportedProviders */ /** @var \Illuminate\Database\Eloquent\Collection $userProviders */ /** @var string $panel */ + /** @var array|null $mergeTarget */ @endphp
@@ -51,7 +52,7 @@ class="absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border- {{-- Content --}}
-
+
{{ $provider->getLabel() }} @if ($connected) @@ -137,4 +138,46 @@ class="rounded bg-gray-800/80 px-1 py-0.5 font-mono text-[9px] text-gray-400 rin @endif
@endforeach + + {{-- Merge Confirmation Modal --}} + @if ($showMergeModal && $mergeTarget) +
+
+
+ +

Conta existente encontrada

+
+ +

Já existe uma conta vinculada a esse provedor:

+ +
+
+ @ {{ $mergeTarget['username'] }} + {{ $mergeTarget['created_at'] }} +
+ @if ($mergeTarget['messages_count'] > 0) +
+ {{ number_format($mergeTarget['messages_count']) }} mensagens +
+ @endif +
+ +

Ao unificar, sua conta atual será absorvida por essa conta e você será relogado automaticamente.

+ +
+ + Unificar + + + Cancelar + +
+
+
+ @endif
From 9d5741db76968c6cda9d28009354a34fffa0e291 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Mon, 25 May 2026 21:48:53 -0300 Subject: [PATCH 15/20] fix(panel-app): use panel URL instead of request()->url() for merge redirect Livewire's request()->url() returns the internal update endpoint, causing a MethodNotAllowedHttpException on redirect after merge. --- app/Livewire/ConnectionHub.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Livewire/ConnectionHub.php b/app/Livewire/ConnectionHub.php index 8db7805af..b0f71acbe 100644 --- a/app/Livewire/ConnectionHub.php +++ b/app/Livewire/ConnectionHub.php @@ -94,7 +94,7 @@ public function confirmMerge(MergeAccountsAction $action): void ->success() ->send(); - $this->redirect(request()->url()); + $this->redirect(filament()->getCurrentPanel()->getUrl(filament()->getTenant())); } public function cancelMerge(): void From 3ed0b40a3737e3eeaeb894d643fb8dda1af32203 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Mon, 25 May 2026 21:50:18 -0300 Subject: [PATCH 16/20] feat(panel-app): OAuth login page with Discord/GitHub buttons and profile updates - Redesigned login page with branded split layout - Added GitHub and Discord OAuth buttons - Profile page and lang file updates - Composer lock and vite config sync --- app-modules/panel-app/lang/en/profile.php | 1 + app-modules/panel-app/lang/pt_BR/profile.php | 1 + .../resources/views/auth/login.blade.php | 130 +++++++++++++++--- .../resources/views/pages/profile.blade.php | 11 +- app-modules/panel-app/src/Pages/LoginPage.php | 22 +++ composer.lock | 38 ++++- phpunit.xml | 14 -- resources/css/filament/app/theme.css | 44 +++++- vite.config.js | 2 +- 9 files changed, 222 insertions(+), 41 deletions(-) diff --git a/app-modules/panel-app/lang/en/profile.php b/app-modules/panel-app/lang/en/profile.php index 12b5c6dec..ffecad987 100644 --- a/app-modules/panel-app/lang/en/profile.php +++ b/app-modules/panel-app/lang/en/profile.php @@ -10,6 +10,7 @@ 'address' => 'Location', 'social_links' => 'Social Links', 'availability' => 'Availability', + 'connections' => 'Connections', ], 'fields' => [ diff --git a/app-modules/panel-app/lang/pt_BR/profile.php b/app-modules/panel-app/lang/pt_BR/profile.php index 79d91e40e..4b5285881 100644 --- a/app-modules/panel-app/lang/pt_BR/profile.php +++ b/app-modules/panel-app/lang/pt_BR/profile.php @@ -10,6 +10,7 @@ 'address' => 'Localização', 'social_links' => 'Links Sociais', 'availability' => 'Disponibilidade', + 'connections' => 'Conexões', ], 'fields' => [ diff --git a/app-modules/panel-app/resources/views/auth/login.blade.php b/app-modules/panel-app/resources/views/auth/login.blade.php index 65453993b..7165646de 100644 --- a/app-modules/panel-app/resources/views/auth/login.blade.php +++ b/app-modules/panel-app/resources/views/auth/login.blade.php @@ -1,30 +1,116 @@ -
- + {{-- Left: Brand panel --}} + + {{-- Landing logo as background watermark --}} +
+ +
+ + {{-- Content --}} +
+
+ + + + +
-
-
-
+

He4rt Developers

+

Comunidade brasileira de desenvolvedores.
+ Aprenda, compartilhe e cresça.

+ +
+
+
5k+
+
membros
+
+
+
200+
+
eventos
+
+
+
50+
+
projetos
+
+
+
+ +
-
- ou + + {{-- Right: Login form --}} +
+
+ {{-- Mobile logo --}} +
+ He4rt Developers +
+ +

Entrar

+

Acesse sua conta He4rt Developers

+ + {{-- OAuth --}} + + + {{-- Divider --}} +
+
+
+
+
+ ou +
+
+ + {{-- Filament email/password form --}} + {{ $this->content }} +
- - {{ $this->content }} diff --git a/app-modules/panel-app/resources/views/pages/profile.blade.php b/app-modules/panel-app/resources/views/pages/profile.blade.php index d4287c0fd..6b6c573a4 100644 --- a/app-modules/panel-app/resources/views/pages/profile.blade.php +++ b/app-modules/panel-app/resources/views/pages/profile.blade.php @@ -17,8 +17,8 @@ {{ $this->form }}
- {{-- Preview card (right, 1/3, sticky) --}} - diff --git a/app-modules/panel-app/src/Pages/LoginPage.php b/app-modules/panel-app/src/Pages/LoginPage.php index 704a713cb..8c8203579 100644 --- a/app-modules/panel-app/src/Pages/LoginPage.php +++ b/app-modules/panel-app/src/Pages/LoginPage.php @@ -5,6 +5,8 @@ namespace He4rt\PanelApp\Pages; use Filament\Auth\Pages\Login; +use Filament\Support\Enums\Width; +use Illuminate\Contracts\Support\Htmlable; class LoginPage extends Login { @@ -21,4 +23,24 @@ public function mount(): void ]); } } + + public function getMaxWidth(): Width|string|null + { + return Width::Full; + } + + public function hasLogo(): bool + { + return false; + } + + public function getHeading(): string|Htmlable|null + { + return null; + } + + public function getSubHeading(): string|Htmlable|null + { + return null; + } } diff --git a/composer.lock b/composer.lock index 64c4073a1..b5440e4c2 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4e47b86d5b937f7c6f0c2389b843576f", + "content-hash": "6782a8a6a8107256add87f9f75234354", "packages": [ { "name": "blade-ui-kit/blade-heroicons", @@ -3455,6 +3455,42 @@ "relative": true } }, + { + "name": "he4rt/integration-github", + "version": "1.0.0", + "dist": { + "type": "path", + "url": "app-modules/integration-github", + "reference": "51b547836d200a8c8150d5e51bdff565e1f90f8f" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "He4rt\\IntegrationGithub\\IntegrationGithubServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "He4rt\\IntegrationGithub\\": "src/", + "He4rt\\IntegrationGithub\\Database\\Factories\\": "database/factories/", + "He4rt\\IntegrationGithub\\Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "He4rt\\IntegrationGithub\\Tests\\": "tests/" + } + }, + "license": [ + "proprietary" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, { "name": "he4rt/integration-twitch", "version": "1.0", diff --git a/phpunit.xml b/phpunit.xml index 457779ffc..1dfa10bc3 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -16,20 +16,6 @@ - - - - - - - - - - - - - - diff --git a/resources/css/filament/app/theme.css b/resources/css/filament/app/theme.css index 7ff9e7138..8bc6c0311 100644 --- a/resources/css/filament/app/theme.css +++ b/resources/css/filament/app/theme.css @@ -2,6 +2,48 @@ @source '../../../../app/Filament/**/*'; @source '../../../../app/Filament/**/*'; -@source '../../../../resources/views/filament/**/*'; +@source '../../../../resources/views/**/*'; @source '../../../../app-modules/**/src/Filament/**/*'; @source '../../../../app-modules/**/resources/views/**/*'; + +/* Login split layout — strip Filament's card wrapper */ +.fi-simple-layout:has(.fi-login-split) { + .fi-simple-main-ctn { + align-items: stretch; + } + + .fi-simple-main { + margin: 0; + padding: 0; + max-width: none; + background: transparent; + box-shadow: none; + border-radius: 0; + --tw-ring-shadow: 0 0 #0000; + } + + .fi-simple-page, + .fi-simple-page-content { + display: contents; + } +} + +@keyframes login-float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-12px); + } +} + +@keyframes login-pulse-glow { + 0%, + 100% { + opacity: 0.2; + } + 50% { + opacity: 0.35; + } +} diff --git a/vite.config.js b/vite.config.js index b21baac53..b9201b1c7 100644 --- a/vite.config.js +++ b/vite.config.js @@ -9,7 +9,7 @@ export default defineConfig({ 'resources/css/app.css', 'resources/js/app.js', 'resources/css/filament/admin/theme.css', - 'resources/css/filament/user/theme.css', + 'resources/css/filament/app/theme.css', 'app-modules/he4rt/resources/css/theme.css', 'app-modules/he4rt/resources/css/themes/3pontos/theme.css', 'app-modules/docs/resources/css/theme.css', From c9604b771518073819d611df452dbe6ca5cf1217 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Mon, 25 May 2026 21:53:38 -0300 Subject: [PATCH 17/20] =?UTF-8?q?fix(identity):=20resolve=20PHPStan=20erro?= =?UTF-8?q?rs=20=E2=80=94=20nullable=20email=20PHPDoc=20and=20tenant=5Fid?= =?UTF-8?q?=20cast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app-modules/identity/src/User/Models/User.php | 2 +- app-modules/integration-devto/src/Polling/SyncDevToArticles.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app-modules/identity/src/User/Models/User.php b/app-modules/identity/src/User/Models/User.php index 48adca7fb..84e7f7dfb 100644 --- a/app-modules/identity/src/User/Models/User.php +++ b/app-modules/identity/src/User/Models/User.php @@ -31,7 +31,7 @@ * @property string $id * @property string $name * @property string $username - * @property string $email + * @property string|null $email * @property bool $is_donator * @property Carbon|null $suspended_until * @property Carbon|null $banned_at diff --git a/app-modules/integration-devto/src/Polling/SyncDevToArticles.php b/app-modules/integration-devto/src/Polling/SyncDevToArticles.php index 87d9183ef..2c1c53a6d 100644 --- a/app-modules/integration-devto/src/Polling/SyncDevToArticles.php +++ b/app-modules/integration-devto/src/Polling/SyncDevToArticles.php @@ -121,7 +121,7 @@ private function processArticle(array $article): string $this->trackActivity->handle(new TrackActivityDTO( characterId: (string) $character->id, - tenantId: (int) $externalIdentity->tenant_id, + tenantId: (string) $externalIdentity->tenant_id, type: ActivityType::Article, provider: IdentityProvider::DevTo, occurredAt: new DateTimeImmutable($article['published_at'] ?? $article['created_at']), From 1f6d352322831b96af1f9bab49287f6f31621b5e Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Mon, 25 May 2026 22:12:55 -0300 Subject: [PATCH 18/20] fix(tests): resolve CI failures from tenant_id UUID migration - Timeline model: remove integer cast on tenant_id (was converting UUIDs to PHP_INT_MAX) - Moderation classifier tests: use Tenant::factory() instead of hardcoded integer IDs - FindExternalIdentity tests: use real tenant UUID instead of '1' - Discord OAuth test: assert APP_URL-based callback instead of configurable redirect_uri - AppPanelProvider: use domain tenancy only in production (fixes UrlGenerationException in tests) --- app-modules/activity/src/Timeline/Timeline.php | 2 +- .../Unit/ExternalIdentity/FindExternalIdentityTest.php | 3 ++- .../tests/Feature/OAuth/DiscordOAuthClientTest.php | 5 ++++- .../Feature/Classification/RuleBasedClassifierTest.php | 7 +++++-- app/Providers/Filament/AppPanelProvider.php | 6 +++--- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/app-modules/activity/src/Timeline/Timeline.php b/app-modules/activity/src/Timeline/Timeline.php index 90b52473a..2c203020a 100644 --- a/app-modules/activity/src/Timeline/Timeline.php +++ b/app-modules/activity/src/Timeline/Timeline.php @@ -85,7 +85,7 @@ protected function casts(): array { return [ 'user_id' => 'string', - 'tenant_id' => 'integer', + 'tenant_id' => 'string', 'root_id' => 'string', 'parent_id' => 'string', 'is_ignored' => 'boolean', diff --git a/app-modules/identity/tests/Unit/ExternalIdentity/FindExternalIdentityTest.php b/app-modules/identity/tests/Unit/ExternalIdentity/FindExternalIdentityTest.php index 5001319a2..e2fe99af2 100644 --- a/app-modules/identity/tests/Unit/ExternalIdentity/FindExternalIdentityTest.php +++ b/app-modules/identity/tests/Unit/ExternalIdentity/FindExternalIdentityTest.php @@ -112,11 +112,12 @@ }); test('throws exception when identity not found', function (): void { + $tenant = Tenant::factory()->create(); $action = new FindExternalIdentity(); $action->handle( provider: IdentityProvider::Discord->value, providerId: 'nonexistent-id', - tenantId: '1', + tenantId: (string) $tenant->id, ); })->throws(ExternalIdentityException::class); diff --git a/app-modules/integration-discord/tests/Feature/OAuth/DiscordOAuthClientTest.php b/app-modules/integration-discord/tests/Feature/OAuth/DiscordOAuthClientTest.php index ac1b7418d..d1a20803f 100644 --- a/app-modules/integration-discord/tests/Feature/OAuth/DiscordOAuthClientTest.php +++ b/app-modules/integration-discord/tests/Feature/OAuth/DiscordOAuthClientTest.php @@ -66,16 +66,19 @@ it('generates correct redirect url', function (): void { config()->set('services.discord.scopes', 'identify email'); + config()->set('app.url', 'http://localhost:8000'); $connector = new DiscordOAuthConnector('my-client-id', 'client-secret', 'https://example.com/callback'); $client = new DiscordOAuthClient($connector); $url = $client->redirectUrl(); + $expectedCallback = urlencode('http://localhost:8000/auth/oauth/discord'); + expect($url) ->toContain('https://discord.com/oauth2/authorize') ->toContain('client_id=my-client-id') ->toContain('response_type=code') - ->toContain('redirect_uri='.urlencode('https://example.com/callback')) + ->toContain('redirect_uri='.$expectedCallback) ->toContain('scope=identify+email'); }); diff --git a/app-modules/moderation/tests/Feature/Classification/RuleBasedClassifierTest.php b/app-modules/moderation/tests/Feature/Classification/RuleBasedClassifierTest.php index debad2475..517192729 100644 --- a/app-modules/moderation/tests/Feature/Classification/RuleBasedClassifierTest.php +++ b/app-modules/moderation/tests/Feature/Classification/RuleBasedClassifierTest.php @@ -124,6 +124,7 @@ function contentDTO(string $text, ?string $tenantId = null): ModerationContentDT test('tenant-scoped rules only match for correct tenant', function (): void { $tenant = Tenant::factory()->create(); + $otherTenant = Tenant::factory()->create(); ModerationRule::query()->create([ 'name' => 'Tenant rule', 'type' => 'keyword', 'pattern' => 'specific', @@ -132,20 +133,22 @@ function contentDTO(string $text, ?string $tenantId = null): ModerationContentDT ]); $matchResult = RuleBasedClassifier::make()->classify(contentDTO('something specific', (string) $tenant->id)); - $noMatchResult = RuleBasedClassifier::make()->classify(contentDTO('something specific', '99999')); + $noMatchResult = RuleBasedClassifier::make()->classify(contentDTO('something specific', (string) $otherTenant->id)); expect($matchResult->scores)->toHaveKey('spam') ->and($noMatchResult->scores)->toBeEmpty(); }); test('global rules (null tenant) match for any tenant', function (): void { + $anyTenant = Tenant::factory()->create(); + ModerationRule::query()->create([ 'name' => 'Global', 'type' => 'keyword', 'pattern' => 'universal', 'violation_type' => 'harassment', 'severity' => 'high', 'action_on_match' => 'mute', 'is_active' => true, 'tenant_id' => null, ]); - $result = RuleBasedClassifier::make()->classify(contentDTO('universal truth', '12345')); + $result = RuleBasedClassifier::make()->classify(contentDTO('universal truth', (string) $anyTenant->id)); expect($result->primary)->toBe(ViolationType::Harassment); }); diff --git a/app/Providers/Filament/AppPanelProvider.php b/app/Providers/Filament/AppPanelProvider.php index 2e323195b..a11c6adaf 100644 --- a/app/Providers/Filament/AppPanelProvider.php +++ b/app/Providers/Filament/AppPanelProvider.php @@ -64,9 +64,9 @@ public function panel(Panel $panel): Panel Authenticate::class, ]); - app()->isLocal() - ? $panel->tenant(model: Tenant::class, slugAttribute: 'slug') - : $panel->tenantDomain('{tenant:domain}')->tenant(model: Tenant::class, slugAttribute: 'domain'); + app()->isProduction() + ? $panel->tenantDomain('{tenant:domain}')->tenant(model: Tenant::class, slugAttribute: 'domain') + : $panel->tenant(model: Tenant::class, slugAttribute: 'slug'); return $panel; } From ad134f7eec7e6e49e1ab718ac161fa09e77b6785 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Mon, 25 May 2026 22:19:51 -0300 Subject: [PATCH 19/20] wip --- phpunit.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/phpunit.xml b/phpunit.xml index 1dfa10bc3..863c80636 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -16,6 +16,14 @@ + + + + + + + + From 1682268343889113ba7d973c95cabcda09b71b68 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Mon, 25 May 2026 22:33:02 -0300 Subject: [PATCH 20/20] fix(tests): resolve remaining CI failures from UUID migration - AuditLogTest: remove (int) cast on tenant_id comparison (UUID string) - phpunit.xml: set CACHE_STORE=array for cache tagging support in tests - Update PHPDoc @property tenant_id from int to string in 7 models: ModerationAuditLog, ModerationRule, ModerationAppeal, ModerationAction, Character, DiscordGuild, TwitchEventLog --- app-modules/gamification/src/Character/Models/Character.php | 2 +- app-modules/integration-discord/src/Models/DiscordGuild.php | 2 +- app-modules/integration-twitch/src/Models/TwitchEventLog.php | 2 +- app-modules/moderation/src/Appeals/ModerationAppeal.php | 2 +- app-modules/moderation/src/Audit/ModerationAuditLog.php | 2 +- app-modules/moderation/src/Enforcement/ModerationAction.php | 2 +- app-modules/moderation/src/Rules/ModerationRule.php | 2 +- app-modules/moderation/tests/Feature/Audit/AuditLogTest.php | 2 +- phpunit.xml | 1 + 9 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app-modules/gamification/src/Character/Models/Character.php b/app-modules/gamification/src/Character/Models/Character.php index 08e1533ca..d3a473a12 100644 --- a/app-modules/gamification/src/Character/Models/Character.php +++ b/app-modules/gamification/src/Character/Models/Character.php @@ -31,7 +31,7 @@ * @property int $level * @property float $percentage_experience * @property bool $can_claim_daily_bonus - * @property int|null $tenant_id + * @property string|null $tenant_id */ #[Appends([ 'ranking', diff --git a/app-modules/integration-discord/src/Models/DiscordGuild.php b/app-modules/integration-discord/src/Models/DiscordGuild.php index 27b89a42f..1d3a868c6 100644 --- a/app-modules/integration-discord/src/Models/DiscordGuild.php +++ b/app-modules/integration-discord/src/Models/DiscordGuild.php @@ -15,7 +15,7 @@ /** * @property int $id - * @property int|null $tenant_id + * @property string|null $tenant_id * @property string $discord_guild_id * @property string $name * @property string|null $icon diff --git a/app-modules/integration-twitch/src/Models/TwitchEventLog.php b/app-modules/integration-twitch/src/Models/TwitchEventLog.php index 13dc27d53..97cfe88e9 100644 --- a/app-modules/integration-twitch/src/Models/TwitchEventLog.php +++ b/app-modules/integration-twitch/src/Models/TwitchEventLog.php @@ -11,7 +11,7 @@ /** * @property int $id - * @property int|null $tenant_id + * @property string|null $tenant_id * @property string $event_type * @property string|null $broadcaster_user_id * @property string|null $user_id diff --git a/app-modules/moderation/src/Appeals/ModerationAppeal.php b/app-modules/moderation/src/Appeals/ModerationAppeal.php index 2e549963e..a5a0496ef 100644 --- a/app-modules/moderation/src/Appeals/ModerationAppeal.php +++ b/app-modules/moderation/src/Appeals/ModerationAppeal.php @@ -28,7 +28,7 @@ * @property string|null $reviewer_notes * @property Carbon|null $resolved_at * @property Carbon $sla_deadline - * @property int|null $tenant_id + * @property string|null $tenant_id * @property Carbon $created_at */ #[Table('moderation_appeals', timestamps: false)] diff --git a/app-modules/moderation/src/Audit/ModerationAuditLog.php b/app-modules/moderation/src/Audit/ModerationAuditLog.php index f91f66427..15b5bf3db 100644 --- a/app-modules/moderation/src/Audit/ModerationAuditLog.php +++ b/app-modules/moderation/src/Audit/ModerationAuditLog.php @@ -16,7 +16,7 @@ * @property string|null $case_id * @property array $details * @property string|null $platform - * @property int|null $tenant_id + * @property string|null $tenant_id * @property Carbon $created_at */ #[Table('moderation_audit_log', timestamps: false)] diff --git a/app-modules/moderation/src/Enforcement/ModerationAction.php b/app-modules/moderation/src/Enforcement/ModerationAction.php index 6ef6ff14e..6e327b1ab 100644 --- a/app-modules/moderation/src/Enforcement/ModerationAction.php +++ b/app-modules/moderation/src/Enforcement/ModerationAction.php @@ -30,7 +30,7 @@ * @property array|null $metadata * @property array|null $execution_results * @property bool $automated - * @property int|null $tenant_id + * @property string|null $tenant_id * @property Carbon $created_at */ #[Table('moderation_actions', timestamps: false)] diff --git a/app-modules/moderation/src/Rules/ModerationRule.php b/app-modules/moderation/src/Rules/ModerationRule.php index 461a82953..b12b25209 100644 --- a/app-modules/moderation/src/Rules/ModerationRule.php +++ b/app-modules/moderation/src/Rules/ModerationRule.php @@ -25,7 +25,7 @@ * @property Severity $severity * @property ActionType $action_on_match * @property bool $is_active - * @property int|null $tenant_id + * @property string|null $tenant_id * @property Carbon $created_at * @property Carbon $updated_at */ diff --git a/app-modules/moderation/tests/Feature/Audit/AuditLogTest.php b/app-modules/moderation/tests/Feature/Audit/AuditLogTest.php index 915be103b..ede8dbf91 100644 --- a/app-modules/moderation/tests/Feature/Audit/AuditLogTest.php +++ b/app-modules/moderation/tests/Feature/Audit/AuditLogTest.php @@ -94,5 +94,5 @@ $listener->handleCaseCreated(new CaseCreated($case)); $log = ModerationAuditLog::query()->where('event_type', 'case_created')->first(); - expect((int) $log->tenant_id)->toBe($tenant->id); + expect($log->tenant_id)->toBe($tenant->id); }); diff --git a/phpunit.xml b/phpunit.xml index 863c80636..8b9699036 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,6 +18,7 @@ +