Skip to content

feat(identity): OAuth flow refactor with account merge strategy#288

Merged
gvieira18 merged 20 commits into
4.xfrom
feat/oauth-flow-and-account-merge
May 26, 2026
Merged

feat(identity): OAuth flow refactor with account merge strategy#288
gvieira18 merged 20 commits into
4.xfrom
feat/oauth-flow-and-account-merge

Conversation

@danielhe4rt
Copy link
Copy Markdown
Contributor

@danielhe4rt danielhe4rt commented May 26, 2026

Summary

  • Refactored the entire OAuth flow to be tenant-based with intent-based routing (Login vs Link)
  • Converted tenant_id from bigint to UUID across 31 tables
  • Added GitHub OAuth integration via Saloon
  • Implemented account merge strategy to prevent duplicate users from ETL vs web OAuth
  • New first_login_at field enriches ETL-created users on their first real login

Key Changes

OAuth Architecture

  • HandleOAuthCallbackAction orchestrates the full callback flow
  • FindOrCreateUserByProvider resolves users cross-tenant with sequential username suffix on collision
  • EnrichUserOnFirstLogin updates email/name on first real login
  • DetectMergeConflict identifies when a Link would create duplicates
  • MergeAccountsAction merges lightweight new user into heavy old user (DB::transaction)

Account Merge (ADR-0001)

  • When a user connects a provider that already belongs to another User, a confirmation modal appears
  • On confirm: transfers ExternalIdentities + tenant memberships from new → old user, deletes new user, re-logs
  • Direction: always keep the old user (has 100k+ messages), absorb the new one

GitHub Integration

  • Full Saloon-based OAuth client at app-modules/integration-github/
  • Connectors, requests, and DTOs for token exchange and user fetch

Login Page

  • Redesigned with Discord and GitHub OAuth buttons
  • Branded split layout

Test plan

  • 25 unit/feature tests covering all new actions
  • Manual: login via Discord → user created/enriched
  • Manual: login via GitHub when username collides → suffixed user created
  • Manual: connect Discord in ConnectionHub when it belongs to another user → merge modal appears
  • Manual: confirm merge → re-logged as old user with unified identities

Related Issues

Closes #284, #285, #286, #287

Description

This PR refactors the OAuth flow to support tenant-based authentication with intent-based routing (Login vs Link), converts tenant_id from bigint to UUID across the application, and introduces GitHub OAuth integration. A key feature is the account merge strategy that detects and resolves conflicts when linking providers that belong to different users, preventing duplicate accounts from ETL vs web OAuth. The system now enriches ETL-created users on first real login and includes a redesigned login page with OAuth provider buttons.

References

  • Issue #284: Handle username collision with sequential suffix on OAuth user creation
  • Issue #285: OAuth flow refactor with account merge strategy
  • Issue #286: GitHub OAuth integration
  • Issue #287: Login page redesign
  • ADR-0001: OAuth user resolution and merge strategy (app-modules/identity/docs/adr/0001-oauth-user-resolution-and-merge-strategy.md)

Dependencies & Requirements

New Dependencies:

  • he4rt/integration-github (>=1)

Environment Variables Added:

  • GITHUB_OAUTH_CLIENT_ID
  • GITHUB_OAUTH_CLIENT_SECRET
  • GITHUB_OAUTH_SCOPES (default: 'read:user user:email')
  • GITHUB_OAUTH_ENABLED (default: true)

Database Changes:

  • Migration 2026_05_25_225721_convert_tenant_id_from_bigint_to_uuid.php: Converts tenant_id from bigint to UUID across 31 tables
  • Migration 2026_05_26_001934_add_first_login_at_to_users_table.php: Adds nullable first_login_at timestamp column to users table

Contributor Summary

Lines Added Lines Removed Files Changed
2,136 329 90

Changes Summary

