knowii-oss / knowii

https://knowii.net
GNU Affero General Public License v3.0
7 stars 1 forks source link

Add support for creating and managing communities #678

Open dsebastien opened 2 weeks ago

dsebastien commented 2 weeks ago
dsebastien commented 2 weeks 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>
  );
}
dsebastien commented 2 weeks ago

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>
  );
}
dsebastien commented 2 weeks ago

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>
  );
}
dsebastien commented 2 weeks ago

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>
  );
}
dsebastien commented 3 days ago

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')
dsebastien commented 3 days ago

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);
    }
}
dsebastien commented 3 days ago

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);
    }
}
dsebastien commented 3 days ago

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);
dsebastien commented 3 days ago

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:

dsebastien commented 3 days ago

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.')
      );
    };
  }
}
dsebastien commented 3 days ago

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');
    }
  }
}
dsebastien commented 3 days ago

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));
  }
}
dsebastien commented 3 days ago

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();
  }
}
dsebastien commented 3 days ago

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
{

}
dsebastien commented 3 days ago

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
{
    //
}
dsebastien commented 3 days ago

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
{
    //
}
dsebastien commented 3 days ago

UpdatesCommunityNames contract:

<?php

namespace App\Contracts\Communities;

/**
 * @method void update(\Illuminate\Foundation\Auth\User $user, \Illuminate\Database\Eloquent\Model $community, array $input)
 */
interface UpdatesCommunityNames
{
    //
}
dsebastien commented 3 days ago

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();
});
dsebastien commented 3 days ago

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);
});
dsebastien commented 3 days ago

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();
});
dsebastien commented 3 days ago

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);
});
dsebastien commented 3 days ago

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();
});
dsebastien commented 3 days ago

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');
});