From 37cabbc513ea8a1d0f8efc0bfd7ad3a05bf86964 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Thu, 29 Oct 2026 18:56:47 +0000 Subject: [PATCH 01/10] feat: let organization admins manage members in the organization's settings --- KNOWLEDGE_BASE.md | 6 +- app/Actions/CreateExport.php | 25 +- app/Actions/CreateOrganizationAdmin.php | 4 +- app/Actions/CreateOrganizationMember.php | 20 + app/Actions/DeleteOrganization.php | 4 +- app/Actions/DeleteOrganizationMember.php | 19 + app/Actions/DeleteUser.php | 2 +- app/Actions/UpdateOrganizationMemberRole.php | 17 + app/Enums/OrganizationRole.php | 14 + .../OrganizationAdminController.php | 2 +- .../Controllers/OrganizationController.php | 9 +- .../OrganizationMemberController.php | 78 ++++ .../CreateOrganizationMemberRequest.php | 85 ++++ .../DeleteOrganizationMemberRequest.php | 69 +++ .../ListOrganizationMembersRequest.php | 36 ++ .../UpdateOrganizationMemberRequest.php | 72 +++ app/Models/Profile.php | 27 +- app/Models/User.php | 6 +- ...0000_create_organization_members_table.php | 37 ++ .../js/pages/organization-member/index.tsx | 430 ++++++++++++++++++ resources/js/pages/organization/show.tsx | 12 +- resources/js/pages/privacy/show.tsx | 9 + routes/web.php | 18 + .../Controllers/ExportControllerTest.php | 13 +- .../OrganizationAdminControllerTest.php | 6 +- .../OrganizationControllerTest.php | 17 +- .../OrganizationMemberControllerTest.php | 207 +++++++++ .../Actions/CreateOrganizationAdminTest.php | 11 + tests/Unit/Actions/DeleteOrganizationTest.php | 7 +- tests/Unit/Actions/DeleteUserTest.php | 8 +- 30 files changed, 1223 insertions(+), 47 deletions(-) create mode 100644 app/Actions/CreateOrganizationMember.php create mode 100644 app/Actions/DeleteOrganizationMember.php create mode 100644 app/Actions/UpdateOrganizationMemberRole.php create mode 100644 app/Enums/OrganizationRole.php create mode 100644 app/Http/Controllers/OrganizationMemberController.php create mode 100644 app/Http/Requests/CreateOrganizationMemberRequest.php create mode 100644 app/Http/Requests/DeleteOrganizationMemberRequest.php create mode 100644 app/Http/Requests/ListOrganizationMembersRequest.php create mode 100644 app/Http/Requests/UpdateOrganizationMemberRequest.php create mode 100644 database/migrations/2026_10_29_130000_create_organization_members_table.php create mode 100644 resources/js/pages/organization-member/index.tsx create mode 100644 tests/Feature/Controllers/OrganizationMemberControllerTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index de152c881..2930d5659 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -114,13 +114,15 @@ 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. -A member of an organization can **Claim** its page, which lists them as its admin. Only the first member to claim it becomes its admin: once a page has one, it can't be claimed again, and the Claim button is gone. 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. +A member of an organization can **Claim** its page, which lists them as its admin. Only the first member to claim it becomes its admin: once a page has one, it can't be claimed again, and the Claim button is gone. 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. + +After that, members are managed on /dev/null, not mirrored from GitHub. An organization's members open its **Settings** from its page, where **Members** lists everyone, each an **admin** or a **member**. Admins add people by their /dev/null handle, except people they blocked or who blocked them, make them admins or members, and remove them. Any member can leave. Its last admin can't leave or become a member. Only its members see who its members are; its page lists its admins to everyone. Your organizations and your role in each are in your data export and go when you delete your account. Every organization has a color, /dev/null's blue until its admin chooses another with **Color** on its page: one of the same six lane colors as developers, or a custom one. It rings the organization's avatar, leads its handle, and colors its link preview. 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), [claims](app/Http/Controllers/OrganizationAdminController.php), [color](resources/js/components/organization-lane-dialog.tsx). +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), [color](resources/js/components/organization-lane-dialog.tsx), [members](app/Http/Controllers/OrganizationMemberController.php). ### Going live diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 079d84250..bc351853d 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -44,7 +44,7 @@ * muting: Generator, * blocking: Generator, * dismissed_suggestions: Generator, - * claimed_organizations: array, + * organizations: array, * messages: Generator, * remote_pins: array, * remote_mutes: array, @@ -111,7 +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), + 'organizations' => $this->organizations($user), 'messages' => $this->messages($user), 'remote_mutes' => Conversation::query() ->join('conversation_participants as mine', 'mine.conversation_id', '=', 'conversations.id') @@ -293,19 +293,20 @@ private function handles(BelongsToMany $users): Generator } /** - * @return array + * @return array */ - private function claimedOrganizations(User $user): array + private function organizations(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']); + /** @var Collection $memberships */ + $memberships = $user->organizations()->newPivotQuery() + ->join('profiles', 'profiles.id', '=', 'organization_members.organization_id') + ->oldest('organization_members.created_at') + ->get(['profiles.handle', 'organization_members.role', 'organization_members.created_at']); - return $claims->map(fn (object $claim): array => [ - 'handle' => $claim->handle, - 'claimed_at' => CarbonImmutable::parse($claim->created_at)->toIso8601String(), + return $memberships->map(fn (object $membership): array => [ + 'handle' => $membership->handle, + 'role' => $membership->role, + 'joined_at' => CarbonImmutable::parse($membership->created_at)->toIso8601String(), ])->all(); } diff --git a/app/Actions/CreateOrganizationAdmin.php b/app/Actions/CreateOrganizationAdmin.php index 8d178e0b2..edda93ac3 100644 --- a/app/Actions/CreateOrganizationAdmin.php +++ b/app/Actions/CreateOrganizationAdmin.php @@ -4,6 +4,7 @@ namespace App\Actions; +use App\Enums\OrganizationRole; use App\Models\Profile; use App\Models\User; use Illuminate\Support\Facades\DB; @@ -24,7 +25,8 @@ public function handle(Profile $organization, User $user): bool return false; } - $organization->admins()->attach($user->id); + // A member is left without an admin when the last one erased their account... + $organization->members()->syncWithoutDetaching([$user->id => ['role' => OrganizationRole::Admin]]); return true; }); diff --git a/app/Actions/CreateOrganizationMember.php b/app/Actions/CreateOrganizationMember.php new file mode 100644 index 000000000..aa81c57e8 --- /dev/null +++ b/app/Actions/CreateOrganizationMember.php @@ -0,0 +1,20 @@ +members()->attach($user->id, ['role' => OrganizationRole::Member]); + } +} diff --git a/app/Actions/DeleteOrganization.php b/app/Actions/DeleteOrganization.php index 6354b35eb..75db3006d 100644 --- a/app/Actions/DeleteOrganization.php +++ b/app/Actions/DeleteOrganization.php @@ -11,7 +11,7 @@ final readonly class DeleteOrganization { /** - * Deletes an organization GitHub no longer has, with its admins, and unlinks the commits that mention it, so none + * Deletes an organization GitHub no longer has, with its members, and unlinks the commits that mention it, so none * links to a page that is gone. */ public function handle(Profile $organization): void @@ -22,7 +22,7 @@ public function handle(Profile $organization): void ->where('topic_id', $organization->id) ->delete(); - $organization->admins()->detach(); + $organization->members()->detach(); $organization->delete(); }); } diff --git a/app/Actions/DeleteOrganizationMember.php b/app/Actions/DeleteOrganizationMember.php new file mode 100644 index 000000000..fe58ae726 --- /dev/null +++ b/app/Actions/DeleteOrganizationMember.php @@ -0,0 +1,19 @@ +members()->detach($member->id); + } +} diff --git a/app/Actions/DeleteUser.php b/app/Actions/DeleteUser.php index 56513ed1f..033a8af2f 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -67,7 +67,7 @@ public function handle(User $user): void $this->deleteOrgs->handle($this->emptyOrgs($user)); - $user->claimedOrganizations()->detach(); + $user->organizations()->detach(); $user->delete(); $user->profile->delete(); diff --git a/app/Actions/UpdateOrganizationMemberRole.php b/app/Actions/UpdateOrganizationMemberRole.php new file mode 100644 index 000000000..6ad5d0933 --- /dev/null +++ b/app/Actions/UpdateOrganizationMemberRole.php @@ -0,0 +1,17 @@ +members()->updateExistingPivot($member->id, ['role' => $role]); + } +} diff --git a/app/Enums/OrganizationRole.php b/app/Enums/OrganizationRole.php new file mode 100644 index 000000000..016814abb --- /dev/null +++ b/app/Enums/OrganizationRole.php @@ -0,0 +1,14 @@ +admins()->oldest('organization_admins.created_at')->first(); + $admin = $organization->admins()->oldest('organization_members.created_at')->first(); if ($admin === null) { return null; diff --git a/app/Http/Controllers/OrganizationController.php b/app/Http/Controllers/OrganizationController.php index 800b57631..0d4edacda 100644 --- a/app/Http/Controllers/OrganizationController.php +++ b/app/Http/Controllers/OrganizationController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; +use App\Enums\OrganizationRole; use App\Http\Requests\ShowOrganizationRequest; use App\Models\Profile; use App\Models\User; @@ -19,8 +20,9 @@ { /** * Organization pages are public, at the "@handle" of users, and show the organization's GitHub profile and the - * commits that mention it, in the lane its admins chose or /dev/null's blue, 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. + * commits that mention it, in the lane its admins chose or /dev/null's blue, with its admins, first one first, and + * the organization's link preview, and its members the way to its settings. 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 { @@ -43,7 +45,8 @@ public function show(ShowOrganizationRequest $request, #[CurrentUser] ?User $cur ->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'), + 'settingsUrl' => $currentUser instanceof User && $organization->roleOf($currentUser) instanceof OrganizationRole ? route('organization-member.index', $organization) : null, + 'admins' => fn (): Collection => $organization->admins()->oldest('organization_members.created_at')->get()->makeHidden('pivot'), ])->withViewData(['preview' => $currentUser instanceof User || $request->inertia() ? null : $metadata->organization($organization)]); } } diff --git a/app/Http/Controllers/OrganizationMemberController.php b/app/Http/Controllers/OrganizationMemberController.php new file mode 100644 index 000000000..4c4089bda --- /dev/null +++ b/app/Http/Controllers/OrganizationMemberController.php @@ -0,0 +1,78 @@ + $organization->makeVisible('id'), + 'role' => $organization->roleOf($user), + 'members' => $organization->members() + ->oldest('organization_members.created_at') + ->withCasts(['joined_at' => 'datetime']) + ->get(['users.*', 'organization_members.role', 'organization_members.created_at as joined_at']) + ->makeHidden('pivot'), + ]); + } + + public function store(CreateOrganizationMemberRequest $request, Profile $organization, CreateOrganizationMember $action): RedirectResponse + { + $member = $request->member(); + assert($member instanceof User); + + $action->handle($organization, $member); + + return back(); + } + + public function update(UpdateOrganizationMemberRequest $request, Profile $organization, User $member, UpdateOrganizationMemberRole $action): RedirectResponse + { + $role = $request->role(); + assert($role instanceof OrganizationRole); + + $action->handle($organization, $member, $role); + + return back(); + } + + /** + * Leaving takes the user to the organization's page, as its settings are for members only. + */ + public function destroy(DeleteOrganizationMemberRequest $request, #[CurrentUser] User $user, Profile $organization, User $member, DeleteOrganizationMember $action): RedirectResponse + { + $action->handle($organization, $member); + + if ($user->is($member)) { + Inertia::flash('toast', ['type' => 'success', 'message' => __('You left @:handle.', ['handle' => $organization->handle])]); + + return to_route('user.show', $organization->handle); + } + + return back(); + } +} diff --git a/app/Http/Requests/CreateOrganizationMemberRequest.php b/app/Http/Requests/CreateOrganizationMemberRequest.php new file mode 100644 index 000000000..992850aae --- /dev/null +++ b/app/Http/Requests/CreateOrganizationMemberRequest.php @@ -0,0 +1,85 @@ +user(); + assert($user instanceof User); + + return match ($this->organization()->roleOf($user)) { + null => Response::denyAsNotFound(), + OrganizationRole::Member => Response::deny('Only admins can add members.'), + OrganizationRole::Admin => Response::allow(), + }; + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'handle' => ['required', 'string', 'max:40'], + ]; + } + + /** + * @return list + */ + public function after(): array + { + return [function (Validator $validator): void { + if ($validator->errors()->isNotEmpty()) { + return; + } + + $user = $this->user(); + assert($user instanceof User); + + $member = $this->member(); + + if (! $member instanceof User) { + $validator->errors()->add('handle', "There's no @{$this->handle()} on /dev/null."); + } elseif ($user->hasBlockWith($member)) { + $validator->errors()->add('handle', "You can't add {$member->mention()}."); + } elseif ($this->organization()->roleOf($member) instanceof OrganizationRole) { + $validator->errors()->add('handle', "{$member->mention()} is already a member."); + } + }]; + } + + public function organization(): Profile + { + $organization = $this->route('organization'); + assert($organization instanceof Profile); + + return $organization; + } + + /** + * The user at the handle, with or without its "@", resolved like "@handle" URLs, whatever its case. + */ + public function member(): ?User + { + $member = (new User)->resolveRouteBinding($this->handle(), 'handle'); + + return $member instanceof User ? $member : null; + } + + private function handle(): string + { + return $this->string('handle')->trim()->ltrim('@')->value(); + } +} diff --git a/app/Http/Requests/DeleteOrganizationMemberRequest.php b/app/Http/Requests/DeleteOrganizationMemberRequest.php new file mode 100644 index 000000000..9d3611d87 --- /dev/null +++ b/app/Http/Requests/DeleteOrganizationMemberRequest.php @@ -0,0 +1,69 @@ +user(); + assert($user instanceof User); + + return match (true) { + ! $this->organization()->roleOf($user) instanceof OrganizationRole || ! $this->organization()->roleOf($this->member()) instanceof OrganizationRole => Response::denyAsNotFound(), + ! $user->is($this->member()) && $this->organization()->roleOf($user) !== OrganizationRole::Admin => Response::deny('Only admins can remove members.'), + default => Response::allow(), + }; + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } + + /** + * @return list + */ + public function after(): array + { + return [function (Validator $validator): void { + $isLastAdmin = $this->organization()->roleOf($this->member()) === OrganizationRole::Admin + && $this->organization()->admins()->whereKeyNot($this->member()->id)->doesntExist(); + + if ($isLastAdmin) { + $validator->errors()->add('member', "@{$this->organization()->handle} needs at least one admin."); + } + }]; + } + + public function organization(): Profile + { + $organization = $this->route('organization'); + assert($organization instanceof Profile); + + return $organization; + } + + public function member(): User + { + $member = $this->route('member'); + assert($member instanceof User); + + return $member; + } +} diff --git a/app/Http/Requests/ListOrganizationMembersRequest.php b/app/Http/Requests/ListOrganizationMembersRequest.php new file mode 100644 index 000000000..7bd3a48b7 --- /dev/null +++ b/app/Http/Requests/ListOrganizationMembersRequest.php @@ -0,0 +1,36 @@ +user(); + assert($user instanceof User); + + $organization = $this->route('organization'); + assert($organization instanceof Profile); + + return $organization->roleOf($user) instanceof OrganizationRole ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/UpdateOrganizationMemberRequest.php b/app/Http/Requests/UpdateOrganizationMemberRequest.php new file mode 100644 index 000000000..2bcec8fe4 --- /dev/null +++ b/app/Http/Requests/UpdateOrganizationMemberRequest.php @@ -0,0 +1,72 @@ +user(); + assert($user instanceof User); + + return match (true) { + ! $this->organization()->roleOf($user) instanceof OrganizationRole || ! $this->organization()->roleOf($this->member()) instanceof OrganizationRole => Response::denyAsNotFound(), + $this->organization()->roleOf($user) !== OrganizationRole::Admin => Response::deny('Only admins can change roles.'), + default => Response::allow(), + }; + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'role' => ['required', Rule::enum(OrganizationRole::class)], + ]; + } + + /** + * @return list + */ + public function after(): array + { + return [function (Validator $validator): void { + if ($this->role() === OrganizationRole::Member && $this->organization()->admins()->whereKeyNot($this->member()->id)->doesntExist()) { + $validator->errors()->add('role', "@{$this->organization()->handle} needs at least one admin."); + } + }]; + } + + public function organization(): Profile + { + $organization = $this->route('organization'); + assert($organization instanceof Profile); + + return $organization; + } + + public function member(): User + { + $member = $this->route('member'); + assert($member instanceof User); + + return $member; + } + + public function role(): ?OrganizationRole + { + return $this->enum('role', OrganizationRole::class); + } +} diff --git a/app/Models/Profile.php b/app/Models/Profile.php index aef8c4e14..dc60ab886 100644 --- a/app/Models/Profile.php +++ b/app/Models/Profile.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Enums\OrganizationRole; use App\Enums\ProfileType; use Carbon\CarbonInterface; use Database\Factories\ProfileFactory; @@ -127,13 +128,33 @@ public function user(): HasOne } /** - * The users who claimed the organization's page. GitHub only lets them in: leaving the organization there doesn't - * remove them here. + * The organization's members on /dev/null, each an admin or a member. GitHub only lets the first admin in, who + * claimed the page: leaving the organization there doesn't remove anyone here. + * + * @return BelongsToMany + */ + public function members(): BelongsToMany + { + return $this->belongsToMany(User::class, 'organization_members', 'organization_id')->withPivot('role')->withTimestamps(updatedAt: false); + } + + /** + * The members who manage the organization's members. Attaching through it makes an admin. * * @return BelongsToMany */ public function admins(): BelongsToMany { - return $this->belongsToMany(User::class, 'organization_admins', 'organization_id')->withTimestamps(updatedAt: false); + return $this->members()->withPivotValue('role', OrganizationRole::Admin->value); + } + + /** + * The user's role in the organization, or null when they aren't a member. + */ + public function roleOf(User $user): ?OrganizationRole + { + $role = $this->members()->newPivotQuery()->where('user_id', $user->id)->value('role'); + + return is_string($role) ? OrganizationRole::from($role) : null; } } diff --git a/app/Models/User.php b/app/Models/User.php index 44679049b..d17824f9e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -334,13 +334,13 @@ public function isBanned(): bool } /** - * The organizations whose page the user claimed, see `Profile::admins()`. + * The organizations the user is a member of, see `Profile::members()`. * * @return BelongsToMany */ - public function claimedOrganizations(): BelongsToMany + public function organizations(): BelongsToMany { - return $this->belongsToMany(Profile::class, 'organization_admins', 'user_id', 'organization_id')->withTimestamps(updatedAt: false); + return $this->belongsToMany(Profile::class, 'organization_members', 'user_id', 'organization_id')->withPivot('role')->withTimestamps(updatedAt: false); } /** diff --git a/database/migrations/2026_10_29_130000_create_organization_members_table.php b/database/migrations/2026_10_29_130000_create_organization_members_table.php new file mode 100644 index 000000000..fde6a47d3 --- /dev/null +++ b/database/migrations/2026_10_29_130000_create_organization_members_table.php @@ -0,0 +1,37 @@ +foreignUuid('organization_id')->constrained('profiles'); + $table->foreignUuid('user_id')->constrained(); + $table->string('role'); + $table->timestamp('created_at'); + + $table->primary(['organization_id', 'user_id']); + $table->index('user_id'); + }); + + DB::table('organization_members')->insertUsing( + ['organization_id', 'user_id', 'role', 'created_at'], + DB::table('organization_admins')->select(['organization_id', 'user_id', DB::raw("'admin'"), 'created_at']), + ); + + Schema::drop('organization_admins'); + } +}; diff --git a/resources/js/pages/organization-member/index.tsx b/resources/js/pages/organization-member/index.tsx new file mode 100644 index 000000000..488dd99d7 --- /dev/null +++ b/resources/js/pages/organization-member/index.tsx @@ -0,0 +1,430 @@ +import { Form, Head, Link, router } from '@inertiajs/react'; +import { useRef, useState } from 'react'; +import { toast } from 'sonner'; +import OrganizationMemberController from '@/actions/App/Http/Controllers/OrganizationMemberController'; +import { show } from '@/actions/App/Http/Controllers/UserController'; +import { OrganizationAvatar } from '@/components/organization-avatar'; +import { PageTabs } from '@/components/page-tabs'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { UserAvatar } from '@/components/user-avatar'; +import { useNow } from '@/hooks/use-now'; +import { useUser } from '@/hooks/use-user'; +import AppLayout from '@/layouts/app-layout'; +import { cn, formatFullDateTime, formatRelativeTime } from '@/lib/utils'; +import type { Organization, User } from '@/types'; + +type Role = 'admin' | 'member'; + +type Member = User & { role: Role; joined_at: string }; + +type OrganizationWithId = Organization & { id: string }; + +export default function Index({ + organization, + role, + members, +}: { + organization: OrganizationWithId; + role: Role; + members: Member[]; +}) { + const headingRef = useRef(null); + const isAdmin = role === 'admin'; + const adminCount = members.filter( + (member) => member.role === 'admin', + ).length; + + // A removed row unmounts with the focus inside it, so it moves to the next row, or the heading... + const focusAfter = (handle: string) => { + const index = members.findIndex((member) => member.handle === handle); + const next = members[index + 1] ?? members[index - 1]; + + const target = next + ? document.querySelector( + `[data-member="${next.handle}"] [data-remove]`, + ) + : null; + + (target ?? headingRef.current)?.focus({ preventScroll: true }); + }; + + return ( + <> + + +
+
+