File Path Change Description
Migrations
database/migrations/2026_05_25_225721_convert_tenant_id_from_bigint_to_uuid.php Converts tenant_id column from bigint to UUID across dependent tables
app-modules/identity/database/migrations/2026_05_26_001934_add_first_login_at_to_users_table.php Adds first_login_at nullable timestamp column
OAuth Actions
app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php Main orchestrator for OAuth callback handling with intent-based routing
app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php Resolves or creates users from OAuth data with username collision handling
app-modules/identity/src/Auth/Actions/EnrichUserOnFirstLogin.php Enriches user data on first real login (email, name, first_login_at)
app-modules/identity/src/Auth/Actions/DetectMergeConflict.php Detects existing external identities for merge conflicts
app-modules/identity/src/Auth/Actions/MergeAccountsAction.php Executes account merging: transfers identities/tenants and deletes duplicate user
app-modules/identity/src/Auth/Actions/AttachProviderToUser.php Attaches external identity to user or tenant
OAuth DTOs & Enums
app-modules/identity/src/Auth/DTOs/OAuthStateDTO.php Refactored to include intent, provider, panel, tenant, returnUrl
app-modules/identity/src/Auth/DTOs/OAuthResultDTO.php Result DTO for OAuth callback with merge conflict support
app-modules/identity/src/Auth/DTOs/MergeConflictDTO.php Serializable DTO for pending merge conflicts
app-modules/identity/src/Auth/Enums/OAuthIntent.php Enum for Login vs Link intents
GitHub OAuth Integration
app-modules/integration-github/src/OAuth/GitHubOAuthClient.php GitHub OAuth client implementing OAuthClientContract
app-modules/integration-github/src/OAuth/DTO/GitHubOAuthAccessDTO.php GitHub access token DTO
app-modules/integration-github/src/OAuth/DTO/GitHubOAuthUserDTO.php GitHub user data DTO
app-modules/integration-github/src/Transport/GitHubOAuthConnector.php Saloon connector for GitHub OAuth endpoints
app-modules/integration-github/src/Transport/GitHubApiConnector.php Saloon connector for GitHub API
app-modules/integration-github/src/Transport/Requests/OAuth/ExchangeCodeForToken.php OAuth token exchange request
app-modules/integration-github/src/Transport/Requests/Users/GetCurrentUser.php GitHub user profile request
app-modules/integration-github/src/IntegrationGithubServiceProvider.php Service provider for GitHub module
Type Migrations
Multiple model files across modules Updated PHPDoc and method signatures: tenant_id from int to string, added email/first_login_at properties to User
app-modules/identity/src/Tenant/Models/Tenant.php Added HasUuids trait for UUID generation
Controllers & Routes
app-modules/identity/src/Auth/Http/Controllers/OAuthController.php Refactored to use new OAuth action orchestration and merge handling
app-modules/identity/routes/authentication-routes.php Updated OAuth routes to consolidate redirect handling
UI Components
app-modules/panel-app/resources/views/auth/login.blade.php Redesigned login page with split layout, Discord/GitHub OAuth buttons
app-modules/panel-app/src/Pages/LoginPage.php Custom Filament login page class
resources/views/livewire/connection-hub.blade.php Updated provider connections UI with merge modal
resources/views/livewire/connection-hub-admin.blade.php Updated admin connections display
app/Livewire/ConnectionHub.php Refactored Livewire component with merge handling
Tests
app-modules/identity/tests/Feature/Auth/DetectMergeConflictTest.php Tests for merge conflict detection across tenants
app-modules/identity/tests/Feature/Auth/FindOrCreateUserByProviderTest.php Tests for user resolution and username suffix generation
app-modules/identity/tests/Feature/Auth/EnrichUserOnFirstLoginTest.php Tests for user enrichment on first login
app-modules/identity/tests/Feature/Auth/MergeAccountsActionTest.php Tests for account merging workflow
Configuration
config/services.php Added GitHub OAuth configuration block
composer.json Added he4rt/integration-github dependency
vite.config.js Updated CSS theme assets

Review Change Stack

…odels in BaseSeeder

TenantUserObserver already auto-creates profiles when users are attached to tenants.
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.
Add OAuth social login buttons to the app panel login page with Discord
and GitHub providers. Add GitHub service config entry.
Replace hardcoded redirect_uri from config with dynamic URL built from
APP_URL. Ensures correct callback domain regardless of reverse proxy.
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.
Migrate tenants.id from auto-increment bigint to UUID. Update all 31
child tables with tenant_id FK. Add HasUuids trait to Tenant model.
Convert all int $tenantId type hints to string across the codebase.
Implement GitHub OAuth integration following the same pattern as Twitch:
GitHubOAuthConnector, GitHubApiConnector, ExchangeCodeForToken request,
GetCurrentUser request, GitHubOAuthAccessDTO, GitHubOAuthUserDTO, and
GitHubOAuthClient. Register in IdentityProvider::getClient().
Redesign connection-hub user view for profile sidebar context:
- Smaller icons, reduced padding, removed accent strip and descriptions
- Disconnect button as centered pill with hover-red styling
- Move timestamp next to provider title, username-only second row
- Action buttons as direct flex children for vertical centering
…uth 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
- 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
- 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
…AccountsAction

- 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
…edirect

Livewire's request()->url() returns the internal update endpoint,
causing a MethodNotAllowedHttpException on redirect after merge.
…file 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
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 26, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Central YAML (inherited)

Review profile: CHILL

Plan: Pro

Run ID: f7c52546-d728-45bb-aa51-c18535c7f8bd

📥 Commits

Reviewing files that changed from the base of the PR and between c9604b7 and 1682268.

