diff --git a/app/Enums/GroupTypeEnum.php b/app/Enums/GroupTypeEnum.php index 1dbc319..6bf2219 100644 --- a/app/Enums/GroupTypeEnum.php +++ b/app/Enums/GroupTypeEnum.php @@ -6,6 +6,7 @@ enum GroupTypeEnum: string { case Default = 'none'; case Department = 'department'; + case Team = 'team'; case Automated = 'automated'; } diff --git a/app/Http/Controllers/Staff/GroupMemberController.php b/app/Http/Controllers/Staff/GroupMemberController.php index f9cb16a..dd3cade 100644 --- a/app/Http/Controllers/Staff/GroupMemberController.php +++ b/app/Http/Controllers/Staff/GroupMemberController.php @@ -18,14 +18,23 @@ class GroupMemberController extends Controller public function index(Group $group, Request $request) { return Inertia::render('Staff/Groups/Tabs/MemberTab', [ - 'group' => $group->loadCount('users')->only(['hashid', 'name', 'users_count']), + 'group' => $group->loadCount('users')->only(['hashid', 'name', 'users_count','parent_id']), + 'parent' => $group->parent?->only(['hashid', 'name']), // Sort Owner, Admin, Moderator, Member and then by name - 'users' => $group->users()->withPivot('level')->get(['id', 'name', 'profile_photo_path'])->map(fn($user + 'users' => $group->users() + ->with([ + 'groups' => fn($q) => $q->where('type', 'team')->where('parent_id', $group->id)->get(['id', 'name']) + ]) + ->withPivot('level')->get(['id', 'name', 'profile_photo_path'])->map(fn($user ) => [ 'id' => $user->hashid, 'name' => $user->name, 'profile_photo_path' => (is_null($user->profile_photo_path)) ? null : Storage::drive('s3-avatars')->url($user->profile_photo_path), 'level' => $user->pivot->level, + 'teams' => $user->groups->map(fn($group) => [ + 'id' => $group->hashid, + 'name' => $group->name, + ]), 'title' => $user->pivot->title, ])->sortBy(fn($user) => [ $user['level'] === 'owner' ? 0 : 1, @@ -33,7 +42,7 @@ public function index(Group $group, Request $request) $user['level'] === 'moderator' ? 2 : 3, $user['name'] ]), - 'canEdit' => $group->isAdmin($request->user()) + 'canEdit' => $request->user()->can('update', $group), ]); } @@ -61,10 +70,14 @@ public function store(Group $group, Request $request) } // If user is already in the department throw validationexception if ($group->users->contains($user)) { - throw ValidationException::withMessages([$field => 'User is already in the department']); + throw ValidationException::withMessages([$field => 'User is already in the group']); } // Attach user to department $group->users()->attach($user, ['level' => GroupUserLevel::Member]); + // If group has a parent, add them to the parent group + if ($group->parent && !$group->parent->users->contains($user)) { + $group->parent->users()->syncWithoutDetaching([$user->id => ['level' => GroupUserLevel::Member]]); + } return redirect()->route('staff.groups.members.index', $group); } @@ -83,7 +96,8 @@ public function edit(Group $group, User $member) */ public function update(Group $group, User $member, Request $request) { - if ($member->id == $request->user()->id) { + $isAdminOfParentGroup = $group->parent?->isAdmin($request->user()); + if ($member->id == $request->user()->id && !$isAdminOfParentGroup) { throw ValidationException::withMessages(["You cannot update your own level."]); } @@ -106,12 +120,16 @@ public function update(Group $group, User $member, Request $request) */ public function destroy(Group $group, User $member, Request $request) { - if ($member->id === $request->user()->id) { + if ($member->id === $request->user()->id && !$group->parent?->isAdmin($request->user())) { throw ValidationException::withMessages(["You cannot remove yourself."]); } $requestMember = $group->users()->find($member)->pivot; $this->authorize('delete', $requestMember); $group->users()->detach($member); + // If group has children, remove them from the children + if ($group->children()->exists()) { + $group->children->each(fn($child) => $child->users()->detach($member)); + } } } diff --git a/app/Http/Controllers/Staff/GroupTeamController.php b/app/Http/Controllers/Staff/GroupTeamController.php new file mode 100644 index 0000000..d2b243e --- /dev/null +++ b/app/Http/Controllers/Staff/GroupTeamController.php @@ -0,0 +1,44 @@ +user()->groups() + ->where('type', GroupTypeEnum::Department)->select('id', 'level')->get() + ->mapWithKeys(fn($role) => [$role->id => ucwords($role->level)]); + return Inertia::render('Staff/Groups/Tabs/TeamTab', [ + 'group' => $group->loadCount('users')->only(['hashid', 'name', 'users_count','parent_id']), + 'parent' => $group->parent?->only(['hashid', 'name']), + 'teams' => $group->children() + ->where('type', GroupTypeEnum::Team) + ->withCount('users') + ->get(['hashid', 'name', 'users_count']), + 'myGroups' => $myDepartments->values(), + 'canEdit' => $group->isAdmin($request->user()) + ]); + } + + public function store(Group $group, Request $request) + { + Gate::authorize('update', $group); + $validated = $request->validate([ + 'name' => 'required|string|max:255', + ]); + $group->children()->create([ + 'name' => $validated['name'], + 'type' => GroupTypeEnum::Team, + ]); + return redirect()->back(); + } +} diff --git a/app/Http/Controllers/Staff/GroupsController.php b/app/Http/Controllers/Staff/GroupsController.php index bfa3bbd..4dfa77d 100644 --- a/app/Http/Controllers/Staff/GroupsController.php +++ b/app/Http/Controllers/Staff/GroupsController.php @@ -29,7 +29,7 @@ public function index(Request $request) ]); return Inertia::render('Staff/Groups/GroupsIndex', [ - 'groups' => $departmentsSortedByMembershipAndUserCount, + 'groups' => $departmentsSortedByMembershipAndUserCount->values(), 'myGroups' => $myDepartments, ]); } @@ -39,9 +39,12 @@ public function show(Group $group, Request $request) $Parsedown = new Parsedown(); $Parsedown->setSafeMode(true); return Inertia::render('Staff/Groups/Tabs/GroupInfoTab', [ - 'group' => $group->loadCount('users')->only(['hashid', 'description', 'name', 'users_count']), + 'group' => $group + ->loadCount('users') + ->only(['hashid', 'name', 'users_count', 'description','parent_id']), + 'parent' => $group->parent?->only(['hashid', 'name']), 'descriptionHtml' => $Parsedown->parse($group->description), - 'canEdit' => $group->isAdmin($request->user()), + 'canEdit' => $request->user()->can('update', $group), ]); } @@ -58,4 +61,13 @@ public function update(GroupUpdateRequest $request, Group $group) ]); return redirect()->back(); } + + public function destroy(Group $group) + { + Gate::authorize('delete', $group); + $parent = $group->parent; + $group->delete(); + return redirect()->route('staff.groups.show', $parent); + + } } diff --git a/app/Models/Group.php b/app/Models/Group.php index 90a4d2c..ca5686f 100644 --- a/app/Models/Group.php +++ b/app/Models/Group.php @@ -102,4 +102,14 @@ public function isAdmin(User $user) } return $member->pivot->level == GroupUserLevel::Admin || $member->pivot->level == GroupUserLevel::Owner; } + + public function children() + { + return $this->hasMany(__CLASS__, 'parent_id'); + } + + public function parent() + { + return $this->belongsTo(__CLASS__, 'parent_id'); + } } diff --git a/app/Observers/GroupObserver.php b/app/Observers/GroupObserver.php index 6a28294..a2d26d5 100644 --- a/app/Observers/GroupObserver.php +++ b/app/Observers/GroupObserver.php @@ -2,6 +2,7 @@ namespace App\Observers; +use App\Enums\GroupTypeEnum; use App\Enums\GroupUserLevel; use App\Models\Group; use App\Services\NextcloudService; @@ -11,6 +12,14 @@ class GroupObserver { public function created(Group $group) { + if ($group->type === GroupTypeEnum::Team && $group->parent->nextcloud_folder_id) { + NextcloudService::createGroup($group->hashid); + // Parent->name / Group->name + NextcloudService::setDisplayName($group->hashid, $group->parent->name.' / '.$group->name); + // Add to parent group folder + NextcloudService::addGroupToFolder($group->parent->nextcloud_folder_id, $group->hashid); + return; + } if (Auth::user()) { $group->users()->attach(Auth::user(), [ "level" => GroupUserLevel::Owner @@ -43,5 +52,15 @@ public function updated(Group $group): void // Update the display name of the group NextcloudService::setDisplayName($group->hashid, $group->name); } + + // Team update nextcloud dipslay name + if ($group->type === GroupTypeEnum::Team && $group->isDirty('name') && $group->parent->nextcloud_folder_id) { + NextcloudService::setDisplayName($group->hashid, $group->parent->name.' / '.$group->name); + } + } + + public function deleted(Group $group) + { + NextcloudService::deleteGroup($group->hashid); } } diff --git a/app/Observers/GroupUserObserver.php b/app/Observers/GroupUserObserver.php index b77e252..433d760 100644 --- a/app/Observers/GroupUserObserver.php +++ b/app/Observers/GroupUserObserver.php @@ -15,10 +15,10 @@ public function created(GroupUser $groupUser): void if ($groupUser->group->type === GroupTypeEnum::Department) { CheckStaffGroupMembershipJob::dispatch($groupUser->user); } - if ($groupUser->group->nextcloud_folder_name && !app()->runningUnitTests()) { + if (($groupUser->group->nextcloud_folder_name || $groupUser->group->parent?->nextcloud_folder_name) && !app()->runningUnitTests()) { NextcloudService::addUserToGroup($groupUser->group, $groupUser->user); $allowAclManagement = in_array($groupUser->level, [GroupUserLevel::Admin, GroupUserLevel::Owner]); - if ($allowAclManagement) { + if ($allowAclManagement && $groupUser->group->type !== GroupTypeEnum::Team) { NextcloudService::setManageAcl($groupUser->group, $groupUser->user, $allowAclManagement); } } @@ -26,7 +26,7 @@ public function created(GroupUser $groupUser): void public function updated(GroupUser $groupUser): void { - if ($groupUser->group->nextcloud_folder_nam && !app()->runningUnitTests()) { + if ($groupUser->group->nextcloud_folder_name && !app()->runningUnitTests()) { if ($groupUser->isDirty('level')) { $allowAclManagement = in_array($groupUser->level, [GroupUserLevel::Admin, GroupUserLevel::Owner]); NextcloudService::setManageAcl($groupUser->group, $groupUser->user, $allowAclManagement); @@ -41,15 +41,9 @@ public function deleted(GroupUser $groupUser): void } if ($groupUser->group->nextcloud_folder_name && !app()->runningUnitTests()) { NextcloudService::removeUserFromGroup($groupUser->group, $groupUser->user); - NextcloudService::setManageAcl($groupUser->group, $groupUser->user, false); + if ($groupUser->group->type !== GroupTypeEnum::Team) { + NextcloudService::setManageAcl($groupUser->group, $groupUser->user, false); + } } } - - public function restored(GroupUser $groupUser): void - { - } - - public function forceDeleted(GroupUser $groupUser): void - { - } } diff --git a/app/Policies/GroupPolicy.php b/app/Policies/GroupPolicy.php index ee78a0a..a6db896 100644 --- a/app/Policies/GroupPolicy.php +++ b/app/Policies/GroupPolicy.php @@ -35,7 +35,7 @@ public function viewAny(User $user): bool public function view(User $user, Group $group): Response { $inGroup = $user->inGroup($group->id); - $staffException = $group->type === GroupTypeEnum::Department && $user->isStaff(); + $staffException = ($group->type === GroupTypeEnum::Department || $group->type === GroupTypeEnum::Team) && $user->isStaff(); $userPermission = $user->scopeCheck('groups.read'); if ($inGroup || $staffException) { @@ -61,12 +61,20 @@ public function update(User $user, Group $group): bool ->whereGroupId($group->id) ->where(fn($q) => $q->whereLevel(GroupUserLevel::Admin)->orWhere('level', GroupUserLevel::Owner)) ->exists(); - return ((Auth::guard("admin")->check() && $user->can('admin.groups.update')) || ($userAdminInGroup && $user->scopeCheck('groups.update'))); + $userAdminInParentGroup = GroupUser::whereUserId($user->id) + ->whereGroupId($group->parent_id) + ->where(fn($q) => $q->whereLevel(GroupUserLevel::Admin)->orWhere('level', GroupUserLevel::Owner)) + ->exists(); + return ((Auth::guard("admin")->check() && $user->can('admin.groups.update')) || (($userAdminInGroup || $userAdminInParentGroup) && $user->scopeCheck('groups.update'))); } public function delete(User $user, Group $group): bool { + if($group->type !== GroupTypeEnum::Team) { + return false; + } $userOwnerInGroup = GroupUser::whereUserId($user->id)->whereGroupId($group->id)->whereLevel(GroupUserLevel::Owner)->exists(); - return ((Auth::guard("admin")->check() && $user->can('admin.groups.delete')) || ($userOwnerInGroup && $user->scopeCheck('groups.delete'))); + $userOwnerInParentGroup = GroupUser::whereUserId($user->id)->whereGroupId($group->parent_id)->whereLevel(GroupUserLevel::Owner)->exists(); + return ((Auth::guard("admin")->check() && $user->can('admin.groups.delete')) || (($userOwnerInGroup || $userOwnerInParentGroup) && $user->scopeCheck('groups.delete'))); } } diff --git a/app/Policies/GroupUserPolicy.php b/app/Policies/GroupUserPolicy.php index fd34af7..c42c95a 100644 --- a/app/Policies/GroupUserPolicy.php +++ b/app/Policies/GroupUserPolicy.php @@ -19,12 +19,12 @@ public function view(User $user, GroupUser $groupUser): bool public function update(User $user, GroupUser $groupUserInitiator): bool { - return $user->scopeCheck('groups.update') && $groupUserInitiator->isAdmin(); + return $user->scopeCheck('groups.update') && ($groupUserInitiator->isAdmin() || $groupUserInitiator->group->parent?->isAdmin($user)); } public function create(User $user, GroupUser $groupUserInitiator): bool { - return $user->scopeCheck('groups.update') && $groupUserInitiator->isAdmin(); + return $user->scopeCheck('groups.update') && ($groupUserInitiator->isAdmin() || $groupUserInitiator->group->parent?->isAdmin($user)); } public function delete(User $user, GroupUser $groupUser): Response @@ -40,6 +40,12 @@ public function delete(User $user, GroupUser $groupUser): Response if ($groupUser->group->isAdmin($user)) { return Response::allow(); } + + // check if user is admin of parent group + if ($groupUser->group->parent && $groupUser->group->parent->isAdmin($user)) { + return Response::allow(); + } + return Response::deny('Insufficient permissions, you cannot delete users.'); } } diff --git a/app/Services/NextcloudService.php b/app/Services/NextcloudService.php index 76af548..7635ce2 100644 --- a/app/Services/NextcloudService.php +++ b/app/Services/NextcloudService.php @@ -16,6 +16,12 @@ public static function createGroup($groupId) return $groupId; } + // delete group + public static function deleteGroup($groupId) + { + Http::nextcloud()->delete("/ocs/v1.php/cloud/groups/{$groupId}")->throw(); + } + public static function setDisplayName($groupId, $displayName) { Http::nextcloud()->put("https://cloud.eurofurence.org/ocs/v2.php/cloud/groups/{$groupId}", [ @@ -41,11 +47,10 @@ public static function addUserToGroup(Group $group, User $user) // Check user if (!self::checkUserExists($user->hashid)) { self::createUser($user); // Create user also adds groups so we don't need to add them here - } else { - Http::nextcloud()->post("ocs/v2.php/cloud/users/{$user->hashid}/groups", [ - "groupid" => $group->hashid, - ])->throw(); } + Http::nextcloud()->post("ocs/v2.php/cloud/users/{$user->hashid}/groups", [ + "groupid" => $group->hashid, + ])->throw(); } public static function removeUserFromGroup(Group $group, User $user) @@ -108,6 +113,14 @@ public static function createFolder(string $folderName, string $groupId): int return (int) $xml->data->id; } + // add group to folder + public static function addGroupToFolder($folderId, $groupId) + { + Http::nextcloud()->post("apps/groupfolders/folders/{$folderId}/groups", [ + "group" => $groupId, + ])->throw(); + } + public static function renameFolder(int $folderId, string $folderName): void { Http::nextcloud()->post("apps/groupfolders/folders/{$folderId}/mountpoint", [ diff --git a/database/migrations/2024_08_14_000454_add_parent_id_to_groups_table.php b/database/migrations/2024_08_14_000454_add_parent_id_to_groups_table.php new file mode 100644 index 0000000..66c8e48 --- /dev/null +++ b/database/migrations/2024_08_14_000454_add_parent_id_to_groups_table.php @@ -0,0 +1,15 @@ +foreignIdFor(Group::class, 'parent_id')->after('id')->nullable()->constrained('groups')->cascadeOnDelete(); + }); + } +}; diff --git a/resources/js/Components/GroupListItem.vue b/resources/js/Components/GroupListItem.vue new file mode 100644 index 0000000..8a7c806 --- /dev/null +++ b/resources/js/Components/GroupListItem.vue @@ -0,0 +1,59 @@ + + + + + + + {{ department.name }} + + + + + {{ department.users_count }} Members + + + + + {{ myGroupLabel }} + + + + + + + + + diff --git a/resources/js/Components/Staff/SiteHeader.vue b/resources/js/Components/Staff/SiteHeader.vue index 6371654..242cd21 100644 --- a/resources/js/Components/Staff/SiteHeader.vue +++ b/resources/js/Components/Staff/SiteHeader.vue @@ -8,7 +8,9 @@ const props = defineProps({ - {{ title }} + + {{title}} + {{subtitle}} diff --git a/resources/js/Pages/Staff/Groups/GroupsIndex.vue b/resources/js/Pages/Staff/Groups/GroupsIndex.vue index a138440..87f7028 100644 --- a/resources/js/Pages/Staff/Groups/GroupsIndex.vue +++ b/resources/js/Pages/Staff/Groups/GroupsIndex.vue @@ -1,53 +1,31 @@ - - - - - - - - - {{ department.name }} - - - - - {{ department.users_count }} Members + - - - - - - {{ myGroups[department.id] }} - - - - + + + + + + No departments created yet. + - diff --git a/resources/js/Pages/Staff/Groups/Tabs/GroupInfoTab.vue b/resources/js/Pages/Staff/Groups/Tabs/GroupInfoTab.vue index 6b32169..7e648b9 100644 --- a/resources/js/Pages/Staff/Groups/Tabs/GroupInfoTab.vue +++ b/resources/js/Pages/Staff/Groups/Tabs/GroupInfoTab.vue @@ -13,13 +13,17 @@ import "md-editor-v3/lib/style.css"; defineOptions({layout: AppLayout}) const props = defineProps({ group: Object, + parent: { + type: Object, + required: false + }, users: Array, canEdit: Boolean, descriptionHtml: String, }) const form = useForm('patch', route('staff.groups.update', {group: props.group.hashid}), { - description: props.group.description, + description: props.group.description ?? '', }) const submit = () => form.submit({ @@ -40,6 +44,7 @@ const showDescriptionLabel = computed(() => { {{ user.name }} - {{ user.level }} + + + {{ user.level }} + + + {{ team.name }} + + - + - Really remove {{ deleteModal.user.name diff --git a/resources/js/Pages/Staff/Groups/Tabs/TabComponent.vue b/resources/js/Pages/Staff/Groups/Tabs/TabComponent.vue index 500e54e..6e622c8 100644 --- a/resources/js/Pages/Staff/Groups/Tabs/TabComponent.vue +++ b/resources/js/Pages/Staff/Groups/Tabs/TabComponent.vue @@ -1,6 +1,7 @@ diff --git a/resources/js/Pages/Staff/Groups/Tabs/TabHeader.vue b/resources/js/Pages/Staff/Groups/Tabs/TabHeader.vue index 61c6076..ee7aed2 100644 --- a/resources/js/Pages/Staff/Groups/Tabs/TabHeader.vue +++ b/resources/js/Pages/Staff/Groups/Tabs/TabHeader.vue @@ -4,29 +4,31 @@ import SiteHeader from "../../../../Components/Staff/SiteHeader.vue"; import Button from 'primevue/button'; import Dropdown from 'primevue/dropdown'; import InputText from "primevue/inputtext"; -import {Head, usePage} from "@inertiajs/vue3"; +import {Head, Link, usePage} from "@inertiajs/vue3"; import Dialog from 'primevue/dialog'; import {ref, watch} from "vue"; import SelectButton from 'primevue/selectbutton'; import {useForm} from "laravel-precognition-vue-inertia"; import {useToast} from "primevue/usetoast"; - const props = defineProps({ group: Object, + parent: Object, canEdit: Boolean, subtitle: String, }) const showAddMemberDialog = ref(false); +const showAddTeamDialog = ref(false); +const confirmDeleteTeamDialog = ref(false); const toast = useToast(); -function submit() { +function submitUserForm() { if (addVia.value === 'Staff List') { - form.email = ''; + addUserForm.email = ''; } else { - form.user_id = ''; + addUserForm.user_id = ''; } - form.submit({ + addUserForm.submit({ preserveScroll: true, onSuccess: () => { showAddMemberDialog.value = false; @@ -34,24 +36,40 @@ function submit() { } }); } + +function submitTeamForm() { + addTeamForm.submit({ + preserveScroll: true, + onSuccess: () => { + showAddTeamDialog.value = false; + addTeamForm.reset(); + toast.add({severity: 'success', summary: 'Success', detail: 'Team added to department'}); + } + }); +} + const staffMemberList = usePage().props.staffMemberList; -const form = useForm('POST',route('staff.groups.members.store',{group: props.group.hashid}), { +const addUserForm = useForm('POST',route('staff.groups.members.store',{group: props.group.hashid}), { user_id: '', email: '', }) -// watch showAddMemberDialog if closed, reset form +const addTeamForm = useForm('POST',route('staff.groups.teams.store',{group: props.group.hashid}), { + name: '', +}) + +// watch showAddMemberDialog if closed, reset addUserForm watch(showAddMemberDialog, (value) => { if (!value) { - form.reset(); + addUserForm.reset(); } }) // If email is selected, reset user_id -watch(() => form.email, (value) => { +watch(() => addUserForm.email, (value) => { if (value) { - form.user_id = ''; + addUserForm.user_id = ''; } }) const addVia = ref('Staff List'); @@ -64,6 +82,11 @@ const options = ref(['Staff List', 'Email']); :title="group.name" :subtitle="subtitle" > + + + {{ parent.name }}/{{ group.name }} + + @@ -71,40 +94,87 @@ const options = ref(['Staff List', 'Email']); size="small" @click="showAddMemberDialog = true">Add User + Add Team + + Delete Team + + - + You can add existing Staff Members via the Dropdown to this Group. Non-Staff Members need to be added using the E-Mail. Staff Member - {{ form.errors.user_id }} + {{ addUserForm.errors.user_id }} E-Mail - - {{ form.errors.email }} + + {{ addUserForm.errors.email }} - Add + Add Member + + + + Team Name + + {{ addTeamForm.errors.name }} + + + Create Team + + + + + + Are you sure you want to delete this Team? + + Cancel + + { + confirmDeleteTeamDialog = false; + toast.add({severity: 'success', summary: 'Success', detail: 'Team deleted'}); + } + })">Delete + + +
{{ department.users_count }} Members
{{subtitle}}
No departments created yet.
You can add existing Staff Members via the Dropdown to this Group. Non-Staff Members need to be added using the E-Mail.
Are you sure you want to delete this Team?