diff --git a/app-modules/activity/src/Message/DTOs/NewMessageDTO.php b/app-modules/activity/src/Message/DTOs/NewMessageDTO.php index 83fa0e251..3fd773e40 100644 --- a/app-modules/activity/src/Message/DTOs/NewMessageDTO.php +++ b/app-modules/activity/src/Message/DTOs/NewMessageDTO.php @@ -10,7 +10,7 @@ final class NewMessageDTO { public function __construct( - public int $tenantId, + public string $tenantId, public IdentityProvider $provider, public string $providerUsername, public string $externalAccountId, diff --git a/app-modules/activity/src/Message/Models/MembershipEvent.php b/app-modules/activity/src/Message/Models/MembershipEvent.php index 9f8c8602b..d79aee873 100644 --- a/app-modules/activity/src/Message/Models/MembershipEvent.php +++ b/app-modules/activity/src/Message/Models/MembershipEvent.php @@ -15,7 +15,7 @@ /** * @property string $id - * @property int $tenant_id + * @property string $tenant_id * @property string $external_identity_id * @property string $kind * @property Carbon $occurred_at diff --git a/app-modules/activity/src/Message/Models/Message.php b/app-modules/activity/src/Message/Models/Message.php index 41e72d821..51d01f9be 100644 --- a/app-modules/activity/src/Message/Models/Message.php +++ b/app-modules/activity/src/Message/Models/Message.php @@ -27,7 +27,7 @@ * @property string $content * @property int $obtained_experience * @property Carbon|null $sent_at - * @property int $tenant_id + * @property string $tenant_id * @property array|null $metadata * @property int $reactions_count * @property int $reactions_total diff --git a/app-modules/activity/src/Message/Models/MessageAttachment.php b/app-modules/activity/src/Message/Models/MessageAttachment.php index 8e8fcd4ac..02793f57d 100644 --- a/app-modules/activity/src/Message/Models/MessageAttachment.php +++ b/app-modules/activity/src/Message/Models/MessageAttachment.php @@ -13,7 +13,7 @@ /** * @property string $id - * @property int $tenant_id + * @property string $tenant_id * @property string $message_id * @property string|null $provider_attachment_id * @property string $url diff --git a/app-modules/activity/src/Message/Models/MessageEmbed.php b/app-modules/activity/src/Message/Models/MessageEmbed.php index b7a02e636..4627b11fd 100644 --- a/app-modules/activity/src/Message/Models/MessageEmbed.php +++ b/app-modules/activity/src/Message/Models/MessageEmbed.php @@ -13,7 +13,7 @@ /** * @property string $id - * @property int $tenant_id + * @property string $tenant_id * @property string $message_id * @property string|null $url * @property string|null $title diff --git a/app-modules/activity/src/Message/Models/MessageMention.php b/app-modules/activity/src/Message/Models/MessageMention.php index a2d38e9b8..d2d3a9618 100644 --- a/app-modules/activity/src/Message/Models/MessageMention.php +++ b/app-modules/activity/src/Message/Models/MessageMention.php @@ -14,7 +14,7 @@ /** * @property string $id - * @property int $tenant_id + * @property string $tenant_id * @property string $message_id * @property string|null $mentioned_identity_id * @property string $mentioned_provider_account_id diff --git a/app-modules/activity/src/Message/Models/MessageThread.php b/app-modules/activity/src/Message/Models/MessageThread.php index ca1959d2b..d54ffe1a8 100644 --- a/app-modules/activity/src/Message/Models/MessageThread.php +++ b/app-modules/activity/src/Message/Models/MessageThread.php @@ -13,7 +13,7 @@ /** * @property string $id - * @property int $tenant_id + * @property string $tenant_id * @property string $message_id * @property string $provider_thread_id * @property string|null $name diff --git a/app-modules/activity/src/Moderation/Models/ModerationEvent.php b/app-modules/activity/src/Moderation/Models/ModerationEvent.php index 2064a3191..824e23e59 100644 --- a/app-modules/activity/src/Moderation/Models/ModerationEvent.php +++ b/app-modules/activity/src/Moderation/Models/ModerationEvent.php @@ -18,7 +18,7 @@ /** * @property string $id - * @property int $tenant_id + * @property string $tenant_id * @property string|null $external_identity_id * @property string|null $moderator_identity_id * @property ModerationType $type diff --git a/app-modules/activity/src/Reaction/Models/Reaction.php b/app-modules/activity/src/Reaction/Models/Reaction.php index a45151909..9c9fd5675 100644 --- a/app-modules/activity/src/Reaction/Models/Reaction.php +++ b/app-modules/activity/src/Reaction/Models/Reaction.php @@ -13,7 +13,7 @@ /** * @property string $id - * @property int $tenant_id + * @property string $tenant_id * @property string $reactable_type * @property string $reactable_id * @property string $emoji_key diff --git a/app-modules/activity/src/Timeline/DTOs/CreatePostDTO.php b/app-modules/activity/src/Timeline/DTOs/CreatePostDTO.php index dbedb1ce5..2f385dca6 100644 --- a/app-modules/activity/src/Timeline/DTOs/CreatePostDTO.php +++ b/app-modules/activity/src/Timeline/DTOs/CreatePostDTO.php @@ -11,7 +11,7 @@ */ public function __construct( public string $userId, - public int $tenantId, + public string $tenantId, public string $content, public array $images = [], ) {} diff --git a/app-modules/activity/src/Timeline/DTOs/CreateReplyDTO.php b/app-modules/activity/src/Timeline/DTOs/CreateReplyDTO.php index e31850f5b..f585f5644 100644 --- a/app-modules/activity/src/Timeline/DTOs/CreateReplyDTO.php +++ b/app-modules/activity/src/Timeline/DTOs/CreateReplyDTO.php @@ -11,7 +11,7 @@ */ public function __construct( public string $userId, - public int $tenantId, + public string $tenantId, public string $parentTimelineId, public string $content, public array $images = [], diff --git a/app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php b/app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php index d931a971b..9f2574538 100644 --- a/app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php +++ b/app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php @@ -54,7 +54,7 @@ public function handle(ActionExecuted $event): void ]); } - private function resolveIdentity(?string $userId, int $tenantId): ?string + private function resolveIdentity(?string $userId, string $tenantId): ?string { if ($userId === null) { return null; diff --git a/app-modules/activity/src/Timeline/Queries/TimelineFeed.php b/app-modules/activity/src/Timeline/Queries/TimelineFeed.php index 929bbe9b6..7c4d8d286 100644 --- a/app-modules/activity/src/Timeline/Queries/TimelineFeed.php +++ b/app-modules/activity/src/Timeline/Queries/TimelineFeed.php @@ -10,7 +10,7 @@ final readonly class TimelineFeed { public function __construct( - private int $tenantId, + private string $tenantId, ) {} /** @return Builder */ diff --git a/app-modules/activity/src/Timeline/Timeline.php b/app-modules/activity/src/Timeline/Timeline.php index a1e6010fb..2c203020a 100644 --- a/app-modules/activity/src/Timeline/Timeline.php +++ b/app-modules/activity/src/Timeline/Timeline.php @@ -20,7 +20,7 @@ /** * @property string $id * @property string $user_id - * @property int $tenant_id + * @property string $tenant_id * @property string $postable_type * @property string $postable_id * @property string|null $root_id @@ -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/activity/src/Tracking/DTOs/TrackActivityDTO.php b/app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php index 7789e8c90..05a5ec8db 100644 --- a/app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php +++ b/app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php @@ -12,7 +12,7 @@ { public function __construct( public string $characterId, - public int $tenantId, + public string $tenantId, public ActivityType $type, public IdentityProvider $provider, public DateTimeImmutable $occurredAt, diff --git a/app-modules/activity/src/Tracking/Models/Interaction.php b/app-modules/activity/src/Tracking/Models/Interaction.php index 48055d960..885222b56 100644 --- a/app-modules/activity/src/Tracking/Models/Interaction.php +++ b/app-modules/activity/src/Tracking/Models/Interaction.php @@ -22,7 +22,7 @@ /** * @property string $id * @property string $character_id - * @property int $tenant_id + * @property string $tenant_id * @property ActivityType $type * @property IdentityProvider $provider * @property ValueTier $value_tier diff --git a/app-modules/activity/src/Voice/Models/Voice.php b/app-modules/activity/src/Voice/Models/Voice.php index 83eaac672..a5b4c8d06 100644 --- a/app-modules/activity/src/Voice/Models/Voice.php +++ b/app-modules/activity/src/Voice/Models/Voice.php @@ -13,7 +13,7 @@ /** * @property int $id * @property string $external_identity_id - * @property int $tenant_id + * @property string $tenant_id * @property string|null $provider_message_id * @property string $channel_name * @property string $state diff --git a/app-modules/community/src/Feedback/Models/Feedback.php b/app-modules/community/src/Feedback/Models/Feedback.php index ef259abaa..4a1315c66 100644 --- a/app-modules/community/src/Feedback/Models/Feedback.php +++ b/app-modules/community/src/Feedback/Models/Feedback.php @@ -17,7 +17,7 @@ /** * @property string $id - * @property int $tenant_id + * @property string $tenant_id * @property string $sender_id * @property string $target_id * @property string $type diff --git a/app-modules/community/src/Feedback/Models/Review.php b/app-modules/community/src/Feedback/Models/Review.php index d4eb783df..306edd556 100644 --- a/app-modules/community/src/Feedback/Models/Review.php +++ b/app-modules/community/src/Feedback/Models/Review.php @@ -16,7 +16,7 @@ /** * @property string $id - * @property int $tenant_id + * @property string $tenant_id * @property string $feedback_id * @property string $staff_id * @property ReviewTypeEnum $status diff --git a/app-modules/community/src/Meeting/Models/Meeting.php b/app-modules/community/src/Meeting/Models/Meeting.php index a082d6389..9419c3eef 100644 --- a/app-modules/community/src/Meeting/Models/Meeting.php +++ b/app-modules/community/src/Meeting/Models/Meeting.php @@ -18,7 +18,7 @@ /** * @property string $id - * @property int $tenant_id + * @property string $tenant_id * @property string $admin_id * @property string|null $content * @property int $meeting_type_id diff --git a/app-modules/gamification/src/Badge/DTOs/NewBadgeDTO.php b/app-modules/gamification/src/Badge/DTOs/NewBadgeDTO.php index 719c6f3e0..e8bc8ae29 100644 --- a/app-modules/gamification/src/Badge/DTOs/NewBadgeDTO.php +++ b/app-modules/gamification/src/Badge/DTOs/NewBadgeDTO.php @@ -14,7 +14,7 @@ public function __construct( private string $description, private string $redeemCode, private bool $active, - private int $tenant_id + private string $tenant_id ) {} /** diff --git a/app-modules/gamification/src/Badge/Models/Badge.php b/app-modules/gamification/src/Badge/Models/Badge.php index 087300d66..6e828fb57 100644 --- a/app-modules/gamification/src/Badge/Models/Badge.php +++ b/app-modules/gamification/src/Badge/Models/Badge.php @@ -20,7 +20,7 @@ /** * @property int $id - * @property int $tenant_id + * @property string $tenant_id * @property IdentityProvider $provider * @property string $name * @property string $description diff --git a/app-modules/gamification/src/Character/Actions/CharacterInitializerAction.php b/app-modules/gamification/src/Character/Actions/CharacterInitializerAction.php index 1a2f7774d..192d04832 100644 --- a/app-modules/gamification/src/Character/Actions/CharacterInitializerAction.php +++ b/app-modules/gamification/src/Character/Actions/CharacterInitializerAction.php @@ -9,7 +9,7 @@ final class CharacterInitializerAction { - public function ensure(User $user, int $tenantId): Character + public function ensure(User $user, string $tenantId): Character { return Character::query()->firstOrCreate( [ 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/gamification/src/Character/Models/PastSeason.php b/app-modules/gamification/src/Character/Models/PastSeason.php index a16384085..109df9909 100644 --- a/app-modules/gamification/src/Character/Models/PastSeason.php +++ b/app-modules/gamification/src/Character/Models/PastSeason.php @@ -16,7 +16,7 @@ /** * @property string $id - * @property int $tenant_id + * @property string $tenant_id * @property string $season_id * @property string $character_id * @property int $ranking_position diff --git a/app-modules/gamification/src/Season/Models/Season.php b/app-modules/gamification/src/Season/Models/Season.php index 2bbdba433..8bd979510 100644 --- a/app-modules/gamification/src/Season/Models/Season.php +++ b/app-modules/gamification/src/Season/Models/Season.php @@ -18,7 +18,7 @@ use Illuminate\Support\Facades\Date; /** - * @property int $tenant_id + * @property string $tenant_id * @property string $name * @property string $description * @property int $messages_count 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/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/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/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/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 new file mode 100644 index 000000000..1618c7097 --- /dev/null +++ b/app-modules/identity/src/Auth/Actions/FindOrCreateUserByProvider.php @@ -0,0 +1,99 @@ +findExistingUser($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 (!$alreadyBelongsToTenant) { + $user->tenants()->attach($tenant); + } + + return $user; + } + + private function findExistingUser(OAuthUserDTO $oauthUser): ?User + { + $identity = ExternalIdentity::query() + ->where('provider', $oauthUser->provider) + ->where('external_account_id', $oauthUser->providerId) + ->where('model_type', (new User)->getMorphClass()) + ->first(); + + if ($identity?->model instanceof User) { + return $identity->model; + } + + 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/src/Auth/Actions/HandleOAuthCallbackAction.php b/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php new file mode 100644 index 000000000..a76a19e76 --- /dev/null +++ b/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php @@ -0,0 +1,88 @@ +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(), + }; + + $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, + 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/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/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 new file mode 100644 index 000000000..e1c10d217 --- /dev/null +++ b/app-modules/identity/src/Auth/DTOs/OAuthResultDTO.php @@ -0,0 +1,27 @@ +mergeConflict instanceof MergeConflictDTO; + } +} diff --git a/app-modules/identity/src/Auth/DTOs/OAuthStateDTO.php b/app-modules/identity/src/Auth/DTOs/OAuthStateDTO.php index 7646a545f..62d33e3ab 100644 --- a/app-modules/identity/src/Auth/DTOs/OAuthStateDTO.php +++ b/app-modules/identity/src/Auth/DTOs/OAuthStateDTO.php @@ -4,15 +4,20 @@ namespace He4rt\Identity\Auth\DTOs; +use He4rt\Identity\Auth\Enums\OAuthIntent; +use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use Illuminate\Support\Facades\Crypt; use JsonSerializable; use Stringable; -class OAuthStateDTO implements JsonSerializable, Stringable +final readonly class OAuthStateDTO implements JsonSerializable, Stringable { public function __construct( + public OAuthIntent $intent, + public IdentityProvider $provider, public string $panel, - public ?string $tenant = null, + public string $tenant, + public ?string $returnUrl = null, ) {} public function __toString(): string @@ -20,9 +25,17 @@ public function __toString(): string return Crypt::encryptString(json_encode($this)); } - public static function fromHashedString(string $state): self + public static function fromEncryptedString(string $state): self { - return new self(...json_decode(Crypt::decryptString($state), true)); + $data = json_decode(Crypt::decryptString($state), true); + + return new self( + intent: OAuthIntent::from($data['intent']), + provider: IdentityProvider::from($data['provider']), + panel: $data['panel'], + tenant: $data['tenant'], + returnUrl: $data['return_url'] ?? null, + ); } /** @@ -30,10 +43,12 @@ public static function fromHashedString(string $state): self */ public function jsonSerialize(): array { - return [ + 'intent' => $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..8e0503822 100644 --- a/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php +++ b/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php @@ -4,59 +4,61 @@ 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->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)); + filament()->setTenant($result->tenant); + } - return redirect()->intended($panel->getUrl()); + return redirect()->to($result->redirectUrl); } } diff --git a/app-modules/identity/src/ExternalIdentity/Actions/CreateAccountByExternalIdentity.php b/app-modules/identity/src/ExternalIdentity/Actions/CreateAccountByExternalIdentity.php index 80a0397d5..9e9dcb784 100644 --- a/app-modules/identity/src/ExternalIdentity/Actions/CreateAccountByExternalIdentity.php +++ b/app-modules/identity/src/ExternalIdentity/Actions/CreateAccountByExternalIdentity.php @@ -13,7 +13,7 @@ class CreateAccountByExternalIdentity { - public function handle(int $tenantId, IdentityProvider $provider, string $providerId, string $username): ExternalIdentity + public function handle(string $tenantId, IdentityProvider $provider, string $providerId, string $username): ExternalIdentity { $existing = ExternalIdentity::query() ->where('provider', $provider->value) diff --git a/app-modules/identity/src/ExternalIdentity/DTOs/NewProviderDTO.php b/app-modules/identity/src/ExternalIdentity/DTOs/NewProviderDTO.php index c9fa67ba2..5f6bcffe1 100644 --- a/app-modules/identity/src/ExternalIdentity/DTOs/NewProviderDTO.php +++ b/app-modules/identity/src/ExternalIdentity/DTOs/NewProviderDTO.php @@ -10,7 +10,7 @@ final readonly class NewProviderDTO implements JsonSerializable { public function __construct( - private int $tenantId, + private string $tenantId, private IdentityProvider $provider, private string $externalAccountId ) {} diff --git a/app-modules/identity/src/ExternalIdentity/DTOs/ResolveUserProviderDTO.php b/app-modules/identity/src/ExternalIdentity/DTOs/ResolveUserProviderDTO.php index ecc6d9666..24718d22b 100644 --- a/app-modules/identity/src/ExternalIdentity/DTOs/ResolveUserProviderDTO.php +++ b/app-modules/identity/src/ExternalIdentity/DTOs/ResolveUserProviderDTO.php @@ -9,7 +9,7 @@ class ResolveUserProviderDTO { public function __construct( - public int $tenantId, + public string $tenantId, public IdentityProvider $provider, public string $externalAccountId, public string $modelType, diff --git a/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php b/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php index f87a499aa..67fbaf3a6 100644 --- a/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php +++ b/app-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.php @@ -11,12 +11,11 @@ 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\IntegrationGithub\OAuth\GitHubOAuthClient; use He4rt\IntegrationTwitch\OAuth\TwitchOAuthClient; -use LogicException; enum IdentityProvider: string implements HasColor, HasDescription, HasIcon, HasLabel { @@ -63,6 +62,7 @@ public function getClient(): ?OAuthClientContract return match ($this) { self::Twitch => resolve(TwitchOAuthClient::class), self::Discord => resolve(DiscordOAuthClient::class), + self::GitHub => resolve(GitHubOAuthClient::class), self::DevTo => resolve(DevToOAuthClient::class), default => null, }; @@ -194,18 +194,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 diff --git a/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php b/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php index d4c85af31..21400e3de 100644 --- a/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php +++ b/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php @@ -26,7 +26,7 @@ /** * @property string $id - * @property int $tenant_id + * @property string $tenant_id * @property string $model_type * @property string $model_id * @property IdentityType $type diff --git a/app-modules/identity/src/Tenant/Models/Tenant.php b/app-modules/identity/src/Tenant/Models/Tenant.php index 36d93b784..89799ecb5 100644 --- a/app-modules/identity/src/Tenant/Models/Tenant.php +++ b/app-modules/identity/src/Tenant/Models/Tenant.php @@ -11,6 +11,7 @@ use He4rt\Identity\Database\Factories\TenantFactory; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use He4rt\Identity\User\Models\User; +use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -20,7 +21,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; /** - * @property int $id + * @property string $id * @property string $name * @property string $slug * @property string|null $domain @@ -35,6 +36,7 @@ class Tenant extends Model /** @use HasFactory */ use HasFactory; + use HasUuids; use SoftDeletes; /** diff --git a/app-modules/identity/src/Tenant/Models/TenantUser.php b/app-modules/identity/src/Tenant/Models/TenantUser.php index 52b65cb51..f8e7a4333 100644 --- a/app-modules/identity/src/Tenant/Models/TenantUser.php +++ b/app-modules/identity/src/Tenant/Models/TenantUser.php @@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Relations\Pivot; /** - * @property int $tenant_id + * @property string $tenant_id * @property string $user_id */ #[Table(name: 'tenant_users')] diff --git a/app-modules/identity/src/User/Models/User.php b/app-modules/identity/src/User/Models/User.php index d28673bc9..84e7f7dfb 100644 --- a/app-modules/identity/src/User/Models/User.php +++ b/app-modules/identity/src/User/Models/User.php @@ -31,10 +31,11 @@ * @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 + * @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/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(); +}); 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 new file mode 100644 index 000000000..daa2f79d1 --- /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 = resolve(FindOrCreateUserByProvider::class); + $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 = resolve(FindOrCreateUserByProvider::class); + $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 = resolve(FindOrCreateUserByProvider::class); + $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 = resolve(FindOrCreateUserByProvider::class); + $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 = resolve(FindOrCreateUserByProvider::class); + $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 = resolve(FindOrCreateUserByProvider::class); + $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 = resolve(FindOrCreateUserByProvider::class); + $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 = resolve(FindOrCreateUserByProvider::class); + $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 = resolve(FindOrCreateUserByProvider::class); + $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(); +}); 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-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-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']), diff --git a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordMessageAction.php b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordMessageAction.php index 15557c7e2..f172d104e 100644 --- a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordMessageAction.php +++ b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordMessageAction.php @@ -31,7 +31,7 @@ final class ImportDiscordMessageAction /** * @param array $replyCache provider_message_id => message uuid */ - public function handle(DiscordMessageDTO $dto, int $tenantId, ?string $cachedIdentityId = null, array $replyCache = []): Message + public function handle(DiscordMessageDTO $dto, string $tenantId, ?string $cachedIdentityId = null, array $replyCache = []): Message { $identityId = $cachedIdentityId ?? $this->resolveAuthorIdentity($dto, $tenantId)->id; $adapter = IdentityProvider::Discord->getMessageAdapter(); @@ -72,7 +72,7 @@ public function handle(DiscordMessageDTO $dto, int $tenantId, ?string $cachedIde * @param array $replyCache * @return array discordMessageId => Message stub */ - public function handleBatch(array $dtos, int $tenantId, array $identityCache, array $replyCache): array + public function handleBatch(array $dtos, string $tenantId, array $identityCache, array $replyCache): array { $adapter = IdentityProvider::Discord->getMessageAdapter(); $now = now(); @@ -136,7 +136,7 @@ public function handleBatch(array $dtos, int $tenantId, array $identityCache, ar * @param array $existingCache * @return array */ - public function prewarm(iterable $dtos, int $tenantId, array $existingCache = []): array + public function prewarm(iterable $dtos, string $tenantId, array $existingCache = []): array { $newAuthors = []; foreach ($dtos as $dto) { @@ -179,7 +179,7 @@ public function prewarm(iterable $dtos, int $tenantId, array $existingCache = [] * @param iterable $dtos * @return array reply_to_provider_message_id => message uuid */ - public function prewarmReplyTargets(iterable $dtos, int $tenantId): array + public function prewarmReplyTargets(iterable $dtos, string $tenantId): array { $adapter = IdentityProvider::Discord->getMessageAdapter(); if (!$adapter instanceof MessageActivityAdapter) { @@ -237,7 +237,7 @@ private function resolveReplyTargetId( DiscordMessageDTO $dto, ?MessageActivityAdapter $adapter, array $replyCache = [], - ?int $tenantId = null, + ?string $tenantId = null, ): ?string { if (!$adapter instanceof MessageActivityAdapter) { return null; @@ -265,7 +265,7 @@ private function resolveReplyTargetId( private function syncMentions( Message $message, DiscordMessageDTO $dto, - int $tenantId, + string $tenantId, MessageActivityAdapter $adapter, ): void { $mentions = $adapter->extractMentions($dto->metadata); @@ -304,7 +304,7 @@ private function syncMentions( private function syncThread( Message $message, DiscordMessageDTO $dto, - int $tenantId, + string $tenantId, MessageActivityAdapter $adapter, ): void { $thread = $adapter->extractThread($dto->metadata); @@ -329,7 +329,7 @@ private function syncThread( private function syncAttachments( Message $message, DiscordMessageDTO $dto, - int $tenantId, + string $tenantId, MessageActivityAdapter $adapter, ): void { $attachments = $adapter->extractAttachments($dto->metadata); @@ -371,7 +371,7 @@ private function syncAttachments( private function syncEmbeds( Message $message, DiscordMessageDTO $dto, - int $tenantId, + string $tenantId, MessageActivityAdapter $adapter, ): void { $embeds = $adapter->extractEmbeds($dto->metadata); @@ -408,7 +408,7 @@ private function syncEmbeds( private function syncMembershipEvent( Message $message, DiscordMessageDTO $dto, - int $tenantId, + string $tenantId, MessageActivityAdapter $adapter, ): void { $event = $adapter->extractMembershipEvent($dto->metadata); @@ -430,7 +430,7 @@ private function syncMembershipEvent( ); } - private function resolveAuthorIdentity(DiscordMessageDTO $dto, int $tenantId): ExternalIdentity + private function resolveAuthorIdentity(DiscordMessageDTO $dto, string $tenantId): ExternalIdentity { $identity = ExternalIdentity::query() ->where('provider', IdentityProvider::Discord) @@ -445,7 +445,7 @@ private function resolveAuthorIdentity(DiscordMessageDTO $dto, int $tenantId): E return $this->createIdentity($dto, $tenantId); } - private function createIdentity(DiscordMessageDTO $dto, int $tenantId): ExternalIdentity + private function createIdentity(DiscordMessageDTO $dto, string $tenantId): ExternalIdentity { $user = $this->resolveOrCreateUser($dto); $user->tenants()->syncWithoutDetaching([$tenantId]); diff --git a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordModerationEventAction.php b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordModerationEventAction.php index bc4d8dffc..b646ddfc7 100644 --- a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordModerationEventAction.php +++ b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordModerationEventAction.php @@ -13,7 +13,7 @@ final class ImportDiscordModerationEventAction { public function handle( DiscordModerationEventDTO $dto, - int $tenantId, + string $tenantId, ?string $sourceMessageId = null, ): ModerationEvent { $subjectIdentity = $this->resolveSubject($dto, $tenantId); @@ -51,7 +51,7 @@ public function handle( return ModerationEvent::query()->create($attributes); } - private function resolveBot(DiscordModerationEventDTO $dto, int $tenantId): ?ExternalIdentity + private function resolveBot(DiscordModerationEventDTO $dto, string $tenantId): ?ExternalIdentity { return ExternalIdentity::query() ->where('provider', IdentityProvider::Discord) @@ -60,7 +60,7 @@ private function resolveBot(DiscordModerationEventDTO $dto, int $tenantId): ?Ext ->first(); } - private function resolveSubject(DiscordModerationEventDTO $dto, int $tenantId): ?ExternalIdentity + private function resolveSubject(DiscordModerationEventDTO $dto, string $tenantId): ?ExternalIdentity { if ($dto->subjectDiscordId) { return ExternalIdentity::query() @@ -82,7 +82,7 @@ private function resolveSubject(DiscordModerationEventDTO $dto, int $tenantId): return null; } - private function resolveModerator(DiscordModerationEventDTO $dto, int $tenantId): ?ExternalIdentity + private function resolveModerator(DiscordModerationEventDTO $dto, string $tenantId): ?ExternalIdentity { if (!$dto->moderatorDiscordId) { return null; diff --git a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordProfileAction.php b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordProfileAction.php index 33665a7c7..f3fc64b24 100644 --- a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordProfileAction.php +++ b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordProfileAction.php @@ -17,7 +17,7 @@ final class ImportDiscordProfileAction { - public function handle(DiscordProfileDTO $dto, int $tenantId): ExternalIdentity + public function handle(DiscordProfileDTO $dto, string $tenantId): ExternalIdentity { $user = $this->resolveUser($dto, $tenantId); $user->tenants()->syncWithoutDetaching([$tenantId]); @@ -46,7 +46,7 @@ public function handle(DiscordProfileDTO $dto, int $tenantId): ExternalIdentity return $discordIdentity; } - private function resolveUser(DiscordProfileDTO $dto, int $tenantId): User + private function resolveUser(DiscordProfileDTO $dto, string $tenantId): User { $identity = ExternalIdentity::query() ->where('provider', IdentityProvider::Discord) @@ -106,7 +106,7 @@ private function syncUserAttributes(User $user, DiscordProfileDTO $dto): User private function upsertConnectedAccount( ConnectedAccountDTO $account, User $user, - int $tenantId, + string $tenantId, DiscordProfileDTO $dto, ): void { $existing = ExternalIdentity::query() diff --git a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordReactionsAction.php b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordReactionsAction.php index 154fb8235..2e169b3b6 100644 --- a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordReactionsAction.php +++ b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordReactionsAction.php @@ -14,7 +14,7 @@ final class ImportDiscordReactionsAction /** * @param list $reactions */ - public function handle(Message $message, array $reactions, int $tenantId): void + public function handle(Message $message, array $reactions, string $tenantId): void { if ($reactions === []) { return; diff --git a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordVoiceLogAction.php b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordVoiceLogAction.php index 865de237c..e22d941bb 100644 --- a/app-modules/integration-discord/src/ETL/Actions/ImportDiscordVoiceLogAction.php +++ b/app-modules/integration-discord/src/ETL/Actions/ImportDiscordVoiceLogAction.php @@ -14,7 +14,7 @@ final class ImportDiscordVoiceLogAction /** * @param array $channelMap */ - public function handle(DiscordVoiceLogDTO $dto, int $tenantId, array $channelMap): ?Voice + public function handle(DiscordVoiceLogDTO $dto, string $tenantId, array $channelMap): ?Voice { $identity = ExternalIdentity::query() ->where('provider', IdentityProvider::Discord) diff --git a/app-modules/integration-discord/src/ETL/Console/ImportDiscordMessagesCommand.php b/app-modules/integration-discord/src/ETL/Console/ImportDiscordMessagesCommand.php index 0f605ebc0..eb2c1677f 100644 --- a/app-modules/integration-discord/src/ETL/Console/ImportDiscordMessagesCommand.php +++ b/app-modules/integration-discord/src/ETL/Console/ImportDiscordMessagesCommand.php @@ -286,7 +286,7 @@ private function processSubEntities( ImportDiscordReactionsAction $reactionsAction, ImportDiscordVoiceLogAction $voiceAction, ImportDiscordModerationEventAction $moderationAction, - int $tenantId, + string $tenantId, array $channelMap, array &$stats, ): void { @@ -377,7 +377,7 @@ private function renderBox(ConsoleSectionOutput $section, string $title, int $cu * @param list> $messages * @return list> */ - private function filterNewMessages(array $messages, int $tenantId): array + private function filterNewMessages(array $messages, string $tenantId): array { $unique = []; foreach ($messages as $m) { 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-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-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/integration-github/composer.json b/app-modules/integration-github/composer.json new file mode 100644 index 000000000..813a972d0 --- /dev/null +++ b/app-modules/integration-github/composer.json @@ -0,0 +1,27 @@ +{ + "name": "he4rt/integration-github", + "description": "", + "type": "library", + "version": "1.0.0", + "license": "proprietary", + "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/" + } + }, + "minimum-stability": "stable", + "extra": { + "laravel": { + "providers": [ + "He4rt\\IntegrationGithub\\IntegrationGithubServiceProvider" + ] + } + } +} diff --git a/app-modules/integration-github/database/factories/.gitkeep b/app-modules/integration-github/database/factories/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app-modules/integration-github/database/migrations/.gitkeep b/app-modules/integration-github/database/migrations/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app-modules/integration-github/database/seeders/.gitkeep b/app-modules/integration-github/database/seeders/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app-modules/integration-github/phpstan.ignore.neon b/app-modules/integration-github/phpstan.ignore.neon new file mode 100644 index 000000000..f51e71c3f --- /dev/null +++ b/app-modules/integration-github/phpstan.ignore.neon @@ -0,0 +1,2 @@ +parameters: + ignoreErrors: [] diff --git a/app-modules/integration-github/phpstan.neon b/app-modules/integration-github/phpstan.neon new file mode 100644 index 000000000..b577d0f29 --- /dev/null +++ b/app-modules/integration-github/phpstan.neon @@ -0,0 +1,6 @@ +includes: + - phpstan.ignore.neon + +parameters: + paths: + - src/ diff --git a/app-modules/integration-github/src/IntegrationGithubServiceProvider.php b/app-modules/integration-github/src/IntegrationGithubServiceProvider.php new file mode 100644 index 000000000..d596c9df8 --- /dev/null +++ b/app-modules/integration-github/src/IntegrationGithubServiceProvider.php @@ -0,0 +1,24 @@ +app->singleton(GitHubOAuthConnector::class, fn () => new GitHubOAuthConnector( + clientId: config('services.github.client_id'), + clientSecret: config('services.github.client_secret'), + )); + + $this->app->singleton(GitHubApiConnector::class, fn () => new GitHubApiConnector()); + } + + public function boot(): void {} +} diff --git a/app-modules/integration-github/src/OAuth/DTO/GitHubOAuthAccessDTO.php b/app-modules/integration-github/src/OAuth/DTO/GitHubOAuthAccessDTO.php new file mode 100644 index 000000000..f0c25f2dc --- /dev/null +++ b/app-modules/integration-github/src/OAuth/DTO/GitHubOAuthAccessDTO.php @@ -0,0 +1,22 @@ + $payload + */ + public static function make(array $payload): self + { + return new self( + accessToken: $payload['access_token'], + refreshToken: $payload['refresh_token'] ?? '', + expiresIn: $payload['expires_in'] ?? null, + ); + } +} diff --git a/app-modules/integration-github/src/OAuth/DTO/GitHubOAuthUserDTO.php b/app-modules/integration-github/src/OAuth/DTO/GitHubOAuthUserDTO.php new file mode 100644 index 000000000..fd777acc5 --- /dev/null +++ b/app-modules/integration-github/src/OAuth/DTO/GitHubOAuthUserDTO.php @@ -0,0 +1,28 @@ + $payload + */ + public static function make(OAuthAccessDTO $credentials, array $payload): self + { + return new self( + credentials: $credentials, + providerId: (string) $payload['id'], + provider: IdentityProvider::GitHub, + username: $payload['login'], + name: $payload['name'] ?? $payload['login'], + email: $payload['email'], + avatarUrl: $payload['avatar_url'], + ); + } +} diff --git a/app-modules/integration-github/src/OAuth/GitHubOAuthClient.php b/app-modules/integration-github/src/OAuth/GitHubOAuthClient.php new file mode 100644 index 000000000..b83d00421 --- /dev/null +++ b/app-modules/integration-github/src/OAuth/GitHubOAuthClient.php @@ -0,0 +1,59 @@ + $this->oauthConnector->clientId, + 'redirect_uri' => $this->callbackUrl(), + 'scope' => config('services.github.scopes'), + 'state' => (string) $state, + ]); + } + + public function auth(string $code): GitHubOAuthAccessDTO + { + $response = $this->oauthConnector->send(new ExchangeCodeForToken( + code: $code, + clientId: $this->oauthConnector->clientId, + clientSecret: $this->oauthConnector->getClientSecret(), + redirectUri: $this->callbackUrl(), + )); + + return GitHubOAuthAccessDTO::make($response->json()); + } + + public function getAuthenticatedUser(OAuthAccessDTO $credentials): GitHubOAuthUserDTO + { + $response = $this->apiConnector->send(new GetCurrentUser( + accessToken: $credentials->accessToken, + )); + + return GitHubOAuthUserDTO::make($credentials, $response->json()); + } + + private function callbackUrl(): string + { + return mb_rtrim(config('app.url'), '/').'/auth/oauth/github'; + } +} diff --git a/app-modules/integration-github/src/Transport/GitHubApiConnector.php b/app-modules/integration-github/src/Transport/GitHubApiConnector.php new file mode 100644 index 000000000..b8d3ef828 --- /dev/null +++ b/app-modules/integration-github/src/Transport/GitHubApiConnector.php @@ -0,0 +1,33 @@ + + */ + protected function defaultHeaders(): array + { + return [ + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + ]; + } +} diff --git a/app-modules/integration-github/src/Transport/GitHubOAuthConnector.php b/app-modules/integration-github/src/Transport/GitHubOAuthConnector.php new file mode 100644 index 000000000..14e3b6111 --- /dev/null +++ b/app-modules/integration-github/src/Transport/GitHubOAuthConnector.php @@ -0,0 +1,42 @@ +clientSecret; + } + + public function resolveBaseUrl(): string + { + return 'https://github.com/login/oauth'; + } + + /** + * @return array + */ + protected function defaultHeaders(): array + { + return [ + 'Accept' => 'application/json', + ]; + } +} diff --git a/app-modules/integration-github/src/Transport/Requests/OAuth/ExchangeCodeForToken.php b/app-modules/integration-github/src/Transport/Requests/OAuth/ExchangeCodeForToken.php new file mode 100644 index 000000000..f5cb4eed8 --- /dev/null +++ b/app-modules/integration-github/src/Transport/Requests/OAuth/ExchangeCodeForToken.php @@ -0,0 +1,42 @@ + + */ + protected function defaultBody(): array + { + return [ + 'code' => $this->code, + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'redirect_uri' => $this->redirectUri, + ]; + } +} diff --git a/app-modules/integration-github/src/Transport/Requests/Users/GetCurrentUser.php b/app-modules/integration-github/src/Transport/Requests/Users/GetCurrentUser.php new file mode 100644 index 000000000..7d88cce8a --- /dev/null +++ b/app-modules/integration-github/src/Transport/Requests/Users/GetCurrentUser.php @@ -0,0 +1,28 @@ +accessToken); + } +} diff --git a/app-modules/integration-github/tests/Feature/.gitkeep b/app-modules/integration-github/tests/Feature/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app-modules/integration-github/tests/Unit/.gitkeep b/app-modules/integration-github/tests/Unit/.gitkeep new file mode 100644 index 000000000..e69de29bb 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/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/integration-twitch/src/Models/TwitchSubscription.php b/app-modules/integration-twitch/src/Models/TwitchSubscription.php index c1940dea7..deddfa673 100644 --- a/app-modules/integration-twitch/src/Models/TwitchSubscription.php +++ b/app-modules/integration-twitch/src/Models/TwitchSubscription.php @@ -21,7 +21,7 @@ * @property string|null $callback_url * @property int $cost * @property string $version - * @property int $tenant_id + * @property string $tenant_id * @property Carbon|null $created_at * @property Carbon|null $updated_at */ 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 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/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-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 new file mode 100644 index 000000000..7165646de --- /dev/null +++ b/app-modules/panel-app/resources/views/auth/login.blade.php @@ -0,0 +1,116 @@ + + + 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/Livewire/Timeline/Feed.php b/app-modules/panel-app/src/Livewire/Timeline/Feed.php index 378b00d22..e4f13c0b7 100644 --- a/app-modules/panel-app/src/Livewire/Timeline/Feed.php +++ b/app-modules/panel-app/src/Livewire/Timeline/Feed.php @@ -16,7 +16,7 @@ final class Feed extends Component use HasLoadMore; #[Locked] - public int $tenantId; + public string $tenantId; #[On('timeline.post-created')] #[On('timeline.reply-created')] 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..8c8203579 --- /dev/null +++ b/app-modules/panel-app/src/Pages/LoginPage.php @@ -0,0 +1,46 @@ +environment(['local', 'staging'])) { + $this->form->fill([ + 'email' => 'admin@admin.com', + 'password' => 'admin', + ]); + } + } + + 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/app-modules/profile/src/Models/Profile.php b/app-modules/profile/src/Models/Profile.php index 96933f5af..135e9ad48 100644 --- a/app-modules/profile/src/Models/Profile.php +++ b/app-modules/profile/src/Models/Profile.php @@ -22,7 +22,7 @@ /** * @property string $id * @property string $user_id - * @property int $tenant_id + * @property string $tenant_id * @property string|null $nickname * @property Carbon|null $birthdate * @property string|null $about diff --git a/app/Livewire/ConnectionHub.php b/app/Livewire/ConnectionHub.php index 558a395de..b0f71acbe 100644 --- a/app/Livewire/ConnectionHub.php +++ b/app/Livewire/ConnectionHub.php @@ -5,24 +5,32 @@ namespace App\Livewire; use Filament\Notifications\Notification; -use He4rt\Identity\Auth\DTOs\OAuthStateDTO; +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 { public string $panel = 'app'; - public int $tenantId = 0; + 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() ?? 0; + $this->tenantId = filament()->getTenant()?->getKey() ?? ''; + $this->checkPendingMerge(); } public function render(): View @@ -41,6 +49,7 @@ public function render(): View 'userProviders' => $this->getUserProviders(), 'supportedProviders' => $supportedProviders, 'panel' => $this->panel, + 'mergeTarget' => $this->getMergeTarget(), ]); } @@ -48,11 +57,51 @@ 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(route('oauth.redirect', [ + 'tenant' => $tenant->domain ?? $tenant->slug, + 'panel' => $this->panel, + 'provider' => $provider->value, + ])); + } + + 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(); - $this->redirect($redirectUri); + 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(filament()->getCurrentPanel()->getUrl(filament()->getTenant())); + } + + public function cancelMerge(): void + { + session()->forget('oauth_merge_pending'); + $this->showMergeModal = false; + $this->mergeData = null; } public function disconnect(IdentityProvider $provider): void @@ -108,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 { @@ -119,9 +209,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/app/Providers/Filament/AppPanelProvider.php b/app/Providers/Filament/AppPanelProvider.php index a3d96985e..a11c6adaf 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()->isProduction() + ? $panel->tenantDomain('{tenant:domain}')->tenant(model: Tenant::class, slugAttribute: 'domain') + : $panel->tenant(model: Tenant::class, slugAttribute: 'slug'); + + return $panel; } } diff --git a/composer.json b/composer.json index f2c3dd711..6302a4ea1 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "he4rt/identity": ">=1", "he4rt/integration-devto": ">=1", "he4rt/integration-discord": ">=1", + "he4rt/integration-github": ">=1", "he4rt/integration-twitch": ">=1", "he4rt/moderation": "^1.0", "he4rt/panel-admin": ">=1", 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/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'), ], diff --git a/database/migrations/2026_05_25_225721_convert_tenant_id_from_bigint_to_uuid.php b/database/migrations/2026_05_25_225721_convert_tenant_id_from_bigint_to_uuid.php new file mode 100644 index 000000000..928a87da9 --- /dev/null +++ b/database/migrations/2026_05_25_225721_convert_tenant_id_from_bigint_to_uuid.php @@ -0,0 +1,98 @@ + */ + private array $childTables = [ + 'activity_reactions', + 'activity_timeline', + 'badges', + 'characters', + 'characters_badges', + 'discord_guilds', + 'external_identities', + 'feedback_reviews', + 'feedbacks', + 'interactions', + 'meetings', + 'membership_events', + 'message_attachments', + 'message_embeds', + 'message_mentions', + 'message_threads', + 'messages', + 'moderation_actions', + 'moderation_appeals', + 'moderation_audit_log', + 'moderation_cases', + 'moderation_events', + 'moderation_rules', + 'seasons', + 'seasons_rankings', + 'tenant_users', + 'twitch_event_logs', + 'twitch_subscriptions', + 'user_profiles', + 'voice_messages', + ]; + + public function up(): void + { + DB::statement('ALTER TABLE tenants ADD COLUMN new_id uuid DEFAULT gen_random_uuid()'); + DB::statement('UPDATE tenants SET new_id = gen_random_uuid() WHERE new_id IS NULL'); + + $mapping = DB::table('tenants')->pluck('new_id', 'id'); + + foreach ($this->childTables as $table) { + if (!Schema::hasTable($table)) { + continue; + } + + DB::statement(sprintf('ALTER TABLE %s ADD COLUMN new_tenant_id uuid', $table)); + + foreach ($mapping as $oldId => $newUuid) { + DB::statement(sprintf('UPDATE %s SET new_tenant_id = ?::uuid WHERE tenant_id = ?', $table), [$newUuid, $oldId]); + } + + $this->dropForeignKeyIfExists($table, 'tenant_id'); + + DB::statement(sprintf('ALTER TABLE %s DROP COLUMN tenant_id', $table)); + DB::statement(sprintf('ALTER TABLE %s RENAME COLUMN new_tenant_id TO tenant_id', $table)); + } + + DB::statement('ALTER TABLE tenants DROP CONSTRAINT tenants_pkey'); + DB::statement('ALTER TABLE tenants DROP COLUMN id'); + DB::statement('ALTER TABLE tenants RENAME COLUMN new_id TO id'); + DB::statement('ALTER TABLE tenants ADD PRIMARY KEY (id)'); + DB::statement('ALTER TABLE tenants ALTER COLUMN id SET DEFAULT gen_random_uuid()'); + DB::statement('ALTER TABLE tenants ALTER COLUMN id SET NOT NULL'); + DB::statement('DROP SEQUENCE IF EXISTS tenants_id_seq'); + } + + public function down(): void + { + // Irreversible — integer ID mapping is lost + } + + private function dropForeignKeyIfExists(string $table, string $column): void + { + $constraints = DB::select(" + SELECT con.conname + FROM pg_constraint con + JOIN pg_attribute att ON att.attnum = ANY(con.conkey) AND att.attrelid = con.conrelid + WHERE con.conrelid = ?::regclass + AND att.attname = ? + AND con.contype = 'f' + ", [$table, $column]); + + foreach ($constraints as $constraint) { + DB::statement(sprintf('ALTER TABLE %s DROP CONSTRAINT %s', $table, $constraint->conname)); + } + } +}; diff --git a/database/seeders/BaseSeeder.php b/database/seeders/BaseSeeder.php index f02b734df..4b7cbb05d 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)) @@ -38,14 +33,7 @@ public function run(): void ->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/phpunit.xml b/phpunit.xml index 457779ffc..8b9699036 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -17,19 +17,14 @@ - + - - - - - - + - + diff --git a/resources/css/filament/app/theme.css b/resources/css/filament/app/theme.css new file mode 100644 index 000000000..8bc6c0311 --- /dev/null +++ b/resources/css/filament/app/theme.css @@ -0,0 +1,49 @@ +@import '../../../../vendor/filament/filament/resources/css/theme.css'; + +@source '../../../../app/Filament/**/*'; +@source '../../../../app/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/resources/css/filament/user/theme.css b/resources/css/filament/user/theme.css deleted file mode 100644 index 7ff9e7138..000000000 --- a/resources/css/filament/user/theme.css +++ /dev/null @@ -1,7 +0,0 @@ -@import '../../../../vendor/filament/filament/resources/css/theme.css'; - -@source '../../../../app/Filament/**/*'; -@source '../../../../app/Filament/**/*'; -@source '../../../../resources/views/filament/**/*'; -@source '../../../../app-modules/**/src/Filament/**/*'; -@source '../../../../app-modules/**/resources/views/**/*'; 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..f77655673 100644 --- a/resources/views/livewire/connection-hub.blade.php +++ b/resources/views/livewire/connection-hub.blade.php @@ -4,9 +4,10 @@ /** @var \He4rt\Identity\ExternalIdentity\Enums\IdentityProvider[] $supportedProviders */ /** @var \Illuminate\Database\Eloquent\Collection $userProviders */ /** @var string $panel */ + /** @var array|null $mergeTarget */ @endphp -
+
@foreach ($supportedProviders as $provider) @php $connected = $userProviders @@ -24,101 +25,90 @@ @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) + + {{ + $connected->connected_at + ->timezone(config('app.display_timezone')) + ->diffForHumans() + }} + + @endif +
@if ($connected) -
+
@if ($connected->metadata['avatar'] ?? null) @endif - {{ + {{ $connected->metadata['username'] ?? $connected->external_account_id }} - · - - {{ - $connected->connected_at - ->timezone(config('app.display_timezone')) - ->diffForHumans() - }} -
- @else -

{{ $provider->getDescription() }}

@endif
{{-- Action --}} -
- @if ($connected) - - Disconnect - - @else - - Connect - - @endif -
+ @if ($connected) + + @else + + Connect + + @endif
- {{-- Expandable scopes --}} + {{-- Permissions --}} @if (!$connected && count($scopes) > 0) -
+