📒 Files selected for processing (14)
  • app-modules/activity/src/Timeline/Timeline.php
  • app-modules/gamification/src/Character/Models/Character.php
  • app-modules/identity/tests/Unit/ExternalIdentity/FindExternalIdentityTest.php
  • app-modules/integration-discord/src/Models/DiscordGuild.php
  • app-modules/integration-discord/tests/Feature/OAuth/DiscordOAuthClientTest.php
  • app-modules/integration-twitch/src/Models/TwitchEventLog.php
  • app-modules/moderation/src/Appeals/ModerationAppeal.php
  • app-modules/moderation/src/Audit/ModerationAuditLog.php
  • app-modules/moderation/src/Enforcement/ModerationAction.php
  • app-modules/moderation/src/Rules/ModerationRule.php
  • app-modules/moderation/tests/Feature/Audit/AuditLogTest.php
  • app-modules/moderation/tests/Feature/Classification/RuleBasedClassifierTest.php
  • app/Providers/Filament/AppPanelProvider.php
  • phpunit.xml
✅ Files skipped from review due to trivial changes (7)
  • app-modules/integration-discord/src/Models/DiscordGuild.php
  • app-modules/integration-twitch/src/Models/TwitchEventLog.php
  • app-modules/moderation/src/Rules/ModerationRule.php
  • app-modules/moderation/src/Enforcement/ModerationAction.php
  • app-modules/gamification/src/Character/Models/Character.php
  • app-modules/moderation/src/Audit/ModerationAuditLog.php
  • app-modules/moderation/src/Appeals/ModerationAppeal.php

📝 Walkthrough

Walkthrough

This PR converts tenant identifiers to string/UUID across models and casts, adds a users.first_login_at column and cast, refactors OAuth state/intent DTOs, implements callback orchestration (FindOrCreateUserByProvider, EnrichUserOnFirstLogin, DetectMergeConflict, AttachProviderToUser, MergeAccountsAction, HandleOAuthCallbackAction), adds GitHub OAuth client/connectors/DTOs, updates Discord/Twitch callback URL handling, propagates tenantId string types through ETL and DTOs, adds UI (custom login, connection-hub merge modal), and includes migrations and tests.

Possibly related issues

Possibly related PRs

Suggested reviewers

  • gvieira18
  • Clintonrocha98

- 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)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app-modules/activity/src/Timeline/Timeline.php (1)

84-94: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Remove or update the integer cast for tenant_id.

The casts() method at line 88 declares 'tenant_id' => 'integer', but the PHPDoc at line 23 correctly reflects the new UUID-based string type. This inconsistency will cause runtime errors when the database returns a UUID string but Eloquent attempts to cast it to an integer.

