diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 8f1d9baa9..a73b4cc22 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -113,9 +113,11 @@ GitHub organizations, like `@laravel`, have a page at `/@{handle}` too, with the The **Mentions** tab lists the commits and replies that mention the organization, newest first, a reply with the commit it answers. A commit stays listed after the organization is renamed, since it is linked to the organization rather than to the handle. It follows the usual visibility: guests see approved commits only, you also see your own pending ones, and commits across a block or by people you muted are left out. +Members of an organization can **Claim** its page, which lists them as its admins. Claiming asks GitHub once for permission to read your organizations, then checks that you are an owner or a member there, not just invited. Organizations that restrict third-party apps only share their members once an owner approved /dev/null: the page then links to GitHub, where an owner grants access and a member requests it. GitHub is only the way in: leaving the organization on GitHub doesn't remove you as an admin on /dev/null. Claims are in your data export and go when you delete your account. + Following organizations is planned, not shipped yet. -Sources: [organization page](resources/js/pages/organization/show.tsx), [mentions](app/Queries/OrganizationMentionsQuery.php), [organization lookups](app/Actions/UpdateCommitTopics.php). +Sources: [organization page](resources/js/pages/organization/show.tsx), [mentions](app/Queries/OrganizationMentionsQuery.php), [organization lookups](app/Actions/UpdateCommitTopics.php), [claims](app/Http/Controllers/OrganizationAdminController.php). ### Going live diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index c8c9e48c5..079d84250 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -44,6 +44,7 @@ * muting: Generator, * blocking: Generator, * dismissed_suggestions: Generator, + * claimed_organizations: array, * messages: Generator, * remote_pins: array, * remote_mutes: array, @@ -110,6 +111,7 @@ public function handle(User $user): array 'muting' => $this->handles($user->muting()), 'blocking' => $this->handles($user->blocking()), 'dismissed_suggestions' => $this->handles($user->dismissedSuggestions()), + 'claimed_organizations' => $this->claimedOrganizations($user), 'messages' => $this->messages($user), 'remote_mutes' => Conversation::query() ->join('conversation_participants as mine', 'mine.conversation_id', '=', 'conversations.id') @@ -290,6 +292,23 @@ private function handles(BelongsToMany $users): Generator } } + /** + * @return array + */ + private function claimedOrganizations(User $user): array + { + /** @var Collection $claims */ + $claims = $user->claimedOrganizations()->newPivotQuery() + ->join('profiles', 'profiles.id', '=', 'organization_admins.organization_id') + ->oldest('organization_admins.created_at') + ->get(['profiles.handle', 'organization_admins.created_at']); + + return $claims->map(fn (object $claim): array => [ + 'handle' => $claim->handle, + 'claimed_at' => CarbonImmutable::parse($claim->created_at)->toIso8601String(), + ])->all(); + } + /** * Moderators stay anonymous, as they are on the notifications page, and level-ups are the user's own doing. * diff --git a/app/Actions/CreateOrganizationAdmin.php b/app/Actions/CreateOrganizationAdmin.php new file mode 100644 index 000000000..96102f357 --- /dev/null +++ b/app/Actions/CreateOrganizationAdmin.php @@ -0,0 +1,20 @@ +admins()->syncWithoutDetaching([$user->id]); + } +} diff --git a/app/Actions/DeleteOrganization.php b/app/Actions/DeleteOrganization.php index dc315b770..6354b35eb 100644 --- a/app/Actions/DeleteOrganization.php +++ b/app/Actions/DeleteOrganization.php @@ -11,8 +11,8 @@ final readonly class DeleteOrganization { /** - * Deletes an organization GitHub no longer has, and unlinks the commits that mention it, so none links to a page - * that is gone. + * Deletes an organization GitHub no longer has, with its admins, and unlinks the commits that mention it, so none + * links to a page that is gone. */ public function handle(Profile $organization): void { @@ -22,6 +22,7 @@ public function handle(Profile $organization): void ->where('topic_id', $organization->id) ->delete(); + $organization->admins()->detach(); $organization->delete(); }); } diff --git a/app/Actions/DeleteUser.php b/app/Actions/DeleteUser.php index 8226ad8b0..56513ed1f 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -67,6 +67,8 @@ public function handle(User $user): void $this->deleteOrgs->handle($this->emptyOrgs($user)); + $user->claimedOrganizations()->detach(); + $user->delete(); $user->profile->delete(); diff --git a/app/Enums/GitHubOrganizationMembership.php b/app/Enums/GitHubOrganizationMembership.php new file mode 100644 index 000000000..8e1aabc2c --- /dev/null +++ b/app/Enums/GitHubOrganizationMembership.php @@ -0,0 +1,22 @@ +session()->put(self::CLAIMING, $organization->id); + + return Inertia::location($this->github()->scopes(['read:org'])->redirect()->getTargetUrl()); + } + + /** + * GitHub only sends back to a path under the callback registered for sign-in, so this is where it lands. + */ + public function store(Request $request, #[CurrentUser] User $user, GitHub $github, CreateOrganizationAdmin $action): RedirectResponse + { + $organization = Profile::query() + ->where('type', ProfileType::Organization) + ->find($request->session()->pull(self::CLAIMING)); + + if (! $organization instanceof Profile) { + Inertia::flash('toast', ['type' => 'error', 'message' => __('Unable to claim this organization. Please try again.')]); + + return to_route('log.show', $user->log); + } + + $page = to_route('user.show', $organization->handle); + + if ($request->has('error')) { + return $this->failed(__('GitHub was not asked about your organizations, so the page was not claimed.'), $page); + } + + try { + $githubUser = $this->github()->user(); + } catch (InvalidStateException) { + return $this->failed(__('Unable to claim this organization. Please try again.'), $page); + } + + assert($githubUser instanceof GitHubUser); + + if ((string) $githubUser->getId() !== $user->github_id) { + return $this->failed(__('GitHub is signed in as @:login. Sign in there as @:handle to claim this page.', [ + 'login' => $githubUser->getNickname(), + 'handle' => $user->profile->handle, + ]), $page); + } + + $handle = $organization->handle; + + // An organization restricting OAuth apps hides its members from those its owners haven't approved, which they + // do from the app's page in their settings, where members request it. + $failure = match ($github->organizationMembership($githubUser->token, $handle, $organization->github_id)) { + GitHubOrganizationMembership::Member => null, + GitHubOrganizationMembership::NotMember => [__('GitHub says you are not a member of @:handle.', ['handle' => $handle]), null], + GitHubOrganizationMembership::Restricted => [ + __('@:handle only shares its members with apps its owners approved. On GitHub, grant /dev/null access if you are an owner, or request it from them, then try again.', ['handle' => $handle]), + 'https://github.com/settings/connections/applications/'.config()->string('services.github.client_id'), + ], + GitHubOrganizationMembership::SingleSignOn => [ + __('@:handle signs its members in with single sign-on. Sign in to @:handle on GitHub, then try again.', ['handle' => $handle]), + "https://github.com/orgs/{$handle}/sso", + ], + }; + + if ($failure !== null) { + return $this->failed($failure[0], $page, $failure[1]); + } + + $action->handle($organization, $user); + + Inertia::flash('toast', ['type' => 'success', 'message' => __('You are now an admin of @:handle.', ['handle' => $organization->handle])]); + + return $page; + } + + /** + * GitHub sends the user back here rather than to sign-in, and checks the token is redeemed for the same URL. + */ + private function github(): GithubProvider + { + $provider = Socialite::driver('github'); + assert($provider instanceof GithubProvider); + + return $provider->redirectUrl(route('organization-admin.store')); + } + + /** + * The page says why the claim failed until the user leaves it, with where to approve /dev/null when that's why. + */ + private function failed(string $message, RedirectResponse $redirect, ?string $approvalUrl = null): RedirectResponse + { + Inertia::flash('claimFailed', ['message' => $message, 'approvalUrl' => $approvalUrl]); + + return $redirect; + } +} diff --git a/app/Http/Controllers/OrganizationController.php b/app/Http/Controllers/OrganizationController.php index 2bfdbf0f1..b31aff726 100644 --- a/app/Http/Controllers/OrganizationController.php +++ b/app/Http/Controllers/OrganizationController.php @@ -10,6 +10,7 @@ use App\Queries\OrganizationMentionsQuery; use App\Services\PageMetadata; use Illuminate\Container\Attributes\CurrentUser; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; @@ -18,8 +19,8 @@ { /** * Organization pages are public, at the "@handle" of users, and show the organization's GitHub profile and the - * commits that mention it, with the organization's link preview. Like users' pages, they move for good to the - * handle's own case. + * commits that mention it, with the users who claimed it, first claim first, and the organization's link preview. + * Like users' pages, they move for good to the handle's own case. */ public function show(ShowOrganizationRequest $request, #[CurrentUser] ?User $currentUser, OrganizationMentionsQuery $mentions, PageMetadata $metadata): RedirectResponse|Response { @@ -40,6 +41,8 @@ public function show(ShowOrganizationRequest $request, #[CurrentUser] ?User $cur 'commits' => Inertia::scroll(fn () => $mentions->builder($organization, $currentUser) ->cursorPaginate(20) ->appends(['tab' => $request->tab()])), + 'claimUrl' => route('organization-admin.create', $organization), + 'admins' => fn (): Collection => $organization->admins()->oldest('organization_admins.created_at')->get()->makeHidden('pivot'), ])->withViewData(['preview' => $currentUser instanceof User || $request->inertia() ? null : $metadata->organization($organization)]); } } diff --git a/app/Http/Requests/CreateOrganizationAdminRequest.php b/app/Http/Requests/CreateOrganizationAdminRequest.php new file mode 100644 index 000000000..5c5972c8d --- /dev/null +++ b/app/Http/Requests/CreateOrganizationAdminRequest.php @@ -0,0 +1,32 @@ +route('organization'); + assert($organization instanceof Profile); + + return $organization->type === ProfileType::Organization ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Models/Profile.php b/app/Models/Profile.php index 2a17f4528..2dcac92e9 100644 --- a/app/Models/Profile.php +++ b/app/Models/Profile.php @@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\Relation; @@ -111,4 +112,15 @@ public function user(): HasOne { return $this->hasOne(User::class, 'github_id', 'github_id'); } + + /** + * The users who claimed the organization's page. GitHub only lets them in: leaving the organization there doesn't + * remove them here. + * + * @return BelongsToMany + */ + public function admins(): BelongsToMany + { + return $this->belongsToMany(User::class, 'organization_admins', 'organization_id')->withTimestamps(updatedAt: false); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 6a5f81113..44679049b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -333,6 +333,16 @@ public function isBanned(): bool return $this->banned_at !== null; } + /** + * The organizations whose page the user claimed, see `Profile::admins()`. + * + * @return BelongsToMany + */ + public function claimedOrganizations(): BelongsToMany + { + return $this->belongsToMany(Profile::class, 'organization_admins', 'user_id', 'organization_id')->withTimestamps(updatedAt: false); + } + /** * @return BelongsToMany */ diff --git a/app/Services/GitHub.php b/app/Services/GitHub.php index de5335e30..d6870134e 100644 --- a/app/Services/GitHub.php +++ b/app/Services/GitHub.php @@ -4,6 +4,7 @@ namespace App\Services; +use App\Enums\GitHubOrganizationMembership; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; @@ -52,6 +53,35 @@ public function login(string $githubId): ?string return $login; } + /** + * Where the user the token is for stands in the organization. The token needs the read:org scope. Matched by the + * organization's ID, so a renamed handle that someone else took since doesn't count. + */ + public function organizationMembership(string $token, string $handle, string $githubId): GitHubOrganizationMembership + { + $response = $this->request($token)->get("user/memberships/orgs/{$handle}"); + + if ($response->notFound()) { + return GitHubOrganizationMembership::NotMember; + } + + if ($response->forbidden() && $response->hasHeader('X-GitHub-SSO')) { + return GitHubOrganizationMembership::SingleSignOn; + } + + if ($response->forbidden() && str_contains($response->fluent()->string('message')->value(), 'OAuth App access restrictions')) { + return GitHubOrganizationMembership::Restricted; + } + + $membership = $response->throw()->fluent(); + + return $membership->string('state')->value() === 'active' + && in_array($membership->string('role')->value(), ['admin', 'member'], true) + && (string) $membership->integer('organization.id') === $githubId + ? GitHubOrganizationMembership::Member + : GitHubOrganizationMembership::NotMember; + } + /** * The IDs of the accounts in a paginated list, such as the followers of a user, up to the given number of pages. * diff --git a/database/migrations/2026_10_28_120000_create_organization_admins_table.php b/database/migrations/2026_10_28_120000_create_organization_admins_table.php new file mode 100644 index 000000000..e9e44c6ad --- /dev/null +++ b/database/migrations/2026_10_28_120000_create_organization_admins_table.php @@ -0,0 +1,26 @@ +foreignUuid('organization_id')->constrained('profiles'); + $table->foreignUuid('user_id')->constrained(); + $table->timestamp('created_at'); + + $table->primary(['organization_id', 'user_id']); + $table->index('user_id'); + }); + } +}; diff --git a/resources/js/lib/legal.ts b/resources/js/lib/legal.ts index 5579bbbdc..63e0484c4 100644 --- a/resources/js/lib/legal.ts +++ b/resources/js/lib/legal.ts @@ -1,4 +1,4 @@ -export const PRIVACY_EFFECTIVE_DATE = '28 October 2026'; +export const PRIVACY_EFFECTIVE_DATE = '29 October 2026'; export const TERMS_EFFECTIVE_DATE = '25 October 2026'; diff --git a/resources/js/pages/organization/show.tsx b/resources/js/pages/organization/show.tsx index d2f508e6a..03f756a1b 100644 --- a/resources/js/pages/organization/show.tsx +++ b/resources/js/pages/organization/show.tsx @@ -1,5 +1,6 @@ -import { Head } from '@inertiajs/react'; -import { Link2, MapPin } from 'lucide-react'; +import { Head, Link, usePage } from '@inertiajs/react'; +import { AlertCircleIcon, Link2, MapPin } from 'lucide-react'; +import { useState } from 'react'; import { show } from '@/actions/App/Http/Controllers/UserController'; import { BioText } from '@/components/bio-text'; import { CommitFeed, EmptyFeed } from '@/components/commit-feed'; @@ -7,18 +8,35 @@ import { GitHubIcon } from '@/components/github-icon'; import { OrganizationAvatar } from '@/components/organization-avatar'; import { PageTabs } from '@/components/page-tabs'; import { ProfileShareButton } from '@/components/profile-share-button'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { UserAvatar } from '@/components/user-avatar'; import AppLayout from '@/layouts/app-layout'; -import type { Commit, CursorPaginated, Organization } from '@/types'; +import type { Commit, CursorPaginated, Organization, User } from '@/types'; export default function Show({ organization, tab, commits, + claimUrl, + admins, }: { organization: Organization & { id: string }; tab: 'mentions'; commits: CursorPaginated; + claimUrl: string; + admins: User[]; }) { + const { props, flash } = usePage(); + const { auth } = props; + // Kept until the user leaves the page, as the background reloads bring no flash and would hide it. + const [claimFailed] = useState(flash.claimFailed); + // While a claim failed, its alert offers to try again instead. + const canClaim = + auth.user !== null && + claimFailed === undefined && + !admins.some((admin) => admin.id === auth.user?.id); + return ( <> @@ -41,6 +59,14 @@ export default function Show({ />
+ {canClaim && ( + + )} + + {admins.length > 0 && ( +
+ Admins + {admins.map((admin) => ( + + + @{admin.handle} + + ))} +
+ )} + + {claimFailed && ( + + + + Couldn't claim @{organization.handle} + + {/* The reason in the text color, as red at this size is hard to read. */} + +

{claimFailed.message}

+
+ {claimFailed.approvalUrl && ( + + )} + +
+
+
+ )}
diff --git a/resources/js/pages/privacy/show.tsx b/resources/js/pages/privacy/show.tsx index 59c7621f8..4d3deb543 100644 --- a/resources/js/pages/privacy/show.tsx +++ b/resources/js/pages/privacy/show.tsx @@ -54,6 +54,16 @@ export default function Show() { listing, your public SSH key fingerprints, and who you follow and who follows you. , + <> + + From GitHub, when you claim an + organization's page: + {' '} + whether you belong to that organization, asked + once with your permission to read your + organizations. Nothing else about them is kept, + and your claim shows on the organization's page. + , <> From Apple, when you sign in with Apple in diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index 07c0226c7..e19525dba 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -21,6 +21,7 @@ declare module '@inertiajs/core' { submitted?: string; localSignIn?: boolean; signedUp?: boolean; + claimFailed?: { message: string; approvalUrl: string | null }; }; sharedPageProps: { name: string; diff --git a/routes/web.php b/routes/web.php index e6eca1793..4cf729966 100644 --- a/routes/web.php +++ b/routes/web.php @@ -74,6 +74,7 @@ use App\Http\Controllers\OnboardingController; use App\Http\Controllers\OperationsController; use App\Http\Controllers\OperationsEventController; +use App\Http\Controllers\OrganizationAdminController; use App\Http\Controllers\OrganizationController; use App\Http\Controllers\OrganizationMentionController; use App\Http\Controllers\OrganizationSitemapController; @@ -323,6 +324,15 @@ // Banned, where a banned user is sent from everywhere else... Route::get('banned', [BannedController::class, 'show'])->name('banned.show'); + // Claiming an organization's page, which GitHub confirms the user belongs to... + Route::post('organizations/{organization}/claim', [OrganizationAdminController::class, 'create']) + ->whereUuid('organization') + ->middleware('throttle:10,1') + ->name('organization-admin.create'); + Route::get('auth/github/callback/organizations', [OrganizationAdminController::class, 'store']) + ->middleware('throttle:10,1') + ->name('organization-admin.store'); + // Followers the viewer knows... Route::get('@{user:handle}/followers-you-know', [UserFollowersYouKnowController::class, 'index']) ->name('user-followers-you-know.index'); diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 23b4ff740..18ea6abda 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -16,6 +16,7 @@ use App\Models\Notification; use App\Models\Poll; use App\Models\PollVote; +use App\Models\Profile; use App\Models\SshKey; use App\Models\User; use App\Models\UserTimeout; @@ -129,6 +130,7 @@ 'muting' => [], 'blocking' => [], 'dismissed_suggestions' => [], + 'claimed_organizations' => [], 'messages' => [], 'remote_mutes' => [], 'remote_pins' => [], @@ -438,6 +440,23 @@ ); }); +it('exports the organizations the user claimed, first claim first, without their other admins', function (): void { + $this->freezeSecond(); + $user = User::factory()->create(); + $laravel = Profile::factory()->organization()->create(['handle' => 'laravel']); + $bref = Profile::factory()->organization()->create(['handle' => 'brefphp']); + $bref->admins()->attach($user->id, ['created_at' => now()->subDay()]); + $laravel->admins()->attach([$user->id => ['created_at' => now()], User::factory()->create()->id => ['created_at' => now()]]); + + $response = $this->actingAs($user)->get(route('export')); + $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); + + expect($data['claimed_organizations'])->toBe([ + ['handle' => 'brefphp', 'claimed_at' => now()->subDay()->toIso8601String()], + ['handle' => 'laravel', 'claimed_at' => now()->toIso8601String()], + ]); +}); + it('exports the devices approved for the app, oldest first', function (): void { $user = User::factory()->create(); $older = Device::factory()->for($user)->create(['name' => 'devnull on iPad', 'approved_at' => now()->subWeek(), 'last_used_at' => now()->subDay()]); diff --git a/tests/Feature/Controllers/OrganizationAdminControllerTest.php b/tests/Feature/Controllers/OrganizationAdminControllerTest.php new file mode 100644 index 000000000..9f83a5b1d --- /dev/null +++ b/tests/Feature/Controllers/OrganizationAdminControllerTest.php @@ -0,0 +1,243 @@ +shouldReceive('redirectUrl')->with(route('organization-admin.store'))->andReturnSelf(); + $provider->shouldReceive('user')->andReturn(GitHubUser::fake(['id' => $id, 'nickname' => 'octocat', 'token' => 'claim-token'])); + + Socialite::shouldReceive('driver')->with('github')->andReturn($provider); +} + +/** + * @param array $membership + * @param array $headers + */ +function fakeGitHubMembership(array $membership = [], int $status = 200, array $headers = []): void +{ + Http::fake([ + 'api.github.com/user/memberships/orgs/brefphp' => Http::response([ + 'state' => 'active', + 'role' => 'member', + 'organization' => ['id' => 45861341], + ...$membership, + ], $status, $headers), + ]); +} + +/** + * @return array{0: User, 1: Profile} + */ +function claimingUserAndOrganization(): array +{ + return [ + User::factory()->create(['github_id' => '720328']), + Profile::factory()->organization()->create(['github_id' => '45861341', 'handle' => 'brefphp']), + ]; +} + +it('asks github for the organizations of the user, coming back to the claim', function (): void { + config(['services.github.client_id' => 'client-id']); + [$user, $organization] = claimingUserAndOrganization(); + + $response = $this->actingAs($user)->post(route('organization-admin.create', $organization)); + + $location = $response->assertRedirect()->headers->get('Location'); + parse_str((string) parse_url((string) $location, PHP_URL_QUERY), $query); + + expect($location)->toStartWith('https://github.com/login/oauth/authorize?') + ->and($query)->toMatchArray([ + 'client_id' => 'client-id', + 'redirect_uri' => route('organization-admin.store'), + 'scope' => 'user:email,read:org', + ]); + $response->assertSessionHas('claiming-organization', $organization->id); +}); + +it('leaves the page for github from the claim button', function (): void { + [$user, $organization] = claimingUserAndOrganization(); + + $this->actingAs($user) + ->post(route('organization-admin.create', $organization), headers: ['X-Inertia' => 'true']) + ->assertConflict() + ->assertHeader('X-Inertia-Location'); +}); + +it('only starts a claim from a form, so no other site can start one for the user', function (): void { + [$user, $organization] = claimingUserAndOrganization(); + + $this->actingAs($user)->get(route('organization-admin.create', $organization)) + ->assertNotFound() + ->assertSessionMissing('claiming-organization'); +}); + +it('only lets users claim organizations', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('organization-admin.create', $user->profile))->assertNotFound(); +}); + +it('asks guests to sign in first', function (): void { + $this->post(route('organization-admin.create', Profile::factory()->organization()->create()))->assertRedirect(route('home')); +}); + +it('makes a member of the organization on github an admin of its page', function (string $role): void { + fakeClaimingGitHubAccount('720328'); + fakeGitHubMembership(['role' => $role]); + [$user, $organization] = claimingUserAndOrganization(); + + $this->actingAs($user) + ->withSession(['claiming-organization' => $organization->id]) + ->get(route('organization-admin.store', ['code' => 'code', 'state' => 'state'])) + ->assertRedirect('/@brefphp') + ->assertSessionMissing('claiming-organization') + ->assertInertiaFlash('toast', ['type' => 'success', 'message' => 'You are now an admin of @brefphp.']); + + expect($organization->admins()->pluck('users.id')->all())->toBe([$user->id]); + Http::assertSent(fn (Request $request): bool => $request->hasHeader('Authorization', 'Bearer claim-token')); +})->with(['admin', 'member']); + +it('keeps the first claim when the user claims again', function (): void { + fakeClaimingGitHubAccount('720328'); + fakeGitHubMembership(); + [$user, $organization] = claimingUserAndOrganization(); + $claimedAt = now()->subWeek()->startOfSecond(); + DB::table('organization_admins')->insert(['organization_id' => $organization->id, 'user_id' => $user->id, 'created_at' => $claimedAt]); + + $this->actingAs($user) + ->withSession(['claiming-organization' => $organization->id]) + ->get(route('organization-admin.store')) + ->assertRedirect('/@brefphp'); + + expect(DB::table('organization_admins')->sole()->created_at)->toBe($claimedAt->toDateTimeString()); +}); + +it('refuses users github does not say are members', function (array $membership, int $status): void { + fakeClaimingGitHubAccount('720328'); + fakeGitHubMembership($membership, $status); + [$user, $organization] = claimingUserAndOrganization(); + + $this->actingAs($user) + ->withSession(['claiming-organization' => $organization->id]) + ->get(route('organization-admin.store')) + ->assertRedirect('/@brefphp') + ->assertSessionMissing('claiming-organization') + ->assertInertiaFlash('claimFailed', ['message' => 'GitHub says you are not a member of @brefphp.', 'approvalUrl' => null]); + + expect($organization->admins()->exists())->toBeFalse(); +})->with([ + 'not a member' => [['message' => 'Not Found'], 404], + 'invited only' => [['state' => 'pending'], 200], + 'a billing manager' => [['role' => 'billing_manager'], 200], + 'of another organization since renamed' => [['organization' => ['id' => 1]], 200], +]); + +it('says where to approve /dev/null when the organization hides its members from it', function (): void { + config(['services.github.client_id' => 'client-id']); + fakeClaimingGitHubAccount('720328'); + fakeGitHubMembership(['message' => 'Although you appear to have the correct authorization credentials, the `brefphp` organization has enabled OAuth App access restrictions.'], 403); + [$user, $organization] = claimingUserAndOrganization(); + + $this->actingAs($user) + ->withSession(['claiming-organization' => $organization->id]) + ->get(route('organization-admin.store')) + ->assertRedirect('/@brefphp') + ->assertInertiaFlash('claimFailed.approvalUrl', 'https://github.com/settings/connections/applications/client-id'); + + expect($organization->admins()->exists())->toBeFalse(); +}); + +it('says where to sign in when the organization uses single sign-on', function (): void { + fakeClaimingGitHubAccount('720328'); + fakeGitHubMembership(['message' => 'Resource protected by organization SAML enforcement. You must grant your OAuth token access to this organization.'], 403, ['X-GitHub-SSO' => 'required; url=https://github.com/orgs/brefphp/sso?authorization_request=abc']); + [$user, $organization] = claimingUserAndOrganization(); + + $this->actingAs($user) + ->withSession(['claiming-organization' => $organization->id]) + ->get(route('organization-admin.store')) + ->assertRedirect('/@brefphp') + ->assertInertiaFlash('claimFailed', [ + 'message' => '@brefphp signs its members in with single sign-on. Sign in to @brefphp on GitHub, then try again.', + 'approvalUrl' => 'https://github.com/orgs/brefphp/sso', + ]); + + expect($organization->admins()->exists())->toBeFalse(); +}); + +it('fails loudly when github fails', function (array $membership, int $status): void { + fakeClaimingGitHubAccount('720328'); + fakeGitHubMembership($membership, $status); + [$user, $organization] = claimingUserAndOrganization(); + + $this->withoutExceptionHandling() + ->actingAs($user) + ->withSession(['claiming-organization' => $organization->id]) + ->get(route('organization-admin.store')); +})->with([ + 'down' => [[], 502], + 'rate limited' => [['message' => 'API rate limit exceeded for user ID 720328.'], 403], +])->throws(RequestException::class); + +it('refuses a github account other than the one the user signed in with', function (): void { + Http::fake(); + fakeClaimingGitHubAccount('583231'); + [$user, $organization] = claimingUserAndOrganization(); + + $this->actingAs($user) + ->withSession(['claiming-organization' => $organization->id]) + ->get(route('organization-admin.store')) + ->assertRedirect('/@brefphp') + ->assertInertiaFlash('claimFailed.message', "GitHub is signed in as @octocat. Sign in there as @{$user->profile->handle} to claim this page."); + + expect($organization->admins()->exists())->toBeFalse(); + Http::assertNothingSent(); +}); + +it('fails when the user denies access on github', function (): void { + [$user, $organization] = claimingUserAndOrganization(); + + $this->actingAs($user) + ->withSession(['claiming-organization' => $organization->id]) + ->get(route('organization-admin.store', ['error' => 'access_denied'])) + ->assertRedirect('/@brefphp') + ->assertInertiaFlash('claimFailed.message', 'GitHub was not asked about your organizations, so the page was not claimed.'); +}); + +it('fails when the state does not match', function (): void { + $provider = Mockery::mock(GithubProvider::class); + $provider->shouldReceive('redirectUrl')->andReturnSelf(); + $provider->shouldReceive('user')->andThrow(new InvalidStateException); + Socialite::shouldReceive('driver')->with('github')->andReturn($provider); + [$user, $organization] = claimingUserAndOrganization(); + + $this->actingAs($user) + ->withSession(['claiming-organization' => $organization->id]) + ->get(route('organization-admin.store')) + ->assertRedirect('/@brefphp') + ->assertInertiaFlash('claimFailed.message', 'Unable to claim this organization. Please try again.'); +}); + +it('fails when no organization is being claimed', function (): void { + [$user] = claimingUserAndOrganization(); + + $this->actingAs($user) + ->get(route('organization-admin.store')) + ->assertRedirect(route('log.show', $user->log)) + ->assertInertiaFlash('toast', ['type' => 'error', 'message' => 'Unable to claim this organization. Please try again.']); +}); diff --git a/tests/Feature/Controllers/OrganizationControllerTest.php b/tests/Feature/Controllers/OrganizationControllerTest.php index 90e732895..1981bbd6e 100644 --- a/tests/Feature/Controllers/OrganizationControllerTest.php +++ b/tests/Feature/Controllers/OrganizationControllerTest.php @@ -29,6 +29,23 @@ ->missing('organization.github_synced_at')); }); +it('shows who claimed the organization, first claim first, and where to claim it', function (): void { + $organization = Profile::factory()->organization()->create(['handle' => 'laravel']); + $first = User::factory()->create(); + $second = User::factory()->create(); + $organization->admins()->attach([$second->id => ['created_at' => now()], $first->id => ['created_at' => now()->subDay()]]); + + $this->get('/@laravel') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->where('claimUrl', route('organization-admin.create', $organization)) + ->where('admins.0.id', $first->id) + ->where('admins.0.handle', $first->profile->handle) + ->where('admins.1.id', $second->id) + ->missing('admins.0.pivot') + ->has('admins', 2)); +}); + it('shows guests the link preview of the organization', function (): void { $laravel = Profile::factory()->organization()->create([ 'handle' => 'laravel', diff --git a/tests/Unit/Actions/DeleteOrganizationTest.php b/tests/Unit/Actions/DeleteOrganizationTest.php new file mode 100644 index 000000000..81a730aac --- /dev/null +++ b/tests/Unit/Actions/DeleteOrganizationTest.php @@ -0,0 +1,22 @@ +create(); + $organization = Profile::factory()->organization()->create(); + $kept = Profile::factory()->organization()->create(); + $organization->admins()->attach($user->id, ['created_at' => now()]); + $kept->admins()->attach($user->id, ['created_at' => now()]); + + resolve(DeleteOrganization::class)->handle($organization); + + expect($organization->fresh())->toBeNull() + ->and($user->fresh())->not->toBeNull() + ->and(DB::table('organization_admins')->pluck('organization_id')->all())->toBe([$kept->id]); +}); diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index bf227ecb2..a50fe8abe 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -189,6 +189,17 @@ ->and(PollVote::query()->sole()->is($keptVote))->toBeTrue(); }); +it('erases the organizations a deleted user claimed, keeping their other admins', function (): void { + $user = User::factory()->create(); + $other = User::factory()->create(); + $organization = Profile::factory()->organization()->create(); + $organization->admins()->attach([$user->id => ['created_at' => now()], $other->id => ['created_at' => now()]]); + + resolve(DeleteUser::class)->handle($user); + + expect($organization->admins()->pluck('users.id')->all())->toBe([$other->id]); +}); + it('erases the devices of a deleted user', function (): void { $user = User::factory()->create(); Device::factory()->for($user)->create();