Settings

+ + + @{organization.handle} + +
+ +
+ +
+
+

+ Members +

+

+ {isAdmin + ? 'Admins add and remove members, and choose who else is an admin. Only members see this list.' + : 'Only members see this list. Admins add and remove members.'} +

+
+ + {isAdmin && } + +
    + {members.map((member) => ( + focusAfter(member.handle)} + /> + ))} +
+
+ + ); +} + +/** + * Admins add people by their handle. They join as members, and can then be made admins. + */ +function AddMemberForm({ organization }: { organization: OrganizationWithId }) { + return ( +
+ {({ errors, processing }) => ( + <> + +
+ + +
+ {/* The line is kept, so an error doesn't push the list down. */} +

+ {errors.handle} +

+ + )} +
+ ); +} + +function MemberRow({ + organization, + member, + isAdmin, + isLastAdmin, + onRemoved, +}: { + organization: OrganizationWithId; + member: Member; + isAdmin: boolean; + isLastAdmin: boolean; + onRemoved: () => void; +}) { + const { user } = useUser(); + const now = useNow(); + const isSelf = member.id === user.id; + const [removal, setRemoval] = useState<'idle' | 'confirming' | 'removing'>( + 'idle', + ); + const [hasCancelled, setHasCancelled] = useState(false); + // The role the select shows until the change is saved, so it doesn't flip back meanwhile. + const [pendingRole, setPendingRole] = useState(null); + const route = { organization: organization.id, member: member.handle }; + const lastAdminReason = `@${organization.handle} needs at least one admin.`; + const joined = formatRelativeTime(member.joined_at, now); + + const changeRole = (role: Role) => { + setPendingRole(role); + + router.visit(OrganizationMemberController.update(route), { + data: { role }, + preserveScroll: true, + // Another admin may have changed the members since the page loaded... + onError: (errors) => toast.error(Object.values(errors)[0]), + onFinish: () => setPendingRole(null), + }); + }; + + const cancel = () => { + setRemoval('idle'); + setHasCancelled(true); + }; + + const remove = () => { + setRemoval('removing'); + + const prefersReducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)', + ).matches; + + window.setTimeout( + () => { + let isRemoved = false; + + router.visit(OrganizationMemberController.destroy(route), { + preserveScroll: true, + onSuccess: () => { + isRemoved = true; + + if (!isSelf) { + onRemoved(); + } + }, + onError: (errors) => toast.error(Object.values(errors)[0]), + // A visit that failed or was cancelled brings the row back, so it can be removed again... + onFinish: () => { + if (!isRemoved) { + setRemoval('idle'); + } + }, + }); + }, + prefersReducedMotion ? 0 : 700, + ); + }; + + const removeLabel = isSelf ? 'Leave' : 'Remove'; + const confirmation = isSelf + ? `Leave @${organization.handle}? An admin would have to add you back.` + : `Remove @${member.handle} from @${organization.handle}?`; + + return ( +
  • +
    + {/* On phones the controls get their own line under the name, which stays readable. */} +
    + + + + +
    +

    + + {member.name} + + {isSelf && ( + + {' '} + (you) + + )} +

    + + {removal === 'idle' && ( +

    + + @{member.handle} + + · + joined{' '} + +

    + )} + + {removal === 'confirming' && ( +

    + {confirmation} +

    + )} + + {removal === 'removing' && ( +

    + rm @{member.handle} → /dev/null +

    + )} +
    + + {removal === 'idle' && ( +
    + {isAdmin ? ( + + ) : ( + + {member.role === 'admin' + ? 'Admin' + : 'Member'} + + )} + + {(isAdmin || isSelf) && ( + + )} +
    + )} + + {removal === 'confirming' && ( +
    { + if (event.key === 'Escape') { + cancel(); + } + }} + className="flex w-full shrink-0 gap-2 pl-12 sm:w-auto sm:pl-0" + > + + +
    + )} +
    +
    +
  • + ); +} + +Index.layout = AppLayout; diff --git a/resources/js/pages/organization/show.tsx b/resources/js/pages/organization/show.tsx index d21197f1c..e812e2e7b 100644 --- a/resources/js/pages/organization/show.tsx +++ b/resources/js/pages/organization/show.tsx @@ -1,5 +1,5 @@ import { Head, Link, usePage } from '@inertiajs/react'; -import { AlertCircleIcon, Link2, MapPin, Palette } from 'lucide-react'; +import { AlertCircleIcon, Link2, MapPin, Palette, Settings } from 'lucide-react'; import type { CSSProperties } from 'react'; import { useState } from 'react'; import { show } from '@/actions/App/Http/Controllers/UserController'; @@ -29,6 +29,7 @@ export default function Show({ tab, commits, claimUrl, + settingsUrl, admins, }: { organization: Organization & { id: string }; @@ -36,6 +37,7 @@ export default function Show({ tab: 'mentions'; commits: CursorPaginated; claimUrl: string; + settingsUrl: string | null; admins: User[]; }) { const { props, flash } = usePage(); @@ -101,6 +103,14 @@ export default function Show({ )} + {settingsUrl && ( + + )} , + <> + + From an organization's admins, when they add + you: + {' '} + that you are a member or an admin of that + organization, which its members see. Admins are + also listed on the organization's page. + , <> From Apple, when you sign in with Apple in diff --git a/routes/web.php b/routes/web.php index 141cc5029..0254aeb87 100644 --- a/routes/web.php +++ b/routes/web.php @@ -80,6 +80,7 @@ use App\Http\Controllers\OrganizationAdminController; use App\Http\Controllers\OrganizationController; use App\Http\Controllers\OrganizationLaneController; +use App\Http\Controllers\OrganizationMemberController; use App\Http\Controllers\OrganizationMentionController; use App\Http\Controllers\OrganizationSitemapController; use App\Http\Controllers\OrgArchiveController; @@ -344,6 +345,23 @@ ->whereUuid('organization') ->name('organization-lane.update'); + // Organization Members, which admins manage in the organization's settings... + Route::get('organizations/{organization}/settings/members', [OrganizationMemberController::class, 'index']) + ->whereUuid('organization') + ->name('organization-member.index'); + Route::post('organizations/{organization}/settings/members', [OrganizationMemberController::class, 'store']) + ->whereUuid('organization') + ->middleware('throttle:30,1') + ->name('organization-member.store'); + Route::put('organizations/{organization}/settings/members/{member:handle}', [OrganizationMemberController::class, 'update']) + ->whereUuid('organization') + ->withoutScopedBindings() + ->name('organization-member.update'); + Route::delete('organizations/{organization}/settings/members/{member:handle}', [OrganizationMemberController::class, 'destroy']) + ->whereUuid('organization') + ->withoutScopedBindings() + ->name('organization-member.destroy'); + // 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 b625919e5..1efad77b9 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -8,6 +8,7 @@ use App\Contracts\Services\TranslationProvider; use App\Enums\ModerationReason; use App\Enums\NotificationType; +use App\Enums\OrganizationRole; use App\Models\Commit; use App\Models\CommitImage; use App\Models\Device; @@ -130,7 +131,7 @@ 'muting' => [], 'blocking' => [], 'dismissed_suggestions' => [], - 'claimed_organizations' => [], + 'organizations' => [], 'messages' => [], 'remote_mutes' => [], 'remote_pins' => [], @@ -441,20 +442,20 @@ ); }); -it('exports the organizations the user claimed, first claim first, without their other admins', function (): void { +it('exports the organizations the user is a member of with their role, first joined first, without their other members', 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()]]); + $laravel->members()->attach([$user->id => ['role' => OrganizationRole::Member, 'created_at' => now()], User::factory()->create()->id => ['role' => OrganizationRole::Admin, '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()], + expect($data['organizations'])->toBe([ + ['handle' => 'brefphp', 'role' => 'admin', 'joined_at' => now()->subDay()->toIso8601String()], + ['handle' => 'laravel', 'role' => 'member', 'joined_at' => now()->toIso8601String()], ]); }); diff --git a/tests/Feature/Controllers/OrganizationAdminControllerTest.php b/tests/Feature/Controllers/OrganizationAdminControllerTest.php index 97acac7b5..cda016eed 100644 --- a/tests/Feature/Controllers/OrganizationAdminControllerTest.php +++ b/tests/Feature/Controllers/OrganizationAdminControllerTest.php @@ -9,7 +9,6 @@ use GuzzleHttp\Psr7\Response as Psr7Response; use Illuminate\Http\Client\RequestException; use Illuminate\Http\Client\Response; -use Illuminate\Support\Facades\DB; use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Two\GithubProvider as GitHubOAuthProvider; use Laravel\Socialite\Two\InvalidStateException; @@ -115,10 +114,7 @@ function claimingUserAndOrganization(): array [$user, $organization] = claimingUserAndOrganization(); $first = User::factory()->create(); $second = User::factory()->create(); - DB::table('organization_admins')->insert([ - ['organization_id' => $organization->id, 'user_id' => $second->id, 'created_at' => now()], - ['organization_id' => $organization->id, 'user_id' => $first->id, 'created_at' => now()->subDay()], - ]); + $organization->admins()->attach([$second->id => ['created_at' => now()], $first->id => ['created_at' => now()->subDay()]]); $this->actingAs($user) ->withSession(['claiming-organization' => $organization->id]) diff --git a/tests/Feature/Controllers/OrganizationControllerTest.php b/tests/Feature/Controllers/OrganizationControllerTest.php index df56d83f0..ec83fe8c6 100644 --- a/tests/Feature/Controllers/OrganizationControllerTest.php +++ b/tests/Feature/Controllers/OrganizationControllerTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Actions\CreateCommitTopics; +use App\Enums\OrganizationRole; use App\Models\Commit; use App\Models\Profile; use App\Models\User; @@ -44,16 +45,18 @@ 'custom' => [['lane_color' => '#1a2b3c'], '#1a2b3c'], ]); -it('shows who claimed the organization, first claim first, and where to claim it', function (): void { +it('shows the admins of the organization but not its other members, first admin 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()]]); + $organization->members()->attach(User::factory()->create()->id, ['role' => OrganizationRole::Member, 'created_at' => now()->subWeek()]); $this->get('/@laravel') ->assertOk() ->assertInertia(fn ($page) => $page ->where('claimUrl', route('organization-admin.create', $organization)) + ->where('settingsUrl', null) ->where('admins.0.id', $first->id) ->where('admins.0.handle', $first->profile->handle) ->where('admins.1.id', $second->id) @@ -61,6 +64,18 @@ ->has('admins', 2)); }); +it('links its members to the settings of the organization', function (): void { + $organization = Profile::factory()->organization()->create(['handle' => 'laravel']); + $member = User::factory()->create(); + $organization->members()->attach($member->id, ['role' => OrganizationRole::Member]); + + $this->actingAs($member)->get('/@laravel') + ->assertInertia(fn ($page) => $page->where('settingsUrl', route('organization-member.index', $organization))); + + $this->actingAs(User::factory()->create())->get('/@laravel') + ->assertInertia(fn ($page) => $page->where('settingsUrl', null)); +}); + it('shows guests the link preview of the organization', function (): void { $laravel = Profile::factory()->organization()->create([ 'handle' => 'laravel', diff --git a/tests/Feature/Controllers/OrganizationMemberControllerTest.php b/tests/Feature/Controllers/OrganizationMemberControllerTest.php new file mode 100644 index 000000000..d09e801d7 --- /dev/null +++ b/tests/Feature/Controllers/OrganizationMemberControllerTest.php @@ -0,0 +1,207 @@ +organization()->create(['handle' => 'laravel']); + $admin = User::factory()->create(); + $member = User::factory()->create(); + $organization->admins()->attach($admin->id, ['created_at' => now()->subDay()]); + $organization->members()->attach($member->id, ['role' => OrganizationRole::Member, 'created_at' => now()]); + + return [$organization, $admin, $member]; +} + +/** + * @return array{organization: string, member: string} + */ +function memberRoute(Profile $organization, User $member): array +{ + return ['organization' => $organization->id, 'member' => $member->handle]; +} + +it('lists the members of the organization to its members, first joined first, with their roles', function (): void { + $this->freezeSecond(); + [$organization, $admin, $member] = organizationWithAdminAndMember(); + + $this->actingAs($member) + ->get(route('organization-member.index', $organization)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('organization-member/index') + ->where('organization.id', $organization->id) + ->where('role', 'member') + ->has('members', 2) + ->where('members.0.handle', $admin->handle) + ->where('members.0.role', 'admin') + ->where('members.0.joined_at', now()->subDay()->toJSON()) + ->where('members.1.handle', $member->handle) + ->where('members.1.role', 'member') + ->missing('members.0.pivot') + ->missing('members.0.email')); +}); + +it('hides the members of the organization from everyone else', function (): void { + [$organization] = organizationWithAdminAndMember(); + $user = User::factory()->create(); + + $this->actingAs($user)->get(route('organization-member.index', $organization))->assertNotFound(); + $this->actingAs($user)->get(route('organization-member.index', $user->profile))->assertNotFound(); +}); + +it('lets admins add a member by their handle, whatever its case and with its @', function (): void { + [$organization, $admin] = organizationWithAdminAndMember(); + $user = User::factory()->create(); + + $this->actingAs($admin) + ->from(route('organization-member.index', $organization)) + ->post(route('organization-member.store', $organization), ['handle' => ' @'.mb_strtoupper($user->handle)]) + ->assertRedirect(route('organization-member.index', $organization)) + ->assertSessionHasNoErrors(); + + expect($organization->roleOf($user))->toBe(OrganizationRole::Member); +}); + +it('refuses to add someone who is not on /dev/null or already a member', function (): void { + [$organization, $admin, $member] = organizationWithAdminAndMember(); + + $this->actingAs($admin) + ->post(route('organization-member.store', $organization), ['handle' => '']) + ->assertSessionHasErrors(['handle' => 'The handle field is required.']); + + $this->actingAs($admin) + ->post(route('organization-member.store', $organization), ['handle' => 'nobody-here']) + ->assertSessionHasErrors(['handle' => "There's no @nobody-here on /dev/null."]); + + $this->actingAs($admin) + ->post(route('organization-member.store', $organization), ['handle' => $organization->handle]) + ->assertSessionHasErrors(['handle' => "There's no @laravel on /dev/null."]); + + $this->actingAs($admin) + ->post(route('organization-member.store', $organization), ['handle' => $member->handle]) + ->assertSessionHasErrors(['handle' => "@{$member->handle} is already a member."]); + + expect($organization->members()->count())->toBe(2); +}); + +it('refuses to add someone across a block, either way', function (): void { + [$organization, $admin] = organizationWithAdminAndMember(); + $blocked = User::factory()->create(); + $blocker = User::factory()->create(); + $admin->blocking()->attach($blocked->id); + $blocker->blocking()->attach($admin->id); + + $this->actingAs($admin) + ->post(route('organization-member.store', $organization), ['handle' => $blocked->handle]) + ->assertSessionHasErrors(['handle' => "You can't add @{$blocked->handle}."]); + + $this->actingAs($admin) + ->post(route('organization-member.store', $organization), ['handle' => $blocker->handle]) + ->assertSessionHasErrors(['handle' => "You can't add @{$blocker->handle}."]); + + expect($organization->members()->count())->toBe(2); +}); + +it('only lets admins add members', function (): void { + [$organization, , $member] = organizationWithAdminAndMember(); + $user = User::factory()->create(); + + $this->actingAs($member)->post(route('organization-member.store', $organization), ['handle' => $user->handle])->assertForbidden(); + $this->actingAs($user)->post(route('organization-member.store', $organization), ['handle' => $user->handle])->assertNotFound(); + + expect($organization->roleOf($user))->toBeNull(); +}); + +it('lets admins make a member an admin, and an admin a member again', function (): void { + [$organization, $admin, $member] = organizationWithAdminAndMember(); + + $this->actingAs($admin) + ->put(route('organization-member.update', memberRoute($organization, $member)), ['role' => 'admin']) + ->assertSessionHasNoErrors(); + + expect($organization->roleOf($member))->toBe(OrganizationRole::Admin); + + $this->actingAs($member) + ->put(route('organization-member.update', memberRoute($organization, $admin)), ['role' => 'member']) + ->assertSessionHasNoErrors(); + + expect($organization->roleOf($admin))->toBe(OrganizationRole::Member); +}); + +it('keeps the last admin of the organization', function (): void { + [$organization, $admin] = organizationWithAdminAndMember(); + + $this->actingAs($admin) + ->put(route('organization-member.update', memberRoute($organization, $admin)), ['role' => 'member']) + ->assertSessionHasErrors(['role' => '@laravel needs at least one admin.']); + + $this->actingAs($admin) + ->delete(route('organization-member.destroy', memberRoute($organization, $admin))) + ->assertSessionHasErrors(['member' => '@laravel needs at least one admin.']); + + expect($organization->roleOf($admin))->toBe(OrganizationRole::Admin); +}); + +it('only lets admins change roles, of members', function (): void { + [$organization, $admin, $member] = organizationWithAdminAndMember(); + $user = User::factory()->create(); + + $this->actingAs($member)->put(route('organization-member.update', memberRoute($organization, $member)), ['role' => 'admin'])->assertForbidden(); + $this->actingAs($admin)->put(route('organization-member.update', memberRoute($organization, $user)), ['role' => 'admin'])->assertNotFound(); + $this->actingAs($admin)->put(route('organization-member.update', memberRoute($organization, $member)), ['role' => 'owner'])->assertSessionHasErrors('role'); + + expect($organization->roleOf($member))->toBe(OrganizationRole::Member) + ->and($organization->roleOf($user))->toBeNull(); +}); + +it('lets admins remove members', function (): void { + [$organization, $admin, $member] = organizationWithAdminAndMember(); + + $this->actingAs($admin) + ->from(route('organization-member.index', $organization)) + ->delete(route('organization-member.destroy', memberRoute($organization, $member))) + ->assertRedirect(route('organization-member.index', $organization)); + + expect($organization->roleOf($member))->toBeNull(); +}); + +it('lets members leave, back to the page of the organization', function (): void { + [$organization, $admin, $member] = organizationWithAdminAndMember(); + + $this->actingAs($member) + ->delete(route('organization-member.destroy', memberRoute($organization, $member))) + ->assertRedirect(route('user.show', 'laravel')) + ->assertInertiaFlash('toast.message', 'You left @laravel.'); + + expect($organization->roleOf($member))->toBeNull() + ->and($organization->roleOf($admin))->toBe(OrganizationRole::Admin); +}); + +it('lets an admin leave while another one stays', function (): void { + [$organization, $admin, $member] = organizationWithAdminAndMember(); + $organization->members()->updateExistingPivot($member->id, ['role' => OrganizationRole::Admin]); + + $this->actingAs($admin) + ->delete(route('organization-member.destroy', memberRoute($organization, $admin))) + ->assertSessionHasNoErrors(); + + expect($organization->roleOf($admin))->toBeNull(); +}); + +it('only lets admins remove someone else', function (): void { + [$organization, $admin, $member] = organizationWithAdminAndMember(); + $user = User::factory()->create(); + + $this->actingAs($member)->delete(route('organization-member.destroy', memberRoute($organization, $admin)))->assertForbidden(); + $this->actingAs($user)->delete(route('organization-member.destroy', memberRoute($organization, $member)))->assertNotFound(); + + expect($organization->members()->count())->toBe(2); +}); diff --git a/tests/Unit/Actions/CreateOrganizationAdminTest.php b/tests/Unit/Actions/CreateOrganizationAdminTest.php index c70f63417..d14166209 100644 --- a/tests/Unit/Actions/CreateOrganizationAdminTest.php +++ b/tests/Unit/Actions/CreateOrganizationAdminTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Actions\CreateOrganizationAdmin; +use App\Enums\OrganizationRole; use App\Models\Profile; use App\Models\User; @@ -14,3 +15,13 @@ ->and(resolve(CreateOrganizationAdmin::class)->handle($organization, $second))->toBeFalse() ->and($organization->admins()->pluck('users.id')->all())->toBe([$first->id]); }); + +it('makes a member the admin of a page left without one', function (): void { + $organization = Profile::factory()->organization()->create(); + $member = User::factory()->create(); + $organization->members()->attach($member->id, ['role' => OrganizationRole::Member]); + + expect(resolve(CreateOrganizationAdmin::class)->handle($organization, $member))->toBeTrue() + ->and($organization->roleOf($member))->toBe(OrganizationRole::Admin) + ->and($organization->members()->count())->toBe(1); +}); diff --git a/tests/Unit/Actions/DeleteOrganizationTest.php b/tests/Unit/Actions/DeleteOrganizationTest.php index 81a730aac..6c6ebeaf8 100644 --- a/tests/Unit/Actions/DeleteOrganizationTest.php +++ b/tests/Unit/Actions/DeleteOrganizationTest.php @@ -3,20 +3,21 @@ declare(strict_types=1); use App\Actions\DeleteOrganization; +use App\Enums\OrganizationRole; use App\Models\Profile; use App\Models\User; use Illuminate\Support\Facades\DB; -it('deletes an organization with its admins, keeping the users', function (): void { +it('deletes an organization with its members, keeping the users', function (): void { $user = User::factory()->create(); $organization = Profile::factory()->organization()->create(); $kept = Profile::factory()->organization()->create(); - $organization->admins()->attach($user->id, ['created_at' => now()]); + $organization->members()->attach($user->id, ['role' => OrganizationRole::Member]); $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]); + ->and(DB::table('organization_members')->pluck('organization_id')->all())->toBe([$kept->id]); }); diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index 90e6ba65e..8715a3d98 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -8,6 +8,7 @@ use App\Actions\UpdateCommitHotScores; use App\Contracts\Services\TranslationProvider; use App\Dto\ThreadEngagement; +use App\Enums\OrganizationRole; use App\Models\Commit; use App\Models\CommitImage; use App\Models\Conversation; @@ -189,15 +190,16 @@ ->and(PollVote::query()->sole()->is($keptVote))->toBeTrue(); }); -it('erases the organizations a deleted user claimed, keeping their other admins', function (): void { +it('erases the organization memberships of a deleted user, keeping their other members', 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()]]); + $organization->admins()->attach($user->id); + $organization->members()->attach($other->id, ['role' => OrganizationRole::Member]); resolve(DeleteUser::class)->handle($user); - expect($organization->admins()->pluck('users.id')->all())->toBe([$other->id]); + expect($organization->members()->pluck('users.id')->all())->toBe([$other->id]); }); it('erases the devices of a deleted user', function (): void { From 37493cde8e36e59030995b1bb0fd5b4f0aa42318 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Thu, 29 Oct 2026 18:56:47 +0000 Subject: [PATCH 02/10] feat: move the organization's color into its settings --- KNOWLEDGE_BASE.md | 6 +- .../OrganizationLaneController.php | 15 ++++ .../Requests/ShowOrganizationLaneRequest.php | 36 +++++++++ .../layouts/organization-settings/layout.tsx | 69 +++++++++++++++++ .../organization-lane/edit.tsx} | 74 +++++++------------ .../js/pages/organization-member/index.tsx | 34 +-------- resources/js/pages/organization/show.tsx | 23 +----- routes/web.php | 13 ++-- .../OrganizationLaneControllerTest.php | 24 ++++++ 9 files changed, 185 insertions(+), 109 deletions(-) create mode 100644 app/Http/Requests/ShowOrganizationLaneRequest.php create mode 100644 resources/js/layouts/organization-settings/layout.tsx rename resources/js/{components/organization-lane-dialog.tsx => pages/organization-lane/edit.tsx} (66%) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 2930d5659..2029e9f70 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -116,13 +116,13 @@ The **Mentions** tab lists the commits and replies that mention the organization A member of an organization can **Claim** its page, which lists them as its admin. Only the first member to claim it becomes its admin: once a page has one, it can't be claimed again, and the Claim button is gone. 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. -After that, members are managed on /dev/null, not mirrored from GitHub. An organization's members open its **Settings** from its page, where **Members** lists everyone, each an **admin** or a **member**. Admins add people by their /dev/null handle, except people they blocked or who blocked them, make them admins or members, and remove them. Any member can leave. Its last admin can't leave or become a member. Only its members see who its members are; its page lists its admins to everyone. Your organizations and your role in each are in your data export and go when you delete your account. +After that, members are managed on /dev/null, not mirrored from GitHub. An organization's members open its **Settings** from its page, where the **Members** tab lists everyone, each an **admin** or a **member**. Admins add people by their /dev/null handle, except people they blocked or who blocked them, make them admins or members, and remove them. Any member can leave. Its last admin can't leave or become a member. Only its members see who its members are; its page lists its admins to everyone. Your organizations and your role in each are in your data export and go when you delete your account. -Every organization has a color, /dev/null's blue until its admin chooses another with **Color** on its page: one of the same six lane colors as developers, or a custom one. It rings the organization's avatar, leads its handle, and colors its link preview. +Every organization has a color, /dev/null's blue until an admin chooses another in the **Color** tab of its **Settings**: one of the same six lane colors as developers, or a custom one. It rings the organization's avatar, leads its handle, and colors its link preview. 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), [claims](app/Http/Controllers/OrganizationAdminController.php), [color](resources/js/components/organization-lane-dialog.tsx), [members](app/Http/Controllers/OrganizationMemberController.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), [color](app/Http/Controllers/OrganizationLaneController.php), [members](app/Http/Controllers/OrganizationMemberController.php). ### Going live diff --git a/app/Http/Controllers/OrganizationLaneController.php b/app/Http/Controllers/OrganizationLaneController.php index 81a989271..fa8fc1be8 100644 --- a/app/Http/Controllers/OrganizationLaneController.php +++ b/app/Http/Controllers/OrganizationLaneController.php @@ -5,13 +5,28 @@ namespace App\Http\Controllers; use App\Actions\UpdateOrganizationLane; +use App\Enums\OrganizationRole; +use App\Http\Requests\ShowOrganizationLaneRequest; use App\Http\Requests\UpdateOrganizationLaneRequest; use App\Models\Profile; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; +use Inertia\Response; +/** + * An organization's color, in its settings. Only its admins choose it. + */ final readonly class OrganizationLaneController { + public function edit(ShowOrganizationLaneRequest $request, Profile $organization): Response + { + return Inertia::render('organization-lane/edit', [ + 'organization' => $organization->makeVisible('id'), + 'role' => OrganizationRole::Admin, + 'lane' => $organization->lane(), + ]); + } + public function update(UpdateOrganizationLaneRequest $request, Profile $organization, UpdateOrganizationLane $action): RedirectResponse { $action->handle($organization, $request->lane(), $request->color()); diff --git a/app/Http/Requests/ShowOrganizationLaneRequest.php b/app/Http/Requests/ShowOrganizationLaneRequest.php new file mode 100644 index 000000000..df7e1e340 --- /dev/null +++ b/app/Http/Requests/ShowOrganizationLaneRequest.php @@ -0,0 +1,36 @@ +user(); + assert($user instanceof User); + + $organization = $this->route('organization'); + assert($organization instanceof Profile); + + return $organization->roleOf($user) === OrganizationRole::Admin ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/resources/js/layouts/organization-settings/layout.tsx b/resources/js/layouts/organization-settings/layout.tsx new file mode 100644 index 000000000..bf623c559 --- /dev/null +++ b/resources/js/layouts/organization-settings/layout.tsx @@ -0,0 +1,69 @@ +import { Link, usePage } from '@inertiajs/react'; +import type { PropsWithChildren } from 'react'; +import OrganizationLaneController from '@/actions/App/Http/Controllers/OrganizationLaneController'; +import OrganizationMemberController from '@/actions/App/Http/Controllers/OrganizationMemberController'; +import { show } from '@/actions/App/Http/Controllers/UserController'; +import { OrganizationAvatar } from '@/components/organization-avatar'; +import { PageTabs } from '@/components/page-tabs'; +import { useCurrentUrl } from '@/hooks/use-current-url'; +import type { Organization } from '@/types'; + +/** + * An organization's settings, like the user's. Every member sees its members; only admins choose its color. + */ +export default function OrganizationSettingsLayout({ + children, +}: PropsWithChildren) { + const { organization, role } = usePage<{ + organization: Organization & { id: string }; + role: 'admin' | 'member'; + }>().props; + const { isCurrentUrl } = useCurrentUrl(); + + const tabs = [ + { + title: 'Members', + href: OrganizationMemberController.index(organization.id), + }, + ...(role === 'admin' + ? [ + { + title: 'Color', + href: OrganizationLaneController.edit(organization.id), + }, + ] + : []), + ]; + + return ( + <> +
    +
    +

    Settings

    + + + @{organization.handle} + +
    + ({ + ...tab, + isActive: isCurrentUrl(tab.href), + }))} + /> +
    + +
    + {children} +
    + + ); +} diff --git a/resources/js/components/organization-lane-dialog.tsx b/resources/js/pages/organization-lane/edit.tsx similarity index 66% rename from resources/js/components/organization-lane-dialog.tsx rename to resources/js/pages/organization-lane/edit.tsx index fc9efcb1b..493d3292b 100644 --- a/resources/js/components/organization-lane-dialog.tsx +++ b/resources/js/pages/organization-lane/edit.tsx @@ -1,52 +1,35 @@ -import { Form, router } from '@inertiajs/react'; +import { Form, Head, router } from '@inertiajs/react'; import type { CSSProperties } from 'react'; import { useState } from 'react'; import OrganizationLaneController from '@/actions/App/Http/Controllers/OrganizationLaneController'; +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 { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogTitle, -} from '@/components/ui/dialog'; +import AppLayout from '@/layouts/app-layout'; +import OrganizationSettingsLayout from '@/layouts/organization-settings/layout'; import { organizationLaneColor, withViewTransition } from '@/lib/utils'; import type { Lane, Organization } from '@/types'; -export function OrganizationLaneDialog({ +export default function Edit({ organization, lane, - open, - onOpenChange, }: { organization: Organization & { id: string }; lane: Lane | null; - open: boolean; - onOpenChange: (open: boolean) => void; }) { const [selected, setSelected] = useState(lane); return ( - { - onOpenChange(isOpen); + <> + - if (!isOpen) { - setSelected(lane); - } - }} - > - - Color - - @{organization.handle}'s lane: the ring around its avatar - and the dot before its handle. - +
    +
    { - onOpenChange(false); - router.flushAll(); - }} + onSuccess={() => router.flushAll()} + className="mt-5" > {({ processing, errors }) => ( <> @@ -89,6 +70,7 @@ export function OrganizationLaneDialog({ selected={selected} automaticLane={null} onSelect={(choice) => { + // Dragging through the custom color picker shouldn't animate every step... if ( typeof choice === 'string' && typeof selected === 'string' @@ -109,22 +91,20 @@ export function OrganizationLaneDialog({ className="mt-2" /> - - - - - - + )} - -
    + + ); } + +Edit.layout = [AppLayout, OrganizationSettingsLayout]; diff --git a/resources/js/pages/organization-member/index.tsx b/resources/js/pages/organization-member/index.tsx index 488dd99d7..9c265f0ae 100644 --- a/resources/js/pages/organization-member/index.tsx +++ b/resources/js/pages/organization-member/index.tsx @@ -3,8 +3,6 @@ import { useRef, useState } from 'react'; import { toast } from 'sonner'; import OrganizationMemberController from '@/actions/App/Http/Controllers/OrganizationMemberController'; import { show } from '@/actions/App/Http/Controllers/UserController'; -import { OrganizationAvatar } from '@/components/organization-avatar'; -import { PageTabs } from '@/components/page-tabs'; import { Button } from '@/components/ui/button'; import { Select, @@ -17,6 +15,7 @@ import { UserAvatar } from '@/components/user-avatar'; import { useNow } from '@/hooks/use-now'; import { useUser } from '@/hooks/use-user'; import AppLayout from '@/layouts/app-layout'; +import OrganizationSettingsLayout from '@/layouts/organization-settings/layout'; import { cn, formatFullDateTime, formatRelativeTime } from '@/lib/utils'; import type { Organization, User } from '@/types'; @@ -59,35 +58,6 @@ export default function Index({ <> -
    -
    -

    Settings

    - - - @{organization.handle} - -
    - -
    -

    admin.id === auth.user?.id); - const [isLaneDialogOpen, setIsLaneDialogOpen] = useState(false); return ( <> @@ -77,24 +74,6 @@ export default function Show({ />
    - {isAdmin && ( - <> - - - - )} {canClaim && (