🔧 Proposed fix
     protected function casts(): array
     {
         return [
             'user_id' => 'string',
-            'tenant_id' => 'integer',
+            'tenant_id' => 'string',
             'root_id' => 'string',
             'parent_id' => 'string',
             'is_ignored' => 'boolean',
             'pinned' => 'boolean',
         ];
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/activity/src/Timeline/Timeline.php` around lines 84 - 94, The
casts() method still maps 'tenant_id' => 'integer' causing Eloquent to cast UUID
tenant IDs incorrectly; change the cast in the casts() array so 'tenant_id' uses
a string cast (or remove the integer entry) to match the PHPDoc and UUID type.
Locate the casts() method in the Timeline class and update the 'tenant_id' entry
to 'string' (or delete the cast if implicit string behavior is preferred) and
run tests to ensure no further type-related errors occur.
app-modules/panel-app/resources/views/pages/profile.blade.php (1)

21-37: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Connections management is inaccessible on mobile layouts.

The new connections section is hidden below xl, so mobile users can’t connect/disconnect providers or resolve merge prompts from this page.

Proposed fix
-<div class="hidden space-y-6 xl:sticky xl:top-4 xl:block">
+<div class="space-y-6 xl:sticky xl:top-4">
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/panel-app/resources/views/pages/profile.blade.php` around lines
21 - 37, The connections panel is currently hidden on mobile due to the
container div's responsive classes ("hidden ... xl:block"); update the wrapper
around the profile-preview-card and the connections block (the div containing
<livewire:connection-hub />) to be visible on smaller screens by removing the
"hidden" and "xl:block" combination (preserve xl:sticky and xl:top-4 if you want
desktop sticky behavior) so the connections UI (livewire:connection-hub) is
rendered and usable on mobile. Ensure the class list still contains spacing like
"space-y-6" and any desired xl-only positioning classes.
🧹 Nitpick comments (1)
app-modules/identity/src/Auth/Actions/AttachProviderToUser.php (1)

35-35: Fix the connected_by nullability concern—DB won’t fail, but Login flows will store null

external_identities.connected_by is a nullable UUID ($table->uuid('connected_by')->nullable()), and it’s only constrained by an FK—so the auth()->id() returning null during unauthenticated OAuth Login won’t cause a DB constraint violation.

In OAuthController::getRedirect(), unauthenticated users are put into OAuthIntent::Login, and OAuthController::getAuthenticate() calls Auth::login($result->user) after HandleOAuthCallbackAction runs—so AttachProviderToUser::execute() (where connected_by is set) will see auth()->id() === null for Login flows by design. If connected_by is intended to record the user who initiated the connect, set it from the resolved $user instead of auth()->id().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/identity/src/Auth/Actions/AttachProviderToUser.php` at line 35,
The AttachProviderToUser action is using auth()->id() to populate the
external_identities.connected_by column which is null during OAuth Login flows;
change AttachProviderToUser::execute() to set connected_by from the resolved
user instance (the $user or $result->user passed/returned by
HandleOAuthCallbackAction / OAuthController::getAuthenticate) instead of
auth()->id() so Login flows record the connected_by correctly; update the
parameter usage in AttachProviderToUser::execute() and any callers to pass or
use the resolved $user when creating the external identity.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@app-modules/identity/database/migrations/2026_05_26_001934_add_first_login_at_to_users_table.php`:
- Around line 9-17: Add a down() rollback that removes the first_login_at column
so rollbacks restore schema parity: implement a public function down() in the
anonymous Migration class that uses Schema::table('users', function (Blueprint
$table) { drop the first_login_at column }); optionally guard the drop with a
column-exists check if your Schema helper supports it; reference the existing
up(), the users table and the first_login_at column when locating where to add
the method.

In `@app-modules/identity/src/Auth/Actions/AttachProviderToUser.php`:
- Around line 19-37: The current use of owner->providers()->updateOrCreate
includes connected_at and connected_by in the values array, which causes those
audit fields to be overwritten on updates; remove connected_at/connected_by from
the values passed to updateOrCreate and instead call updateOrCreate with only
updatable fields (type, credentials_type, credentials, metadata), then check the
returned model's wasRecentlyCreated property and, if true, set connected_at =
now() and connected_by = auth()->id() and save the model so those audit fields
are only set on initial create.

In `@app-modules/identity/src/Auth/Actions/EnrichUserOnFirstLogin.php`:
- Around line 22-24: The retry path in EnrichUserOnFirstLogin assumes only
username uniqueness can fail; if email causes a
UniqueConstraintViolationException we must remove or alter the conflicting field
before retrying. Update the exception handling around the database save/retry
logic (EnrichUserOnFirstLogin) so that when a UniqueConstraintViolationException
occurs you detect which field conflicts (check exception message or look up an
existing user by email/username) and then strip/modify that specific key from
the $updates array (e.g., unset($updates['email']) or
unset($updates['username'])) before attempting the retry; ensure the same logic
is applied in both places where $updates is built/used (the blocks that set
$updates['email'] and $updates['username'] around the shown lines).

In `@app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php`:
- Around line 29-33: The exists()+attach() pair in
FindOrCreateUserByProvider.php is racy; instead of checking then attaching, call
the atomic pivot attach helper to avoid duplicate pivot inserts—replace the
$user->tenants()->where(...)->exists() and $user->tenants()->attach($tenant)
logic with a single atomic operation such as
$user->tenants()->syncWithoutDetaching($tenant->getKey()) (or an equivalent
pivot upsert) so concurrent requests cannot create duplicate membership rows.
- Around line 65-80: The current one-shot fallback in FindOrCreateUserByProvider
can still race and throw UniqueConstraintViolationException again; change the
logic to perform a bounded retry loop (e.g., maxAttempts = 3-5) around the
create call: inside a retry loop call DB::transaction (or use transaction +
savepoint) and attempt User::query()->create(...) with a username generated by
generateSuffixedUsername($oauthUser->username); on
UniqueConstraintViolationException catch, increment attempt count, re-generate a
new suffix and retry until attempts exhausted, and only then rethrow the
exception; ensure you update both the initial create path and the fallback path
to use the same bounded retry behavior.

In `@app-modules/identity/src/Auth/Actions/MergeAccountsAction.php`:
- Around line 60-65: The retry fallback only unsets 'username' on
UniqueConstraintViolationException which fails if the violation is for 'email'
(or another unique key); update the catch to inspect the exception/underlying
SQL error to determine which unique constraint triggered the failure (or
conservatively remove all known unique fields from $updates), then retry
$target->update($updates) inside the transaction; target symbols:
DB::transaction, $target->update(), $updates, and
UniqueConstraintViolationException — map constraint names to fields (e.g. email,
username) and unset the offending keys before the retry so the merge succeeds
for email collisions as well.

In `@app-modules/identity/src/Auth/DTOs/MergeConflictDTO.php`:
- Around line 26-33: The DTO is serializing raw OAuth tokens via
$this->credentials->toDatabase(), which must not be persisted in session; change
MergeConflictDTO to exclude sensitive token fields and only include non-secret
metadata (e.g., provider, expiry, scopes, token_type) by either calling a new
safe serializer like credentials->toSessionSafe() or by constructing the
'credentials' array manually without access_token/refresh_token, and persist
full tokens in a secure token store (or save a reference/id) instead; update the
credentials class to add toSessionSafe() (omitting token values) and replace
uses of toDatabase() in MergeConflictDTO (and any consumers) to use the safe
serializer.

In `@app-modules/identity/src/Auth/Http/Controllers/OAuthController.php`:
- Around line 34-35: The code currently stores url()->previous() into the OAuth
state (returnUrl) and later redirects to it, which allows open redirects; change
this to only accept and persist internal relative paths or same-host URLs.
Validate/sanitize the value returned by url()->previous() before putting it into
the OAuth state in OAuthController (the returnUrl assignment) and likewise
verify state->returnUrl before performing the redirects at the later redirect
points: allow only values that start with '/' (relative paths) or whose host
matches the app's configured host (e.g., compare parse_url(..., PHP_URL_HOST) to
config('app.url')/parse host), otherwise set returnUrl to null or a safe default
(e.g., '/') so external domains cannot be redirected to.
- Around line 46-49: The OAuth callback handler (OAuthController) currently
calls OAuthStateDTO::fromEncryptedString(request()->input('state')) and
$action->execute($state, $identityProvider, request()->input('code')) without
validating inputs, which can cause runtime failures; before calling
fromEncryptedString or execute, explicitly check that request()->input('state')
and request()->input('code') are present and non-empty, return a controlled HTTP
error (e.g., 400 Bad Request) if missing, and wrap the call to
OAuthStateDTO::fromEncryptedString in a try/catch to handle malformed or
tampered state (returning a 400/422 error) so $action->execute only runs with
validated $state and $code.

