diff --git a/.env.example b/.env.example index 99ed3e8..dc2204e 100644 --- a/.env.example +++ b/.env.example @@ -74,3 +74,9 @@ SUPPORT_EMAIL= FRONTEND_URL= +CLOUDFLARE_R2_ACCESS_KEY_ID= +CLOUDFLARE_R2_SECRET_ACCESS_KEY= +CLOUDFLARE_R2_BUCKET= +CLOUDFLARE_R2_ENDPOINT= +CLOUDFLARE_R2_URL= + diff --git a/composer.json b/composer.json index 8d5aaa9..42ea096 100644 --- a/composer.json +++ b/composer.json @@ -7,13 +7,16 @@ "require": { "php": "^8.2", "ext-fileinfo": "*", + "ext-http": "*", "darkaonline/l5-swagger": "^8.6", "laravel/breeze": "^2.1", "laravel/framework": "^11.9", "laravel/sanctum": "^4.0", "laravel/socialite": "^5.15", "laravel/tinker": "^2.9", - "larowka/prevent-duplicate-requests": "^1.1" + "larowka/prevent-duplicate-requests": "^1.1", + "league/flysystem": "^3.0", + "league/flysystem-aws-s3-v3": "^3.0" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/config/filesystems.php b/config/filesystems.php index c5f244d..0698c5d 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -56,6 +56,26 @@ 'throw' => false, ], + 'uploads' => [ + 'driver' => 'local', + 'root' => storage_path('app/public/uploads'), + 'url' => env('APP_URL').'/storage/uploads', + 'visibility' => 'public', + ], + + 'r2' => [ + 'driver' => 's3', + 'key' => env('CLOUDFLARE_R2_ACCESS_KEY_ID'), + 'secret' => env('CLOUDFLARE_R2_SECRET_ACCESS_KEY'), + 'region' => 'auto', + 'bucket' => env('CLOUDFLARE_R2_BUCKET'), + 'url' => env('CLOUDFLARE_R2_URL'), + 'visibility' => 'private', + 'endpoint' => env('CLOUDFLARE_R2_ENDPOINT'), + 'use_path_style_endpoint' => env('CLOUDFLARE_R2_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + ], + ], /* diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 59ff8c1..c14bebb 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -16,13 +16,18 @@ public function up(): void Schema::create('roles', function (Blueprint $table): void { $table->id(); $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); $table->bigInteger('created_at')->useCurrent(); $table->bigInteger('updated_at')->useCurrent(); }); Schema::create('users', function (Blueprint $table): void { $table->uuid('id')->primary(); - $table->string('name'); + $table->string('first_name'); + $table->string('last_name'); + $table->string('avatar')->nullable(); + $table->string('phone')->nullable(); $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); diff --git a/database/migrations/2023_01_01_000000_create_admins_table.php b/database/migrations/2023_01_01_000000_create_admins_table.php new file mode 100644 index 0000000..55846c2 --- /dev/null +++ b/database/migrations/2023_01_01_000000_create_admins_table.php @@ -0,0 +1,43 @@ +uuid('id')->primary(); + $table->string('first_name'); + $table->string('last_name'); + $table->string('avatar')->nullable(); + $table->string('phone')->nullable(); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password')->nullable(); + $table->text('verification_token')->nullable(); + $table->boolean('super_admin')->default(false); + $table->dateTime('verification_token_expiry')->nullable(); + $table->rememberToken(); + $table->string('status')->default('PENDING'); + $table->bigInteger('created_at')->useCurrent(); + $table->bigInteger('updated_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('system_users'); + } +}; diff --git a/database/migrations/2024_07_01_212031_create_personal_access_tokens_table.php b/database/migrations/2024_07_01_212031_create_personal_access_tokens_table.php index 946bb5f..1e85c04 100644 --- a/database/migrations/2024_07_01_212031_create_personal_access_tokens_table.php +++ b/database/migrations/2024_07_01_212031_create_personal_access_tokens_table.php @@ -16,9 +16,11 @@ public function up(): void $table->uuidMorphs('tokenable'); $table->string('name'); $table->string('token', 64)->unique(); + $table->string('refresh_token', 64)->unique(); $table->text('abilities')->nullable(); $table->timestamp('last_used_at')->nullable(); $table->timestamp('expires_at')->nullable(); + $table->timestamp('refresh_token_expires_at'); $table->timestamp('created_at')->useCurrent(); $table->timestamp('updated_at')->useCurrent(); }); diff --git a/database/migrations/2025_01_26_074254_create_notifications_table.php b/database/migrations/2025_01_26_074254_create_notifications_table.php new file mode 100644 index 0000000..d50bae3 --- /dev/null +++ b/database/migrations/2025_01_26_074254_create_notifications_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->string('type'); + $table->uuidMorphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2025_02_23_153233_create_permissions_table.php b/database/migrations/2025_02_23_153233_create_permissions_table.php new file mode 100644 index 0000000..f51ce32 --- /dev/null +++ b/database/migrations/2025_02_23_153233_create_permissions_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name')->unique(); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->bigInteger('created_at')->useCurrent(); + $table->bigInteger('updated_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('permissions'); + } +}; diff --git a/database/migrations/2025_02_23_153433_create_permission_role_table.php b/database/migrations/2025_02_23_153433_create_permission_role_table.php new file mode 100644 index 0000000..fc11ae1 --- /dev/null +++ b/database/migrations/2025_02_23_153433_create_permission_role_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('permission_id')->constrained()->onDelete('cascade'); + $table->foreignId('role_id')->constrained()->onDelete('cascade'); + $table->bigInteger('created_at')->useCurrent(); + $table->bigInteger('updated_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('permission_role'); + } +}; diff --git a/database/migrations/2025_04_08_091743_create_admin_activity_logs_table.php b/database/migrations/2025_04_08_091743_create_admin_activity_logs_table.php new file mode 100644 index 0000000..11fbc6d --- /dev/null +++ b/database/migrations/2025_04_08_091743_create_admin_activity_logs_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->foreignUuid('admin_id')->references('id')->on('system_users')->cascadeOnDelete(); + $table->string('action'); + $table->string('model_type')->nullable(); // e.g., User, Contract + $table->uuid('model_id')->nullable(); + $table->json('meta')->nullable(); // Extra metadata + $table->bigInteger('created_at')->useCurrent(); + $table->bigInteger('updated_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('admin_activity_logs'); + } +}; diff --git a/database/seeders/AdminSeeder.php b/database/seeders/AdminSeeder.php new file mode 100644 index 0000000..cbbc908 --- /dev/null +++ b/database/seeders/AdminSeeder.php @@ -0,0 +1,35 @@ + 'Super', + 'last_name' => 'Admin', + 'email' => 'admin@gmail.com', + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + 'status' => StatusEnum::ACTIVE, + 'super_admin' => true, + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + ]); + }); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 923aabc..c34be95 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -1,4 +1,5 @@ [ + 'view users', + 'create users', + 'edit users', + 'delete users', + ], + 'role_management' => [ + 'view roles', + 'create roles', + 'edit roles', + 'delete roles', + ], + 'permission_management' => [ + 'view permissions', + 'assign permissions', + ], + 'creator_management' => [ + 'view creators', + 'approve creators', + 'suspend creators', + ], + 'content_management' => [ + 'view content', + 'approve content', + 'delete content', + ], + ]; + + // Seed permissions + foreach ($permissions as $group => $perms) { + foreach ($perms as $perm) { + Permission::firstOrCreate([ + 'name' => $perm, + 'slug' => Str::slug($perm), + ]); + } + } + } +} diff --git a/database/seeders/RoleSeeder.php b/database/seeders/RoleSeeder.php index 51a499b..ac8c6a4 100644 --- a/database/seeders/RoleSeeder.php +++ b/database/seeders/RoleSeeder.php @@ -5,6 +5,7 @@ namespace Database\Seeders; use Illuminate\Database\Seeder; +use Illuminate\Support\Str; use Modules\V1\User\Enums\RoleEnum; use Modules\V1\User\Models\Role; @@ -17,7 +18,7 @@ public function run(): void { $roles = RoleEnum::names(); foreach ($roles as $role) { - Role::firstOrCreate(['name' => $role]); + Role::firstOrCreate(['name' => $role, 'slug' => Str::slug($role)]); } } } diff --git a/routes/v1/admin/admin.php b/routes/v1/admin/admin.php new file mode 100644 index 0000000..653a979 --- /dev/null +++ b/routes/v1/admin/admin.php @@ -0,0 +1,13 @@ +as('admins:')->group(function (): void { + Route::get('', [AdminController::class, 'index'])->name('index'); + Route::post('', [AdminController::class, 'store'])->name('store'); + Route::patch('{admin}', [AdminController::class, 'update'])->name('update'); + Route::patch('{admin}/change-role', [AdminController::class, 'changeRole'])->name('changeRole'); +}); diff --git a/routes/v1/admin/api.php b/routes/v1/admin/api.php new file mode 100644 index 0000000..f482043 --- /dev/null +++ b/routes/v1/admin/api.php @@ -0,0 +1,30 @@ +name('logout'); + +/** + * Users Routes + */ +Route::prefix('users')->as('users:')->group( + base_path('routes/v1/admin/users.php'), +); + +Route::prefix('logs')->as('logs:')->group( + base_path('routes/v1/admin/log.php'), +); + +/** + * Admin Routes + */ +Route::as('')->group( + base_path('routes/v1/admin/admin.php'), +); + + diff --git a/routes/v1/admin/log.php b/routes/v1/admin/log.php new file mode 100644 index 0000000..0bfc280 --- /dev/null +++ b/routes/v1/admin/log.php @@ -0,0 +1,5 @@ +name('index'); +Route::get('/{user}', [UserController::class, 'show'])->name('show'); + + diff --git a/routes/v1/auth.php b/routes/v1/auth.php index 590eaac..6c6070e 100644 --- a/routes/v1/auth.php +++ b/routes/v1/auth.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Illuminate\Support\Facades\Route; +use Modules\V1\Admin\Controllers\Auth\LoginController; use Modules\V1\Auth\Controllers\AuthenticatedSessionController; use Modules\V1\Auth\Controllers\EmailVerificationNotificationController; use Modules\V1\Auth\Controllers\NewPasswordController; @@ -42,3 +43,9 @@ Route::post('/refresh-token', [AuthenticatedSessionController::class, 'refreshToken']); }); + +Route::prefix('admin/auth')->group(function (): void { + Route::post('login', LoginController::class) + ->middleware('guest') + ->name('login'); +}); diff --git a/src/modules/V1/Admin/Controllers/AdminBaseController.php b/src/modules/V1/Admin/Controllers/AdminBaseController.php new file mode 100644 index 0000000..bc5e741 --- /dev/null +++ b/src/modules/V1/Admin/Controllers/AdminBaseController.php @@ -0,0 +1,15 @@ +email)->exists()) { + return ResponseHelper::error('Account already exist', 209); + } + + try { + $request->password = Hash::make($request->password); + $role = Role::find($request->role); + $admin = Admin::create($request->validated()); + + $timestamp = DateTimeHelper::timestamp(); + $admin->roles()->attach($role, [ + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + ]); + + // create password reset + $token = $admin->createVerificationToken(); + $inviteLink = config('constants.admin_invite_link') . "/{$token}"; + $admin->notify(new AdminInvite($inviteLink)); + + return ResponseHelper::success(data: new AdminResource($admin), message: 'Registration successful', status: 201); + } catch (Exception $e) { + Log::error($e); + + return ResponseHelper::error(); + } + } + + /** + * @OA\Put( + * path="/admin/admins/{admin}", + * summary="Update an admin", + * tags={"Admin"}, + * + * @OA\Parameter( + * name="admin", + * in="path", + * required=true, + * description="UUID of the admin", + * + * @OA\Schema(type="string", format="uuid") + * ), + * + * @OA\RequestBody( + * required=true, + * + * @OA\JsonContent( + * required={"first_name", "last_name", "email"}, + * + * @OA\Property(property="first_name", type="string", example="John"), + * @OA\Property(property="last_name", type="string", example="Doe"), + * @OA\Property(property="email", type="string", format="email", example="admin@example.com") + * ) + * ), + * + * @OA\Response( + * response=200, + * description="Admin updated successfully", + * + * @OA\JsonContent(ref="#/components/schemas/AdminResource") + * ), + * + * @OA\Response( + * response=500, + * description="Internal server error" + * ) + * ) + */ + public function update(Request $request, Admin $admin) + { + try { + // Validate request data + $request->validate([ + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['required', 'string', 'max:255'], + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + Rule::unique('users', 'email')->ignore($admin->id), + ], + ]); + + // Update the admin + $admin->update($request->only(['first_name', 'last_name', 'email'])); + + return ResponseHelper::success(data: new AdminResource($admin), message: 'Admin updated successfully'); + } catch (Exception $e) { + Log::error($e); + + return ResponseHelper::error(); + } + } + + /** + * Change roles for the specified admin. + * + * + * @OA\Patch( + * path="/admin/admins/{admin}/change-role", + * operationId="changeAdminRole", + * tags={"Admins"}, + * summary="Change roles for the specified admin", + * description="Updates the roles assigned to a specific admin.", + * + * @OA\Parameter( + * name="admin", + * in="path", + * required=true, + * description="ID of the admin", + * + * @OA\Schema( + * type="string", + * ) + * ), + * + * @OA\RequestBody( + * required=true, + * description="Role data", + * + * @OA\JsonContent( + * required={"roles"}, + * + * @OA\Property(property="roles", type="array", @OA\Items(type="integer"), example={1, 2}, description="Array of role IDs to assign to the admin") + * ) + * ), + * + * @OA\Response( + * response=200, + * description="Successful operation", + * + * @OA\JsonContent( + * type="object", + * + * @OA\Property( + * property="success", + * type="boolean", + * example=true, + * description="Indicates if the request was successful" + * ), + * @OA\Property( + * property="data", + * type="object", + * description="Admin resource", + * ref="#/components/schemas/AdminResource" + * ), + * @OA\Property( + * property="message", + * type="string", + * example="Role changed successfully", + * description="A message indicating the result of the operation" + * ) + * ) + * ), + * + * @OA\Response( + * response=400, + * description="Bad request", + * + * @OA\JsonContent( + * type="object", + * + * @OA\Property( + * property="message", + * type="string", + * example="The given data was invalid.", + * description="Error message indicating invalid data" + * ) + * ) + * ), + * + * @OA\Response( + * response=500, + * description="Internal server error", + * + * @OA\JsonContent( + * type="object", + * + * @OA\Property( + * property="message", + * type="string", + * example="Server Error", + * description="Error message indicating internal server error" + * ) + * ) + * ) + * ) + */ + public function changeRole(Request $request, Admin $admin): \Illuminate\Http\JsonResponse + { + try { + // Validate request data + $this->validate($request, [ + 'roles' => 'required|array', + 'roles.*' => 'required|exists:roles,id', + ]); + + // Sync the admins roles + $admin->roles()->sync($request->roles); + + return ResponseHelper::success(data: new AdminResource($admin), message: 'Role changed successfully'); + } catch (Exception $e) { + Log::error($e); + + return ResponseHelper::error(); + } + } +} diff --git a/src/modules/V1/Admin/Controllers/Auth/LoginController.php b/src/modules/V1/Admin/Controllers/Auth/LoginController.php new file mode 100644 index 0000000..932e4aa --- /dev/null +++ b/src/modules/V1/Admin/Controllers/Auth/LoginController.php @@ -0,0 +1,82 @@ +email)->first(); + + if ( ! $user || ! Hash::check($request->password, $user->password)) { + return response()->json([ + 'message' => 'The provided credentials are incorrect.', + 'status' => 'error', + 'statusCode' => '422', + ], 422); + } + + return ResponseHelper::success( + data: new AdminResource($user), + message: 'Login successful', + meta: ['accessToken' => AuthenticationService::createToken($user, $request)] + ); + } +} diff --git a/src/modules/V1/Admin/Controllers/Auth/LogoutController.php b/src/modules/V1/Admin/Controllers/Auth/LogoutController.php new file mode 100644 index 0000000..de0d5c6 --- /dev/null +++ b/src/modules/V1/Admin/Controllers/Auth/LogoutController.php @@ -0,0 +1,71 @@ +check()) { +// return response()->json([ +// 'message' => 'Unauthorized user', +// 'status' => 'error', +// 'statusCode' => '402', +// ], 402); +// } + + // Revoke the token that was used to authenticate the current request + if($request->user()->currentAccessToken()->delete()){ + return ResponseHelper::success(message: 'logged out successfully'); + } + + return ResponseHelper::error(); + } +} diff --git a/src/modules/V1/Admin/Enums/AdminStatusEnum.php b/src/modules/V1/Admin/Enums/AdminStatusEnum.php new file mode 100644 index 0000000..bc11660 --- /dev/null +++ b/src/modules/V1/Admin/Enums/AdminStatusEnum.php @@ -0,0 +1,12 @@ + strtolower($roles->name), self::cases()); + } + + public function name(): string + { + // Get the position of the case within the enum + return StringHelper::toTitleCase($this->name); + } +} diff --git a/src/modules/V1/Admin/Models/Admin.php b/src/modules/V1/Admin/Models/Admin.php new file mode 100644 index 0000000..e840d37 --- /dev/null +++ b/src/modules/V1/Admin/Models/Admin.php @@ -0,0 +1,79 @@ + + */ + protected $fillable = [ + 'first_name', + 'last_name', + 'phone', + 'email', + 'password', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var array + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; + + protected function casts() + { + return [ + "super_admin" => 'boolean' + ]; + } + + public function roles() + { + return $this->belongsToMany(AdminRole::class, 'role_user', 'user_id', 'role_id'); + } + + public function hasRole($role) + { + if (is_string($role)) { + return $this->roles->contains('slug', $role); + } + return !! $role->intersect($this->roles)->count(); + } + + public function hasPermission($permission) + { + return $this->roles->flatMap(function ($role) { + return $role->permissions; + })->contains('slug', $permission); + } +} diff --git a/src/modules/V1/Admin/Models/AdminRole.php b/src/modules/V1/Admin/Models/AdminRole.php new file mode 100644 index 0000000..2e9115d --- /dev/null +++ b/src/modules/V1/Admin/Models/AdminRole.php @@ -0,0 +1,56 @@ +belongsToMany(Admin::class)->withTimestamps(); + } + + public function permissions() + { + return $this->belongsToMany(Permission::class); + } + + /** + * get a role + * @param RoleEnum $role + * @return AdminRole + */ + public static function get(RoleEnum $role): AdminRole { + return self::where('name', $role->name())->first(); + } + +} diff --git a/src/modules/V1/Admin/Models/Permission.php b/src/modules/V1/Admin/Models/Permission.php new file mode 100644 index 0000000..af07cb8 --- /dev/null +++ b/src/modules/V1/Admin/Models/Permission.php @@ -0,0 +1,33 @@ +belongsToMany(Role::class); + } + +} diff --git a/src/modules/V1/Admin/Notifications/AdminGeneralNotification.php b/src/modules/V1/Admin/Notifications/AdminGeneralNotification.php new file mode 100644 index 0000000..8abb154 --- /dev/null +++ b/src/modules/V1/Admin/Notifications/AdminGeneralNotification.php @@ -0,0 +1,19 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage()) + ->view( + 'email.auth.reset_password', // The name of the Blade view file + [ + 'link' => $this->verificationLink, + ] + ); + } +} diff --git a/src/modules/V1/Admin/Requests/AdminInviteRequest.php b/src/modules/V1/Admin/Requests/AdminInviteRequest.php new file mode 100644 index 0000000..2cbf805 --- /dev/null +++ b/src/modules/V1/Admin/Requests/AdminInviteRequest.php @@ -0,0 +1,48 @@ +|string> + */ + public function rules(): array + { + return [ + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['required', 'string', 'max:255'], + 'roles' => ['required', 'array', 'min:1'], + 'roles.*' => ['required', 'exists:roles,id'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:'.Admin::class], + 'password' => ['required', 'string'], + ]; + } +} diff --git a/src/modules/V1/Admin/Resources/AdminResource.php b/src/modules/V1/Admin/Resources/AdminResource.php new file mode 100644 index 0000000..2f85c27 --- /dev/null +++ b/src/modules/V1/Admin/Resources/AdminResource.php @@ -0,0 +1,41 @@ + $this->id, + 'firstName' => StringHelper::toTitleCase($this->first_name), + 'lastName' => StringHelper::toTitleCase($this->last_name), + 'email' => $this->email, +// 'roles' => $this->role->name, + 'status' => $this->status, + 'isSuperAdmin' => $this->super_admin, + 'createdAt' => DateTimeHelper::dateTime($this->created_at), + ]; + } +} diff --git a/src/modules/V1/Admin/Resources/PermissionResource.php b/src/modules/V1/Admin/Resources/PermissionResource.php new file mode 100644 index 0000000..71964e4 --- /dev/null +++ b/src/modules/V1/Admin/Resources/PermissionResource.php @@ -0,0 +1,31 @@ + $this->id, + 'name' => $this->name, + 'slug' => $this->slug, + 'description' => $this->description, + ]; + } +} diff --git a/src/modules/V1/Admin/Resources/RoleResource.php b/src/modules/V1/Admin/Resources/RoleResource.php new file mode 100644 index 0000000..150e6cf --- /dev/null +++ b/src/modules/V1/Admin/Resources/RoleResource.php @@ -0,0 +1,36 @@ + $this->id, + 'name' => $this->name, + 'slug' => $this->slug, + 'description' => $this->description, + 'permissions' => PermissionResource::collection($this->whenLoaded('permissions')), + 'createdAt' => DateTimeHelper::dateTime($this->created_at), + ]; + } +} diff --git a/src/modules/V1/Auth/Controllers/Oauth/GoogleAuthController.php b/src/modules/V1/Auth/Controllers/Oauth/GoogleAuthController.php index 1b8b413..20a101b 100644 --- a/src/modules/V1/Auth/Controllers/Oauth/GoogleAuthController.php +++ b/src/modules/V1/Auth/Controllers/Oauth/GoogleAuthController.php @@ -159,13 +159,10 @@ public function googleAuthLogin(Request $request): \Illuminate\Http\JsonResponse $user = AuthenticationService::findOrCreateUser($authUser); // Create a new token for the user - $device = Str::limit($request->userAgent(), 255); - $token = $user->createToken($device)->plainTextToken; - return ResponseHelper::success( data: new UserResource($user), message: 'Login successful', - meta: ['accessToken' => $token] + meta: ['accessToken' => AuthenticationService::createToken($user, $request)] ); } } diff --git a/src/modules/V1/Auth/Controllers/RegisteredUserController.php b/src/modules/V1/Auth/Controllers/RegisteredUserController.php index 6e47a88..ddcc609 100644 --- a/src/modules/V1/Auth/Controllers/RegisteredUserController.php +++ b/src/modules/V1/Auth/Controllers/RegisteredUserController.php @@ -47,7 +47,8 @@ public function store(RegisterRequest $request): \Illuminate\Http\JsonResponse { $user = new User(); - $user->name = $request->name; + $user->first_name = $request->firstName; + $user->last_name = $request->lastName; $user->email = $request->email; $user->role_id = RoleEnum::USER->value; $user->password = Hash::make($request->password); diff --git a/src/modules/V1/Auth/Controllers/VerifyEmailController.php b/src/modules/V1/Auth/Controllers/VerifyEmailController.php index 464102a..80f046a 100644 --- a/src/modules/V1/Auth/Controllers/VerifyEmailController.php +++ b/src/modules/V1/Auth/Controllers/VerifyEmailController.php @@ -6,7 +6,6 @@ use App\Http\Controllers\V1\Controller; use Exception; -use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; @@ -14,7 +13,6 @@ use Modules\V1\Auth\Notifications\Welcome; use Modules\V1\User\Models\User; use Modules\V1\User\Resources\UserResource; -use Shared\Helpers\GlobalHelper; use Shared\Helpers\ResponseHelper; final class VerifyEmailController extends Controller @@ -132,7 +130,7 @@ public function __invoke(Request $request): \Illuminate\Http\JsonResponse 'token' => ['required', 'string'], ]); - $token = GlobalHelper::decrypt($request->token); + $token = $request->token; // Find the user by the verification token $user = User::where('verification_token', $token)->first(); @@ -159,17 +157,12 @@ public function __invoke(Request $request): \Illuminate\Http\JsonResponse $device = Str::limit($request->userAgent(), 255); $token = $user->createToken($device)->plainTextToken; - return response()->json([ - 'message' => 'User verified successfully', - 'status' => 'success', - 'statusCode' => '200', - 'accessToken' => $token, - 'data' => new UserResource($user), - ]); - } catch (DecryptException $e) { - Log::error('Invalid decryption token: ' . $e); - - return ResponseHelper::error('Invalid verification token', 422); // or throw a custom exception + return ResponseHelper::success( + data: new UserResource($user), + message: 'User verified successfully', + status: 201, + meta: ['accessToken' => $token] + ); } catch (Exception $exception) { Log::error($exception); diff --git a/src/modules/V1/Auth/Model/AccessToken.php b/src/modules/V1/Auth/Model/AccessToken.php new file mode 100644 index 0000000..2a4e471 --- /dev/null +++ b/src/modules/V1/Auth/Model/AccessToken.php @@ -0,0 +1,30 @@ +morphTo(); + } +} diff --git a/src/modules/V1/Auth/Notifications/VerifyEmailAddress.php b/src/modules/V1/Auth/Notifications/VerifyEmailAddress.php index 5acdefc..b4e9161 100644 --- a/src/modules/V1/Auth/Notifications/VerifyEmailAddress.php +++ b/src/modules/V1/Auth/Notifications/VerifyEmailAddress.php @@ -16,7 +16,7 @@ final class VerifyEmailAddress extends Notification /** * Create a new notification instance. */ - public function __construct(public User $user, public string $verificationLink) {} + public function __construct(public User $user, public string $verificationToken) {} /** * Get the notification's delivery channels. @@ -38,7 +38,7 @@ public function toMail(object $notifiable): MailMessage 'email.auth.verify_email', // The name of the Blade view file [ 'name' => $this->user->name, - 'token' => $this->verificationLink, + 'token' => $this->verificationToken, ] ); } diff --git a/src/modules/V1/Auth/Requests/RegisterRequest.php b/src/modules/V1/Auth/Requests/RegisterRequest.php index 4d8ab75..0165810 100644 --- a/src/modules/V1/Auth/Requests/RegisterRequest.php +++ b/src/modules/V1/Auth/Requests/RegisterRequest.php @@ -12,10 +12,13 @@ * title="User Registration Request", * description="Schema for the user registration request", * type="object", - * required={"name", "email", "password"}, - * @OA\Property(property="name", type="string", maxLength=255, example="John"), + * required={"firstName", "lastName", "email"}, + * + * @OA\Property(property="firstName", type="string", maxLength=255, example="John"), + * @OA\Property(property="lastName", type="string", maxLength=255, example="Doe"), * @OA\Property(property="email", type="string", format="email", example="john@example.com"), * @OA\Property(property="password", type="string", example="password123"), + * * ) */ class RegisterRequest extends FormRequest @@ -36,7 +39,8 @@ public function authorize(): bool public function rules(): array { return [ - 'name' => ['required', 'string', 'max:255'], + 'firstName' => ['required', 'string', 'max:255'], + 'lastName' => ['required', 'string', 'max:255'], 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class], 'password' => ['required', 'string', Rules\Password::defaults()], ]; diff --git a/src/modules/V1/Auth/Services/AuthenticationService.php b/src/modules/V1/Auth/Services/AuthenticationService.php index 65dc16a..582dfbe 100644 --- a/src/modules/V1/Auth/Services/AuthenticationService.php +++ b/src/modules/V1/Auth/Services/AuthenticationService.php @@ -4,14 +4,16 @@ namespace Modules\V1\Auth\Services; +use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Str; +use Laravel\Socialite\Two\User as SocialiteUser; use Modules\V1\Auth\Enums\AuthProviderEnum; +use Modules\V1\Auth\Notifications\Welcome; use Modules\V1\User\Enums\RoleEnum; use Modules\V1\User\Models\User; -use Laravel\Socialite\Two\User as SocialiteUser; use Shared\Helpers\GlobalHelper; - final class AuthenticationService { public static function findOrCreateUser(SocialiteUser $authUser): User @@ -20,9 +22,15 @@ public static function findOrCreateUser(SocialiteUser $authUser): User $user = User::where('email', $authUser->getEmail())->first(); if ( ! $user) { - // User does not exist, create a new user + $fullName = $authUser->getName(); + $nameParts = explode(' ', $fullName); + + $firstName = $nameParts[0] ?? ''; + $lastName = implode(' ', array_slice($nameParts, 1)) ?? ''; + $user = User::create([ - 'name' => $authUser->getName(), + 'first_name' => $firstName, + 'last_name' => $lastName, 'email' => $authUser->getEmail(), 'provider_type' => AuthProviderEnum::google->name, 'provider_id' => $authUser->getId(), @@ -30,8 +38,19 @@ public static function findOrCreateUser(SocialiteUser $authUser): User 'password' => Hash::make(GlobalHelper::generateCode(new User())), 'oauth' => true, ]); + + $user->markEmailAsVerified(); + $user->notify(new Welcome($user, config('constants.user_dashboard'))); } return $user; } + + public static function createToken(User|Authenticatable $user, $request): string + { + $device = Str::limit($request->userAgent(), 255); + $token = $user->createToken($device)->plainTextToken; + + return $token; + } } diff --git a/src/modules/V1/Auth/Services/TokenService.php b/src/modules/V1/Auth/Services/TokenService.php new file mode 100644 index 0000000..47c0f38 --- /dev/null +++ b/src/modules/V1/Auth/Services/TokenService.php @@ -0,0 +1,64 @@ +authTokens()->delete(); + + $accessTokenExpiry = now()->addMinutes(config('sanctum.expiration', 1440)); + $refreshTokenExpiry = now()->addDays(30); + + $token = AccessToken::create([ + 'tokenable_type' => get_class($user), + 'tokenable_id' => $user->id, + 'token' => base64_encode(Str::random(64)), + 'refresh_token' => base64_encode(Str::random(64)), + 'abilities' => ['*'], + 'expires_at' => $accessTokenExpiry, + 'refresh_token_expires_at' => $refreshTokenExpiry, + ]); + + return [ + 'access_token' => $token->access_token, + 'refresh_token' => $token->refresh_token, + 'token_type' => 'Bearer', + 'access_token_expires_at' => $accessTokenExpiry, + 'refresh_token_expires_at' => $refreshTokenExpiry, + ]; + } + + public function refreshAccessToken(string $refreshToken) + { + $token = AccessToken::where('refresh_token', $refreshToken) + ->where('refresh_token_expires_at', '>', now()) + ->first(); + + if (!$token) { + return null; + } + + $user = $token->tokenable; + $token->delete(); + + return $this->createTokens($user); + } + + public function generateTokenString() + { + return sprintf( + '%s%s%s', + config('sanctum.token_prefix', ''), + $tokenEntropy = Str::random(40), + hash('crc32b', $tokenEntropy) + ); + } +} diff --git a/src/modules/V1/User/Models/User.php b/src/modules/V1/User/Models/User.php index a5b7e94..b834e0e 100644 --- a/src/modules/V1/User/Models/User.php +++ b/src/modules/V1/User/Models/User.php @@ -18,10 +18,11 @@ use Modules\V1\Auth\Notifications\ResetPassword; use Modules\V1\Auth\Notifications\VerifyEmailAddress; use Shared\Helpers\GlobalHelper; +use Shared\Services\UserService; class User extends Authenticatable implements MustVerifyEmail { - use HasApiTokens, HasFactory, HasUuids, Notifiable; + use HasApiTokens, HasFactory, HasUuids, Notifiable, UserService; /** * The storage format of the model's date columns. @@ -77,42 +78,6 @@ public static function active(): ?\Illuminate\Contracts\Auth\Authenticatable return Auth::user(); } - /** - * Create a verification token with an optional expiry time. - * - * @param int $hours The number of hours until the token expires (default is 24 hours). - * @return string The encrypted verification token. - */ - public function createVerificationToken(int $hours = 24): string - { - // Generate a unique verification token - $token = Str::random(64); // Adjust the length as needed - - // Set token expiry time (e.g., 24 hours from now) - $expiry = Carbon::now()->addHours($hours); - - // Store the token and expiry timestamp in the database - $this->verification_token = $token; - $this->verification_token_expiry = $expiry; - $this->save(); - - return GlobalHelper::encrypt($token); - } - - public function createEmailVerificationToken($hours = 24): string - { - // Generate a unique verification token - $token = substr(str_shuffle("0123456789"), 0, 5); // Adjust the length as needed - - // Set token expiry time (e.g., 24 hours from now) - $expiry = \Illuminate\Support\Carbon::now()->addHours($hours); - $this->verification_token = $token; - $this->verification_token_expiry = $expiry; - $this->save(); - - return $token; - } - public function sendEmailVerificationNotification(): void { $verificationToken = $this->createEmailVerificationToken(); @@ -127,15 +92,6 @@ public function sendPasswordResetNotification($token = ''): void } - public function markEmailAsVerified(): bool - { - $this->email_verified_at = Carbon::now(); - $this->verification_token = null; - $this->verification_token_expiry = null; - - return $this->save(); - } - protected static function newFactory(): UserFactory { return UserFactory::new(); diff --git a/src/shared/Enums/StatusEnum.php b/src/shared/Enums/StatusEnum.php index 83e6612..adf0c9c 100644 --- a/src/shared/Enums/StatusEnum.php +++ b/src/shared/Enums/StatusEnum.php @@ -11,6 +11,7 @@ enum StatusEnum: string const APPROVED = 'APPROVED'; const FAILED = 'FAILED'; + const ACTIVE = 'ACTIVE'; const SUCCESS = 'SUCCESS'; diff --git a/src/shared/Helpers/DateTimeHelper.php b/src/shared/Helpers/DateTimeHelper.php index b287e2d..6af2b90 100644 --- a/src/shared/Helpers/DateTimeHelper.php +++ b/src/shared/Helpers/DateTimeHelper.php @@ -28,4 +28,9 @@ public static function dateTime($date): string { return $date->toDateTimeString(); } + + public static function timestamp(): string|int|float + { + return Carbon::now()->timestamp; + } } diff --git a/src/shared/Services/FileService.php b/src/shared/Services/FileService.php index e4a1efc..ad5cd56 100644 --- a/src/shared/Services/FileService.php +++ b/src/shared/Services/FileService.php @@ -44,9 +44,10 @@ public function __construct(string $name, string $originalName, string $mime, st public function toArray(): array { return [ + 'name' => $this->name, 'file_display_name' => $this->name, 'file_name' => $this->originalName, - 'file_mime_type' => $this->mime, + 'mime_type' => $this->mime, 'file_path' => $this->path, 'file_size' => $this->size, 'file_disk' => $this->disk, @@ -76,23 +77,27 @@ public static function upload(UploadedFile $file, string $folderName = ''): fals try { $name = $file->getClientOriginalName(); $fileName = "{$folderName}/" . self::formatName($name); - $disk = 'public'; - // Store the file in the 'public' disk (storage/app/public) - Storage::disk($disk)->put($fileName, file_get_contents($file)); + $disk = env('FILESYSTEM_DISK', 'public'); + $filePath = Storage::disk($disk)->put($fileName, file_get_contents($file->getRealPath())); // Generate SHA-256 hash for the file - $fileHash = hash('sha256', file_get_contents($file)); - - return (new self( - name: $name, - originalName: $fileName, - mime: $file->getClientMimeType(), - path: Storage::disk($disk)->url($fileName), - disk: $disk, - hash: $fileHash, - size: $file->getSize(), - collection: $folderName - ))->toArray(); + $fileHash = hash('sha256', file_get_contents($file->getRealPath())); + + if ($filePath) { + // Create and return an OrderDocument instance + return (new self( + name: $name, + originalName: $fileName, + mime: $file->getClientMimeType(), + path: Storage::disk($disk)->url($fileName), + disk: $disk, + hash: $fileHash, + size: $file->getSize(), + collection: $folderName + ))->toArray(); + } else { + throw new Exception('File upload failed.'); + } } catch (Exception $e) { Log::channel('upload_error')->error("upload Failed: \n" . $e->getMessage()); diff --git a/src/shared/Services/UserService.php b/src/shared/Services/UserService.php new file mode 100644 index 0000000..3fa135e --- /dev/null +++ b/src/shared/Services/UserService.php @@ -0,0 +1,55 @@ +addHours($hours); + + // Store the token and expiry timestamp in the database + $this->verification_token = $token; + $this->verification_token_expiry = $expiry; + $this->save(); + + return GlobalHelper::encrypt($token); + } + + public function createEmailVerificationToken($hours = 24): string + { + // Generate a unique verification token + $token = mb_substr(str_shuffle('0123456789'), 0, 6); // Adjust the length as needed + + // Set token expiry time (e.g., 24 hours from now) + $expiry = \Illuminate\Support\Carbon::now()->addHours($hours); + $this->verification_token = $token; + $this->verification_token_expiry = $expiry; + $this->save(); + + return $token; + } + + public function markEmailAsVerified(): bool + { + $this->email_verified_at = Carbon::now(); + $this->verification_token = null; + $this->verification_token_expiry = null; + + return $this->save(); + } +}