diff --git a/app/Api/v1/Controllers/TwoFAccountController.php b/app/Api/v1/Controllers/TwoFAccountController.php index f3aef246..b6bc812f 100644 --- a/app/Api/v1/Controllers/TwoFAccountController.php +++ b/app/Api/v1/Controllers/TwoFAccountController.php @@ -85,7 +85,7 @@ public function store(TwoFAccountDynamicRequest $request) $request->user()->twofaccounts()->save($twofaccount); // Possible group association - Groups::assign($twofaccount->id, $request->user()); + Groups::assign($twofaccount->id, $request->user(), Arr::get($validated, 'group_id', null)); return (new TwoFAccountReadResource($twofaccount->refresh())) ->response() @@ -106,6 +106,16 @@ public function update(TwoFAccountUpdateRequest $request, TwoFAccount $twofaccou $twofaccount->fillWithOtpParameters($validated); $request->user()->twofaccounts()->save($twofaccount); + // Possible group change + $groupId = Arr::get($validated, 'group_id', null); + if ($twofaccount->group_id != $groupId) { + if ((int) $groupId === 0) { + TwoFAccounts::withdraw($twofaccount->id); + } + else Groups::assign($twofaccount->id, $request->user(), $groupId); + $twofaccount->refresh(); + } + return (new TwoFAccountReadResource($twofaccount)) ->response() ->setStatusCode(200); diff --git a/app/Api/v1/Requests/TwoFAccountDynamicRequest.php b/app/Api/v1/Requests/TwoFAccountDynamicRequest.php index c6914fa9..f5fbc425 100644 --- a/app/Api/v1/Requests/TwoFAccountDynamicRequest.php +++ b/app/Api/v1/Requests/TwoFAccountDynamicRequest.php @@ -5,6 +5,8 @@ use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Fluent; +use Illuminate\Validation\Validator; class TwoFAccountDynamicRequest extends FormRequest { @@ -32,6 +34,18 @@ public function rules() return $rules; } + /** + * Get the "withValidator" validation callables for the request. + */ + public function withValidator(Validator $validator) : void + { + // The account may have to be assign to a specific group. + // If so, we check if the provided group exists. + $validator->sometimes('group_id', 'exists:groups,id', function (Fluent $input) { + return $input['group_id'] > 0; + }); + } + /** * Prepare the data for validation. * @@ -45,5 +59,11 @@ protected function prepareForValidation() 'otp_type' => strtolower($this->otp_type), 'algorithm' => strtolower($this->algorithm), ]); + + if ($this->has('group_id') && $this->group_id === '') { + $this->merge([ + 'group_id' => null, + ]); + } } } diff --git a/app/Api/v1/Requests/TwoFAccountStoreRequest.php b/app/Api/v1/Requests/TwoFAccountStoreRequest.php index b6a3e9c6..279ecb32 100644 --- a/app/Api/v1/Requests/TwoFAccountStoreRequest.php +++ b/app/Api/v1/Requests/TwoFAccountStoreRequest.php @@ -28,6 +28,7 @@ public function rules() 'service' => 'nullable|string|regex:/^[^:]+$/i', 'account' => 'required|string|regex:/^[^:]+$/i', 'icon' => 'nullable|string', + 'group_id' => 'sometimes|nullable|integer|min:0', 'otp_type' => 'required|string|in:totp,hotp,steamtotp', 'secret' => ['string', 'bail', new \App\Rules\IsBase32Encoded], 'digits' => 'nullable|integer|between:5,10', diff --git a/app/Api/v1/Requests/TwoFAccountUpdateRequest.php b/app/Api/v1/Requests/TwoFAccountUpdateRequest.php index 473bd707..8ee4f425 100644 --- a/app/Api/v1/Requests/TwoFAccountUpdateRequest.php +++ b/app/Api/v1/Requests/TwoFAccountUpdateRequest.php @@ -4,6 +4,8 @@ use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Fluent; +use Illuminate\Validation\Validator; class TwoFAccountUpdateRequest extends FormRequest { @@ -28,6 +30,7 @@ public function rules() 'service' => 'present|nullable|string|regex:/^[^:]+$/i', 'account' => 'required|string|regex:/^[^:]+$/i', 'icon' => 'present|nullable|string', + 'group_id' => 'sometimes|nullable|integer|min:0', 'otp_type' => 'required|string|in:totp,hotp,steamtotp', 'secret' => ['present', 'string', 'bail', new \App\Rules\IsBase32Encoded], 'digits' => 'present|integer|between:5,10', @@ -37,6 +40,18 @@ public function rules() ]; } + /** + * Get the "withValidator" validation callables for the request. + */ + public function withValidator(Validator $validator) : void + { + // The account may have to be assign to a specific group. + // If so, we check if the provided group exists. + $validator->sometimes('group_id', 'exists:groups,id', function (Fluent $input) { + return $input['group_id'] > 0; + }); + } + /** * Prepare the data for validation. * diff --git a/app/Services/GroupService.php b/app/Services/GroupService.php index 5b121055..efde2d43 100644 --- a/app/Services/GroupService.php +++ b/app/Services/GroupService.php @@ -15,12 +15,39 @@ class GroupService * Assign one or more accounts to a group * * @param array|int $ids accounts ids to assign - * @param \App\Models\Group|null $group The group the accounts will be assigned to + * @param mixed $targetGroup The group the accounts should be assigned to * * @throws \Illuminate\Auth\Access\AuthorizationException */ - public static function assign($ids, User $user, ?Group $group = null) : void + public static function assign($ids, User $user, mixed $targetGroup = null) : void { + // targetGroup == 0 == The pseudo group named 'All' == No group + // It means we do not want the accounts to be associated to a group, either a + // specific group or the default group from user preferences. + // If you need to release the accounts from an existing association, use the + // TwoFAccountService::withdraw() method. + if ($targetGroup === 0 || $targetGroup === '0') { + Log::info('Group assignment skipped, no group explicitly requested'); + + return; + } + + // Two main cases : + // - A group (or group id) is passed as parameter => It has priority for use, if the group is valid + // - No group is passed => We try to identify a destination group through user preferences + $group = null; + + if(! is_null($targetGroup)) { + if ($targetGroup instanceof Group && $targetGroup->exists && $targetGroup->user_id == $user->id) { + $group = $targetGroup; + } + else { + $group = Group::where('id', (int) $targetGroup) + ->where('user_id', $user->id) + ->first(); + } + } + if (! $group) { $group = self::defaultGroup($user); } diff --git a/resources/js/views/twofaccounts/CreateUpdate.vue b/resources/js/views/twofaccounts/CreateUpdate.vue index fb92995a..cf5206f6 100644 --- a/resources/js/views/twofaccounts/CreateUpdate.vue +++ b/resources/js/views/twofaccounts/CreateUpdate.vue @@ -6,6 +6,7 @@ import twofaccountService from '@/services/twofaccountService' import { useUserStore } from '@/stores/user' import { useTwofaccounts } from '@/stores/twofaccounts' + import { useGroups } from '@/stores/groups' import { useBusStore } from '@/stores/bus' import { useNotifyStore } from '@/stores/notify' import { UseColorMode } from '@vueuse/components' @@ -22,6 +23,7 @@ account: '', otp_type: '', icon: '', + group_id: user.preferences.defaultGroup == -1 ? user.preferences.activeGroup : user.preferences.defaultGroup, secret: '', algorithm: '', digits: null, @@ -85,10 +87,19 @@ return props.twofaccountId != undefined }) + const groups = computed(() => { + return useGroups().items.map((item) => { + return { text: item.id > 0 ? item.name : '- ' + trans('groups.no_group') + ' -', value: item.id } + }) + }) + onMounted(() => { if (route.name == 'editAccount') { twofaccountService.get(props.twofaccountId).then(response => { form.fill(response.data) + if (form.group_id == null) { + form.group_id = 0 + } form.setOriginal() // set account icon as temp icon tempIcon.value = form.icon @@ -513,6 +524,8 @@

