diff --git a/database/factories/TeamFolderInvitationFactory.php b/database/factories/TeamFolderInvitationFactory.php index 246dd37a..e9c7a69f 100644 --- a/database/factories/TeamFolderInvitationFactory.php +++ b/database/factories/TeamFolderInvitationFactory.php @@ -26,7 +26,7 @@ class TeamFolderInvitationFactory extends Factory 'parent_id' => $this->faker->uuid, 'inviter_id' => $this->faker->uuid, 'email' => $this->faker->email, - 'permission' => $this->faker->randomElement(['can-edit', 'can-view', 'can-view-and-download']), + 'permission' => $this->faker->randomElement(['can-edit', 'can-view']), 'status' => $this->faker->randomElement(['pending', 'accepted', 'rejected']), ]; } diff --git a/database/factories/TeamFolderMemberFactory.php b/database/factories/TeamFolderMemberFactory.php new file mode 100644 index 00000000..001c002a --- /dev/null +++ b/database/factories/TeamFolderMemberFactory.php @@ -0,0 +1,30 @@ + $this->faker->uuid, + 'user_id' => $this->faker->uuid, + 'permission' => $this->faker->randomElement(['can-edit', 'can-view', 'owner']), + ]; + } +} diff --git a/public/mix-manifest.json b/public/mix-manifest.json index 67eec03b..686c2fb7 100644 --- a/public/mix-manifest.json +++ b/public/mix-manifest.json @@ -60,7 +60,7 @@ "/chunks/plan-settings.js": "/chunks/plan-settings.js?id=c07a2a970dc5414a7d51", "/chunks/plan-subscribers.js": "/chunks/plan-subscribers.js?id=5151a942550b58d3ba69", "/chunks/plans.js": "/chunks/plans.js?id=0aa91553ed338315d39a", - "/chunks/platform.js": "/chunks/platform.js?id=e25d3e8c863a76737a4b", + "/chunks/platform.js": "/chunks/platform.js?id=ddb79fb6130d8c6d85fe", "/chunks/platform~chunks/shared.js": "/chunks/platform~chunks/shared.js?id=e64e3b67ccb89de9dd55", "/chunks/platform~chunks/shared~chunks/shared-with-me~chunks/team-folders.js": "/chunks/platform~chunks/shared~chunks/shared-with-me~chunks/team-folders.js?id=7d983dfdc91de607d737", "/chunks/profile.js": "/chunks/profile.js?id=8688d9c7ff850e6989e6", @@ -559,5 +559,12 @@ "/js/main.c85b0e6c1c407b517efa.hot-update.js": "/js/main.c85b0e6c1c407b517efa.hot-update.js", "/js/main.6d054575b90412bd027d.hot-update.js": "/js/main.6d054575b90412bd027d.hot-update.js", "/js/main.1b9dd6c7b70765387854.hot-update.js": "/js/main.1b9dd6c7b70765387854.hot-update.js", - "/js/main.1be136e3d8e2c392d8aa.hot-update.js": "/js/main.1be136e3d8e2c392d8aa.hot-update.js" + "/js/main.1be136e3d8e2c392d8aa.hot-update.js": "/js/main.1be136e3d8e2c392d8aa.hot-update.js", + "/chunks/platform.9b9c3e26dbe41d5f8465.hot-update.js": "/chunks/platform.9b9c3e26dbe41d5f8465.hot-update.js", + "/chunks/platform.3b1ff5b0e9effcce5ee9.hot-update.js": "/chunks/platform.3b1ff5b0e9effcce5ee9.hot-update.js", + "/chunks/platform.686e9ec6741d10aa9bc0.hot-update.js": "/chunks/platform.686e9ec6741d10aa9bc0.hot-update.js", + "/chunks/platform.dfdf9807b5e5a42df629.hot-update.js": "/chunks/platform.dfdf9807b5e5a42df629.hot-update.js", + "/chunks/platform.106a731410cf0cfa904f.hot-update.js": "/chunks/platform.106a731410cf0cfa904f.hot-update.js", + "/chunks/platform.8317102f76e6a7ccb6dd.hot-update.js": "/chunks/platform.8317102f76e6a7ccb6dd.hot-update.js", + "/chunks/platform.2ddb2dd6bf6a6992a1f4.hot-update.js": "/chunks/platform.2ddb2dd6bf6a6992a1f4.hot-update.js" } diff --git a/resources/js/components/Teams/CreateTeamFolderPopup.vue b/resources/js/components/Teams/CreateTeamFolderPopup.vue index 60213e99..0b0a1cce 100644 --- a/resources/js/components/Teams/CreateTeamFolderPopup.vue +++ b/resources/js/components/Teams/CreateTeamFolderPopup.vue @@ -91,6 +91,7 @@ import InfoBox from "../Others/Forms/InfoBox"; import {events} from '/resources/js/bus' import axios from "axios"; + import {mapGetters} from "vuex"; export default { name: 'CreateTeamFolderPopup', @@ -109,6 +110,9 @@ InfoBox, }, computed: { + ...mapGetters([ + 'user' + ]), popupTitle() { return this.item ? this.$t('Convert as Team Folder') : this.$t('Create Team Folder') }, @@ -198,6 +202,17 @@ return } + // Get team folder limitations + let limit = this.user.data.meta.limitations.max_team_members + + if (limit.percentage >= 100 && ! limit.meta.allowed_emails.includes(email)) { + this.$refs.teamFolderForm.setErrors({ + 'Email': this.$t("You have to upgrade your account to add this new member.") + }); + + return + } + this.$refs.teamFolderForm.reset() this.invitations.push({ diff --git a/resources/js/components/Teams/EditTeamFolderPopup.vue b/resources/js/components/Teams/EditTeamFolderPopup.vue index a78ac414..82307213 100644 --- a/resources/js/components/Teams/EditTeamFolderPopup.vue +++ b/resources/js/components/Teams/EditTeamFolderPopup.vue @@ -80,6 +80,7 @@ import InfoBox from "../Others/Forms/InfoBox"; import {events} from '/resources/js/bus' import axios from "axios"; + import {mapGetters} from "vuex"; export default { name: 'EditTeamFolderPopup', @@ -98,6 +99,9 @@ InfoBox, }, computed: { + ...mapGetters([ + 'user' + ]), isDisabledSubmit() { return Object.values(this.members).length === 0 && Object.values(this.invitations).length === 0 } @@ -157,6 +161,17 @@ }); } + // Get team folder limitations + let limit = this.user.data.meta.limitations.max_team_members + + if (limit.percentage >= 100 && ! limit.meta.allowed_emails.includes(email)) { + this.$refs.teamFolderForm.setErrors({ + 'Email': this.$t("You have to upgrade your account to add this new member.") + }); + + return + } + this.$refs.teamFolderForm.reset() this.invitations.push({ diff --git a/src/App/Users/Models/User.php b/src/App/Users/Models/User.php index 18523765..62545c49 100644 --- a/src/App/Users/Models/User.php +++ b/src/App/Users/Models/User.php @@ -109,34 +109,6 @@ class User extends Authenticatable implements MustVerifyEmail ]; } - // TODO: caching & refactoring - public function accountLimitations(): array - { - $members = \DB::table('team_folder_members') - ->where('user_id', $this->id) - ->pluck('parent_id'); - - $membersUse = \DB::table('team_folder_members') - ->where('user_id', '!=', $this->id) - ->whereIn('parent_id', $members) - ->pluck('user_id') - ->unique() - ->count(); - - return [ - 'max_storage_amount' => [ - 'use' => Metric::bytes($this->usedCapacity)->format(), - 'total' => format_gigabytes($this->limitations->max_storage_amount), - 'percentage' => (float)get_storage_fill_percentage($this->usedCapacity, $this->limitations->max_storage_amount), - ], - 'max_team_members' => [ - 'use' => $membersUse, - 'total' => (int)$this->limitations->max_team_members, - 'percentage' => ($membersUse / $this->limitations->max_team_members) * 100, - ], - ]; - } - /** * Get user used storage capacity in bytes */ @@ -209,6 +181,11 @@ class User extends Authenticatable implements MustVerifyEmail return $this->hasMany(File::class); } + public function folders(): HasMany + { + return $this->hasMany(Folder::class); + } + /** * Send the password reset notification. */ @@ -227,7 +204,7 @@ class User extends Authenticatable implements MustVerifyEmail // Create default limitations $user->limitations()->create([ 'max_storage_amount' => get_settings('default_storage_amount') ?? 1, - 'max_team_members' => 3, + 'max_team_members' => 5, ]); // Create user directory for his files diff --git a/src/App/Users/Models/UserLimitation.php b/src/App/Users/Models/UserLimitation.php index 1089d80b..509150e0 100644 --- a/src/App/Users/Models/UserLimitation.php +++ b/src/App/Users/Models/UserLimitation.php @@ -1,10 +1,19 @@ hasOne(User::class, 'id', 'user_id'); + } + + /** + * Get summary of user limitations and their usage + */ + public function summary(): array + { + return [ + 'max_storage_amount' => $this->getMaxStorageAmount(), + 'max_team_members' => $this->getMaxTeamMembers(), + ]; + } + + /** + * Get usage data of user storage + */ + private function getMaxStorageAmount(): array + { + $userCapacity = $this->user->usedCapacity; + + return [ + 'use' => Metric::bytes($userCapacity)->format(), + 'total' => format_gigabytes($this->max_storage_amount), + 'percentage' => get_storage_fill_percentage($userCapacity, $this->max_storage_amount), + ]; + } + + /** + * Get usage data of team members + */ + private function getMaxTeamMembers(): array + { + $userTeamFolderIds = DB::table('team_folder_members') + ->where('user_id', $this->user_id) + ->pluck('parent_id'); + + $memberIds = DB::table('team_folder_members') + ->where('user_id', '!=', $this->user_id) + ->whereIn('parent_id', $userTeamFolderIds) + ->pluck('user_id') + ->unique(); + + // Get member emails + $memberEmails = User::whereIn('id', $memberIds) + ->pluck('email'); + + // Get active invitation emails + $InvitationEmails = DB::table('team_folder_invitations') + ->where('status', 'pending') + ->where('inviter_id', $this->user_id) + ->pluck('email') + ->unique(); + + // Get allowed emails in the limit + $totalUsedEmails = $memberEmails->merge($InvitationEmails) + ->unique(); + + return [ + 'use' => $totalUsedEmails->count(), + 'total' => (int) $this->max_team_members, + 'percentage' => ($totalUsedEmails->count() / $this->max_team_members) * 100, + 'meta' => [ + 'allowed_emails' => $totalUsedEmails, + ], + ]; + } } diff --git a/src/App/Users/Resources/UserResource.php b/src/App/Users/Resources/UserResource.php index 3eb2eaa2..c67b4b28 100644 --- a/src/App/Users/Resources/UserResource.php +++ b/src/App/Users/Resources/UserResource.php @@ -45,7 +45,7 @@ class UserResource extends JsonResource ]), ], 'meta' => [ - 'limitations' => $this->accountLimitations(), + 'limitations' => $this->limitations->summary(), ], ], ]; diff --git a/src/Domain/Teams/Actions/CheckMaxTeamMembersLimitAction.php b/src/Domain/Teams/Actions/CheckMaxTeamMembersLimitAction.php new file mode 100644 index 00000000..260a1a3b --- /dev/null +++ b/src/Domain/Teams/Actions/CheckMaxTeamMembersLimitAction.php @@ -0,0 +1,32 @@ +limitations->summary(); + + // Get currently used member emails + $allowedEmails = $limits['max_team_members']['meta']['allowed_emails']; + + // Get new email invites from request + $invitationEmails = collect($invitations) + ->pluck('email'); + + // Count total unique members + $totalMembers = $allowedEmails + ->merge($invitationEmails) + ->unique() + ->count(); + + // Check if there is more unique members than total max team members are allowed + if ($totalMembers > $limits['max_team_members']['total']) { + abort(423, 'You exceed your members limit.'); + } + } +} \ No newline at end of file diff --git a/src/Domain/Teams/Controllers/ConvertFolderIntoTeamFolderController.php b/src/Domain/Teams/Controllers/ConvertFolderIntoTeamFolderController.php index 8b847fde..4d7b66f8 100644 --- a/src/Domain/Teams/Controllers/ConvertFolderIntoTeamFolderController.php +++ b/src/Domain/Teams/Controllers/ConvertFolderIntoTeamFolderController.php @@ -1,9 +1,10 @@ checkMaxTeamMembersLimit)($request->input('invitations'), $folder->owner); + // Update root team folder $folder->update([ 'team_folder' => 1, 'parent_id' => null, ]); + // Mark all children folders as team folder ($this->setTeamFolderPropertyForAllChildren)($folder, true); // Attach owner into members - DB::table('team_folder_members') - ->insert([ - 'parent_id' => $folder->id, - 'user_id' => $folder->user_id, - 'permission' => 'owner', - ]); + TeamFolderMember::create([ + 'parent_id' => $folder->id, + 'user_id' => $folder->user_id, + 'permission' => 'owner', + ]); // Invite team members ($this->inviteMembers)($request->input('invitations'), $folder); diff --git a/src/Domain/Teams/Controllers/TeamFoldersController.php b/src/Domain/Teams/Controllers/TeamFoldersController.php index a2a2d6ef..0cc846c6 100644 --- a/src/Domain/Teams/Controllers/TeamFoldersController.php +++ b/src/Domain/Teams/Controllers/TeamFoldersController.php @@ -1,6 +1,8 @@ checkMaxTeamMembersLimit)($data->invitations, $request->user()); + + // Create folder $folder = Folder::create([ 'user_id' => $request->user()->id, 'name' => $data->name, @@ -65,12 +72,11 @@ class TeamFoldersController extends Controller ]); // Attach owner into members - DB::table('team_folder_members') - ->insert([ - 'parent_id' => $folder->id, - 'user_id' => $request->user()->id, - 'permission' => 'owner', - ]); + TeamFolderMember::create([ + 'parent_id' => $folder->id, + 'user_id' => $request->user()->id, + 'permission' => 'owner', + ]); // Invite team members $this->inviteMembers->onQueue()->execute($data->invitations, $folder); @@ -86,6 +92,9 @@ class TeamFoldersController extends Controller ): ResponseFactory | Response { $this->authorize('owner', $folder); + // Check if user didn't exceed max team members limit + ($this->checkMaxTeamMembersLimit)($request->input('invitations'), $request->user()); + $updateInvitations( $folder, $request->input('invitations') diff --git a/src/Domain/Teams/Models/TeamFolderMember.php b/src/Domain/Teams/Models/TeamFolderMember.php new file mode 100644 index 00000000..3ad264ab --- /dev/null +++ b/src/Domain/Teams/Models/TeamFolderMember.php @@ -0,0 +1,31 @@ +each( - fn ($size) => - Storage::disk('local') + fn($size) => Storage::disk('local') ->assertExists("avatars/{$size['name']}-{$user->settings->getRawOriginal('avatar')}") ); } @@ -137,26 +137,27 @@ class UserAccountTest extends TestCase ->assertStatus(200) ->assertExactJson([ 'data' => [ - 'id' => (string) $user->id, + 'id' => (string)$user->id, 'type' => 'user', 'attributes' => [ - 'email' => $user->email, - 'role' => $user->role, - 'two_factor_authentication' => false, - 'folders' => [], - 'storage' => [ + 'avatar' => null, + 'email' => $user->email, + 'role' => $user->role, + 'two_factor_authentication' => false, + 'folders' => [], + 'storage' => [ 'used' => 0, - 'used_formatted' => '0.00%', + 'used_formatted' => '0%', 'capacity' => '1', 'capacity_formatted' => '1GB', ], - 'created_at' => format_date($user->created_at, '%d. %B. %Y'), - 'updated_at' => format_date($user->updated_at, '%d. %B. %Y'), + 'created_at' => format_date($user->created_at, '%d. %b. %Y'), + 'updated_at' => format_date($user->updated_at, '%d. %b. %Y'), ], 'relationships' => [ - 'settings' => [ + 'settings' => [ 'data' => [ - 'id' => (string) $user->id, + 'id' => (string)$user->id, 'type' => 'settings', 'attributes' => [ 'avatar' => $user->settings->avatar, @@ -171,13 +172,13 @@ class UserAccountTest extends TestCase ], ], ], - 'favourites' => [ + 'favourites' => [ 'data' => [], ], 'limitations' => [ - 'id' => $user->id, - 'type' => 'limitations', - 'data' => [ + 'id' => $user->id, + 'type' => 'limitations', + 'data' => [ 'attributes' => $user->limitations, ], ], diff --git a/tests/Domain/Admin/DashboardTest.php b/tests/Domain/Admin/DashboardTest.php index 191a491b..718503d5 100644 --- a/tests/Domain/Admin/DashboardTest.php +++ b/tests/Domain/Admin/DashboardTest.php @@ -32,6 +32,7 @@ class DashboardTest extends TestCase ->assertStatus(200) ->assertExactJson([ 'license' => 'Regular', + 'total_premium_users' => 0, 'app_version' => config('vuefilemanager.version'), 'total_users' => 1, 'total_used_space' => '2.00MB', diff --git a/tests/Domain/Teams/TeamLimitsTest.php b/tests/Domain/Teams/TeamLimitsTest.php new file mode 100644 index 00000000..5e636039 --- /dev/null +++ b/tests/Domain/Teams/TeamLimitsTest.php @@ -0,0 +1,67 @@ +hasFolders([ + 'team_folder' => true, + ]) + ->create(); + + TeamFolderMember::create([ + 'parent_id' => $user->folders[0]->id, + 'user_id' => $user->id, + 'permission' => 'owner', + ]); + + $members = User::factory() + ->count(5) + ->create(); + + $members->each(fn($member) => TeamFolderMember::factory() + ->create([ + 'parent_id' => $user->folders[0]->id, + 'user_id' => $member->id, + ]) + ); + + // Try invite new member + $this + ->actingAs($user) + ->post('/api/teams/folders', [ + 'name' => 'Company Project', + 'invitations' => [ + [ + 'email' => 'test@doe.com', + 'permission' => 'can-edit', + ], + ], + ]) + ->assertStatus(423); + + // Invite existing member + $this + ->actingAs($user) + ->post('/api/teams/folders', [ + 'name' => 'Company Project', + 'invitations' => [ + [ + 'email' => $members[0]->email, + 'permission' => 'can-edit', + ], + ], + ]) + ->assertCreated(); + } +} \ No newline at end of file