diff --git a/app/Actions/UpdateOrganizationProfile.php b/app/Actions/UpdateOrganizationProfile.php new file mode 100644 index 0000000000..b49670e8b1 --- /dev/null +++ b/app/Actions/UpdateOrganizationProfile.php @@ -0,0 +1,49 @@ +profile->github_id; + $account = $this->github->account($githubId); + + // Never follow a renamed handle to a different account, or turn a user into an organization. + if ($account === null + || ($account['type'] ?? null) !== 'Organization' + || ! is_int($account['id'] ?? null) + || (string) $account['id'] !== $githubId + || ! is_string($account['login'] ?? null) + || $account['login'] === '') { + return false; + } + + $updated = $this->organizations->handle($account); + + return $updated->is($organization); + } catch (HttpClientException|UniqueConstraintViolationException $exception) { + report($exception); + + return false; + } + } +} diff --git a/app/Http/Controllers/OrganizationProfileController.php b/app/Http/Controllers/OrganizationProfileController.php index aa80bf0e59..422bb10938 100644 --- a/app/Http/Controllers/OrganizationProfileController.php +++ b/app/Http/Controllers/OrganizationProfileController.php @@ -4,9 +4,11 @@ namespace App\Http\Controllers; +use App\Actions\UpdateOrganizationProfile; use App\Enums\OrganizationRole; use App\Http\Requests\ShowOrganizationProfileRequest; use App\Models\Organization; +use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; @@ -20,6 +22,22 @@ public function edit(ShowOrganizationProfileRequest $request, Organization $orga return Inertia::render('organization-profile/edit', [ 'organization' => $organization, 'role' => OrganizationRole::Admin, + 'githubSyncedAt' => $organization->github_synced_at?->toIso8601String(), ]); } + + public function update(ShowOrganizationProfileRequest $request, Organization $organization, UpdateOrganizationProfile $action): RedirectResponse + { + $synced = $action->handle($organization); + + Inertia::flash('toast', $synced ? [ + 'type' => 'success', + 'message' => 'Synced from GitHub.', + ] : [ + 'type' => 'error', + 'message' => 'Could not sync from GitHub. Try again in a minute.', + ]); + + return back(); + } } diff --git a/resources/js/pages/organization-profile/edit.tsx b/resources/js/pages/organization-profile/edit.tsx index a609e77ccb..721c24fba2 100644 --- a/resources/js/pages/organization-profile/edit.tsx +++ b/resources/js/pages/organization-profile/edit.tsx @@ -1,25 +1,128 @@ import { Form, Head, router } from '@inertiajs/react'; +import { ArrowUpRight, RefreshCw } from 'lucide-react'; import type { CSSProperties } from 'react'; import { useState } from 'react'; import OrganizationLaneController from '@/actions/App/Http/Controllers/OrganizationLaneController'; +import OrganizationProfileController from '@/actions/App/Http/Controllers/OrganizationProfileController'; import Heading from '@/components/heading'; import InputError from '@/components/input-error'; import { LaneOptions } from '@/components/lane-picker'; import { OrganizationAvatar } from '@/components/organization-avatar'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; +import { useNow } from '@/hooks/use-now'; import AppLayout from '@/layouts/app-layout'; import OrganizationSettingsLayout from '@/layouts/organization-settings/layout'; -import { organizationLaneColor, withViewTransition } from '@/lib/utils'; +import { + formatFullDateTime, + formatSyncedAgo, + organizationLaneColor, + withViewTransition, +} from '@/lib/utils'; import type { Lane, Organization } from '@/types'; -export default function Edit({ organization }: { organization: Organization }) { +export default function Edit({ + organization, + githubSyncedAt, +}: { + organization: Organization; + githubSyncedAt: string | null; +}) { const { lane } = organization; + const now = useNow(); const [selected, setSelected] = useState(lane); return ( <> +
+ + +
+ {[ + { label: 'name', value: organization.name }, + { label: 'username', value: `@${organization.handle}` }, + { label: 'bio', value: organization.bio }, + { label: 'location', value: organization.location }, + { label: 'website', value: organization.website }, + ].map((field) => ( +
+
+ {field.label} +
+
+ {field.value ?? 'null'} +
+
+ ))} +
+ +
+ +
router.flushAll()} + > + {({ processing }) => ( + + )} +
+
+ +