+ +
diff --git a/resources/lang/en/twofaccounts.php b/resources/lang/en/twofaccounts.php index 04673914..92ddb567 100644 --- a/resources/lang/en/twofaccounts.php +++ b/resources/lang/en/twofaccounts.php @@ -60,6 +60,10 @@ 'i_m_lucky' => 'Try my luck', 'i_m_lucky_legend' => 'The "Try my luck" button try to get the official icon of the given service. Enter actual service name without ".xyz" extension and try to avoid typo. (beta feature)', 'test' => 'Test', + 'group' => [ + 'label' => 'Group', + 'help' => 'The group to which the account is to be assigned' + ], 'secret' => [ 'label' => 'Secret', 'help' => 'The key used to generate your security codes' diff --git a/tests/Api/v1/Controllers/TwoFAccountControllerTest.php b/tests/Api/v1/Controllers/TwoFAccountControllerTest.php index 0abf2ee7..265c1ec9 100644 --- a/tests/Api/v1/Controllers/TwoFAccountControllerTest.php +++ b/tests/Api/v1/Controllers/TwoFAccountControllerTest.php @@ -517,6 +517,102 @@ public function test_store_with_invalid_uri_returns_validation_error() ->assertStatus(422); } + #[Test] + public function test_store_assigns_created_account_to_provided_groupid() + { + // Set the default group to No group + $this->user['preferences->defaultGroup'] = 0; + $this->user->save(); + + $response = $this->actingAs($this->user, 'api-guard') + ->json('POST', '/api/v1/twofaccounts', array_merge( + OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP, + ['group_id' => $this->userGroupA->id] + )) + ->assertJsonFragment([ + 'group_id' => $this->userGroupA->id, + ]); + } + + #[Test] + public function test_store_with_assignement_to_missing_groupid_returns_validation_error() + { + $response = $this->actingAs($this->user, 'api-guard') + ->json('POST', '/api/v1/twofaccounts', array_merge( + OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP, + ['group_id' => 9999999] + )) + ->assertJsonValidationErrorFor('group_id'); + } + + #[Test] + public function test_store_with_assignement_to_null_groupid_does_not_assign_account_to_group() + { + // Set the default group to No group + $this->user['preferences->defaultGroup'] = 0; + $this->user->save(); + + $response = $this->actingAs($this->user, 'api-guard') + ->json('POST', '/api/v1/twofaccounts', array_merge( + OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP, + ['group_id' => null] + )) + ->assertJsonFragment([ + 'group_id' => null, + ]); + } + + #[Test] + public function test_store_with_assignement_to_null_groupid_is_overriden_by_specific_default_group() + { + // Set the default group to a specific group + $this->user['preferences->defaultGroup'] = $this->userGroupA->id; + $this->user->save(); + + $response = $this->actingAs($this->user, 'api-guard') + ->json('POST', '/api/v1/twofaccounts', array_merge( + OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP, + ['group_id' => null] + )) + ->assertJsonFragment([ + 'group_id' => $this->user->preferences['defaultGroup'], + ]); + } + + #[Test] + public function test_store_with_assignement_to_zero_groupid_overrides_specific_default_group() + { + // Set the default group to a specific group + $this->user['preferences->defaultGroup'] = $this->userGroupA->id; + $this->user->save(); + + $response = $this->actingAs($this->user, 'api-guard') + ->json('POST', '/api/v1/twofaccounts', array_merge( + OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP, + ['group_id' => 0] + )) + ->assertJsonFragment([ + 'group_id' => null, + ]); + } + + #[Test] + public function test_store_with_assignement_to_provided_groupid_overrides_specific_default_group() + { + // Set the default group to a specific group + $this->user['preferences->defaultGroup'] = $this->userGroupA->id; + $this->user->save(); + + $response = $this->actingAs($this->user, 'api-guard') + ->json('POST', '/api/v1/twofaccounts', array_merge( + OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP, + ['group_id' => $this->userGroupB->id] + )) + ->assertJsonFragment([ + 'group_id' => $this->userGroupB->id, + ]); + } + #[Test] public function test_store_assigns_created_account_when_default_group_is_a_specific_one() { @@ -529,7 +625,7 @@ public function test_store_assigns_created_account_when_default_group_is_a_speci 'uri' => OtpTestData::TOTP_SHORT_URI, ]) ->assertJsonFragment([ - 'group_id' => $this->userGroupA->id, + 'group_id' => $this->user->preferences['defaultGroup'], ]); } @@ -547,7 +643,7 @@ public function test_store_assigns_created_account_when_default_group_is_the_act 'uri' => OtpTestData::TOTP_SHORT_URI, ]) ->assertJsonFragment([ - 'group_id' => $this->userGroupA->id, + 'group_id' => $this->user->preferences['activeGroup'], ]); } @@ -609,6 +705,68 @@ public function test_update_missing_twofaccount_returns_not_found() ->assertNotFound(); } + #[Test] + public function test_update_with_assignement_to_null_group_returns_success_with_updated_resource() + { + $this->assertNotEquals(null, $this->twofaccountA->group_id); + + $response = $this->actingAs($this->user, 'api-guard') + ->json('PUT', '/api/v1/twofaccounts/' . $this->twofaccountA->id, array_merge( + OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP, + ['group_id' => null] + )) + ->assertOk() + ->assertJsonFragment([ + 'group_id' => null + ]) + ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_CUSTOM_TOTP); + } + + #[Test] + public function test_update_with_assignement_to_zero_group_returns_success_with_updated_resource() + { + $this->assertNotEquals(null, $this->twofaccountA->group_id); + + $response = $this->actingAs($this->user, 'api-guard') + ->json('PUT', '/api/v1/twofaccounts/' . $this->twofaccountA->id, array_merge( + OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP, + ['group_id' => 0] + )) + ->assertOk() + ->assertJsonFragment([ + 'group_id' => null + ]) + ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_CUSTOM_TOTP); + } + + #[Test] + public function test_update_with_assignement_to_new_groupid_returns_success_with_updated_resource() + { + $this->assertEquals($this->userGroupA->id, $this->twofaccountA->group_id); + + $response = $this->actingAs($this->user, 'api-guard') + ->json('PUT', '/api/v1/twofaccounts/' . $this->twofaccountA->id, array_merge( + OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP, + ['group_id' => $this->userGroupB->id] + )) + ->assertOk() + ->assertJsonFragment([ + 'group_id' => $this->userGroupB->id + ]) + ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_CUSTOM_TOTP); + } + + #[Test] + public function test_update_with_assignement_to_missing_groupid_returns_validation_error() + { + $response = $this->actingAs($this->user, 'api-guard') + ->json('PUT', '/api/v1/twofaccounts/' . $this->twofaccountA->id, array_merge( + OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP, + ['group_id' => 9999999] + )) + ->assertJsonValidationErrorFor('group_id'); + } + #[Test] public function test_update_twofaccount_with_invalid_data_returns_validation_error() {