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