Open dsebastien opened 2 months ago
Show team: Pages/Teams/Show.tsx
import DeleteTeamForm from '@/Pages/Teams/Partials/DeleteTeamForm';
import TeamMemberManager from '@/Pages/Teams/Partials/TeamMemberManager';
import UpdateTeamNameForm from '@/Pages/Teams/Partials/UpdateTeamNameForm';
import SectionBorder from '@/Components/SectionBorder';
import AppLayout from '@/Layouts/AppLayout';
import { JetstreamTeamPermissions, Role, Team, TeamInvitation, User } from '@/types';
import React from 'react';
interface UserMembership extends User {
membership: {
role: string;
};
}
interface Props {
team: Team & {
owner: User;
team_invitations: TeamInvitation[];
users: UserMembership[];
};
availableRoles: Role[];
permissions: JetstreamTeamPermissions;
}
export default function Show({ team, availableRoles, permissions }: Props) {
return (
<AppLayout
title="Team Settings"
renderHeader={() => <h2 className="font-semibold text-xl text-gray-800 leading-tight">Team Settings</h2>}
>
<div>
<div className="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
<UpdateTeamNameForm team={team} permissions={permissions} />
<div className="mt-10 sm:mt-0">
<TeamMemberManager team={team} availableRoles={availableRoles} userPermissions={permissions} />
</div>
{permissions.canDeleteTeam && !team.personal_team ? (
<>
<SectionBorder />
<div className="mt-10 sm:mt-0">
<DeleteTeamForm team={team} />
</div>
</>
) : null}
</div>
</div>
</AppLayout>
);
}
Delete team: Pages/Teams/Partials/DeleteTeamForm.tsx
import useRoute from '@/Hooks/useRoute';
import ActionSection from '@/Components/ActionSection';
import ConfirmationModal from '@/Components/ConfirmationModal';
import DangerButton from '@/Components/DangerButton';
import SecondaryButton from '@/Components/SecondaryButton';
import { Team } from '@/types';
import { useForm } from '@inertiajs/react';
import classNames from 'classnames';
import React, { useState } from 'react';
interface Props {
team: Team;
}
export default function DeleteTeamForm({ team }: Props) {
const route = useRoute();
const [confirmingTeamDeletion, setConfirmingTeamDeletion] = useState(false);
const form = useForm({});
function confirmTeamDeletion() {
setConfirmingTeamDeletion(true);
}
function deleteTeam() {
form.delete(route('teams.destroy', [team]), {
errorBag: 'deleteTeam',
});
}
return (
<ActionSection title={'Delete Team'} description={'Permanently delete this team.'}>
<div className="max-w-xl text-sm text-gray-600">
Once a team is deleted, all of its resources and data will be permanently deleted. Before deleting this team, please download any
data or information regarding this team that you wish to retain.
</div>
<div className="mt-5">
<DangerButton onClick={confirmTeamDeletion}>Delete Team</DangerButton>
</div>
{/* <!-- Delete Team Confirmation Modal --> */}
<ConfirmationModal isOpen={confirmingTeamDeletion} onClose={() => setConfirmingTeamDeletion(false)}>
<ConfirmationModal.Content title={'Delete Team'}>
Are you sure you want to delete this team? Once a team is deleted, all of its resources and data will be permanently deleted.
</ConfirmationModal.Content>
<ConfirmationModal.Footer>
<SecondaryButton onClick={() => setConfirmingTeamDeletion(false)}>Cancel</SecondaryButton>
<DangerButton onClick={deleteTeam} className={classNames('ml-2', { 'opacity-25': form.processing })} disabled={form.processing}>
Delete Team
</DangerButton>
</ConfirmationModal.Footer>
</ConfirmationModal>
</ActionSection>
);
}
Manage team members: Pages/Teams/Partials/DeleteTeamForm.tsx
import useRoute from '@/Hooks/useRoute';
import useTypedPage from '@/Hooks/useTypedPage';
import ActionMessage from '@/Components/ActionMessage';
import ActionSection from '@/Components/ActionSection';
import ConfirmationModal from '@/Components/ConfirmationModal';
import DangerButton from '@/Components/DangerButton';
import DialogModal from '@/Components/DialogModal';
import FormSection from '@/Components/FormSection';
import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import PrimaryButton from '@/Components/PrimaryButton';
import TextInput from '@/Components/TextInput';
import SecondaryButton from '@/Components/SecondaryButton';
import SectionBorder from '@/Components/SectionBorder';
import { JetstreamTeamPermissions, Nullable, Role, Team, TeamInvitation, User } from '@/types';
import { router } from '@inertiajs/core';
import { useForm } from '@inertiajs/react';
import classNames from 'classnames';
import React, { useState } from 'react';
interface UserMembership extends User {
membership: {
role: string;
};
}
interface Props {
team: Team & {
team_invitations: TeamInvitation[];
users: UserMembership[];
};
availableRoles: Role[];
userPermissions: JetstreamTeamPermissions;
}
export default function TeamMemberManager({ team, availableRoles, userPermissions }: Props) {
const route = useRoute();
const addTeamMemberForm = useForm({
email: '',
role: null as Nullable<string>,
});
const updateRoleForm = useForm({
role: null as Nullable<string>,
});
const leaveTeamForm = useForm({});
const removeTeamMemberForm = useForm({});
const [currentlyManagingRole, setCurrentlyManagingRole] = useState(false);
const [managingRoleFor, setManagingRoleFor] = useState<Nullable<User>>(null);
const [confirmingLeavingTeam, setConfirmingLeavingTeam] = useState(false);
const [teamMemberBeingRemoved, setTeamMemberBeingRemoved] = useState<Nullable<User>>(null);
const page = useTypedPage();
function addTeamMember() {
addTeamMemberForm.post(route('team-members.store', [team]), {
errorBag: 'addTeamMember',
preserveScroll: true,
onSuccess: () => addTeamMemberForm.reset(),
});
}
function cancelTeamInvitation(invitation: TeamInvitation) {
router.delete(route('team-invitations.destroy', [invitation]), {
preserveScroll: true,
});
}
function manageRole(teamMember: UserMembership) {
setManagingRoleFor(teamMember);
updateRoleForm.setData('role', teamMember.membership.role);
setCurrentlyManagingRole(true);
}
function updateRole() {
if (!managingRoleFor) {
return;
}
updateRoleForm.put(route('team-members.update', [team, managingRoleFor]), {
preserveScroll: true,
onSuccess: () => setCurrentlyManagingRole(false),
});
}
function confirmLeavingTeam() {
setConfirmingLeavingTeam(true);
}
function leaveTeam() {
leaveTeamForm.delete(route('team-members.destroy', [team, page.props.auth.user!]));
}
function confirmTeamMemberRemoval(teamMember: User) {
setTeamMemberBeingRemoved(teamMember);
}
function removeTeamMember() {
if (!teamMemberBeingRemoved) {
return;
}
removeTeamMemberForm.delete(route('team-members.destroy', [team, teamMemberBeingRemoved]), {
errorBag: 'removeTeamMember',
preserveScroll: true,
preserveState: true,
onSuccess: () => setTeamMemberBeingRemoved(null),
});
}
function displayableRole(role: string) {
return availableRoles.find((r) => r.key === role)?.name;
}
return (
<div>
{userPermissions.canAddTeamMembers ? (
<div>
<SectionBorder />
{/* <!-- Add Team Member --> */}
<FormSection
onSubmit={addTeamMember}
title={'Add Team Member'}
description={'Add a new team member to your team, allowing them to collaborate with you.'}
renderActions={() => (
<>
<ActionMessage on={addTeamMemberForm.recentlySuccessful} className="mr-3">
Added.
</ActionMessage>
<PrimaryButton
className={classNames({
'opacity-25': addTeamMemberForm.processing,
})}
disabled={addTeamMemberForm.processing}
>
Add
</PrimaryButton>
</>
)}
>
<div className="col-span-6">
<div className="max-w-xl text-sm text-gray-600">
Please provide the email address of the person you would like to add to this team.
</div>
</div>
{/* <!-- Member Email --> */}
<div className="col-span-6 sm:col-span-4">
<InputLabel htmlFor="email" value="Email" />
<TextInput
id="email"
type="email"
className="mt-1 block w-full"
value={addTeamMemberForm.data.email}
onChange={(e) => addTeamMemberForm.setData('email', e.currentTarget.value)}
/>
<InputError message={addTeamMemberForm.errors.email} className="mt-2" />
</div>
{/* <!-- Role --> */}
{availableRoles.length > 0 ? (
<div className="col-span-6 lg:col-span-4">
<InputLabel htmlFor="roles" value="Role" />
<InputError message={addTeamMemberForm.errors.role} className="mt-2" />
<div className="relative z-0 mt-1 border border-gray-200 rounded-lg cursor-pointer">
{availableRoles.map((role, i) => (
<button
type="button"
className={classNames(
'relative px-4 py-3 inline-flex w-full rounded-lg focus:z-10 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500',
{
'border-t border-gray-200 focus:border-none rounded-t-none': i > 0,
'rounded-b-none': i != Object.keys(availableRoles).length - 1,
},
)}
onClick={() => addTeamMemberForm.setData('role', role.key)}
key={role.key}
>
<div
className={classNames({
'opacity-50': addTeamMemberForm.data.role && addTeamMemberForm.data.role != role.key,
})}
>
{/* <!-- Role Name --> */}
<div className="flex items-center">
<div
className={classNames('text-sm text-gray-600', {
'font-semibold': addTeamMemberForm.data.role == role.key,
})}
>
{role.name}
</div>
{addTeamMemberForm.data.role == role.key ? (
<svg
className="ml-2 h-5 w-5 text-green-400"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
) : null}
</div>
{/* <!-- Role Description --> */}
<div className="mt-2 text-xs text-gray-600">{role.description}</div>
</div>
</button>
))}
</div>
</div>
) : null}
</FormSection>
</div>
) : null}
{team.team_invitations.length > 0 && userPermissions.canAddTeamMembers ? (
<div>
<SectionBorder />
{/* <!-- Team Member Invitations --> */}
<div className="mt-10 sm:mt-0" />
<ActionSection
title={'Pending Team Invitations'}
description={
'These people have been invited to your team and have been sent an invitation email. They may join the team by accepting the email invitation.'
}
>
{/* <!-- Pending Team Member Invitation List --> */}
<div className="space-y-6">
{team.team_invitations.map((invitation) => (
<div className="flex items-center justify-between" key={invitation.id}>
<div className="text-gray-600">{invitation.email}</div>
<div className="flex items-center">
{/* <!-- Cancel Team Invitation --> */}
{userPermissions.canRemoveTeamMembers ? (
<button
className="cursor-pointer ml-6 text-sm text-red-500 focus:outline-none"
onClick={() => cancelTeamInvitation(invitation)}
>
Cancel
</button>
) : null}
</div>
</div>
))}
</div>
</ActionSection>
</div>
) : null}
{team.users.length > 0 ? (
<div>
<SectionBorder />
{/* <!-- Manage Team Members --> */}
<div className="mt-10 sm:mt-0" />
<ActionSection title={'Team Members'} description={'All of the people that are part of this team.'}>
{/* <!-- Team Member List --> */}
<div className="space-y-6">
{team.users.map((user) => (
<div className="flex items-center justify-between" key={user.id}>
<div className="flex items-center">
<img className="w-8 h-8 rounded-full" src={user.profile_photo_url} alt={user.name} />
<div className="ml-4">{user.name}</div>
</div>
<div className="flex items-center">
{/* <!-- Manage Team Member Role --> */}
{userPermissions.canAddTeamMembers && availableRoles.length ? (
<button className="ml-2 text-sm text-gray-400 underline" onClick={() => manageRole(user)}>
{displayableRole(user.membership.role)}
</button>
) : availableRoles.length ? (
<div className="ml-2 text-sm text-gray-400">{displayableRole(user.membership.role)}</div>
) : null}
{/* <!-- Leave Team --> */}
{page.props.auth.user?.id === user.id ? (
<button className="cursor-pointer ml-6 text-sm text-red-500" onClick={confirmLeavingTeam}>
Leave
</button>
) : null}
{/* <!-- Remove Team Member --> */}
{userPermissions.canRemoveTeamMembers ? (
<button className="cursor-pointer ml-6 text-sm text-red-500" onClick={() => confirmTeamMemberRemoval(user)}>
Remove
</button>
) : null}
</div>
</div>
))}
</div>
</ActionSection>
</div>
) : null}
{/* <!-- Role Management Modal --> */}
<DialogModal isOpen={currentlyManagingRole} onClose={() => setCurrentlyManagingRole(false)}>
<DialogModal.Content title={'Manage Role'}></DialogModal.Content>
{managingRoleFor ? (
<div>
<div className="relative z-0 mt-1 border border-gray-200 rounded-lg cursor-pointer">
{availableRoles.map((role, i) => (
<button
type="button"
className={classNames(
'relative px-4 py-3 inline-flex w-full rounded-lg focus:z-10 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500',
{
'border-t border-gray-200 focus:border-none rounded-t-none': i > 0,
'rounded-b-none': i !== Object.keys(availableRoles).length - 1,
},
)}
onClick={() => updateRoleForm.setData('role', role.key)}
key={role.key}
>
<div
className={classNames({
'opacity-50': updateRoleForm.data.role && updateRoleForm.data.role !== role.key,
})}
>
{/* <!-- Role Name --> */}
<div className="flex items-center">
<div
className={classNames('text-sm text-gray-600', {
'font-semibold': updateRoleForm.data.role === role.key,
})}
>
{role.name}
</div>
{updateRoleForm.data.role === role.key ? (
<svg
className="ml-2 h-5 w-5 text-green-400"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
) : null}
</div>
{/* <!-- Role Description --> */}
<div className="mt-2 text-xs text-gray-600">{role.description}</div>
</div>
</button>
))}
</div>
</div>
) : null}
<DialogModal.Footer>
<SecondaryButton onClick={() => setCurrentlyManagingRole(false)}>Cancel</SecondaryButton>
<PrimaryButton
onClick={updateRole}
className={classNames('ml-2', {
'opacity-25': updateRoleForm.processing,
})}
disabled={updateRoleForm.processing}
>
Save
</PrimaryButton>
</DialogModal.Footer>
</DialogModal>
{/* <!-- Leave Team Confirmation Modal --> */}
<ConfirmationModal isOpen={confirmingLeavingTeam} onClose={() => setConfirmingLeavingTeam(false)}>
<ConfirmationModal.Content title={'Leave Team'}>Are you sure you would like to leave this team?</ConfirmationModal.Content>
<ConfirmationModal.Footer>
<SecondaryButton onClick={() => setConfirmingLeavingTeam(false)}>Cancel</SecondaryButton>
<DangerButton
onClick={leaveTeam}
className={classNames('ml-2', {
'opacity-25': leaveTeamForm.processing,
})}
disabled={leaveTeamForm.processing}
>
Leave
</DangerButton>
</ConfirmationModal.Footer>
</ConfirmationModal>
{/* <!-- Remove Team Member Confirmation Modal --> */}
<ConfirmationModal isOpen={!!teamMemberBeingRemoved} onClose={() => setTeamMemberBeingRemoved(null)}>
<ConfirmationModal.Content title={'Remove Team Member'}>
Are you sure you would like to remove this person from the team?
</ConfirmationModal.Content>
<ConfirmationModal.Footer>
<SecondaryButton onClick={() => setTeamMemberBeingRemoved(null)}>Cancel</SecondaryButton>
<DangerButton
onClick={removeTeamMember}
className={classNames('ml-2', {
'opacity-25': removeTeamMemberForm.processing,
})}
disabled={removeTeamMemberForm.processing}
>
Remove
</DangerButton>
</ConfirmationModal.Footer>
</ConfirmationModal>
</div>
);
}
Update team name: Pages/Teams/Partials/UpdateTeamNameForm.tsx
import useRoute from '@/Hooks/useRoute';
import ActionMessage from '@/Components/ActionMessage';
import FormSection from '@/Components/FormSection';
import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import PrimaryButton from '@/Components/PrimaryButton';
import TextInput from '@/Components/TextInput';
import { JetstreamTeamPermissions, Team, User } from '@/types';
import { useForm } from '@inertiajs/react';
import classNames from 'classnames';
import React from 'react';
interface Props {
team: Team & { owner: User };
permissions: JetstreamTeamPermissions;
}
export default function UpdateTeamNameForm({ team, permissions }: Props) {
const route = useRoute();
const form = useForm({
name: team.name,
});
function updateTeamName() {
form.put(route('teams.update', [team]), {
errorBag: 'updateTeamName',
preserveScroll: true,
});
}
return (
<FormSection
onSubmit={updateTeamName}
title={'Team Name'}
description={`The team's name and owner information.`}
renderActions={
permissions.canUpdateTeam
? () => (
<>
<ActionMessage on={form.recentlySuccessful} className="mr-3">
Saved.
</ActionMessage>
<PrimaryButton className={classNames({ 'opacity-25': form.processing })} disabled={form.processing}>
Save
</PrimaryButton>
</>
)
: undefined
}
>
{/* <!-- Team Owner Information --> */}
<div className="col-span-6">
<InputLabel value="Team Owner" />
<div className="flex items-center mt-2">
<img className="w-12 h-12 rounded-full object-cover" src={team.owner.profile_photo_url} alt={team.owner.name} />
<div className="ml-4 leading-tight">
<div className="text-gray-900">{team.owner.name}</div>
<div className="text-gray-700 text-sm">{team.owner.email}</div>
</div>
</div>
</div>
{/* <!-- Team Name --> */}
<div className="col-span-6 sm:col-span-4">
<InputLabel htmlFor="name" value="Team Name" />
<TextInput
id="name"
type="text"
className="mt-1 block w-full"
value={form.data.name}
onChange={(e) => form.setData('name', e.currentTarget.value)}
disabled={!permissions.canUpdateTeam}
/>
<InputError message={form.errors.name} className="mt-2" />
</div>
</FormSection>
);
}
Old routes:
Route::post('/communities/{community}/members', [CommunityMemberController::class, 'store'])->name('community-members.store');
Route::put('/communities/{community}/members/{user}', [CommunityMemberController::class, 'update'])->name('community-members.update');
Route::delete('/communities/{community}/members/{user}', [CommunityMemberController::class, 'destroy'])->name('community-members.destroy');
Route::get('/community-invitations/{invitation}', [CommunityInvitationController::class, 'accept'])
->middleware(['signed'])
->name('community-invitations.accept');
Route::delete('/community-invitations/{invitation}', [CommunityInvitationController::class, 'destroy'])
->name('community-invitations.destroy')
Community member controller:
<?php
namespace App\Http\Controllers\Inertia;
use App\Actions\Communities\UpdateCommunityMemberRole;
use App\Contracts\Communities\InvitesCommunityMembers;
use App\Contracts\Communities\RemovesCommunityMembers;
use App\Knowii;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Laravel\Jetstream\Jetstream;
class CommunityMemberController extends Controller
{
/**
* Add a new member to a community.
*
* @param \Illuminate\Http\Request $request
* @param int $communityId
* @return \Illuminate\Http\RedirectResponse
*/
public function store(Request $request, $communityId)
{
$community = Knowii::newCommunityModel()->findOrFail($communityId);
app(InvitesCommunityMembers::class)->invite(
$request->user(),
$community,
$request->email ?: '',
$request->role
);
return back(303);
}
/**
* Update the given community member's role.
*
* @param \Illuminate\Http\Request $request
* @param int $communityId
* @param int $userId
* @return \Illuminate\Http\RedirectResponse
*/
public function update(Request $request, $communityId, $userId)
{
app(UpdateCommunityMemberRole::class)->update(
$request->user(),
Knowii::newCommunityModel()->findOrFail($communityId),
$userId,
$request->role
);
return back(303);
}
/**
* Remove the given user from the given community.
*
* @param \Illuminate\Http\Request $request
* @param int $communityId
* @param int $userId
* @return \Illuminate\Http\RedirectResponse
*/
public function destroy(Request $request, $communityId, $userId)
{
$community = Knowii::newCommunityModel()->findOrFail($communityId);
app(RemovesCommunityMembers::class)->remove(
$request->user(),
$community,
$user = Jetstream::findUserByIdOrFail($userId)
);
if ($request->user()->id === $user->id) {
return redirect(config('fortify.home'));
}
return back(303);
}
}
Community invitation controller:
<?php
namespace App\Http\Controllers;
use App\Contracts\Communities\AddsCommunityMembers;
use App\Knowii;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Gate;
class CommunityInvitationController extends Controller
{
/**
* Accept a community invitation.
*
* @param \Illuminate\Http\Request $request
* @param int $invitationId
* @return \Illuminate\Http\RedirectResponse
*/
public function accept(Request $request, int $invitationId)
{
$model = Knowii::communityInvitationModel();
$invitation = $model::whereKey($invitationId)->firstOrFail();
app(AddsCommunityMembers::class)->add(
$invitation->community->owner,
$invitation->community,
$invitation->email,
$invitation->role
);
$invitation->delete();
return redirect(config('fortify.home'))->banner(
__('Great! You have accepted the invitation to join the following community: :community.', ['community' => $invitation->community->community]),
);
}
/**
* Cancel the given community invitation.
*
* @param \Illuminate\Http\Request $request
* @param int $invitationId
* @return \Illuminate\Http\RedirectResponse
*/
public function destroy(Request $request, $invitationId)
{
$model = Knowii::communityInvitationModel();
$invitation = $model::whereKey($invitationId)->firstOrFail();
if (! Gate::forUser($request->user())->check('removeCommunityMember', $invitation->community)) {
throw new AuthorizationException;
}
$invitation->delete();
return back(303);
}
}
AppServiceProvider entries:
app()->singleton(UpdatesCommunityNames::class, UpdateCommunityName::class);
app()->singleton(AddsCommunityMembers::class, AddCommunityMember::class);
app()->singleton(InvitesCommunityMembers::class, InviteCommunityMember::class);
app()->singleton(RemovesCommunityMembers::class, RemoveCommunityMember::class);
app()->singleton(DeletesCommunities::class, DeleteCommunity::class);
AddCommunityMember:
<?php
namespace App\Actions\Communities;
use App\Contracts\Communities\AddsCommunityMembers;
use App\Events\Communities\AddingCommunityMember;
use App\Events\Communities\CommunityMemberAdded;
use App\Models\Community;
use App\Models\User;
use Closure;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\Rules\Role;
class AddCommunityMember implements AddsCommunityMembers
{
/**
* Add a new member to the given community.
*/
public function add(User $user, Community $community, string $email, ?string $role = null): void
{
Gate::forUser($user)->authorize('addCommunityMember', $community);
$this->validate($community, $email, $role);
$newCommunityMember = Jetstream::findUserByEmailOrFail($email);
AddingCommunityMember::dispatch($community, $newCommunityMember);
$community->users()->attach(
$newCommunityMember, ['role' => $role]
);
CommunityMemberAdded::dispatch($community, $newCommunityMember);
}
/**
* Validate the add member operation.
*/
protected function validate(Community $community, string $email, ?string $role): void
{
Validator::make([
'email' => $email,
'role' => $role,
], $this->rules(), [
'email.exists' => __('We were unable to find a registered user with this email address.'),
])->after(
$this->ensureUserIsNotAlreadyInCommunity($community, $email)
);
}
/**
* Get the validation rules for adding a community member.
*
* @return array<string, Rule|array|string>
*/
protected function rules(): array
{
return array_filter([
'email' => ['required', 'email', 'exists:users'],
'role' => Jetstream::hasRoles()
? ['required', 'string', new Role]
: null,
]);
}
/**
* Ensure that the user is not already in the community.
*/
protected function ensureUserIsNotAlreadyInCommunity(Community $community, string $email): Closure
{
return function ($validator) use ($community, $email) {
$validator->errors()->addIf(
$community->hasUserWithEmail($email),
'email',
__('This user already belongs to the community.')
);
};
}
}
Tests:
InviteCommunityMember action:
<?php
namespace App\Actions\Communities;
use App\Contracts\Communities\InvitesCommunityMembers;
use App\Events\Communities\InvitingCommunityMember;
use App\Knowii;
use App\Mail\CommunityInvitation;
use App\Models\Community;
use App\Models\User;
use Closure;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\Rules\Role;
class InviteCommunityMember implements InvitesCommunityMembers
{
/**
* Invite a new member to the given community.
*/
final public function invite(User $user, Community $community, string $email, ?string $role = null): void
{
Gate::forUser($user)->authorize('addCommunityMember', $community);
$this->validate($community, $email, $role);
InvitingCommunityMember::dispatch($community, $email, $role);
$invitation = $community->communityInvitations()->create([
'email' => $email,
'role' => $role,
]);
Mail::to($email)->send(new CommunityInvitation($invitation));
}
/**
* Validate the invite member operation.
*/
final protected function validate(Community $community, string $email, ?string $role): void
{
Validator::make([
'email' => $email,
'role' => $role,
], $this->rules($community), [
'email.unique' => __('This user has already been invited to the community.'),
])->after(
$this->ensureUserIsNotAlreadyInCommunity($community, $email)
);
}
/**
* Get the validation rules for inviting a community member.
*
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
*/
final protected function rules(Community $community): array
{
return array_filter([
'email' => [
'required', 'email',
Rule::unique(Knowii::communityInvitationModel())->where(function (Builder $query) use ($community) {
$query->where('community_id', $community->id);
}),
],
'role' => Jetstream::hasRoles()
? ['required', 'string', new Role]
: null,
]);
}
/**
* Ensure that the user is not already in the community.
*/
final protected function ensureUserIsNotAlreadyInCommunity(Community $community, string $email): Closure
{
return function ($validator) use ($community, $email) {
$validator->errors()->addIf(
$community->hasUserWithEmail($email),
'email',
__('This user already belongs to the community.')
);
};
}
}
RemoveCommunityMember action:
<?php
namespace App\Actions\Communities;
use App\Contracts\Communities\RemovesCommunityMembers;
use App\Events\Communities\CommunityMemberRemoved;
use App\Models\Community;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
class RemoveCommunityMember implements RemovesCommunityMembers
{
/**
* Remove the member.
*/
final public function remove(User $user, Community $community, User $communityMember): void
{
$this->authorize($user, $community, $communityMember);
$this->ensureUserDoesNotOwnCommunity($communityMember, $community);
$community->removeUser($communityMember);
CommunityMemberRemoved::dispatch($community, $communityMember);
}
/**
* Authorize that the user can remove the member.
*/
final protected function authorize(User $user, Community $community, User $communityMember): void
{
if (!Gate::forUser($user)->check('removeCommunityMember', $community) &&
$user->id !== $communityMember->id) {
throw new AuthorizationException;
}
}
/**
* Ensure that the currently authenticated user does not own the community.
*/
final protected function ensureUserDoesNotOwnCommunity(User $communityMember, Community $community): void
{
if ($communityMember->id === $community->owner->id) {
throw ValidationException::withMessages([
'community' => [__('You may not leave a community that you created.')],
])->errorBag('removeCommunityMember');
}
}
}
UpdateCommunityMemberRole action:
<?php
namespace App\Actions\Communities;
use App\Events\Communities\CommunityMemberUpdated;
use App\Models\Community;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\Rules\Role;
class UpdateCommunityMemberRole
{
/**
* Update the role for the given member.
*
* @param User $user
* @param Community $community
* @param int $communityMemberId
* @param string $role
* @return void
*/
public function update(User $user, Community $community, int $communityMemberId, string $role): void
{
Gate::forUser($user)->authorize('updateCommunityMember', $community);
Validator::make([
'role' => $role,
], [
'role' => ['required', 'string', new Role],
])->validate();
$community->users()->updateExistingPivot($communityMemberId, [
'role' => $role,
]);
CommunityMemberUpdated::dispatch($community->fresh(), Jetstream::findUserByIdOrFail($communityMemberId));
}
}
UpdateCommunityName action:
<?php
namespace App\Actions\Communities;
use App\Constants;
use App\Contracts\Communities\UpdatesCommunityNames;
use App\Models\Community;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
class UpdateCommunityName implements UpdatesCommunityNames
{
/**
* Validate and update the given community's name.
*
* @param User $user
* @param Community $community
* @param array $input
*/
public function update(User $user, Community $community, array $input): void
{
Gate::forUser($user)->authorize('update', $community);
Validator::make($input, [
// WARNING: Those validation rules must match those in the community creation form in Dashboard.tsx and those in CreateCommunity.php
'name' => ['required', 'string', 'min:'.Constants::$MIN_LENGTH_COMMUNITY_NAME, 'max:'.Constants::$MAX_LENGTH_COMMUNITY_NAME, 'regex:'.Constants::$ALLOWED_COMMUNITY_NAME_CHARACTERS_REGEX],
]);
$community->forceFill([
'name' => $input['name'],
])->save();
}
}
AddsCommunityMembers contract:
<?php
namespace App\Contracts\Communities;
use App\Models\Community;
/**
* @method void add(\Illuminate\Foundation\Auth\User $user, Community $community, string $email, string $role = null)
*/
interface AddsCommunityMembers
{
}
InvitesCommunityMembers contract:
<?php
namespace App\Contracts\Communities;
/**
* @method void invite(\Illuminate\Foundation\Auth\User $user, \Illuminate\Database\Eloquent\Model $community, string $email, string $role = null)
*/
interface InvitesCommunityMembers
{
//
}
RemovesCommunityMembers contract:
<?php
namespace App\Contracts\Communities;
/**
* @method void remove(\Illuminate\Foundation\Auth\User $user, \Illuminate\Database\Eloquent\Model $community, \Illuminate\Foundation\Auth\User $communityMember)
*/
interface RemovesCommunityMembers
{
//
}
UpdatesCommunityNames contract:
<?php
namespace App\Contracts\Communities;
/**
* @method void update(\Illuminate\Foundation\Auth\User $user, \Illuminate\Database\Eloquent\Model $community, array $input)
*/
interface UpdatesCommunityNames
{
//
}
DeleteCommunityTest:
<?php
use App\Contracts\Communities\DeletesCommunities;
use App\KnowiiCommunityVisibility;
use App\Models\Community;
use App\Models\User;
use Illuminate\Validation\ValidationException;
test('communities can be deleted', function () {
$this->actingAs($user = User::factory()->withPersonalCommunity()->create());
$user->ownedCommunities()->save($community = Community::factory()->make([
'visibility' => KnowiiCommunityVisibility::Public,
]));
$community->users()->attach(
$otherUser = User::factory()->create(), ['role' => 'test-role']
);
$deleter = app(DeletesCommunities::class);
$deleter->delete($user, $community->cuid);
expect($community->fresh())->toBeNull();
expect($otherUser->fresh()->communities)->toHaveCount(0);
});
test('personal communities cannot be deleted', function () {
$this->actingAs($user = User::factory()->withPersonalCommunity()->create());
$this->expectException(ValidationException::class);
$deleter = app(DeletesCommunities::class);
$deleter->delete($user, $user->personalCommunity()->cuid);
expect($user->personalCommunity()->fresh())->not->toBeNull();
});
InviteCommunityMemberTest:
<?php
use App\Mail\CommunityInvitation;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
test('users can be invited to a community', function () {
Mail::fake();
$this->actingAs($user = User::factory()->withPersonalCommunity()->create());
$this->post('/communities/'.$user->personalCommunity()->id.'/members', [
'email' => 'test@example.com',
'role' => 'admin',
]);
Mail::assertSent(CommunityInvitation::class);
expect($user->personalCommunity()->communityInvitations)->toHaveCount(1);
});
test('community member invitations can be cancelled', function () {
Mail::fake();
$this->actingAs($user = User::factory()->withPersonalCommunity()->create());
$invitation = $user->personalCommunity()->communityInvitations()->create([
'email' => 'test@example.com',
'role' => 'admin',
]);
$this->delete('/community-invitations/'.$invitation->id);
expect($user->personalCommunity()->fresh()->communityInvitations)->toHaveCount(0);
});
LeaveCommunityTest:
<?php
use App\Models\User;
test('users can leave communities', function () {
$user = User::factory()->withPersonalCommunity()->create();
$user->personalCommunity()->users()->attach(
$otherUser = User::factory()->create(), ['role' => 'admin']
);
$this->actingAs($otherUser);
$this->delete('/communities/'.$user->personalCommunity()->id.'/members/'.$otherUser->id);
expect($user->personalCommunity()->fresh()->users)->toHaveCount(0);
});
test('community owners cannot leave their own community', function () {
$this->actingAs($user = User::factory()->withPersonalCommunity()->create());
$response = $this->delete('/communities/'.$user->personalCommunity()->id.'/members/'.$user->id);
$response->assertSessionHasErrorsIn('removeCommunityMember', ['community']);
expect($user->personalCommunity()->fresh())->not->toBeNull();
});
RemoveCommunityMemberTest:
<?php
use App\Models\User;
test('members can be removed from communities', function () {
$this->actingAs($user = User::factory()->withPersonalCommunity()->create());
$user->personalCommunity()->users()->attach(
$otherUser = User::factory()->create(), ['role' => 'admin']
);
$this->delete('/communities/'.$user->personalCommunity()->id.'/members/'.$otherUser->id);
expect($user->personalCommunity()->fresh()->users)->toHaveCount(0);
});
test('only community owner can remove members', function () {
$user = User::factory()->withPersonalCommunity()->create();
$user->personalCommunity()->users()->attach(
$otherUser = User::factory()->create(), ['role' => 'admin']
);
$this->actingAs($otherUser);
$response = $this->delete('/communities/'.$user->personalCommunity()->id.'/members/'.$user->id);
$response->assertStatus(403);
});
UpdateCommunityMemberRoleTest:
<?php
use App\Models\User;
test('community member roles can be updated', function () {
$this->actingAs($user = User::factory()->withPersonalCommunity()->create());
$user->personalCommunity()->users()->attach(
$otherUser = User::factory()->create(), ['role' => 'admin']
);
$this->put('/communities/'.$user->personalCommunity()->id.'/members/'.$otherUser->id, [
'role' => 'editor',
]);
expect($otherUser->fresh()->hasCommunityRole(
$user->personalCommunity()->fresh(), 'editor'
))->toBeTrue();
});
test('only community owner can update community member roles', function () {
$user = User::factory()->withPersonalCommunity()->create();
$user->personalCommunity()->users()->attach(
$otherUser = User::factory()->create(), ['role' => 'admin']
);
$this->actingAs($otherUser);
$this->put('/communities/'.$user->personalCommunity()->id.'/members/'.$otherUser->id, [
'role' => 'editor',
]);
expect($otherUser->fresh()->hasCommunityRole(
$user->personalCommunity()->fresh(), 'admin'
))->toBeTrue();
});
UpdateCommunityNameTest:
<?php
use App\Actions\Communities\UpdateCommunityName;
use App\Models\User;
test('community names can be updated', function () {
$this->actingAs($user = User::factory()->withPersonalCommunity()->create());
$input = [
'name' => 'Cool Community',
];
$updater = new UpdateCommunityName();
$updater->update($user, $user->personalCommunity(), $input);
expect($user->personalCommunity()->fresh()->name)->toEqual('Cool Community');
});
CommunityMemberAdded:
<?php
namespace App\Events\Communities;
use App\Models\Community;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
class CommunityMemberAdded
{
use Dispatchable;
/**
* The instance.
*
* @var mixed
*/
public Community $community;
/**
* The member that was added.
*
* @var User
*/
public User $user;
/**
* Create a new event instance.
*
* @param Community $community
* @param User $user
* @return void
*/
public function __construct(Community $community, User $user)
{
$this->community = $community;
$this->user = $user;
}
}
CommunityMemberRemoved:
<?php
namespace App\Events\Communities;
use App\Models\Community;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
class CommunityMemberRemoved
{
use Dispatchable;
/**
* The instance.
*
* @var Community
*/
public Community $community;
/**
* The member that was removed.
*
* @var User
*/
public User $user;
/**
* Create a new event instance.
*
* @param Community $community
* @param User $user
* @return void
*/
public function __construct(Community $community, User $user)
{
$this->community = $community;
$this->user = $user;
}
}
CommunityMemberUpdated:
<?php
namespace App\Events\Communities;
use App\Models\Community;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
class CommunityMemberUpdated
{
use Dispatchable;
/**
* The instance.
*
* @var Community
*/
public Community $community;
/**
* The member that was updated.
*
* @var User
*/
public User $user;
/**
* Create a new event instance.
*
* @param Community $community
* @param User $user
* @return void
*/
public function __construct(Community $community, User $user)
{
$this->community = $community;
$this->user = $user;
}
}
AddingCommunityMember:
<?php
namespace App\Events\Communities;
use App\Models\Community;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
class AddingCommunityMember
{
use Dispatchable;
/**
* The instance.
*
* @var Community
*/
public Community $community;
/**
* The member being added.
*
* @var User
*/
public User $user;
/**
* Create a new event instance.
*
* @param Community $community
* @param User $user
* @return void
*/
public function __construct(Community $community, User $user)
{
$this->community = $community;
$this->user = $user;
}
}
InvitingCommunityMember:
<?php
namespace App\Events\Communities;
use App\Models\Community;
use Illuminate\Foundation\Events\Dispatchable;
class InvitingCommunityMember
{
use Dispatchable;
/**
* The instance.
*
* @var Community
*/
public Community $community;
/**
* The email address of the invitee.
*
* @var mixed
*/
public mixed $email;
/**
* The role of the invitee.
*
* @var mixed
*/
public mixed $role;
/**
* Create a new event instance.
*
* @param Community $community
* @param mixed $email
* @param mixed $role
* @return void
*/
public function __construct(Community $community, mixed $email, mixed $role)
{
$this->community = $community;
$this->email = $email;
$this->role = $role;
}
}
RemovingCommunityMember:
<?php
namespace App\Events\Communities;
use App\Models\Community;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
class RemovingCommunityMember
{
use Dispatchable;
/**
* The instance.
*
* @var mixed
*/
public Community $community;
/**
* The member being removed.
*
* @var User
*/
public User $user;
/**
* Create a new event instance.
*
* @param Community $community
* @param User $user
* @return void
*/
public function __construct(Community $community, User $user)
{
$this->community = $community;
$this->user = $user;
}
}