Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions app/Actions/CreateExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
* muting: Generator<int, ExportUser>,
* blocking: Generator<int, ExportUser>,
* dismissed_suggestions: Generator<int, ExportUser>,
* claimed_organizations: array<int, array{handle: string, claimed_at: string}>,
* messages: Generator<int, ExportMessage>,
* remote_pins: array<int, array{to: string|null, pinned_at: string|null}>,
* remote_mutes: array<int, array{to: string|null, muted_until_at: string|null}>,
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -290,6 +292,23 @@ private function handles(BelongsToMany $users): Generator
}
}

/**
* @return array<int, array{handle: string, claimed_at: string}>
*/
private function claimedOrganizations(User $user): array
{
/** @var Collection<int, object{handle: string, created_at: string}> $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.
*
Expand Down
20 changes: 20 additions & 0 deletions app/Actions/CreateOrganizationAdmin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\Profile;
use App\Models\User;

final readonly class CreateOrganizationAdmin
{
/**
* Makes the user an admin of the organization's page, once GitHub confirmed they belong to it. Claiming again
* changes nothing.
*/
public function handle(Profile $organization, User $user): void
{
$organization->admins()->syncWithoutDetaching([$user->id]);
Comment thread
mnapoli marked this conversation as resolved.
Resolved
}
}
5 changes: 3 additions & 2 deletions app/Actions/DeleteOrganization.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -22,6 +22,7 @@ public function handle(Profile $organization): void
->where('topic_id', $organization->id)
->delete();

$organization->admins()->detach();
$organization->delete();
});
}
Expand Down
2 changes: 2 additions & 0 deletions app/Actions/DeleteUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ public function handle(User $user): void

$this->deleteOrgs->handle($this->emptyOrgs($user));

$user->claimedOrganizations()->detach();

$user->delete();
$user->profile->delete();

Expand Down
22 changes: 22 additions & 0 deletions app/Enums/GitHubOrganizationMembership.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace App\Enums;

/**
* What GitHub says about a user's place in an organization, when they ask with the read:org scope.
*/
enum GitHubOrganizationMembership
{
/** An owner or a member, who accepted the invitation. */
case Member;

case NotMember;

/** The organization restricts OAuth apps, and its owners haven't approved /dev/null. */
case Restricted;

/** The organization signs its members in with SAML, and the user hasn't signed in that way. */
case SingleSignOn;
}
126 changes: 126 additions & 0 deletions app/Http/Controllers/OrganizationAdminController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\CreateOrganizationAdmin;
use App\Enums\GitHubOrganizationMembership;
use App\Enums\ProfileType;
use App\Http\Requests\CreateOrganizationAdminRequest;
use App\Models\Profile;
use App\Models\User;
use App\Services\GitHub;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Laravel\Socialite\Socialite;
use Laravel\Socialite\Two\GithubProvider;
use Laravel\Socialite\Two\InvalidStateException;
use Laravel\Socialite\Two\User as GitHubUser;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;

/**
* Claiming an organization's page asks GitHub, with the read:org scope sign-in doesn't ask for, whether the user
* belongs to the organization. The token is only used for that, and isn't kept.
*/
final readonly class OrganizationAdminController
{
private const string CLAIMING = 'claiming-organization';

/**
* Claiming starts from a form, so no other site can send a user who already granted the scope through it.
*/
public function create(CreateOrganizationAdminRequest $request, Profile $organization): SymfonyResponse
{
$request->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;
}
}
7 changes: 5 additions & 2 deletions app/Http/Controllers/OrganizationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
{
Expand All @@ -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)]);
}
}
32 changes: 32 additions & 0 deletions app/Http/Requests/CreateOrganizationAdminRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Enums\ProfileType;
use App\Models\Profile;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;

final class CreateOrganizationAdminRequest extends FormRequest
{
/**
* Only organizations' pages can be claimed. A user's profile looks like it does not exist.
*/
public function authorize(): Response
{
$organization = $this->route('organization');
assert($organization instanceof Profile);

return $organization->type === ProfileType::Organization ? Response::allow() : Response::denyAsNotFound();
}

/**
* @return array{}
*/
public function rules(): array
{
return [];
}
}
12 changes: 12 additions & 0 deletions app/Models/Profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<User, $this>
*/
public function admins(): BelongsToMany
{
return $this->belongsToMany(User::class, 'organization_admins', 'organization_id')->withTimestamps(updatedAt: false);
}
}
10 changes: 10 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<Profile, $this>
*/
public function claimedOrganizations(): BelongsToMany
{
return $this->belongsToMany(Profile::class, 'organization_admins', 'user_id', 'organization_id')->withTimestamps(updatedAt: false);
}

/**
* @return BelongsToMany<Commit, $this>
*/
Expand Down
30 changes: 30 additions & 0 deletions app/Services/GitHub.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Services;

use App\Enums\GitHubOrganizationMembership;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;

Expand Down Expand Up @@ -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.
*
Expand Down
Loading
Loading