+ {githubSyncedAt ? ( + <> + Last synced{' '} + + + ) : ( + 'Not yet synced from GitHub' + )} +

+
+
whereUuid('organization') ->name('organization-profile.edit'); + Route::put('organizations/{organization}/settings/profile', [OrganizationProfileController::class, 'update']) + ->whereUuid('organization') + ->middleware('throttle:profile-syncs') + ->name('organization-profile.update'); Route::put('organizations/{organization}/settings/lane', [OrganizationLaneController::class, 'update']) ->whereUuid('organization') ->name('organization-lane.update'); diff --git a/tests/Feature/Controllers/OrganizationProfileControllerTest.php b/tests/Feature/Controllers/OrganizationProfileControllerTest.php index ae74ea7022..16aac1f28c 100644 --- a/tests/Feature/Controllers/OrganizationProfileControllerTest.php +++ b/tests/Feature/Controllers/OrganizationProfileControllerTest.php @@ -19,6 +19,7 @@ ->where('organization.id', $organization->id) ->where('role', 'admin') ->where('organization.lane', '#1a2b3c') + ->where('githubSyncedAt', $organization->github_synced_at?->toIso8601String()) ->missing('organization.lane_color')); }); diff --git a/tests/Feature/Controllers/OrganizationProfileSyncTest.php b/tests/Feature/Controllers/OrganizationProfileSyncTest.php new file mode 100644 index 0000000000..60bba7971f --- /dev/null +++ b/tests/Feature/Controllers/OrganizationProfileSyncTest.php @@ -0,0 +1,171 @@ +create(); + $organization = Organization::factory()->create([ + 'github_id' => '958072', + 'handle' => 'old-laravel', + 'name' => 'Old name', + 'bio' => 'Old bio', + 'lane_color' => '#1a2b3c', + 'github_synced_at' => now()->subWeek(), + ]); + $organization->admins()->attach($admin->id); + $other = Organization::factory()->create(['handle' => 'other-organization']); + + $this->mock(GitHubProvider::class) + ->shouldReceive('account') + ->once() + ->with('958072') + ->andReturn([ + 'id' => 958072, + 'type' => 'Organization', + 'login' => 'laravel', + 'name' => 'Laravel', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/958072', + 'bio' => 'The PHP framework.', + 'location' => 'Little Rock', + 'blog' => 'laravel.com', + ]); + + $this->actingAs($admin)->from(route('organization-profile.edit', $organization)) + ->put(route('organization-profile.update', $organization)) + ->assertRedirectToRoute('organization-profile.edit', $organization) + ->assertInertiaFlash('toast', ['type' => 'success', 'message' => 'Synced from GitHub.']); + + expect($organization->refresh()) + ->handle->toBe('laravel') + ->name->toBe('Laravel') + ->bio->toBe('The PHP framework.') + ->location->toBe('Little Rock') + ->website->toBe('https://laravel.com') + ->avatar_url->toBe('https://avatars.githubusercontent.com/u/958072') + ->github_synced_at->toDateTimeString()->toBe(now()->toDateTimeString()) + ->lane_color->toBe('#1a2b3c'); + + expect($organization->isAdmin($admin))->toBeTrue() + ->and($other->refresh()->handle)->toBe('other-organization'); +}); + +it('only lets admins of that specific organization sync it', function (string $role): void { + $admin = User::factory()->create(); + $organization = Organization::factory()->create(['bio' => 'Old bio']); + $other = Organization::factory()->create(); + $user = match ($role) { + 'member' => User::factory()->create(), + 'nonmember' => User::factory()->create(), + 'other admin' => $admin, + }; + + $organization->admins()->attach(User::factory()->create()->id); + if ($role === 'member') { + $organization->members()->attach($user->id, ['role' => OrganizationRole::Member]); + } + if ($role === 'other admin') { + $other->admins()->attach($user->id); + } + + $this->mock(GitHubProvider::class)->shouldNotReceive('account'); + + $this->actingAs($user)->put(route('organization-profile.update', $organization)) + ->assertNotFound(); + + expect($organization->refresh()->bio)->toBe('Old bio'); +})->with(['member', 'nonmember', 'other admin']); + +it('requires sign-in to sync an organization', function (): void { + $organization = Organization::factory()->create(); + + $this->put(route('organization-profile.update', $organization)) + ->assertRedirectToRoute('home'); +}); + +it('does not sync another GitHub account or a non-organization', function (array $account): void { + $admin = User::factory()->create(); + $organization = Organization::factory()->create([ + 'github_id' => '958072', + 'handle' => 'old-laravel', + 'bio' => 'Old bio', + 'github_synced_at' => now()->subWeek(), + ]); + $organization->admins()->attach($admin->id); + + $this->mock(GitHubProvider::class) + ->shouldReceive('account') + ->once() + ->with('958072') + ->andReturn($account); + + $this->actingAs($admin)->from(route('organization-profile.edit', $organization)) + ->put(route('organization-profile.update', $organization)) + ->assertInertiaFlash('toast', [ + 'type' => 'error', + 'message' => 'Could not sync from GitHub. Try again in a minute.', + ]); + + expect($organization->refresh()) + ->handle->toBe('old-laravel') + ->bio->toBe('Old bio') + ->github_synced_at->toDateTimeString()->toBe(now()->subWeek()->toDateTimeString()); +})->with([ + 'different account' => [['id' => 12, 'type' => 'Organization', 'login' => 'taken-over']], + 'GitHub user' => [['id' => 958072, 'type' => 'User', 'login' => 'some-user']], + 'missing type' => [['id' => 958072, 'login' => 'laravel']], + 'missing ID' => [['type' => 'Organization', 'login' => 'laravel']], + 'missing handle' => [['id' => 958072, 'type' => 'Organization']], +]); + +it('leaves the profile alone when GitHub is unavailable', function (bool $missing): void { + $admin = User::factory()->create(); + $organization = Organization::factory()->create([ + 'github_id' => '958072', + 'handle' => 'laravel', + 'bio' => 'Old bio', + 'github_synced_at' => now()->subWeek(), + ]); + $organization->admins()->attach($admin->id); + + $github = $this->mock(GitHubProvider::class); + $expectation = $github->shouldReceive('account')->once()->with('958072'); + + $missing + ? $expectation->andReturnNull() + : $expectation->andThrow(new RequestException(new Response(new Psr7Response(503)))); + + $this->actingAs($admin)->from(route('organization-profile.edit', $organization)) + ->put(route('organization-profile.update', $organization)) + ->assertRedirectToRoute('organization-profile.edit', $organization) + ->assertInertiaFlash('toast', [ + 'type' => 'error', + 'message' => 'Could not sync from GitHub. Try again in a minute.', + ]); + + expect($organization->refresh()) + ->handle->toBe('laravel') + ->bio->toBe('Old bio') + ->github_synced_at->toDateTimeString()->toBe(now()->subWeek()->toDateTimeString()); +})->with(['missing' => true, 'GitHub error' => false]); + +it('does not allow a signed-in user to sync another organization through their own admin role', function (): void { + $admin = User::factory()->create(); + $mine = Organization::factory()->create(); + $another = Organization::factory()->create(); + + $mine->admins()->attach($admin->id); + + $this->mock(GitHubProvider::class)->shouldNotReceive('account'); + + $this->actingAs($admin) + ->put(route('organization-profile.update', $another)) + ->assertNotFound(); +});