diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 28312b72c..3d7cd8ce9 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -11,9 +11,12 @@ use App\Http\Requests\ResendByEmailRequest; use App\Http\Requests\ResendRequest; use App\Http\Requests\ResetRequest; +use App\Http\Requests\SendLoginDetailsRequest; +use App\Http\Requests\SetPasswordRequest; use App\Http\Requests\VerifyRequest; use App\Http\Resources\V2\User\MeResource; use App\Jobs\ResetPasswordJob; +use App\Jobs\SendLoginDetailsJob; use App\Jobs\UserVerificationJob; use App\Models\PasswordReset as PasswordResetModel; use App\Models\V2\Projects\ProjectInvite; @@ -24,6 +27,7 @@ use Exception; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; @@ -160,6 +164,74 @@ public function resetAction(ResetRequest $request): JsonResponse return JsonResponseHelper::success((object) [], 200); } + public function sendLoginDetailsAction(SendLoginDetailsRequest $request): JsonResponse + { + $this->authorize('reset', 'App\\Models\\Auth'); + $data = $request->json()->all(); + + try { + $user = UserModel::where('email_address', '=', $data['email_address']) + ->whereNull('password') + ->firstOrFail(); + } catch (Exception $exception) { + return JsonResponseHelper::success((object) [], 200); + } + + SendLoginDetailsJob::dispatch($user, isset($data['callback_url']) ? $data['callback_url'] : null); + + return JsonResponseHelper::success((object) [], 200); + } + + public function getEmailByResetTokenAction(Request $request): JsonResponse + { + $data = $request->query(); + + $passwordReset = PasswordResetModel::where('token', '=', $data['token'])->first(); + + if (! $passwordReset) { + return JsonResponseHelper::success((object) [ + 'email_address' => null, + 'token_used' => true, + ], 200); + } + if (Carbon::parse($passwordReset->created_at)->addDays(7)->isPast()) { + $passwordReset->delete(); + + return JsonResponseHelper::success((object) [ + 'email_address' => null, + 'token_used' => true, + ], 200); + } + + $user = UserModel::findOrFail($passwordReset->user_id); + + return JsonResponseHelper::success((object) [ + 'email_address' => $user->email_address, + 'token_used' => false, + ], 200); + } + + public function setNewPasswordAction(SetPasswordRequest $request): JsonResponse + { + $this->authorize('change', 'App\\Models\\Auth'); + $data = $request->json()->all(); + $passwordReset = PasswordResetModel::where('token', '=', $data['token'])->firstOrFail(); + $user = UserModel::findOrFail($passwordReset->user_id); + if (Hash::check($data['password'], $user->password)) { + throw new SamePasswordException(); + } + $user->password = $data['password']; + + if (empty($user->email_address_verified_at)) { + $user->email_address_verified_at = new DateTime('now', new DateTimeZone('UTC')); + } + + $user->saveOrFail(); + $passwordReset->delete(); + + return JsonResponseHelper::success((object) [], 200); + } + public function changeAction(ChangePasswordRequest $request): JsonResponse { $this->authorize('change', 'App\\Models\\Auth'); diff --git a/app/Http/Controllers/V2/User/AdminUserCreationController.php b/app/Http/Controllers/V2/User/AdminUserCreationController.php new file mode 100644 index 000000000..dd510bfd5 --- /dev/null +++ b/app/Http/Controllers/V2/User/AdminUserCreationController.php @@ -0,0 +1,68 @@ +authorize('create', User::class); + + try { + return DB::transaction(function () use ($request) { + $validatedData = $request->validated(); + + $user = new User($validatedData); + $user->save(); + + $user->email_address_verified_at = $user->created_at; + $user->save(); + + $role = $validatedData['role']; + $user->syncRoles([$role]); + + if (! empty($validatedData['organisation'])) { + $organisation = Organisation::isUuid($validatedData['organisation'])->first(); + if ($organisation) { + $organisation->partners()->updateExistingPivot($user, ['status' => 'approved'], false); + $user->organisation_id = $organisation->id; + $user->save(); + } + } + + if (! empty($validatedData['direct_frameworks'])) { + $frameworkIds = Framework::whereIn('slug', $validatedData['direct_frameworks']) + ->pluck('id') + ->toArray(); + $user->frameworks()->sync($frameworkIds); + } + + return JsonResponseHelper::success(new UserResource($user), 201); + }); + } catch (\Exception $e) { + Log::error('User creation failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return JsonResponseHelper::error([ + 'message' => 'Failed to create user', + 'details' => $e->getMessage(), + ], 500); + } + } +} diff --git a/app/Http/Requests/SendLoginDetailsRequest.php b/app/Http/Requests/SendLoginDetailsRequest.php new file mode 100644 index 000000000..095d90b0a --- /dev/null +++ b/app/Http/Requests/SendLoginDetailsRequest.php @@ -0,0 +1,25 @@ + [ + 'required', + 'string', + 'email', + ], + 'callback_url' => [ + 'sometimes', + 'string', + 'url', + 'max:5000', + ], + ]; + } +} diff --git a/app/Http/Requests/SetPasswordRequest.php b/app/Http/Requests/SetPasswordRequest.php new file mode 100644 index 000000000..f75541d5c --- /dev/null +++ b/app/Http/Requests/SetPasswordRequest.php @@ -0,0 +1,24 @@ + [ + 'required', + 'string', + 'exists:password_resets,token', + ], + 'password' => ['required', 'string', Password::min(8)->mixedCase()->numbers()], + ]; + } +} diff --git a/app/Http/Requests/V2/User/AdminUserCreationRequest.php b/app/Http/Requests/V2/User/AdminUserCreationRequest.php new file mode 100644 index 000000000..0c8f2d9a2 --- /dev/null +++ b/app/Http/Requests/V2/User/AdminUserCreationRequest.php @@ -0,0 +1,62 @@ + 'required|string|max:255', + 'last_name' => 'required|string|max:255', + 'email_address' => [ + 'required', + 'string', + 'email', + 'max:255', + 'unique:users,email_address', + ], + 'role' => 'required|string', + 'job_role' => 'sometimes|nullable|string|max:255', + 'country' => 'sometimes|nullable|string|max:2', + 'phone_number' => 'sometimes|nullable|string|max:20', + 'program' => 'sometimes|nullable|string|max:255', + 'organisation' => [ + 'sometimes', + 'nullable', + 'array', + function ($attribute, $value, $fail) { + if (! empty($value) && empty($value['uuid'])) { + $fail('The organisation must contain a uuid.'); + } + }, + ], + 'monitoring_organisations' => 'sometimes|array', + 'monitoring_organisations.*' => 'uuid|exists:organisations,uuid', + 'direct_frameworks' => 'sometimes|array', + 'direct_frameworks.*' => 'string|exists:frameworks,slug', + ]; + } + + public function messages(): array + { + return [ + 'email_address.unique' => 'This email address is already in use.', + 'role.in' => 'Invalid role selected.', + 'organisation.uuid' => 'Invalid organisation identifier.', + 'organisation.exists' => 'Organisation not found.', + 'country.max' => 'Country code must be 2 characters long.', + 'phone_number.max' => 'Phone number cannot exceed 20 characters.', + ]; + } +} diff --git a/app/Jobs/SendLoginDetailsJob.php b/app/Jobs/SendLoginDetailsJob.php new file mode 100644 index 000000000..0c6925407 --- /dev/null +++ b/app/Jobs/SendLoginDetailsJob.php @@ -0,0 +1,54 @@ +model = $model; + $this->callbackUrl = $callbackUrl; + } + + public function handle() + { + try { + if (get_class($this->model) !== \App\Models\V2\User::class) { + throw new Exception('Invalid model type'); + } + + $passwordReset = new PasswordResetModel(); + $passwordReset->user_id = $this->model->id; + $passwordReset->token = Str::random(32); + $passwordReset->saveOrFail(); + Mail::to($this->model->email_address) + ->send(new SendLoginDetails($passwordReset->token, $this->callbackUrl, $this->model)); + } catch (\Throwable $e) { + Log::error('Job failed', ['error' => $e->getMessage()]); + + throw $e; + } + } +} diff --git a/app/Mail/SendLoginDetails.php b/app/Mail/SendLoginDetails.php new file mode 100644 index 000000000..af448a717 --- /dev/null +++ b/app/Mail/SendLoginDetails.php @@ -0,0 +1,25 @@ +setSubjectKey('send-login-details.subject') + ->setTitleKey('send-login-details.title') + ->setBodyKey('send-login-details.body') + ->setParams([ + '{userName}' => e($user->first_name . ' ' . $user->last_name), + '{mail}' => e($user->email_address), + ]) + ->setCta('send-login-details.cta'); + + $this->link = $callbackUrl ? + $callbackUrl . urlencode($token) : + '/set-password?token=' . urlencode($token); + + $this->transactional = true; + } +} diff --git a/database/seeders/LocalizationKeysTableSeeder.php b/database/seeders/LocalizationKeysTableSeeder.php index 18b304341..1676b6c68 100644 --- a/database/seeders/LocalizationKeysTableSeeder.php +++ b/database/seeders/LocalizationKeysTableSeeder.php @@ -165,6 +165,20 @@ public function run(): void 'If you have any questions, feel free to message us at info@terramatch.org.'); $this->createLocalizationKey('reset-password.cta', 'Reset Password'); + // send-login-details + $this->createLocalizationKey('send-login-details.subject', 'Welcome to TerraMatch!'); + $this->createLocalizationKey('send-login-details.title', 'Welcome to TerraMatch 🌱 !'); + $this->createLocalizationKey('send-login-details.body', 'Hi {userName},

' . + 'We\'re thrilled to let you know that your access to TerraMatch is now active!

' . + 'Your user email used for your account is {mail}

' . + 'Please click on the button below to set your new password. This link is valid for 7 days from the day you received this email.

' . + 'If you have any questions or require assistance, our support team is ready to help at info@terramatch.org or +44 7456 289369 (WhatsApp only).

'. + 'We look forward to working with you!

' . + '

' . + 'Best regards,

' . + 'TerraMatch Support'); + $this->createLocalizationKey('send-login-details.cta', 'Set Password'); + // satellite-map-created $this->createLocalizationKey('satellite-map-created.subject', 'Remote Sensing Map Received'); $this->createLocalizationKey('satellite-map-created.title', 'Remote Sensing Map Received'); diff --git a/openapi-src/V2/definitions/AdminUserCreate.yml b/openapi-src/V2/definitions/AdminUserCreate.yml new file mode 100644 index 000000000..47702f755 --- /dev/null +++ b/openapi-src/V2/definitions/AdminUserCreate.yml @@ -0,0 +1,22 @@ +type: object +properties: + first_name: + type: string + last_name: + type: string + email_address: + type: string + job_role: + type: string + phone_number: + type: string + organisation: + type: string + role: + type: string + country: + type: string + program: + type: string + direct_frameworks: + type: boolean \ No newline at end of file diff --git a/openapi-src/V2/definitions/_index.yml b/openapi-src/V2/definitions/_index.yml index fb2f8e31c..3e84461b9 100644 --- a/openapi-src/V2/definitions/_index.yml +++ b/openapi-src/V2/definitions/_index.yml @@ -238,6 +238,8 @@ ProjectReportRead: $ref: './ProjectReportRead.yml' UserCreate: $ref: './UserCreate.yml' +AdminUserCreate: + $ref: './AdminUserCreate.yml' UpdateRequestsPaginated: $ref: './UpdateRequestsPaginated.yml' UpdateRequestRead: diff --git a/openapi-src/V2/paths/Auth/get-auth-mail.yml b/openapi-src/V2/paths/Auth/get-auth-mail.yml new file mode 100644 index 000000000..6b997c49f --- /dev/null +++ b/openapi-src/V2/paths/Auth/get-auth-mail.yml @@ -0,0 +1,51 @@ +summary: "Get email address by reset token" +description: "Retrieves the email address associated with a reset token. If the token has already been used or does not exist, indicates that the token is used." +parameters: + - in: query + name: token + required: true + description: "The reset token" + type: string +responses: + 200: + description: "Successful response" + schema: + type: "object" + properties: + success: + type: "boolean" + example: true + data: + type: "object" + properties: + email_address: + type: "string" + nullable: true + example: "user@example.com" + description: "The email address associated with the reset token, or null if the token is already used." + token_used: + type: "boolean" + example: false + description: "Indicates whether the token has already been used. `true` if the token was used or does not exist, and `false` otherwise." + 404: + description: "User not found for the associated token" + schema: + type: "object" + properties: + success: + type: "boolean" + example: false + message: + type: "string" + example: "User not found" + 500: + description: "Internal server error" + schema: + type: "object" + properties: + success: + type: "boolean" + example: false + message: + type: "string" + example: "Internal Server Error" \ No newline at end of file diff --git a/openapi-src/V2/paths/Auth/post-auth-send-login-details.yml b/openapi-src/V2/paths/Auth/post-auth-send-login-details.yml new file mode 100644 index 000000000..bedd8bcef --- /dev/null +++ b/openapi-src/V2/paths/Auth/post-auth-send-login-details.yml @@ -0,0 +1,20 @@ +operationId: post-auth-send-login-details +summary: Send a password reset email to a user or admin +tags: + - Auth +security: [] +consumes: + - application/json +produces: + - application/json +parameters: + - name: Body + in: body + required: true + schema: + $ref: '../../definitions/_index.yml#/AuthReset' +responses: + '200': + description: OK + schema: + $ref: '../../definitions/_index.yml#/Empty' \ No newline at end of file diff --git a/openapi-src/V2/paths/Auth/post-auth-store.yml b/openapi-src/V2/paths/Auth/post-auth-store.yml new file mode 100644 index 000000000..1f4feee31 --- /dev/null +++ b/openapi-src/V2/paths/Auth/post-auth-store.yml @@ -0,0 +1,20 @@ +operationId: post-auth-store +summary: set a user's or admin's password +tags: + - Auth +security: [] +consumes: + - application/json +produces: + - application/json +parameters: + - name: Body + in: body + required: true + schema: + $ref: '../../definitions/_index.yml#/AuthChange' +responses: + '200': + description: OK + schema: + $ref: '../../definitions/_index.yml#/Empty' \ No newline at end of file diff --git a/openapi-src/V2/paths/_index.yml b/openapi-src/V2/paths/_index.yml index 8f9fa32b8..fa643036d 100644 --- a/openapi-src/V2/paths/_index.yml +++ b/openapi-src/V2/paths/_index.yml @@ -793,6 +793,23 @@ description: OK schema: $ref: '../definitions/_index.yml#/V2AdminUserRead' +/v2/admin/users/create: + post: + summary: Create a user + operationId: v2-admin-post-user + tags: + - V2 Admin + - V2 Users + parameters: + - in: body + name: body + schema: + $ref: '../definitions/_index.yml#/AdminUserCreate' + responses: + '201': + description: Created + schema: + $ref: '../definitions/_index.yml#/V2AdminUserRead' /v2/admin/users/export: get: summary: Export CSV document of all users @@ -2483,6 +2500,15 @@ /auth/reset: post: $ref: './Auth/post-auth-reset.yml' +/auth/send-login-details: + post: + $ref: './Auth/post-auth-send-login-details.yml' +/auth/mail: + get: + $ref: './Auth/get-auth-mail.yml' +/auth/store: + post: + $ref: './Auth/post-auth-store.yml' /v2/auth/verify: patch: $ref: './Auth/patch-v2-auth-verify.yml' diff --git a/resources/docs/swagger-v2.yml b/resources/docs/swagger-v2.yml index 2884a7c92..6ffff9c2a 100644 --- a/resources/docs/swagger-v2.yml +++ b/resources/docs/swagger-v2.yml @@ -42333,6 +42333,29 @@ definitions: type: string program: type: string + AdminUserCreate: + type: object + properties: + first_name: + type: string + last_name: + type: string + email_address: + type: string + job_role: + type: string + phone_number: + type: string + organisation: + type: string + role: + type: string + country: + type: string + program: + type: string + direct_frameworks: + type: boolean UpdateRequestsPaginated: title: UpdateRequestsPaginated type: object @@ -69086,6 +69109,74 @@ paths: type: string date_added: type: string + /v2/admin/users/create: + post: + summary: Create a user + operationId: v2-admin-post-user + tags: + - V2 Admin + - V2 Users + parameters: + - in: body + name: body + schema: + type: object + properties: + first_name: + type: string + last_name: + type: string + email_address: + type: string + job_role: + type: string + phone_number: + type: string + organisation: + type: string + role: + type: string + country: + type: string + program: + type: string + direct_frameworks: + type: boolean + responses: + '201': + description: Created + schema: + title: AdminUserRead + type: object + properties: + uuid: + type: string + status: + type: string + readable_status: + type: string + type: + type: string + first_name: + type: string + last_name: + type: string + email_address: + type: string + job_role: + type: string + facebook: + type: string + instagram: + type: string + linkedin: + type: string + twitter: + type: string + whatsapp_phone: + type: string + date_added: + type: string /v2/admin/users/export: get: summary: Export CSV document of all users @@ -93109,6 +93200,113 @@ paths: description: OK schema: type: object + /auth/send-login-details: + post: + operationId: post-auth-send-login-details + summary: Send a password reset email to a user or admin + tags: + - Auth + security: [] + consumes: + - application/json + produces: + - application/json + parameters: + - name: Body + in: body + required: true + schema: + type: object + properties: + email_address: + type: string + callback_url: + type: string + responses: + '200': + description: OK + schema: + type: object + /auth/mail: + get: + summary: Get email address by reset token + description: 'Retrieves the email address associated with a reset token. If the token has already been used or does not exist, indicates that the token is used.' + parameters: + - in: query + name: token + required: true + description: The reset token + type: string + responses: + '200': + description: Successful response + schema: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + email_address: + type: string + nullable: true + example: user@example.com + description: 'The email address associated with the reset token, or null if the token is already used.' + token_used: + type: boolean + example: false + description: 'Indicates whether the token has already been used. `true` if the token was used or does not exist, and `false` otherwise.' + '404': + description: User not found for the associated token + schema: + type: object + properties: + success: + type: boolean + example: false + message: + type: string + example: User not found + '500': + description: Internal server error + schema: + type: object + properties: + success: + type: boolean + example: false + message: + type: string + example: Internal Server Error + /auth/store: + post: + operationId: post-auth-store + summary: set a user's or admin's password + tags: + - Auth + security: [] + consumes: + - application/json + produces: + - application/json + parameters: + - name: Body + in: body + required: true + schema: + type: object + properties: + token: + type: string + password: + type: string + responses: + '200': + description: OK + schema: + type: object /v2/auth/verify: patch: operationId: patch-v2-auth-verify diff --git a/routes/api.php b/routes/api.php index 372b4b028..640697e0a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -115,6 +115,9 @@ Route::get('/auth/resend', [AuthController::class, 'resendAction']); Route::post('/auth/reset', [AuthController::class, 'resetAction']); Route::patch('/auth/change', [AuthController::class, 'changeAction']); + Route::post('/auth/send-login-details', [AuthController::class, 'sendLoginDetailsAction']); + Route::get('/auth/mail', [AuthController::class, 'getEmailByResetTokenAction']); + Route::post('/auth/store', [AuthController::class, 'setNewPasswordAction']); Route::patch('/v2/auth/verify', [AuthController::class, 'verifyUnauthorizedAction']); Route::put('/v2/auth/complete/signup', [AuthController::class, 'completeUserSignup']); }); diff --git a/routes/api_v2.php b/routes/api_v2.php index e1d77685e..1df197c28 100644 --- a/routes/api_v2.php +++ b/routes/api_v2.php @@ -214,6 +214,7 @@ use App\Http\Controllers\V2\User\AdminExportUsersController; use App\Http\Controllers\V2\User\AdminResetPasswordController; use App\Http\Controllers\V2\User\AdminUserController; +use App\Http\Controllers\V2\User\AdminUserCreationController; use App\Http\Controllers\V2\User\AdminUserMultiController; use App\Http\Controllers\V2\User\AdminUsersOrganizationController; use App\Http\Controllers\V2\User\AdminVerifyUserController; @@ -363,6 +364,7 @@ Route::put('reset-password/{user}', AdminResetPasswordController::class); Route::patch('verify/{user}', AdminVerifyUserController::class); Route::get('users-organisation-list/{organisation}', AdminUsersOrganizationController::class); + Route::post('/create', [AdminUserCreationController::class, 'store']); }); Route::resource('users', AdminUserController::class);