In `@app-modules/integration-github/src/IntegrationGithubServiceProvider.php`:
- Around line 15-18: IntegrationGithubServiceProvider is using untyped
config('services.github.client_id')/config('services.github.client_secret') when
binding GitHubOAuthConnector; change these to the typed
config()->string('services.github.client_id') and
config()->string('services.github.client_secret') so the container gets
non-nullable strings consistent with other providers and to avoid runtime type
issues when env values are missing.

In `@app-modules/integration-github/src/OAuth/GitHubOAuthClient.php`:
- Around line 55-57: The callbackUrl() method in GitHubOAuthClient.php uses
mb_rtrim which requires the ext-mbstring extension; update the implementation to
avoid that dependency by replacing mb_rtrim(config('app.url'), '/') with PHP's
built-in rtrim (i.e., use rtrim(...) ) in the callbackUrl() method of the
GitHubOAuthClient class (and similarly remove/replace other uses of mb_rtrim
across modules) OR alternatively add ext-mbstring to composer.json require if
you intentionally depend on mbstring; choose one approach and apply
consistently.

In `@app-modules/panel-app/resources/views/auth/login.blade.php`:
- Around line 85-93: The OAuth links are passing request()->getHost() as the
'tenant' param which is wrong for slug-based panel routing; update both href
lines that call route('oauth.redirect', ['tenant' => request()->getHost(), ...])
to pass the route tenant context instead (e.g. use request()->route('tenant') or
the existing tenant route variable) so the 'tenant' parameter uses the
slug-based tenant from the current route rather than the host.

In `@app/Livewire/ConnectionHub.php`:
- Around line 58-64: Tenant::query()->find($this->tenantId) can return null, so
before building the route in redirect(route('oauth.redirect', ...)) guard
against a missing tenant: check the result of
Tenant::query()->find($this->tenantId) (referenced as $tenant) and handle the
null case (for example abort(404), emit an error, or return early) instead of
dereferencing $tenant->domain or $tenant->slug; only build the route using
$tenant->domain ?? $tenant->slug when $tenant is not null.

In
`@database/migrations/2026_05_25_225721_convert_tenant_id_from_bigint_to_uuid.php`:
- Around line 78-81: The down() method is a no-op and should explicitly fail for
this irreversible migration; modify the down() method in the migration class to
throw a runtime exception (e.g., throw new \RuntimeException('Irreversible
migration: tenant ID conversion cannot be reverted')) so rollbacks fail fast and
are clearly signaled.
- Around line 63-67: The migration's up() drops tenant_id foreign keys via
dropForeignKeyIfExists() and swaps columns (DROP/RENAME) for each table in
$childTables but never re-creates the foreign key/index to tenants(id); update
the up() loop after the RENAME to add back the referential integrity for each
table in $childTables by creating an index (if needed) and a foreign key
constraint on tenant_id referencing tenants(id) (use the same naming convention
as dropForeignKeyIfExists()/existing constraints), and ensure the down() mirrors
this by dropping that new FK before reverting columns so foreign keys are
consistently recreated and removed when migrating up/down.

---

Outside diff comments:
In `@app-modules/activity/src/Timeline/Timeline.php`:
- Around line 84-94: The casts() method still maps 'tenant_id' => 'integer'
causing Eloquent to cast UUID tenant IDs incorrectly; change the cast in the
casts() array so 'tenant_id' uses a string cast (or remove the integer entry) to
match the PHPDoc and UUID type. Locate the casts() method in the Timeline class
and update the 'tenant_id' entry to 'string' (or delete the cast if implicit
string behavior is preferred) and run tests to ensure no further type-related
errors occur.

In `@app-modules/panel-app/resources/views/pages/profile.blade.php`:
- Around line 21-37: The connections panel is currently hidden on mobile due to
the container div's responsive classes ("hidden ... xl:block"); update the
wrapper around the profile-preview-card and the connections block (the div
containing <livewire:connection-hub />) to be visible on smaller screens by
removing the "hidden" and "xl:block" combination (preserve xl:sticky and
xl:top-4 if you want desktop sticky behavior) so the connections UI
(livewire:connection-hub) is rendered and usable on mobile. Ensure the class
list still contains spacing like "space-y-6" and any desired xl-only positioning
classes.

---

Nitpick comments:
In `@app-modules/identity/src/Auth/Actions/AttachProviderToUser.php`:
- Line 35: The AttachProviderToUser action is using auth()->id() to populate the
external_identities.connected_by column which is null during OAuth Login flows;
change AttachProviderToUser::execute() to set connected_by from the resolved
user instance (the $user or $result->user passed/returned by
HandleOAuthCallbackAction / OAuthController::getAuthenticate) instead of
auth()->id() so Login flows record the connected_by correctly; update the
parameter usage in AttachProviderToUser::execute() and any callers to pass or
use the resolved $user when creating the external identity.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Central YAML (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 784181be-4459-419d-ad80-795991bb06c7

📥 Commits

Reviewing files that changed from the base of the PR and between 193445f and c9604b7.

⛔ Files ignored due to path filters (1)
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing (100)
  • app-modules/activity/src/Message/DTOs/NewMessageDTO.php
  • app-modules/activity/src/Message/Models/MembershipEvent.php
  • app-modules/activity/src/Message/Models/Message.php
  • app-modules/activity/src/Message/Models/MessageAttachment.php
  • app-modules/activity/src/Message/Models/MessageEmbed.php
  • app-modules/activity/src/Message/Models/MessageMention.php
  • app-modules/activity/src/Message/Models/MessageThread.php
  • app-modules/activity/src/Moderation/Models/ModerationEvent.php
  • app-modules/activity/src/Reaction/Models/Reaction.php
  • app-modules/activity/src/Timeline/DTOs/CreatePostDTO.php
  • app-modules/activity/src/Timeline/DTOs/CreateReplyDTO.php
  • app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php
  • app-modules/activity/src/Timeline/Queries/TimelineFeed.php
  • app-modules/activity/src/Timeline/Timeline.php
  • app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php
  • app-modules/activity/src/Tracking/Models/Interaction.php
  • app-modules/activity/src/Voice/Models/Voice.php
  • app-modules/community/src/Feedback/Models/Feedback.php
  • app-modules/community/src/Feedback/Models/Review.php
  • app-modules/community/src/Meeting/Models/Meeting.php
  • app-modules/gamification/src/Badge/DTOs/NewBadgeDTO.php
  • app-modules/gamification/src/Badge/Models/Badge.php
  • app-modules/gamification/src/Character/Actions/CharacterInitializerAction.php
  • app-modules/gamification/src/Character/Models/PastSeason.php
  • app-modules/gamification/src/Season/Models/Season.php
  • app-modules/identity/database/migrations/2026_05_26_001934_add_first_login_at_to_users_table.php
  • app-modules/identity/docs/adr/0001-oauth-user-resolution-and-merge-strategy.md
  • app-modules/identity/routes/authentication-routes.php
  • app-modules/identity/src/Auth/Actions/AttachProviderToUser.php
  • app-modules/identity/src/Auth/Actions/AuthenticateAction.php
  • app-modules/identity/src/Auth/Actions/DetectMergeConflict.php
  • app-modules/identity/src/Auth/Actions/EnrichUserOnFirstLogin.php
  • app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php
  • app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php
  • app-modules/identity/src/Auth/Actions/MergeAccountsAction.php
  • app-modules/identity/src/Auth/DTOs/MergeConflictDTO.php
  • app-modules/identity/src/Auth/DTOs/OAuthResultDTO.php
  • app-modules/identity/src/Auth/DTOs/OAuthStateDTO.php
  • app-modules/identity/src/Auth/Enums/OAuthIntent.php
  • app-modules/identity/src/Auth/Exceptions/OAuthFlowException.php
  • app-modules/identity/src/Auth/Http/Controllers/OAuthController.php
  • app-modules/identity/src/ExternalIdentity/Actions/CreateAccountByExternalIdentity.php
  • app-modules/identity/src/ExternalIdentity/DTOs/NewProviderDTO.php
  • app-modules/identity/src/ExternalIdentity/DTOs/ResolveUserProviderDTO.php
  • app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php
  • app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php
  • app-modules/identity/src/Tenant/Models/Tenant.php
  • app-modules/identity/src/Tenant/Models/TenantUser.php
  • app-modules/identity/src/User/Models/User.php
  • app-modules/identity/tests/Feature/Auth/DetectMergeConflictTest.php
  • app-modules/identity/tests/Feature/Auth/EnrichUserOnFirstLoginTest.php
  • app-modules/identity/tests/Feature/Auth/FindOrCreateUserByProviderTest.php
  • app-modules/identity/tests/Feature/Auth/MergeAccountsActionTest.php
  • app-modules/integration-devto/src/Polling/SyncDevToArticles.php
  • app-modules/integration-discord/src/ETL/Actions/ImportDiscordMessageAction.php
  • app-modules/integration-discord/src/ETL/Actions/ImportDiscordModerationEventAction.php
  • app-modules/integration-discord/src/ETL/Actions/ImportDiscordProfileAction.php
  • app-modules/integration-discord/src/ETL/Actions/ImportDiscordReactionsAction.php
  • app-modules/integration-discord/src/ETL/Actions/ImportDiscordVoiceLogAction.php
  • app-modules/integration-discord/src/ETL/Console/ImportDiscordMessagesCommand.php
  • app-modules/integration-discord/src/OAuth/DiscordOAuthClient.php
  • app-modules/integration-github/composer.json
  • app-modules/integration-github/database/factories/.gitkeep
  • app-modules/integration-github/database/migrations/.gitkeep
  • app-modules/integration-github/database/seeders/.gitkeep
  • app-modules/integration-github/phpstan.ignore.neon
  • app-modules/integration-github/phpstan.neon
  • app-modules/integration-github/src/IntegrationGithubServiceProvider.php
  • app-modules/integration-github/src/OAuth/DTO/GitHubOAuthAccessDTO.php
  • app-modules/integration-github/src/OAuth/DTO/GitHubOAuthUserDTO.php
  • app-modules/integration-github/src/OAuth/GitHubOAuthClient.php
  • app-modules/integration-github/src/Transport/GitHubApiConnector.php
  • app-modules/integration-github/src/Transport/GitHubOAuthConnector.php
  • app-modules/integration-github/src/Transport/Requests/OAuth/ExchangeCodeForToken.php
  • app-modules/integration-github/src/Transport/Requests/Users/GetCurrentUser.php
  • app-modules/integration-github/tests/Feature/.gitkeep
  • app-modules/integration-github/tests/Unit/.gitkeep
  • app-modules/integration-twitch/src/IntegrationTwitchServiceProvider.php
  • app-modules/integration-twitch/src/Models/TwitchSubscription.php
  • app-modules/integration-twitch/src/OAuth/TwitchOAuthClient.php
  • app-modules/integration-twitch/src/Transport/TwitchOAuthConnector.php
  • app-modules/panel-app/lang/en/profile.php
  • app-modules/panel-app/lang/pt_BR/profile.php
  • app-modules/panel-app/resources/views/auth/login.blade.php
  • app-modules/panel-app/resources/views/pages/profile.blade.php
  • app-modules/panel-app/src/Livewire/Timeline/Feed.php
  • app-modules/panel-app/src/Pages/LoginPage.php
  • app-modules/profile/src/Models/Profile.php
  • app/Livewire/ConnectionHub.php
  • app/Providers/Filament/AppPanelProvider.php
  • composer.json
  • config/services.php
  • database/migrations/2026_05_25_225721_convert_tenant_id_from_bigint_to_uuid.php
  • database/seeders/BaseSeeder.php
  • phpunit.xml
  • resources/css/filament/app/theme.css
  • resources/css/filament/user/theme.css
  • resources/views/livewire/connection-hub-admin.blade.php
  • resources/views/livewire/connection-hub.blade.php
  • vite.config.js
💤 Files with no reviewable changes (4)
  • app-modules/identity/src/Auth/Actions/AuthenticateAction.php
  • app-modules/integration-twitch/src/Transport/TwitchOAuthConnector.php
  • resources/css/filament/user/theme.css
  • phpunit.xml

Comment thread app-modules/identity/src/Auth/Actions/AttachProviderToUser.php
Comment thread app-modules/identity/src/Auth/Actions/EnrichUserOnFirstLogin.php
Comment thread app-modules/integration-github/src/OAuth/GitHubOAuthClient.php
Comment thread app-modules/panel-app/resources/views/auth/login.blade.php
Comment thread app/Livewire/ConnectionHub.php
- 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
Copy link
Copy Markdown

@gabrielrbarbosa gabrielrbarbosa left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Copy link
Copy Markdown
Member

@davicbtoliveira davicbtoliveira left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@gvieira18 gvieira18 merged commit 3f7d417 into 4.x May 26, 2026
6 checks passed
@gvieira18 gvieira18 deleted the feat/oauth-flow-and-account-merge branch May 26, 2026 01:44
gvieira18 pushed a commit that referenced this pull request May 26, 2026
…rovements (#290)

## Summary

- **OAuth error handling**: Gracefully handle token exchange failures
and missing credentials instead of crashing with unhandled exceptions
- **ConnectionHub UX**: Compact layout for profile sidebar, light/dark
theme support, refined disconnect button styling
- **Portal improvements**: Add "Área do Usuário" navbar link, Twitch as
login provider, fix hero avatar metadata key (`name` vs `username` for
legacy/new GitHub records)
- **Provider reconnection**: Clear `disconnected_at` when reconnecting
an existing provider

## Changes

### Identity module
- `OAuthController`: catch `OAuthFlowException` on token exchange,
redirect with error notification
- `AttachProviderToUser`: clear `disconnected_at` on reconnect
- OAuth clients (Discord, GitHub, Twitch): throw `OAuthFlowException` on
missing credentials

### Panel App
- `ConnectionHub`: compact card layout, dark/light theme support,
pill-style disconnect button, timestamp next to title
- `LoginPage`: add Twitch OAuth button
- Profile wrapper: light mode compatible (`bg-white dark:bg-gray-900`)

### Portal
- Navbar: outlined "Área do Usuário" button linking to `/app`
- `HeroSection`: support both `username` (new) and `name` (legacy)
metadata keys for GitHub avatars

## Test plan
- [ ] OAuth login with Discord, GitHub, and Twitch
- [ ] Disconnect/reconnect a provider from profile ConnectionHub
- [ ] Verify ConnectionHub renders correctly in both light and dark mode
- [ ] Portal landing page loads without errors (hero avatars)
- [ ] "Área do Usuário" navbar link navigates to `/app`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Description

This PR enhances OAuth security and user experience across the platform.
It adds comprehensive error handling for OAuth token exchanges,
validates provider credentials at initialization, and resets
disconnected provider states on reconnection. The ConnectionHub UI is
updated with improved light/dark theme support and refined styling,
while the portal gains Twitch OAuth support and fixes GitHub avatar
metadata handling. The identity module prevents unhandled exceptions by
catching OAuth failures with explicit error notifications.

## References

- PR `#288`: [OAuth flow refactor with account merge
strategy](#288) — Related
OAuth improvements
- PR `#272`: [Twitch integration with admin panel, tenant-scoped
EventSub, and subscription
management](#272) — Twitch
integration foundation
- PR `#289`: [Data migration
indexes](#289) — Related
database changes

## Dependencies & Requirements

No new dependencies were added or updated in this PR. All changes
utilize existing framework and module capabilities.

## Contributor Summary

| Contributor | Lines Added | Lines Removed | Files Changed |
|---|---|---|---|
| danielhe4rt | 136 | 45 | 12 |

## Changes Summary

| File Path | Change Description |
|---|---|
| app-modules/identity/src/Auth/Exceptions/OAuthFlowException.php |
Added `tokenExchangeFailed()` factory method for formatted OAuth token
exchange error messages |
| app-modules/identity/src/Auth/Http/Controllers/OAuthController.php |
Enhanced error handling for missing OAuth configurations, denied
callbacks, and token exchange failures with try/catch blocks and
redirects |
| app-modules/identity/src/Auth/Actions/AttachProviderToUser.php | Reset
`disconnected_at` to null on reconnection to mark providers as active
again |
| app-modules/integration-discord/src/OAuth/DiscordOAuthClient.php |
Added validation for `access_token` presence in Discord token response;
throws `OAuthFlowException` on missing token |
|
app-modules/integration-github/src/IntegrationGithubServiceProvider.php
| Added validation for GitHub client credentials with explicit
RuntimeException on missing configuration |
| app-modules/integration-github/src/OAuth/GitHubOAuthClient.php | Added
validation for `access_token` in GitHub token response; throws
`OAuthFlowException` on missing token |
| app-modules/integration-twitch/src/OAuth/TwitchOAuthClient.php | Added
validation for `access_token` in Twitch token response; throws
`OAuthFlowException` on missing token |
| app-modules/panel-app/resources/views/auth/login.blade.php | Added
Twitch OAuth login button alongside Discord and GitHub options |
| app-modules/panel-app/resources/views/pages/profile.blade.php |
Updated profile card styling with rounded backgrounds, rings, and dark
mode variants |
| app-modules/portal/resources/views/components/navbar.blade.php | Added
responsive "Área do Usuário" link and restructured Discord button for
mobile/desktop variants |
| app-modules/portal/src/Livewire/HeroSection.php | Updated avatar
metadata mapping to support both legacy `name` and new `username` keys
for GitHub records |
| resources/views/livewire/connection-hub.blade.php | Refined
ConnectionHub styling for light/dark theme compatibility, updated
disconnect button and permissions section appearance |

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/he4rt/heartdevs.com/pull/290?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(identity): handle username collision with sequential suffix on OAuth user creation

6 participants