diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 529af71a8..fb937004f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -223,7 +223,7 @@ These **must** be run before committing code, from the root of the monorepo: ## Changelog Guidelines -**Every change must include a changelog update** — do this automatically, without waiting to be asked: +Before committing, **every change must include a changelog update**. Once a change is completed, ask the user if he wants to update the changelog. If yes, follow these steps: 1. Update the relevant `packages/*/CHANGELOG.md` file(s) for affected packages 2. Add entries under the `## [Unreleased]` section diff --git a/.vscode/settings.json b/.vscode/settings.json index 1dda7349c..9d3227c58 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,12 @@ "package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .prettier*, prettier*, .editorconfig", "composer.json": "composer.lock, phpunit.xml*, phpstan.neon*, phpstan-baseline.neon*, .php-cs*, .php_cs*, .styleci.yml*, .phpdoc.*" }, - "cSpell.words": ["axios", "pinia", "Sprunjer", "tdate", "TOTP"] + "cSpell.words": [ + "axios", + "Morphable", + "pinia", + "Sprunjer", + "tdate", + "TOTP" + ] } diff --git a/packages/sprinkle-account/CHANGELOG.md b/packages/sprinkle-account/CHANGELOG.md index 0d051d460..6ab6980ce 100644 --- a/packages/sprinkle-account/CHANGELOG.md +++ b/packages/sprinkle-account/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Changed +- Added activity logging for profile changes. + ### New Feature - Added MorphMapProvider for Eloquent polymorphic type aliases. diff --git a/packages/sprinkle-account/app/src/Account.php b/packages/sprinkle-account/app/src/Account.php index ebbe61804..6ba1e9246 100644 --- a/packages/sprinkle-account/app/src/Account.php +++ b/packages/sprinkle-account/app/src/Account.php @@ -35,6 +35,7 @@ use UserFrosting\Sprinkle\Account\Database\Migrations\v600\DropVerificationsTable; use UserFrosting\Sprinkle\Account\Database\Migrations\v600\UpdateUsersTable as V600UpdateUsersTable; use UserFrosting\Sprinkle\Account\Database\Migrations\v600\UserVerificationTable; +use UserFrosting\Sprinkle\Account\Database\Migrations\v610\ActivitiesV2Table; use UserFrosting\Sprinkle\Account\Database\Seeds\DefaultGroups; use UserFrosting\Sprinkle\Account\Database\Seeds\DefaultPermissions; use UserFrosting\Sprinkle\Account\Database\Seeds\DefaultRoles; @@ -48,8 +49,11 @@ use UserFrosting\Sprinkle\Account\Listener\UpgradePassword; use UserFrosting\Sprinkle\Account\Listener\UserLogoutActivity; use UserFrosting\Sprinkle\Account\Listener\UserSignInActivity; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; +use UserFrosting\Sprinkle\Account\Log\UserActivityTypes; use UserFrosting\Sprinkle\Account\Routes\AuthRoutes; use UserFrosting\Sprinkle\Account\ServicesProvider\AccessConditionsService; +use UserFrosting\Sprinkle\Account\ServicesProvider\ActivityService; use UserFrosting\Sprinkle\Account\ServicesProvider\AuthorizationService; use UserFrosting\Sprinkle\Account\ServicesProvider\AuthService; use UserFrosting\Sprinkle\Account\ServicesProvider\I18nService; @@ -57,6 +61,7 @@ use UserFrosting\Sprinkle\Account\ServicesProvider\MFAServices; use UserFrosting\Sprinkle\Account\ServicesProvider\ModelsService; use UserFrosting\Sprinkle\Account\ServicesProvider\MorphMapProvider; +use UserFrosting\Sprinkle\Account\Sprinkle\Recipe\ActivityRecipe; use UserFrosting\Sprinkle\Account\Twig\AccountExtension; use UserFrosting\Sprinkle\BakeryRecipe; use UserFrosting\Sprinkle\Core\Bakery\Event\BakeCommandEvent; @@ -68,6 +73,7 @@ class Account implements SprinkleRecipe, + ActivityRecipe, MigrationRecipe, SeedRecipe, EventListenerRecipe, @@ -113,6 +119,17 @@ public function getSprinkles(): array ]; } + /** + * {@inheritDoc} + */ + public function getActivityTypes(): array + { + return [ + UserActivityTypes::class, + AccountActivityTypes::class, + ]; + } + /** * {@inheritDoc} */ @@ -130,6 +147,7 @@ public function getServices(): array { return [ AccessConditionsService::class, + ActivityService::class, AuthorizationService::class, AuthService::class, ModelsService::class, @@ -171,6 +189,8 @@ public function getMigrations(): array V600UpdateUsersTable::class, DropPasswordResetsTable::class, DropVerificationsTable::class, + // v610 + ActivitiesV2Table::class, ]; } diff --git a/packages/sprinkle-account/app/src/Bakery/CreateUser.php b/packages/sprinkle-account/app/src/Bakery/CreateUser.php index 255bc039a..3054bd4fc 100644 --- a/packages/sprinkle-account/app/src/Bakery/CreateUser.php +++ b/packages/sprinkle-account/app/src/Bakery/CreateUser.php @@ -36,8 +36,8 @@ use UserFrosting\Sprinkle\Account\Database\Models\User; use UserFrosting\Sprinkle\Account\Event\UserCreatedEvent; use UserFrosting\Sprinkle\Account\Exceptions\AccountException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; -use UserFrosting\Sprinkle\Account\Log\UserActivityTypes; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Validators\UserValidation; use UserFrosting\Sprinkle\Core\Bakery\Helper\DatabaseTest; use UserFrosting\Sprinkle\Core\Database\Migrator\MigrationRepositoryInterface; @@ -70,7 +70,7 @@ class CreateUser extends Command protected EventDispatcherInterface $eventDispatcher; #[Inject] - protected UserActivityLoggerInterface $logger; + protected ActivityRecorderInterface $logger; #[Inject] protected Capsule $capsule; @@ -190,10 +190,11 @@ protected function execute(InputInterface $input, OutputInterface $output) $user = $this->eventDispatcher->dispatch($event)->user; // Create activity record - $this->logger->info("User {$user->user_name} account was created.", [ - 'type' => UserActivityTypes::REGISTER, - 'user_id' => $user->id, - ]); + $this->logger->record( + user: null, + type: AccountActivityTypes::CREATE, + subject: $user + ); return $user; }); diff --git a/packages/sprinkle-account/app/src/Controller/EmailVerificationValidationAction.php b/packages/sprinkle-account/app/src/Controller/EmailVerificationValidationAction.php index 25cb8f523..452f1fa9c 100644 --- a/packages/sprinkle-account/app/src/Controller/EmailVerificationValidationAction.php +++ b/packages/sprinkle-account/app/src/Controller/EmailVerificationValidationAction.php @@ -27,7 +27,7 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\FailedVerificationException; use UserFrosting\Sprinkle\Account\Exceptions\VerificationDisabledException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Log\UserActivityTypes; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Throttle\Throttler; @@ -73,7 +73,7 @@ class EmailVerificationValidationAction * @param RequestDataTransformer $transformer * @param ServerSideValidator $validator * @param UserInterface $userModel - * @param UserActivityLoggerInterface $logger + * @param ActivityRecorderInterface $logger * @param Connection $db * @param Throttler $throttler */ @@ -84,7 +84,7 @@ public function __construct( protected RequestDataTransformer $transformer, protected ServerSideValidator $validator, protected UserInterface $userModel, - protected UserActivityLoggerInterface $logger, + protected ActivityRecorderInterface $logger, protected Connection $db, protected Throttler $throttler, protected Config $config, @@ -163,10 +163,11 @@ protected function handle(Request $request): void $user->save(); // Create activity record - $this->logger->info("User {$user->user_name} verified it's account.", [ - 'type' => UserActivityTypes::VERIFIED, - 'user_id' => $user->id, - ]); + $this->logger->record( + user: $user, + type: UserActivityTypes::VERIFIED, + subject: $user + ); }); } diff --git a/packages/sprinkle-account/app/src/Controller/ForgetPasswordSetPasswordAction.php b/packages/sprinkle-account/app/src/Controller/ForgetPasswordSetPasswordAction.php index 4470a3215..c0f632791 100644 --- a/packages/sprinkle-account/app/src/Controller/ForgetPasswordSetPasswordAction.php +++ b/packages/sprinkle-account/app/src/Controller/ForgetPasswordSetPasswordAction.php @@ -25,7 +25,7 @@ use UserFrosting\Sprinkle\Account\Authenticate\Interfaces\EmailVerificationProvider; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\PasswordResetInvalidException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Log\UserActivityTypes; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; @@ -56,15 +56,15 @@ class ForgetPasswordSetPasswordAction /** * Inject dependencies. * - * @param Translator $translator - * @param Config $config - * @param RouteParserInterface $routeParser - * @param EmailVerificationProvider $emailVerification - * @param RequestDataTransformer $transformer - * @param ServerSideValidator $validator - * @param Connection $db - * @param UserInterface $userModel - * @param UserActivityLoggerInterface $logger + * @param Translator $translator + * @param Config $config + * @param RouteParserInterface $routeParser + * @param EmailVerificationProvider $emailVerification + * @param RequestDataTransformer $transformer + * @param ServerSideValidator $validator + * @param Connection $db + * @param UserInterface $userModel + * @param ActivityRecorderInterface $logger */ public function __construct( protected Translator $translator, @@ -75,7 +75,7 @@ public function __construct( protected ServerSideValidator $validator, protected Connection $db, protected UserInterface $userModel, - protected UserActivityLoggerInterface $logger, + protected ActivityRecorderInterface $logger, ) { } @@ -134,10 +134,11 @@ protected function handle(Request $request): void $user->save(); // Create activity record - $this->logger->info("User {$user->user_name} reset it's password.", [ - 'type' => UserActivityTypes::PASSWORD_RESET, - 'user_id' => $user->id, - ]); + $this->logger->record( + user: $user, + type: UserActivityTypes::PASSWORD_RESET, + subject: $user + ); }); } diff --git a/packages/sprinkle-account/app/src/Controller/ProfileEditAction.php b/packages/sprinkle-account/app/src/Controller/ProfileEditAction.php index f8cee8b4c..a13c9f0c8 100644 --- a/packages/sprinkle-account/app/src/Controller/ProfileEditAction.php +++ b/packages/sprinkle-account/app/src/Controller/ProfileEditAction.php @@ -12,6 +12,7 @@ namespace UserFrosting\Sprinkle\Account\Controller; +use Illuminate\Database\Connection; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use UserFrosting\Fortress\RequestSchema; @@ -23,7 +24,8 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; use UserFrosting\Sprinkle\Account\Exceptions\LocaleNotFoundException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\I18n\SiteLocale; use UserFrosting\Sprinkle\Core\Util\ApiResponse; @@ -53,9 +55,10 @@ public function __construct( protected Translator $translator, protected Authenticator $authenticator, protected SiteLocale $locale, - protected UserActivityLoggerInterface $logger, + protected ActivityRecorderInterface $logger, protected RequestDataTransformer $transformer, - protected ServerSideValidator $validator + protected ServerSideValidator $validator, + protected Connection $db ) { } @@ -124,14 +127,19 @@ protected function handle(Request $request): void // Looks good, let's update with new values! // Note that only fields listed in `profile-settings.yaml` will be // permitted in $data, so this prevents the user from updating all columns in the DB - $currentUser->fill($data); - $currentUser->save(); - - // Create activity record - $this->logger->info("User {$currentUser->user_name} updated their profile settings.", [ - 'type' => 'update_profile_settings', - 'user_id' => $currentUser->id, - ]); + $this->db->transaction(function () use ($currentUser, $data): void { + $currentUser->fill($data); + + // Record while the subject still contains its dirty attributes. + $this->logger->record( + user: $currentUser, + type: AccountActivityTypes::UPDATE_PROFILE_SETTINGS, + subject: $currentUser, + withProperties: true, + ); + + $currentUser->save(); + }); } /** diff --git a/packages/sprinkle-account/app/src/Controller/ProfileEmailEditAction.php b/packages/sprinkle-account/app/src/Controller/ProfileEmailEditAction.php index 7d10b0d7c..86cfb47f3 100644 --- a/packages/sprinkle-account/app/src/Controller/ProfileEmailEditAction.php +++ b/packages/sprinkle-account/app/src/Controller/ProfileEmailEditAction.php @@ -12,6 +12,7 @@ namespace UserFrosting\Sprinkle\Account\Controller; +use Illuminate\Database\Connection; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use UserFrosting\Config\Config; @@ -25,7 +26,8 @@ use UserFrosting\Sprinkle\Account\Exceptions\EmailNotUniqueException; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; use UserFrosting\Sprinkle\Account\Exceptions\PasswordInvalidException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; @@ -55,10 +57,11 @@ public function __construct( protected Translator $translator, protected Authenticator $authenticator, protected Config $config, - protected UserActivityLoggerInterface $logger, + protected ActivityRecorderInterface $logger, protected UserInterface $userModel, protected RequestDataTransformer $transformer, - protected ServerSideValidator $validator + protected ServerSideValidator $validator, + protected Connection $db ) { } @@ -123,17 +126,28 @@ protected function handle(Request $request): void throw new EmailNotUniqueException(); } + // Keep the old email for activity log + $oldEmail = $currentUser->email; + // Looks good, let's update with new values! // Note that only fields listed in `account-email.yaml` will be // permitted in $data, so this prevents the user from updating all columns in the DB - $currentUser->fill($data); - $currentUser->save(); - - // Create activity record - $this->logger->info("User {$currentUser->user_name} updated their account settings.", [ - 'type' => 'update_account_settings', - 'user_id' => $currentUser->id, - ]); + $this->db->transaction(function () use ($currentUser, $data, $oldEmail): void { + $currentUser->fill($data); + + // Record while the subject still contains its dirty attributes. + $this->logger->record( + user: $currentUser, + type: AccountActivityTypes::UPDATE_EMAIL, + subject: $currentUser, + metadata: [ + 'old' => $oldEmail, + 'new' => $data['email'], + ] + ); + + $currentUser->save(); + }); } /** diff --git a/packages/sprinkle-account/app/src/Controller/RegisterAction.php b/packages/sprinkle-account/app/src/Controller/RegisterAction.php index 75a4a8697..dce8645fa 100644 --- a/packages/sprinkle-account/app/src/Controller/RegisterAction.php +++ b/packages/sprinkle-account/app/src/Controller/RegisterAction.php @@ -29,7 +29,7 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Event\UserCreatedEvent; use UserFrosting\Sprinkle\Account\Exceptions\RegistrationException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Log\UserActivityTypes; use UserFrosting\Sprinkle\Account\Validators\UserValidation; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; @@ -78,7 +78,7 @@ public function __construct( protected Session $session, protected SiteLocale $locale, protected Throttler $throttler, - protected UserActivityLoggerInterface $logger, + protected ActivityRecorderInterface $logger, protected UserInterface $userModel, protected UserValidation $userValidation, protected EmailVerificationProvider $emailVerification, @@ -174,10 +174,11 @@ protected function handle(Request $request): array $user = $this->eventDispatcher->dispatch($event)->user; // Create activity record - $this->logger->info("User {$user->user_name} registered for a new account.", [ - 'type' => UserActivityTypes::REGISTER, - 'user_id' => $user->id, - ]); + $this->logger->record( + user: $user, + type: UserActivityTypes::REGISTER, + subject: $user + ); // Send activation email if ($this->requireEmailVerification() === true) { diff --git a/packages/sprinkle-account/app/src/Controller/SettingsEditAction.php b/packages/sprinkle-account/app/src/Controller/SettingsEditAction.php index dc511c1d3..70f196bf7 100644 --- a/packages/sprinkle-account/app/src/Controller/SettingsEditAction.php +++ b/packages/sprinkle-account/app/src/Controller/SettingsEditAction.php @@ -12,6 +12,7 @@ namespace UserFrosting\Sprinkle\Account\Controller; +use Illuminate\Database\Connection; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use UserFrosting\Config\Config; @@ -24,7 +25,8 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; use UserFrosting\Sprinkle\Account\Exceptions\PasswordInvalidException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; @@ -54,10 +56,11 @@ public function __construct( protected Translator $translator, protected Authenticator $authenticator, protected Config $config, - protected UserActivityLoggerInterface $logger, + protected ActivityRecorderInterface $logger, protected UserInterface $userModel, protected RequestDataTransformer $transformer, - protected ServerSideValidator $validator + protected ServerSideValidator $validator, + protected Connection $db ) { } @@ -123,14 +126,19 @@ protected function handle(Request $request): void // Looks good, let's update with new values! // Note that only fields listed in `account-settings.yaml` will be // permitted in $data, so this prevents the user from updating all columns in the DB - $currentUser->fill($data); - $currentUser->save(); - - // Create activity record - $this->logger->info("User {$currentUser->user_name} updated their account settings.", [ - 'type' => 'update_account_settings', - 'user_id' => $currentUser->id, - ]); + $this->db->transaction(function () use ($currentUser, $data): void { + $currentUser->fill($data); + + // Record while the subject still contains its dirty attributes. + $this->logger->record( + user: $currentUser, + type: AccountActivityTypes::UPDATE_PASSWORD, + subject: $currentUser, + withProperties: true, + ); + + $currentUser->save(); + }); } /** diff --git a/packages/sprinkle-account/app/src/Database/Migrations/v400/ActivitiesTable.php b/packages/sprinkle-account/app/src/Database/Migrations/v400/ActivitiesTable.php index c3a12572d..ab5f8e244 100644 --- a/packages/sprinkle-account/app/src/Database/Migrations/v400/ActivitiesTable.php +++ b/packages/sprinkle-account/app/src/Database/Migrations/v400/ActivitiesTable.php @@ -16,7 +16,7 @@ use UserFrosting\Sprinkle\Core\Database\Migration; /** - * Sessions table migration + * Activities table migration * Version 4.0.0. */ class ActivitiesTable extends Migration diff --git a/packages/sprinkle-account/app/src/Database/Migrations/v610/ActivitiesV2Table.php b/packages/sprinkle-account/app/src/Database/Migrations/v610/ActivitiesV2Table.php new file mode 100644 index 000000000..f3d604094 --- /dev/null +++ b/packages/sprinkle-account/app/src/Database/Migrations/v610/ActivitiesV2Table.php @@ -0,0 +1,81 @@ +schema->hasColumn('activities', 'context_type')) { + $this->schema->table('activities', function (Blueprint $table) { + $table->string('context_type', 100)->nullable(); + $table->char('context_id', 36)->nullable(); + $table->string('subject_type', 100)->nullable(); + $table->char('subject_id', 36)->nullable(); + $table->json('metadata')->nullable(); + $table->json('properties')->nullable(); + + $table->index(['context_type', 'context_id']); + $table->index(['subject_type', 'subject_id']); + + // Make user_id nullable to support activities that are not associated with a user (eg. system activities). + $table->unsignedInteger('user_id')->nullable()->change(); + }); + } + } + + /** + * {@inheritdoc} + */ + public function down(): void + { + if ($this->schema->hasColumn('activities', 'context_type')) { + if ($this->schema->getConnection()->table('activities')->whereNull('user_id')->exists()) { + throw new RuntimeException('Cannot downgrade activities while NULL user_id values exist.'); + } + + $this->schema->withoutForeignKeyConstraints(function () { + $this->schema->table('activities', function (Blueprint $table) { + $table->dropColumn([ + 'context_type', + 'context_id', + 'subject_type', + 'subject_id', + 'metadata', + 'properties', + ]); + + $table->unsignedInteger('user_id')->nullable(false)->change(); + }); + }); + } + } +} diff --git a/packages/sprinkle-account/app/src/Database/Models/Activity.php b/packages/sprinkle-account/app/src/Database/Models/Activity.php index 8a493a983..e9dfc4f17 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Activity.php +++ b/packages/sprinkle-account/app/src/Database/Models/Activity.php @@ -16,6 +16,7 @@ use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Query\Builder as QueryBuilder; use UserFrosting\Sprinkle\Account\Database\Factories\ActivityFactory; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\ActivityInterface; @@ -25,7 +26,18 @@ /** * Activity Model. * - * Represents a single user activity at a specified point in time. + * Represents an activity generated by a user at a specified point in time. + * Provide a subject and context to the activity to link it to other models. + * The subject is the model the action was performed on, while the context is + * an optional third related model. In all cases, the user is the one who caused + * the activity to be generated, and can be null for system activities. + * + * For example, if a user comments on a post, the subject would be the comment + * and the context would be the post. This allows for a more flexible + * representation of activities, as they can be linked to any model in the + * application, and thus displayed in a more meaningful and specific way to the + * users (eg. show activities related to a specific post, comment, or a + * specific user). * * @mixin \Illuminate\Database\Eloquent\Builder */ @@ -44,9 +56,15 @@ class Activity extends Model implements ActivityInterface protected $fillable = [ 'ip_address', 'user_id', + 'context_type', + 'context_id', + 'subject_type', + 'subject_id', 'type', + 'metadata', + 'properties', 'occurred_at', - 'description', + 'description', // @deprecated 6.1 kept for backward compatibility ]; /** @@ -54,9 +72,27 @@ class Activity extends Model implements ActivityInterface */ protected $casts = [ 'user_id' => 'integer', + 'metadata' => 'array', + 'properties' => 'array', 'occurred_at' => 'datetime', ]; + /** + * @var array The attributes that should be visible in arrays. + */ + protected $visible = [ + 'id', + 'ip_address', + 'type', + 'occurred_at', + 'properties', + 'description', + 'label', + 'user', + 'context', + 'subject', + ]; + /** * @var bool Disable timestamps for this class. */ @@ -80,7 +116,7 @@ public function getDescriptionAttribute(?string $value): string public function scopeJoinUser(Builder $query): Builder|QueryBuilder { return $query->select('activities.*') - ->join('users', 'activities.user_id', '=', 'users.id'); + ->leftJoin('users', 'activities.user_id', '=', 'users.id'); } /** @@ -102,6 +138,22 @@ public function user(): BelongsTo return $this->belongsTo($relation, 'user_id'); } + /** + * Get the optional third model related to this activity. + */ + public function context(): MorphTo + { + return $this->morphTo(); + } + + /** + * Get the model this activity was performed on. + */ + public function subject(): MorphTo + { + return $this->morphTo(); + } + /** * Create a new factory instance for the model. * diff --git a/packages/sprinkle-account/app/src/Database/Models/Interfaces/ActivityInterface.php b/packages/sprinkle-account/app/src/Database/Models/Interfaces/ActivityInterface.php index a4f0f0384..747448fa3 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Interfaces/ActivityInterface.php +++ b/packages/sprinkle-account/app/src/Database/Models/Interfaces/ActivityInterface.php @@ -15,7 +15,9 @@ use DateTime; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Query\Builder as QueryBuilder; +use UserFrosting\Sprinkle\Core\Database\Models\Interfaces\MorphableModelInterface; use UserFrosting\Sprinkle\Core\Database\Models\Model; /** @@ -25,18 +27,26 @@ * @mixin \Illuminate\Database\Eloquent\Model * @mixin \UserFrosting\Sprinkle\Core\Database\Models\Model * - * @property int $id - * @property string|null $ip_address - * @property int $user_id - * @property string $type - * @property Datetime|null $occurred_at - * @property string $description - * @property UserInterface $user + * @property int $id + * @property string|null $ip_address + * @property int|null $user_id + * @property string $type + * @property Datetime|null $occurred_at + * @property string $description @deprecated 6.1 + * @property UserInterface|null $user + * @property string|null $context_type + * @property string|null $context_id + * @property string|null $subject_type + * @property string|null $subject_id + * @property array|null $metadata + * @property array|null $properties + * @property-read UserInterface|null $user * - * @method $this joinUser() - * @method static $this joinUser() + * @method $this joinUser() + * @method static $this joinUser() + * @method Builder newQuery() */ -interface ActivityInterface +interface ActivityInterface extends MorphableModelInterface { /** * Users which belong to this activity. @@ -45,6 +55,16 @@ interface ActivityInterface */ public function user(): BelongsTo; + /** + * Get the optional third model related to this activity. + */ + public function context(): MorphTo; + + /** + * Get the model this activity was performed on. + */ + public function subject(): MorphTo; + /** * Scope a query to only include specific type. * diff --git a/packages/sprinkle-account/app/src/Database/Models/Interfaces/GroupInterface.php b/packages/sprinkle-account/app/src/Database/Models/Interfaces/GroupInterface.php index ea53d2454..685c7f94a 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Interfaces/GroupInterface.php +++ b/packages/sprinkle-account/app/src/Database/Models/Interfaces/GroupInterface.php @@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\HasMany; +use UserFrosting\Sprinkle\Core\Database\Models\Interfaces\MorphableModelInterface; use UserFrosting\Sprinkle\Core\Database\Models\Model; /** @@ -32,7 +33,7 @@ * @property \DateTimeImmutable $updated_at * @property Collection $users */ -interface GroupInterface +interface GroupInterface extends MorphableModelInterface { /** * Users which belong to this group. diff --git a/packages/sprinkle-account/app/src/Database/Models/Interfaces/PermissionInterface.php b/packages/sprinkle-account/app/src/Database/Models/Interfaces/PermissionInterface.php index d4aa872dc..c2a08d0aa 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Interfaces/PermissionInterface.php +++ b/packages/sprinkle-account/app/src/Database/Models/Interfaces/PermissionInterface.php @@ -16,6 +16,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Query\Builder as QueryBuilder; +use UserFrosting\Sprinkle\Core\Database\Models\Interfaces\MorphableModelInterface; use UserFrosting\Sprinkle\Core\Database\Models\Model; use UserFrosting\Sprinkle\Core\Database\Relations\BelongsToManyThrough; @@ -39,7 +40,7 @@ * @method $this notForRole(int|RoleInterface $role) * @method static $this notForRole(int|RoleInterface $role) */ -interface PermissionInterface +interface PermissionInterface extends MorphableModelInterface { /** * Get a list of roles to which this permission is assigned. diff --git a/packages/sprinkle-account/app/src/Database/Models/Interfaces/PersistenceInterface.php b/packages/sprinkle-account/app/src/Database/Models/Interfaces/PersistenceInterface.php index 9109b929f..33d680587 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Interfaces/PersistenceInterface.php +++ b/packages/sprinkle-account/app/src/Database/Models/Interfaces/PersistenceInterface.php @@ -16,6 +16,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Query\Builder as QueryBuilder; +use UserFrosting\Sprinkle\Core\Database\Models\Interfaces\MorphableModelInterface; use UserFrosting\Sprinkle\Core\Database\Models\Model; /** @@ -37,7 +38,7 @@ * @method $this notExpired() * @method static $this notExpired() */ -interface PersistenceInterface +interface PersistenceInterface extends MorphableModelInterface { /** * Relation with the user table. diff --git a/packages/sprinkle-account/app/src/Database/Models/Interfaces/RoleInterface.php b/packages/sprinkle-account/app/src/Database/Models/Interfaces/RoleInterface.php index f8427b93e..ab3bb87c3 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Interfaces/RoleInterface.php +++ b/packages/sprinkle-account/app/src/Database/Models/Interfaces/RoleInterface.php @@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use UserFrosting\Sprinkle\Core\Database\Models\Interfaces\MorphableModelInterface; use UserFrosting\Sprinkle\Core\Database\Models\Model; /** @@ -33,7 +34,7 @@ * @method $this forUser(int|UserInterface $user) * @method static $this forUser(int|UserInterface $user) */ -interface RoleInterface +interface RoleInterface extends MorphableModelInterface { /** * Get a list of permissions assigned to this role. diff --git a/packages/sprinkle-account/app/src/Database/Models/Interfaces/UserInterface.php b/packages/sprinkle-account/app/src/Database/Models/Interfaces/UserInterface.php index 69836512c..d46f924c3 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Interfaces/UserInterface.php +++ b/packages/sprinkle-account/app/src/Database/Models/Interfaces/UserInterface.php @@ -21,6 +21,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Query\Builder as QueryBuilder; use UserFrosting\Sprinkle\Account\Database\Models\Activity; +use UserFrosting\Sprinkle\Core\Database\Models\Interfaces\MorphableModelInterface; use UserFrosting\Sprinkle\Core\Database\Models\Model; use UserFrosting\Sprinkle\Core\Database\Relations\BelongsToManyThrough; @@ -61,7 +62,7 @@ * @method $this forRole(int|RoleInterface $role) * @method static $this forRole(int|RoleInterface $role) */ -interface UserInterface +interface UserInterface extends MorphableModelInterface { /** * Allows you to get the full name of the user using `$user->full_name`. diff --git a/packages/sprinkle-account/app/src/Database/Models/Interfaces/UserVerificationInterface.php b/packages/sprinkle-account/app/src/Database/Models/Interfaces/UserVerificationInterface.php index 8351ee95c..fd735e4c4 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Interfaces/UserVerificationInterface.php +++ b/packages/sprinkle-account/app/src/Database/Models/Interfaces/UserVerificationInterface.php @@ -14,6 +14,7 @@ use DateTime; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use UserFrosting\Sprinkle\Core\Database\Models\Interfaces\MorphableModelInterface; use UserFrosting\Sprinkle\Core\Database\Models\Model; /** @@ -36,7 +37,7 @@ * @method $this expired() Link to the expired scope * @method $this notExpired() Link to the not expired scope */ -interface UserVerificationInterface +interface UserVerificationInterface extends MorphableModelInterface { /** * User associated with this verification request. diff --git a/packages/sprinkle-account/app/src/Database/Models/RoleUsers.php b/packages/sprinkle-account/app/src/Database/Models/RoleUsers.php index 05d2da12d..6bd022071 100644 --- a/packages/sprinkle-account/app/src/Database/Models/RoleUsers.php +++ b/packages/sprinkle-account/app/src/Database/Models/RoleUsers.php @@ -13,14 +13,14 @@ namespace UserFrosting\Sprinkle\Account\Database\Models; use Illuminate\Database\Eloquent\Relations\Pivot; -use UserFrosting\Sprinkle\Core\Database\Models\Model; +use UserFrosting\Sprinkle\Core\Database\Models\Interfaces\MorphableModelInterface; /** * Represents a the User-Role many-to-many relationship intermediate table. * * @mixin \Illuminate\Database\Eloquent\Builder */ -class RoleUsers extends Pivot +class RoleUsers extends Pivot implements MorphableModelInterface { /** * @var string The name of the table for the current model. diff --git a/packages/sprinkle-account/app/src/Listener/UpgradePassword.php b/packages/sprinkle-account/app/src/Listener/UpgradePassword.php index 41cb12524..e0af92657 100644 --- a/packages/sprinkle-account/app/src/Listener/UpgradePassword.php +++ b/packages/sprinkle-account/app/src/Listener/UpgradePassword.php @@ -14,7 +14,7 @@ use UserFrosting\Sprinkle\Account\Authenticate\Hasher; use UserFrosting\Sprinkle\Account\Event\UserAuthenticatedEvent; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Log\UserActivityTypes; /** @@ -24,7 +24,7 @@ class UpgradePassword { public function __construct( protected Hasher $hasher, - protected UserActivityLoggerInterface $logger, + protected ActivityRecorderInterface $logger, ) { } @@ -39,10 +39,11 @@ public function __invoke(UserAuthenticatedEvent $event): void $event->user->save(); // Save changes // Add a sign in activity (time is automatically set by database) - $this->logger->debug("User {$event->user->user_name} outdated password hash has been automatically updated to modern hashing.", [ - 'type' => UserActivityTypes::PASSWORD_UPGRADED, - 'user_id' => $event->user->id, - ]); + $this->logger->record( + user: $event->user, + type: UserActivityTypes::PASSWORD_UPGRADED, + subject: $event->user + ); } } } diff --git a/packages/sprinkle-account/app/src/Listener/UserLogoutActivity.php b/packages/sprinkle-account/app/src/Listener/UserLogoutActivity.php index 9a4eabb84..9c8acb830 100644 --- a/packages/sprinkle-account/app/src/Listener/UserLogoutActivity.php +++ b/packages/sprinkle-account/app/src/Listener/UserLogoutActivity.php @@ -13,7 +13,7 @@ namespace UserFrosting\Sprinkle\Account\Listener; use UserFrosting\Sprinkle\Account\Event\UserLoggedOutEvent; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Log\UserActivityTypes; /** @@ -22,16 +22,17 @@ class UserLogoutActivity { public function __construct( - protected UserActivityLoggerInterface $logger, + protected ActivityRecorderInterface $logger, ) { } public function __invoke(UserLoggedOutEvent $event): void { - // Add a sign in activity (time is automatically set by database) - $this->logger->info("User {$event->user->user_name} signed out.", [ - 'type' => UserActivityTypes::LOGGED_OUT, - 'user_id' => $event->user->id, - ]); + // Add a sign out activity (time is automatically set by database) + $this->logger->record( + user: $event->user, + type: UserActivityTypes::LOGGED_OUT, + subject: $event->user + ); } } diff --git a/packages/sprinkle-account/app/src/Listener/UserSignInActivity.php b/packages/sprinkle-account/app/src/Listener/UserSignInActivity.php index f9627559f..a134e2b57 100644 --- a/packages/sprinkle-account/app/src/Listener/UserSignInActivity.php +++ b/packages/sprinkle-account/app/src/Listener/UserSignInActivity.php @@ -13,7 +13,7 @@ namespace UserFrosting\Sprinkle\Account\Listener; use UserFrosting\Sprinkle\Account\Event\UserLoggedInEvent; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Log\UserActivityTypes; /** @@ -22,16 +22,16 @@ class UserSignInActivity { public function __construct( - protected UserActivityLoggerInterface $logger, + protected ActivityRecorderInterface $logger, ) { } public function __invoke(UserLoggedInEvent $event): void { - // Add a sign in activity (time is automatically set by database) - $this->logger->info("User {$event->user->user_name} signed in.", [ - 'type' => UserActivityTypes::LOGGED_IN, - 'user_id' => $event->user->id, - ]); + $this->logger->record( + user: $event->user, + type: UserActivityTypes::LOGGED_IN, + subject: $event->user + ); } } diff --git a/packages/sprinkle-account/app/src/Log/AccountActivityTypes.php b/packages/sprinkle-account/app/src/Log/AccountActivityTypes.php new file mode 100644 index 000000000..ca6199293 --- /dev/null +++ b/packages/sprinkle-account/app/src/Log/AccountActivityTypes.php @@ -0,0 +1,55 @@ + 'ACCOUNT.ACTIVITY.CREATE', + self::DELETE => 'ACCOUNT.ACTIVITY.DELETE', + self::UPDATE_PROFILE_SETTINGS => 'ACCOUNT.ACTIVITY.UPDATE_PROFILE_SETTINGS', + self::UPDATE_ACCOUNT_SETTINGS => 'ACCOUNT.ACTIVITY.UPDATE_ACCOUNT_SETTINGS', + self::UPDATE_EMAIL => 'ACCOUNT.ACTIVITY.UPDATE_EMAIL', + self::UPDATE_PASSWORD => 'ACCOUNT.ACTIVITY.UPDATE_PASSWORD', + default => null, + }; + } + + /** + * {@inheritDoc} + */ + public static function getLabelI18nKey(string $value): ?string + { + return match (self::tryFrom($value)) { + self::CREATE => 'ACCOUNT.ACTIVITY.LABEL.CREATE', + self::DELETE => 'ACCOUNT.ACTIVITY.LABEL.DELETE', + self::UPDATE_PROFILE_SETTINGS => 'ACCOUNT.ACTIVITY.LABEL.UPDATE_PROFILE_SETTINGS', + self::UPDATE_ACCOUNT_SETTINGS => 'ACCOUNT.ACTIVITY.LABEL.UPDATE_ACCOUNT_SETTINGS', + self::UPDATE_EMAIL => 'ACCOUNT.ACTIVITY.LABEL.UPDATE_EMAIL', + self::UPDATE_PASSWORD => 'ACCOUNT.ACTIVITY.LABEL.UPDATE_PASSWORD', + default => null, + }; + } +} diff --git a/packages/sprinkle-account/app/src/Log/ActivityRecorder.php b/packages/sprinkle-account/app/src/Log/ActivityRecorder.php new file mode 100644 index 000000000..454371fd8 --- /dev/null +++ b/packages/sprinkle-account/app/src/Log/ActivityRecorder.php @@ -0,0 +1,113 @@ + isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : null, + 'user_id' => $user?->getKey(), + 'context_type' => $context?->getMorphClass(), + 'context_id' => $context?->getKey(), + 'subject_type' => $subject?->getMorphClass(), + 'subject_id' => $subject?->getKey(), + 'type' => $type->value, + 'metadata' => $metadata, + 'properties' => $withProperties ? $this->getProperties($subject) : null, + 'occurred_at' => new DateTimeImmutable(), + ]); + + $activity->save(); + + return $activity; + } + + /** + * Extract safe old and new values from a dirty Eloquent subject. + * + * @param MorphableModelInterface|null $subject + * + * @return array|null + */ + protected function getProperties(?MorphableModelInterface $subject): ?array + { + if (!$subject instanceof EloquentModel) { + return null; + } + + $hidden = $subject->getHidden(); + $properties = []; + foreach (array_keys($subject->getDirty()) as $key) { + if (in_array($key, $hidden, true) || $this->isSensitive($key)) { + continue; + } + + $properties[$key] = [ + 'old' => $this->normalizeValue($subject->getOriginal($key)), + 'new' => $this->normalizeValue($subject->getAttribute($key)), + ]; + } + + return $properties === [] ? null : $properties; + } + + /** + * Determine whether a property should never be recorded. + */ + protected function isSensitive(string $key): bool + { + $key = strtolower($key); + + return $key === 'password' + || str_contains($key, 'token') + || str_contains($key, 'secret'); + } + + /** + * Normalize a value so it can safely be stored in a JSON column. + */ + protected function normalizeValue(mixed $value): mixed + { + if ($value instanceof \DateTimeInterface) { + return $value->format(DATE_ATOM); + } + + if (is_scalar($value) || $value === null) { + return $value; + } + + if (is_array($value)) { + return array_map(fn (mixed $item): mixed => $this->normalizeValue($item), $value); + } + + return (string) $value; + } +} diff --git a/packages/sprinkle-account/app/src/Log/ActivityRecorderInterface.php b/packages/sprinkle-account/app/src/Log/ActivityRecorderInterface.php new file mode 100644 index 000000000..29111b27a --- /dev/null +++ b/packages/sprinkle-account/app/src/Log/ActivityRecorderInterface.php @@ -0,0 +1,59 @@ +|null> $metadata Additional placeholders passed to the translator. + * @param MorphableModelInterface|null $context Optional third related model (polymorphic context). + * @param MorphableModelInterface|null $subject Model the action is performed on (polymorphic subject). + * @param bool $withProperties Whether to derive changed properties from the subject. + * + * @return ActivityInterface + */ + public function record( + ?UserInterface $user, + BackedEnum $type, + array $metadata = [], + ?MorphableModelInterface $context = null, + ?MorphableModelInterface $subject = null, + bool $withProperties = false, + ): ActivityInterface; +} diff --git a/packages/sprinkle-account/app/src/Log/ActivityTypeRegistryInterface.php b/packages/sprinkle-account/app/src/Log/ActivityTypeRegistryInterface.php new file mode 100644 index 000000000..8382c9867 --- /dev/null +++ b/packages/sprinkle-account/app/src/Log/ActivityTypeRegistryInterface.php @@ -0,0 +1,41 @@ + + */ +interface ActivityTypeRegistryInterface extends ClassRepositoryInterface +{ + /** + * Return the i18n key for a persisted activity type value. + * + * @param string $value + * + * @return string|null + */ + public function getI18nKey(string $value): ?string; + + /** + * Return the i18n key for a concise label for a persisted activity type value. + * + * @param string $value + * + * @return string|null + */ + public function getLabelI18nKey(string $value): ?string; +} diff --git a/packages/sprinkle-account/app/src/Log/ActivityTypes.php b/packages/sprinkle-account/app/src/Log/ActivityTypes.php new file mode 100644 index 000000000..4b8abefe7 --- /dev/null +++ b/packages/sprinkle-account/app/src/Log/ActivityTypes.php @@ -0,0 +1,34 @@ + + */ +final class SprinkleActivityTypeRegistry extends ClassRepository implements ActivityTypeRegistryInterface +{ + /** + * @var ActivityTypes[]|null + */ + protected ?array $activityTypes = null; + + /** + * @var array|null + */ + protected ?array $activityTypesByValue = null; + + public function __construct( + protected SprinkleManager $sprinkleManager, + ) { + } + + /** + * {@inheritDoc} + */ + public function all(): array + { + $this->loadActivityTypes(); + + return $this->activityTypes ?? []; + } + + /** + * {@inheritDoc} + */ + public function getI18nKey(string $value): ?string + { + $this->loadActivityTypes(); + + $activityType = $this->activityTypesByValue[$value] ?? null; + if ($activityType === null) { + return null; + } + + return get_class($activityType)::getI18nKey($value); + } + + /** + * {@inheritDoc} + */ + public function getLabelI18nKey(string $value): ?string + { + $this->loadActivityTypes(); + + $activityType = $this->activityTypesByValue[$value] ?? null; + if ($activityType === null) { + return null; + } + + return get_class($activityType)::getLabelI18nKey($value); + } + + /** + * Expand the activity enum classes registered by all loaded Sprinkles. + */ + protected function loadActivityTypes(): void + { + if ($this->activityTypes !== null) { + return; + } + + /** @var ActivityTypes[] $activityTypes */ + $activityTypes = []; + + /** @var array $activityTypesByValue */ + $activityTypesByValue = []; + + foreach ($this->sprinkleManager->getSprinkles() as $sprinkle) { + if (!$sprinkle instanceof ActivityRecipe) { + continue; + } + + foreach ($sprinkle->getActivityTypes() as $activityTypeClass) { + foreach ($this->getActivityTypeCases($activityTypeClass) as $activityType) { + if (isset($activityTypesByValue[$activityType->value])) { + throw new LogicException("Activity type value `{$activityType->value}` is registered more than once."); + } + + $activityTypes[] = $activityType; + $activityTypesByValue[$activityType->value] = $activityType; + } + } + } + + $this->activityTypes = $activityTypes; + $this->activityTypesByValue = $activityTypesByValue; + } + + /** + * Validate a registered class and return all of its enum cases. + * + * @param string $activityTypeClass + * + * @return array + */ + protected function getActivityTypeCases(string $activityTypeClass): array + { + if (!class_exists($activityTypeClass)) { + throw new BadClassNameException("Activity type class `$activityTypeClass` not found."); + } + + if (!is_subclass_of($activityTypeClass, BackedEnum::class) || !is_subclass_of($activityTypeClass, ActivityTypes::class)) { + throw new BadInstanceOfException("Activity type class `$activityTypeClass` doesn't implement " . ActivityTypes::class . ' and ' . BackedEnum::class . '.'); + } + + /** @var class-string $activityTypeClass */ + return $activityTypeClass::cases(); + } +} diff --git a/packages/sprinkle-account/app/src/Log/UserActivityDatabaseHandler.php b/packages/sprinkle-account/app/src/Log/UserActivityDatabaseHandler.php index e607f6516..df8987e77 100644 --- a/packages/sprinkle-account/app/src/Log/UserActivityDatabaseHandler.php +++ b/packages/sprinkle-account/app/src/Log/UserActivityDatabaseHandler.php @@ -21,6 +21,8 @@ /** * Monolog handler for storing user activities to the database. + * + * @deprecated 6.1 Use ActivityRecorder instead */ class UserActivityDatabaseHandler extends AbstractProcessingHandler { @@ -52,6 +54,9 @@ public function __construct( */ protected function write(LogRecord $record): void { + // Trigger deprecation warning + trigger_error('UserActivityLogger/UserActivityLoggerInterface is deprecated since UserFrosting 6.1 and will be removed in 6.2. Use ActivityRecorder instead.', E_USER_DEPRECATED); + if (!is_array($record['context']) || !isset($record['context']['user_id'])) { throw new LogicException('UserActivityLogger requires a `user_id` to be set in the context.'); } diff --git a/packages/sprinkle-account/app/src/Log/UserActivityLogger.php b/packages/sprinkle-account/app/src/Log/UserActivityLogger.php index 96b9efa20..cfc11ba68 100644 --- a/packages/sprinkle-account/app/src/Log/UserActivityLogger.php +++ b/packages/sprinkle-account/app/src/Log/UserActivityLogger.php @@ -18,6 +18,8 @@ * User Activity Logger. * * @todo : We could bring back the processor, to add the current user into the context + * + * @deprecated 6.1 Use ActivityRecorder instead */ class UserActivityLogger extends Logger implements UserActivityLoggerInterface { diff --git a/packages/sprinkle-account/app/src/Log/UserActivityLoggerInterface.php b/packages/sprinkle-account/app/src/Log/UserActivityLoggerInterface.php index c2b26acf1..e4ffd7c4a 100644 --- a/packages/sprinkle-account/app/src/Log/UserActivityLoggerInterface.php +++ b/packages/sprinkle-account/app/src/Log/UserActivityLoggerInterface.php @@ -16,6 +16,8 @@ /** * Monolog alias for dependency injection. + * + * @deprecated 6.1 Use ActivityRecorder instead */ interface UserActivityLoggerInterface extends LoggerInterface { diff --git a/packages/sprinkle-account/app/src/Log/UserActivityTypes.php b/packages/sprinkle-account/app/src/Log/UserActivityTypes.php index 00237bbdf..2a122880e 100644 --- a/packages/sprinkle-account/app/src/Log/UserActivityTypes.php +++ b/packages/sprinkle-account/app/src/Log/UserActivityTypes.php @@ -12,7 +12,7 @@ namespace UserFrosting\Sprinkle\Account\Log; -enum UserActivityTypes: string +enum UserActivityTypes: string implements ActivityTypes { case REGISTER = 'sign_up'; case VERIFIED = 'verified'; @@ -20,4 +20,36 @@ enum UserActivityTypes: string case LOGGED_IN = 'sign_in'; case LOGGED_OUT = 'sign_out'; case PASSWORD_UPGRADED = 'password_upgraded'; + + /** + * {@inheritDoc} + */ + public static function getI18nKey(string $value): ?string + { + return match (self::tryFrom($value)) { + self::REGISTER => 'ACCOUNT.ACTIVITY.REGISTER', + self::VERIFIED => 'ACCOUNT.ACTIVITY.VERIFIED', + self::PASSWORD_RESET => 'ACCOUNT.ACTIVITY.PASSWORD_RESET', + self::LOGGED_IN => 'ACCOUNT.ACTIVITY.LOGGED_IN', + self::LOGGED_OUT => 'ACCOUNT.ACTIVITY.LOGGED_OUT', + self::PASSWORD_UPGRADED => 'ACCOUNT.ACTIVITY.PASSWORD_UPGRADED', + default => null, + }; + } + + /** + * {@inheritDoc} + */ + public static function getLabelI18nKey(string $value): ?string + { + return match (self::tryFrom($value)) { + self::REGISTER => 'ACCOUNT.ACTIVITY.LABEL.REGISTER', + self::VERIFIED => 'ACCOUNT.ACTIVITY.LABEL.VERIFIED', + self::PASSWORD_RESET => 'ACCOUNT.ACTIVITY.LABEL.PASSWORD_RESET', + self::LOGGED_IN => 'ACCOUNT.ACTIVITY.LABEL.LOGGED_IN', + self::LOGGED_OUT => 'ACCOUNT.ACTIVITY.LABEL.LOGGED_OUT', + self::PASSWORD_UPGRADED => 'ACCOUNT.ACTIVITY.LABEL.PASSWORD_UPGRADED', + default => null, + }; + } } diff --git a/packages/sprinkle-account/app/src/ServicesProvider/ActivityService.php b/packages/sprinkle-account/app/src/ServicesProvider/ActivityService.php new file mode 100644 index 000000000..35cbd7a66 --- /dev/null +++ b/packages/sprinkle-account/app/src/ServicesProvider/ActivityService.php @@ -0,0 +1,27 @@ + \DI\autowire(SprinkleActivityTypeRegistry::class), + ]; + } +} diff --git a/packages/sprinkle-account/app/src/ServicesProvider/LoggersService.php b/packages/sprinkle-account/app/src/ServicesProvider/LoggersService.php index 5b3767a2a..154cac5fa 100644 --- a/packages/sprinkle-account/app/src/ServicesProvider/LoggersService.php +++ b/packages/sprinkle-account/app/src/ServicesProvider/LoggersService.php @@ -13,6 +13,8 @@ namespace UserFrosting\Sprinkle\Account\ServicesProvider; use UserFrosting\ServicesProvider\ServicesProviderInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorder; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Log\AuthLogger; use UserFrosting\Sprinkle\Account\Log\AuthLoggerInterface; use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; @@ -24,7 +26,8 @@ public function register(): array { return [ AuthLoggerInterface::class => \DI\autowire(AuthLogger::class), - UserActivityLoggerInterface::class => \DI\autowire(UserActivityLogger::class), + UserActivityLoggerInterface::class => \DI\autowire(UserActivityLogger::class), // @phpstan-ignore-line classConstant.deprecatedInterface classConstant.deprecatedClass + ActivityRecorderInterface::class => \DI\autowire(ActivityRecorder::class), ]; } } diff --git a/packages/sprinkle-account/app/src/Sprinkle/Recipe/ActivityRecipe.php b/packages/sprinkle-account/app/src/Sprinkle/Recipe/ActivityRecipe.php new file mode 100644 index 000000000..dc1377be9 --- /dev/null +++ b/packages/sprinkle-account/app/src/Sprinkle/Recipe/ActivityRecipe.php @@ -0,0 +1,26 @@ +[] + */ + public function getActivityTypes(): array; +} diff --git a/packages/sprinkle-account/app/tests/Bakery/CreateAdminUserTest.php b/packages/sprinkle-account/app/tests/Bakery/CreateAdminUserTest.php index 48e092115..6daa32212 100644 --- a/packages/sprinkle-account/app/tests/Bakery/CreateAdminUserTest.php +++ b/packages/sprinkle-account/app/tests/Bakery/CreateAdminUserTest.php @@ -17,11 +17,12 @@ use Psr\EventDispatcher\EventDispatcherInterface; use UserFrosting\Event\EventDispatcher; use UserFrosting\Sprinkle\Account\Bakery\CreateAdminUser; +use UserFrosting\Sprinkle\Account\Database\Models\Activity; use UserFrosting\Sprinkle\Account\Database\Models\User; use UserFrosting\Sprinkle\Account\Event\UserCreatedEvent; use UserFrosting\Sprinkle\Account\Exceptions\UsernameNotUniqueException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorder; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Tests\AccountTestCase; use UserFrosting\Sprinkle\Account\Validators\UserValidation; use UserFrosting\Sprinkle\Core\Database\Migrator\MigrationRepositoryInterface; @@ -42,6 +43,12 @@ public function setUp(): void $this->refreshDatabase(); } + public function tearDown(): void + { + Activity::query()->delete(); + parent::tearDown(); + } + /** * WARNING : This test doesn't work on Windows. * @see https://symfony.com/doc/current/components/console/helpers/questionhelper.html#testing-a-command-that-expects-input @@ -65,10 +72,10 @@ public function testCommandWithUserInput(): void $this->getContainer()->set(EventDispatcherInterface::class, $eventDispatcher); // Mock userActivityLogger to assert it's being called properly. - $userActivityLogger = Mockery::mock(UserActivityLogger::class) - ->shouldReceive('info')->once() + $userActivityLogger = Mockery::mock(ActivityRecorder::class) + ->shouldReceive('record')->once() ->getMock(); - $this->getContainer()->set(UserActivityLoggerInterface::class, $userActivityLogger); + $this->getContainer()->set(ActivityRecorderInterface::class, $userActivityLogger); /** @var CreateAdminUser */ $command = $this->getService(CreateAdminUser::class); diff --git a/packages/sprinkle-account/app/tests/Controller/ProfileEditActionTest.php b/packages/sprinkle-account/app/tests/Controller/ProfileEditActionTest.php index d2daa4bd2..791ea2531 100644 --- a/packages/sprinkle-account/app/tests/Controller/ProfileEditActionTest.php +++ b/packages/sprinkle-account/app/tests/Controller/ProfileEditActionTest.php @@ -13,7 +13,9 @@ namespace UserFrosting\Sprinkle\Account\Tests\Controller; use UserFrosting\Config\Config; +use UserFrosting\Sprinkle\Account\Database\Models\Activity; use UserFrosting\Sprinkle\Account\Database\Models\User; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; use UserFrosting\Sprinkle\Account\Testing\WithTestUser; use UserFrosting\Sprinkle\Account\Tests\AccountTestCase; use UserFrosting\Sprinkle\Core\Testing\RefreshDatabase; @@ -37,6 +39,7 @@ public function testProfile(): void /** @var User */ $user = User::factory()->create(); $this->actAsUser($user, true); + $oldFirstName = $user->first_name; // Create request with method and url and fetch response $request = $this->createJsonRequest('POST', '/account/settings/profile', [ @@ -59,6 +62,16 @@ public function testProfile(): void $this->assertSame('foo', $editedUser->first_name); $this->assertSame($user->last_name, $editedUser->last_name); $this->assertSame($user->locale, $editedUser->locale); + + /** @var Activity */ + $activity = Activity::where('type', AccountActivityTypes::UPDATE_PROFILE_SETTINGS->value)->latest('id')->first(); + $this->assertEquals([ + 'first_name' => [ + 'old' => $oldFirstName, + 'new' => 'foo', + ], + ], $activity->properties); + $this->assertSame([], $activity->metadata); } public function testProfileWithNoPermissions(): void diff --git a/packages/sprinkle-account/app/tests/Database/Migrations/MigrationsTest.php b/packages/sprinkle-account/app/tests/Database/Migrations/MigrationsTest.php index c4d128b6b..75e0b0ca9 100644 --- a/packages/sprinkle-account/app/tests/Database/Migrations/MigrationsTest.php +++ b/packages/sprinkle-account/app/tests/Database/Migrations/MigrationsTest.php @@ -12,8 +12,11 @@ namespace UserFrosting\Sprinkle\Account\Tests\Database\Migrations; +use DateTimeImmutable; use Illuminate\Database\Schema\Builder; +use RuntimeException; use UserFrosting\Sprinkle\Account\Database\Migrations\v400\ActivitiesTable; +use UserFrosting\Sprinkle\Account\Database\Migrations\v610\ActivitiesV2Table; use UserFrosting\Sprinkle\Account\Tests\AccountTestCase; use UserFrosting\Sprinkle\Core\Database\Migrator\Migrator; @@ -51,6 +54,51 @@ public function testMigrations(): void } } + public function testActivitiesV2MigrationIsIdempotent(): void + { + /** @var Builder */ + $builder = $this->getService(Builder::class); + + /** @var Migrator */ + $migrator = $this->getService(Migrator::class); + $migrator->reset(); + $migrator->migrate(); + + $migration = new ActivitiesV2Table($builder); + $migration->up(); + + $this->assertTrue($builder->hasColumn('activities', 'context_type')); + + $migrator->rollback(); + } + + public function testActivitiesV2MigrationRefusesNullUserDowngrade(): void + { + /** @var Builder */ + $builder = $this->getService(Builder::class); + + /** @var Migrator */ + $migrator = $this->getService(Migrator::class); + $migrator->reset(); + $migrator->migrate(); + + $builder->getConnection()->table('activities')->insert([ + 'user_id' => null, + 'type' => 'test', + 'occurred_at' => new DateTimeImmutable(), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot downgrade activities while NULL user_id values exist.'); + + try { + (new ActivitiesV2Table($builder))->down(); + } finally { + $builder->getConnection()->table('activities')->delete(); + $migrator->rollback(); + } + } + /** @return array */ public function tablesProvider(): array { @@ -62,6 +110,12 @@ public function tablesProvider(): array 'occurred_at', 'type', 'user_id', + 'context_id', + 'context_type', + 'subject_id', + 'subject_type', + 'metadata', + 'properties', ], 'groups' => [ 'id', diff --git a/packages/sprinkle-account/app/tests/Database/Models/ActivityTest.php b/packages/sprinkle-account/app/tests/Database/Models/ActivityTest.php index 1b89fc339..46aa64d18 100644 --- a/packages/sprinkle-account/app/tests/Database/Models/ActivityTest.php +++ b/packages/sprinkle-account/app/tests/Database/Models/ActivityTest.php @@ -14,8 +14,10 @@ use Illuminate\Database\Eloquent\Factories\Sequence; use UserFrosting\Sprinkle\Account\Database\Models\Activity; +use UserFrosting\Sprinkle\Account\Database\Models\Group; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\ActivityInterface; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; +use UserFrosting\Sprinkle\Account\Database\Models\Role; use UserFrosting\Sprinkle\Account\Database\Models\User; use UserFrosting\Sprinkle\Account\Tests\AccountTestCase; use UserFrosting\Sprinkle\Core\Testing\RefreshDatabase; @@ -51,7 +53,6 @@ public function testActivity(): void ]); $activity->user()->associate($user); $activity->save(); - $this->assertInstanceOf(ActivityInterface::class, $activity); // @phpstan-ignore-line // Assert new state $this->assertSame(1, Activity::count()); @@ -63,6 +64,8 @@ public function testActivity(): void $this->assertSame($user->id, $fetched->user_id); $this->assertSame('TEST', $fetched->type); $this->assertNull($fetched->occurred_at); + $this->assertNull($fetched->properties); + $this->assertArrayNotHasKey('metadata', $fetched->toArray()); $this->assertSame('', $fetched->description); // Delete @@ -121,6 +124,50 @@ public function testUserRelation(): void $this->assertSame(0, Activity::count()); } + public function testContextSubjectRelations(): void + { + /** @var User */ + $user = User::factory()->create(); + + /** @var Role */ + $context = Role::create([ + 'slug' => 'test_role', + 'name' => 'Test Role', + 'description' => 'A role for testing purposes.', + ]); + + /** @var Group */ + $subject = Group::create([ + 'slug' => 'test_group', + 'name' => 'Test Group', + 'description' => 'A group for testing purposes.', + ]); + + /** @var Activity */ + $activity = Activity::create([ + 'user_id' => $user->id, + 'context_type' => 'role', + 'context_id' => $context->id, + 'subject_type' => 'group', + 'subject_id' => $subject->id, + 'type' => 'role_created', + 'metadata' => ['role_name' => $context->name], + 'occurred_at' => '2026-01-01 10:00:00', + ]); + + // Refetch the activity to ensure relations are loaded correctly + $activity->refresh(); + + // Assert results + $this->assertEquals($user->id, $activity->user?->id); + $this->assertEquals($context->id, $activity->context_id); + $this->assertEquals($subject->id, $activity->subject_id); + $this->assertEquals('role', $activity->context_type); + $this->assertEquals('group', $activity->subject_type); + $this->assertEquals($context->id, $activity->context->id); + $this->assertEquals($subject->id, $activity->subject->id); + } + public function testNoLastActivity(): void { /** @var User */ @@ -133,6 +180,7 @@ public function testNoLastActivity(): void /** * Test for join operation for Sprunje. + * N.B.: Apply to the activity related helper from the User model. */ public function testUserJoinLastActivity(): void { @@ -185,8 +233,8 @@ public function testJoinUser(): void // Default order is 'foo', 'bar' $nonSortedActivity = Activity::all(); $this->assertContainsOnlyInstancesOf(ActivityInterface::class, $nonSortedActivity); - $this->assertSame('foo', $nonSortedActivity[0]->user->user_name); - $this->assertSame('bar', $nonSortedActivity[1]->user->user_name); + $this->assertSame('foo', $nonSortedActivity[0]->user?->user_name); + $this->assertSame('bar', $nonSortedActivity[1]->user?->user_name); $this->assertSame([$userFoo->id, $userBar->id], $nonSortedActivity->pluck('id')->toArray()); // @phpstan-ignore-line // Sort by lastActivity, order will be 2, 1 @@ -195,8 +243,8 @@ public function testJoinUser(): void ->get(); $this->assertContainsOnlyInstancesOf(ActivityInterface::class, $sortedUsers); - $this->assertSame('bar', $sortedUsers[0]->user->user_name); - $this->assertSame('foo', $sortedUsers[1]->user->user_name); + $this->assertSame('bar', $sortedUsers[0]->user?->user_name); + $this->assertSame('foo', $sortedUsers[1]->user?->user_name); $this->assertSame([$userBar->id, $userFoo->id], $sortedUsers->pluck('id')->toArray()); // @phpstan-ignore-line } } diff --git a/packages/sprinkle-account/app/tests/Listener/UpgradePasswordTest.php b/packages/sprinkle-account/app/tests/Listener/UpgradePasswordTest.php index 9ecb165ac..4e78ab84d 100644 --- a/packages/sprinkle-account/app/tests/Listener/UpgradePasswordTest.php +++ b/packages/sprinkle-account/app/tests/Listener/UpgradePasswordTest.php @@ -15,8 +15,8 @@ use UserFrosting\Sprinkle\Account\Database\Models\User; use UserFrosting\Sprinkle\Account\Event\UserAuthenticatedEvent; use UserFrosting\Sprinkle\Account\Listener\UpgradePassword; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorder; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Tests\AccountTestCase; /** @@ -45,19 +45,17 @@ public function testNoNeedToUpgrade(): void public function testUpgrade(): void { - /** @var Mockery\MockInterface&UserActivityLogger */ - $logger = Mockery::mock(UserActivityLogger::class) - ->shouldReceive('debug')->once() + /** @var Mockery\MockInterface&ActivityRecorderInterface */ + $logger = Mockery::mock(ActivityRecorder::class) + ->shouldReceive('record')->once() ->getMock(); - $this->getContainer()->set(UserActivityLoggerInterface::class, $logger); + $this->getContainer()->set(ActivityRecorderInterface::class, $logger); /** @var User */ $user = Mockery::mock(User::class) ->shouldReceive('getAttribute')->with('password')->once()->andReturn('87e995bde9ebdc73fc58cc75a9fadc4ae630d8207650fbe94e148ccc8058d5de5') ->shouldReceive('setAttribute')->with('password', 'MyPassword')->once() ->shouldReceive('save')->once() - ->shouldReceive('getAttribute')->with('user_name')->once()->andReturn('My Username') - ->shouldReceive('getAttribute')->with('id')->once()->andReturn(1) ->getMock(); // Create event diff --git a/packages/sprinkle-account/app/tests/Log/ActivityRecorderTest.php b/packages/sprinkle-account/app/tests/Log/ActivityRecorderTest.php new file mode 100644 index 000000000..eb474e4ba --- /dev/null +++ b/packages/sprinkle-account/app/tests/Log/ActivityRecorderTest.php @@ -0,0 +1,253 @@ +refreshDatabase(); + } + + public function testRecord(): void + { + /** @var User */ + $user = User::factory()->create(); + + /** @var Role */ + $context = Role::create([ + 'slug' => 'test_role', + 'name' => 'Test Role', + 'description' => 'A role for testing purposes.', + ]); + + /** @var Group */ + $subject = Group::create([ + 'slug' => 'test_group', + 'name' => 'Test Group', + 'description' => 'A group for testing purposes.', + ]); + + $metadata = [ + 'key1' => 'value1', + 'key2' => 'value2', + ]; + + /** @var ActivityRecorderInterface */ + $recorder = $this->getService(ActivityRecorderInterface::class); + $activity = $recorder->record( + user: $user, + type: TestActivityTypes::TEST_ACTIVITY, + metadata: $metadata, + context: $context, + subject: $subject + ); + + $this->assertEquals($user->id, $activity->user?->id); + $this->assertEquals($context->id, $activity->context_id); + $this->assertEquals($subject->id, $activity->subject_id); + $this->assertEquals('role', $activity->context_type); + $this->assertEquals('group', $activity->subject_type); + $this->assertEquals($context->id, $activity->context->id); + $this->assertEquals($subject->id, $activity->subject->id); + $this->assertSame('TEST_ACTIVITY', $activity->type); + $this->assertSame($metadata, $activity->metadata); + $this->assertNull($activity->properties); + $this->assertNotNull($activity->occurred_at); + } + + public function testRecordDerivesPropertiesFromSubject(): void + { + /** @var User */ + $user = User::factory()->create(); + + /** @var Group */ + $subject = Group::factory()->create(); + $oldName = $subject->name; + $subject->name = 'Updated group'; + + /** @var ActivityRecorderInterface */ + $recorder = $this->getService(ActivityRecorderInterface::class); + $activity = $recorder->record( + user: $user, + type: TestActivityTypes::TEST_ACTIVITY, + subject: $subject, + withProperties: true, + ); + + $this->assertSame([ + 'name' => [ + 'old' => $oldName, + 'new' => 'Updated group', + ], + ], $activity->properties); + $this->assertArrayNotHasKey('metadata', $activity->toArray()); + $this->assertArrayHasKey('properties', $activity->toArray()); + } + + public function testRecordOmitsSensitiveSubjectProperties(): void + { + /** @var User */ + $user = User::factory()->create(); + $user->email = 'updated@example.com'; + $user->password = 'new-password'; + + /** @var ActivityRecorderInterface */ + $recorder = $this->getService(ActivityRecorderInterface::class); + $activity = $recorder->record( + user: $user, + type: TestActivityTypes::TEST_ACTIVITY, + subject: $user, + withProperties: true, + ); + + $properties = $activity->properties; + $this->assertIsArray($properties); + $this->assertArrayHasKey('email', $properties); + $this->assertArrayNotHasKey('password', $properties); + $this->assertArrayNotHasKey('password_last_set', $properties); + } + + public function testBuiltinModelsImplementActivitySubjectInterface(): void + { + $models = [ + new Activity(), + new Group(), + new Permission(), + new Persistence(), + new Role(), + new RoleUsers(), + new User(), + new UserVerification(), + ]; + + foreach ($models as $model) { + // @phpstan-ignore-next-line method.alreadyNarrowedType + $this->assertInstanceOf(MorphableModelInterface::class, $model); + } + } + + public function testRecordForNullRelations(): void + { + /** @var User */ + $user = User::factory()->create(); + + /** @var ActivityRecorderInterface */ + $recorder = $this->getService(ActivityRecorderInterface::class); + $activity = $recorder->record( + user: $user, + type: TestActivityTypes::TEST_NULL_RELATIONS, + metadata: ['test' => 'success'] + ); + + /** @var Activity|null $fetched */ + $fetched = Activity::find($activity->id); + + $this->assertNotNull($fetched); + $this->assertNull($fetched->context_type); + $this->assertNull($fetched->context_id); + $this->assertNull($fetched->subject_type); + $this->assertNull($fetched->subject_id); + $this->assertSame(['test' => 'success'], $fetched->metadata); + } + + public function testRecordForNullUser(): void + { + /** @var ActivityRecorderInterface */ + $recorder = $this->getService(ActivityRecorderInterface::class); + $activity = $recorder->record( + user: null, + type: TestActivityTypes::TEST_NULL_RELATIONS + ); + + $this->assertNull($activity->user_id); + $this->assertNull($activity->user); + + $activity->delete(); + } + + public function testNormalizesValuesAndDetectsSensitiveKeys(): void + { + $recorder = new TestableActivityRecorder(); + $date = new DateTimeImmutable('2024-01-02T03:04:05+00:00'); + + $this->assertSame('2024-01-02T03:04:05+00:00', $recorder->normalize($date)); + $this->assertSame([ + 'date' => '2024-01-02T03:04:05+00:00', + 'value' => 42, + ], $recorder->normalize([ + 'date' => $date, + 'value' => 42, + ])); + $this->assertSame('object value', $recorder->normalize(new class() { + public function __toString(): string + { + return 'object value'; + } + })); + $this->assertTrue($recorder->sensitive('access_token')); + $this->assertTrue($recorder->sensitive('api_secret')); + $this->assertFalse($recorder->sensitive('display_name')); + } + + public function testIgnoresNonEloquentPropertiesSubject(): void + { + $subject = Mockery::mock(MorphableModelInterface::class); + + $this->assertNull((new TestableActivityRecorder())->properties($subject)); + } +} + +class TestableActivityRecorder extends ActivityRecorder +{ + public function normalize(mixed $value): mixed + { + return $this->normalizeValue($value); + } + + public function sensitive(string $key): bool + { + return $this->isSensitive($key); + } + + /** @return array|null */ + public function properties(MorphableModelInterface $subject): ?array + { + return $this->getProperties($subject); + } +} + +enum TestActivityTypes: string +{ + case TEST_ACTIVITY = 'TEST_ACTIVITY'; + case TEST_NULL_RELATIONS = 'TEST_NULL_RELATIONS'; +} diff --git a/packages/sprinkle-account/app/tests/Log/UserActivityLoggerTest.php b/packages/sprinkle-account/app/tests/Log/UserActivityLoggerTest.php index 7cd03a639..37d443057 100644 --- a/packages/sprinkle-account/app/tests/Log/UserActivityLoggerTest.php +++ b/packages/sprinkle-account/app/tests/Log/UserActivityLoggerTest.php @@ -20,6 +20,9 @@ use UserFrosting\Sprinkle\Account\Tests\AccountTestCase; use UserFrosting\Sprinkle\Core\Testing\RefreshDatabase; +/** + * @deprecated 6.1 + */ final class UserActivityLoggerTest extends AccountTestCase { use RefreshDatabase; @@ -55,7 +58,7 @@ public function testLogger(): void $this->assertSame('User did a test', $activity->description); $this->assertSame('test', $activity->type); $this->assertSame($user->id, $activity->user_id); - $this->assertSame($user->id, $activity->user->id); + $this->assertSame($user->id, $activity->user?->id); } public function testLoggerWithNoDefaultData(): void diff --git a/packages/sprinkle-account/app/tests/Unit/Log/SprinkleActivityTypeRegistryTest.php b/packages/sprinkle-account/app/tests/Unit/Log/SprinkleActivityTypeRegistryTest.php new file mode 100644 index 000000000..ecbde8d5f --- /dev/null +++ b/packages/sprinkle-account/app/tests/Unit/Log/SprinkleActivityTypeRegistryTest.php @@ -0,0 +1,167 @@ +shouldReceive('getActivityTypes')->andReturn([ + UserActivityTypes::class, + AccountActivityTypes::class, + ])->getMock(); + $nonActivitySprinkle = Mockery::mock(SprinkleRecipe::class); + /** @var SprinkleManager&Mockery\MockInterface $manager */ + $manager = Mockery::mock(SprinkleManager::class) + ->shouldReceive('getSprinkles')->andReturn([$nonActivitySprinkle, $recipe])->getMock(); + + $registry = new SprinkleActivityTypeRegistry($manager); + + $this->assertTrue($registry->has(UserActivityTypes::class)); + $this->assertInstanceOf(UserActivityTypes::class, $registry->get(UserActivityTypes::class)); + + foreach ([ + 'sign_up' => ['ACCOUNT.ACTIVITY.REGISTER', 'ACCOUNT.ACTIVITY.LABEL.REGISTER'], + ] as $value => [$expectedI18nKey, $expectedLabelI18nKey]) { + $this->assertSame($expectedI18nKey, $registry->getI18nKey($value)); + $this->assertSame($expectedLabelI18nKey, $registry->getLabelI18nKey($value)); + } + $this->assertNull($registry->getI18nKey('unknown_activity')); + $this->assertNull($registry->getLabelI18nKey('unknown_activity')); + + /** @var array, array> $expectedI18nKeys */ + $expectedI18nKeys = [ + UserActivityTypes::class => [ + 'verified' => 'ACCOUNT.ACTIVITY.VERIFIED', + 'password_reset' => 'ACCOUNT.ACTIVITY.PASSWORD_RESET', + 'sign_in' => 'ACCOUNT.ACTIVITY.LOGGED_IN', + 'sign_out' => 'ACCOUNT.ACTIVITY.LOGGED_OUT', + 'password_upgraded' => 'ACCOUNT.ACTIVITY.PASSWORD_UPGRADED', + ], + AccountActivityTypes::class => [ + 'account_create' => 'ACCOUNT.ACTIVITY.CREATE', + 'account_delete' => 'ACCOUNT.ACTIVITY.DELETE', + 'update_profile_settings' => 'ACCOUNT.ACTIVITY.UPDATE_PROFILE_SETTINGS', + 'update_account_settings' => 'ACCOUNT.ACTIVITY.UPDATE_ACCOUNT_SETTINGS', + 'update_password' => 'ACCOUNT.ACTIVITY.UPDATE_PASSWORD', + ], + ]; + foreach ($expectedI18nKeys as $activityTypeClass => $expectedKeys) { + foreach ($expectedKeys as $value => $expectedKey) { + $this->assertSame($expectedKey, $activityTypeClass::getI18nKey($value)); + } + } + + /** @var array, list> $expectedLabelI18nKeys */ + $expectedLabelI18nKeys = [ + UserActivityTypes::class => [ + 'ACCOUNT.ACTIVITY.LABEL.REGISTER', + 'ACCOUNT.ACTIVITY.LABEL.VERIFIED', + 'ACCOUNT.ACTIVITY.LABEL.PASSWORD_RESET', + 'ACCOUNT.ACTIVITY.LABEL.LOGGED_IN', + 'ACCOUNT.ACTIVITY.LABEL.LOGGED_OUT', + 'ACCOUNT.ACTIVITY.LABEL.PASSWORD_UPGRADED', + ], + AccountActivityTypes::class => [ + 'ACCOUNT.ACTIVITY.LABEL.CREATE', + 'ACCOUNT.ACTIVITY.LABEL.DELETE', + 'ACCOUNT.ACTIVITY.LABEL.UPDATE_PROFILE_SETTINGS', + 'ACCOUNT.ACTIVITY.LABEL.UPDATE_ACCOUNT_SETTINGS', + 'ACCOUNT.ACTIVITY.LABEL.UPDATE_EMAIL', + 'ACCOUNT.ACTIVITY.LABEL.UPDATE_PASSWORD', + ], + ]; + foreach ($expectedLabelI18nKeys as $activityTypeClass => $expectedKeys) { + $this->assertSame($expectedKeys, array_map( + static fn (BackedEnum&ActivityTypes $activityType): ?string => $activityTypeClass::getLabelI18nKey((string) $activityType->value), + $activityTypeClass::cases() + )); + } + + foreach (array_keys($expectedI18nKeys) as $activityTypeClass) { + $this->assertNull($activityTypeClass::getI18nKey('unknown_activity')); + $this->assertNull($activityTypeClass::getLabelI18nKey('unknown_activity')); + } + } + + public function testMissingClassIsRejected(): void + { + $recipe = Mockery::mock(ActivityRecipe::class) + ->shouldReceive('getActivityTypes')->andReturn(['/Not/An/ActivityType'])->getMock(); + /** @var SprinkleManager&Mockery\MockInterface $manager */ + $manager = Mockery::mock(SprinkleManager::class) + ->shouldReceive('getSprinkles')->andReturn([$recipe])->getMock(); + $registry = new SprinkleActivityTypeRegistry($manager); + + $this->expectException(BadClassNameException::class); + $this->expectExceptionMessage('Activity type class `/Not/An/ActivityType` not found.'); + $registry->all(); + } + + public function testClassWithWrongInterfaceIsRejected(): void + { + $recipe = Mockery::mock(ActivityRecipe::class) + ->shouldReceive('getActivityTypes')->andReturn([stdClass::class])->getMock(); + /** @var SprinkleManager&Mockery\MockInterface $manager */ + $manager = Mockery::mock(SprinkleManager::class) + ->shouldReceive('getSprinkles')->andReturn([$recipe])->getMock(); + $registry = new SprinkleActivityTypeRegistry($manager); + + $this->expectException(BadInstanceOfException::class); + $this->expectExceptionMessage('Activity type class `stdClass` doesn\'t implement ' . ActivityTypes::class); + $registry->all(); + } + + public function testDuplicateValuesAreRejected(): void + { + $recipe = Mockery::mock(ActivityRecipe::class) + ->shouldReceive('getActivityTypes')->andReturn([ + UserActivityTypes::class, + DuplicateActivityType::class, + ])->getMock(); + /** @var SprinkleManager&Mockery\MockInterface $manager */ + $manager = Mockery::mock(SprinkleManager::class) + ->shouldReceive('getSprinkles')->andReturn([$recipe])->getMock(); + $registry = new SprinkleActivityTypeRegistry($manager); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Activity type value `sign_up` is registered more than once.'); + $registry->all(); + } +} diff --git a/packages/sprinkle-account/app/tests/Unit/ServicesProvider/ActivityServiceTest.php b/packages/sprinkle-account/app/tests/Unit/ServicesProvider/ActivityServiceTest.php new file mode 100644 index 000000000..2b6bf16bf --- /dev/null +++ b/packages/sprinkle-account/app/tests/Unit/ServicesProvider/ActivityServiceTest.php @@ -0,0 +1,28 @@ +register()); + $manager = Mockery::mock(SprinkleManager::class); + $container->set(SprinkleManager::class, $manager); + + $this->assertInstanceOf(SprinkleActivityTypeRegistry::class, $container->get(ActivityTypeRegistryInterface::class)); + } +} diff --git a/packages/sprinkle-admin/CHANGELOG.md b/packages/sprinkle-admin/CHANGELOG.md index f089c2776..a00b693b0 100644 --- a/packages/sprinkle-admin/CHANGELOG.md +++ b/packages/sprinkle-admin/CHANGELOG.md @@ -8,8 +8,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] ### Changed +- Split role permission updates into a dedicated API action with focused validation and activity tracking. +- Split administrative user management controllers into dedicated update operations with focused API validation and activity tracking. +- User creation no longer assigns a group; groups can be managed separately. - Converted locale message files from PHP arrays to YAML. +### Removed +- Removed the unused generic role field update endpoint in favor of full role updates and dedicated update actions. + ## [6.0.2](https://github.com/userfrosting/sprinkle-admin/compare/6.0.1...6.0.2) - 2026-08-02 ### Fixed diff --git a/packages/sprinkle-admin/app/assets/composables/useRoleUpdateApi.ts b/packages/sprinkle-admin/app/assets/composables/useRoleUpdateApi.ts index 31da9f092..bd97981e7 100644 --- a/packages/sprinkle-admin/app/assets/composables/useRoleUpdateApi.ts +++ b/packages/sprinkle-admin/app/assets/composables/useRoleUpdateApi.ts @@ -3,45 +3,40 @@ import axios from 'axios' import { Severity } from '@userfrosting/sprinkle-core/interfaces' import type { ApiResponse, ApiErrorResponse } from '@userfrosting/sprinkle-core/interfaces' import { useAlertsStore } from '@userfrosting/sprinkle-core/stores' - -// TODO : Add validation - This composable is only used to associates the -// permissions with the role. It should have a dedicated schema for this, plus -// be merged with 'useRolePermissionsApi' -// 'schema://requests/role/edit-field.yaml' +import type { RolePermissionsRequest } from '../interfaces' /** - * API used to update role. - * - * This API is tied to the `RoleUpdateFieldAction` API, accessed at the - * GET `/api/roles/r/{slug}/{field}` endpoint. + * API used to update role permissions. * - * This composable can be used to update {field} for a specific role. + * This API is tied to the `RolePermissionsAction` API, accessed at the + * PUT `/api/roles/r/{slug}/permissions` endpoint. */ export function useRoleUpdateApi() { const apiLoading = ref(false) const apiError = ref(null) - async function submitRoleUpdate(slug: string, fieldName: string, formData: any) { + async function submitRolePermissions(slug: string, data: RolePermissionsRequest) { apiLoading.value = true apiError.value = null return axios - .put('/api/roles/r/' + slug + '/' + fieldName, formData) + .put('/api/roles/r/' + slug + '/permissions', data) .then((response) => { useAlertsStore().push({ - ...{ style: Severity.Success }, - ...response.data + ...response.data, + style: Severity.Success }) return response.data }) .catch((err) => { apiError.value = err.response.data + throw apiError.value }) .finally(() => { apiLoading.value = false }) } - return { submitRoleUpdate, apiLoading, apiError } + return { submitRolePermissions, apiLoading, apiError } } diff --git a/packages/sprinkle-admin/app/assets/composables/useUserApi.ts b/packages/sprinkle-admin/app/assets/composables/useUserApi.ts index 28094a36b..b0846008b 100644 --- a/packages/sprinkle-admin/app/assets/composables/useUserApi.ts +++ b/packages/sprinkle-admin/app/assets/composables/useUserApi.ts @@ -39,7 +39,6 @@ import { useAlertsStore } from '@userfrosting/sprinkle-core/stores' export function useUserApi() { const defaultFormData = (): UserCreateRequest => ({ user_name: '', - group_id: 0, first_name: '', last_name: '', email: '', diff --git a/packages/sprinkle-admin/app/assets/composables/useUserUpdateApi.ts b/packages/sprinkle-admin/app/assets/composables/useUserUpdateApi.ts index eec463a32..694b1c412 100644 --- a/packages/sprinkle-admin/app/assets/composables/useUserUpdateApi.ts +++ b/packages/sprinkle-admin/app/assets/composables/useUserUpdateApi.ts @@ -3,39 +3,68 @@ import axios from 'axios' import { Severity } from '@userfrosting/sprinkle-core/interfaces' import type { ApiErrorResponse, ApiResponse } from '@userfrosting/sprinkle-core/interfaces' import { useAlertsStore } from '@userfrosting/sprinkle-core/stores' +import type { + UserGroupRequest, + UserPasswordRequest, + UserRolesRequest, + UserStatusRequest, + UserVerificationRequest +} from '../interfaces' -// TODO : Add validation - See comment in the PHP action. The API needs to be -// split into sub-api with their own schema first. -// 'schema://requests/user/edit-field.yaml' - -/** - * API Composable - */ export function useUserUpdateApi() { const apiLoading = ref(false) const apiError = ref(null) - async function submitUserUpdate(user_name: string, fieldName: string, formData: any) { + async function submit(user_name: string, endpoint: string, formData: T) { apiLoading.value = true apiError.value = null return axios - .put('/api/users/u/' + user_name + '/' + fieldName, formData) + .put('/api/users/u/' + user_name + '/' + endpoint, formData) .then((response) => { useAlertsStore().push({ - ...{ style: Severity.Success }, - ...response.data + ...response.data, + style: Severity.Success }) return response.data }) .catch((err) => { apiError.value = err.response.data + throw apiError.value }) .finally(() => { apiLoading.value = false }) } - return { submitUserUpdate, apiLoading, apiError } + function submitUserStatus(user_name: string, data: UserStatusRequest) { + return submit(user_name, 'status', data) + } + + function submitUserVerification(user_name: string, data: UserVerificationRequest) { + return submit(user_name, 'verification', data) + } + + function submitUserGroup(user_name: string, data: UserGroupRequest) { + return submit(user_name, 'group', data) + } + + function submitUserRoles(user_name: string, data: UserRolesRequest) { + return submit(user_name, 'roles', data) + } + + function submitUserPassword(user_name: string, data: UserPasswordRequest) { + return submit(user_name, 'password', data) + } + + return { + submitUserStatus, + submitUserVerification, + submitUserGroup, + submitUserRoles, + submitUserPassword, + apiLoading, + apiError + } } diff --git a/packages/sprinkle-admin/app/assets/interfaces/RoleUpdateApi.ts b/packages/sprinkle-admin/app/assets/interfaces/RoleUpdateApi.ts new file mode 100644 index 000000000..dcd99dc91 --- /dev/null +++ b/packages/sprinkle-admin/app/assets/interfaces/RoleUpdateApi.ts @@ -0,0 +1,4 @@ +/** Request payload for the role permissions endpoint. */ +export interface RolePermissionsRequest { + permissions: number[] +} diff --git a/packages/sprinkle-admin/app/assets/interfaces/UserCreateApi.ts b/packages/sprinkle-admin/app/assets/interfaces/UserCreateApi.ts index dcc2383b9..e37e8b467 100644 --- a/packages/sprinkle-admin/app/assets/interfaces/UserCreateApi.ts +++ b/packages/sprinkle-admin/app/assets/interfaces/UserCreateApi.ts @@ -5,7 +5,6 @@ import type { ApiResponse } from '@userfrosting/sprinkle-core/interfaces' */ export interface UserCreateRequest { user_name: string - group_id: number | null first_name: string last_name: string email: string diff --git a/packages/sprinkle-admin/app/assets/interfaces/UserPasswordApi.ts b/packages/sprinkle-admin/app/assets/interfaces/UserPasswordApi.ts index 2d9b40d6e..3a616030c 100644 --- a/packages/sprinkle-admin/app/assets/interfaces/UserPasswordApi.ts +++ b/packages/sprinkle-admin/app/assets/interfaces/UserPasswordApi.ts @@ -1,8 +1,8 @@ /** * API Interfaces - What the API expects and what it returns * - * This interface is tied to the `UserUpdateFieldAction` API, accessed at the - * GET `/api/users/u/{user_name}/password` endpoint. + * This interface is tied to the user password update API, accessed at the + * PUT `/api/users/u/{user_name}/password` endpoint. * * This api doesn't have a corresponding Response data interface. `ApiResponse` * is used as response interface. diff --git a/packages/sprinkle-admin/app/assets/interfaces/UserUpdateApi.ts b/packages/sprinkle-admin/app/assets/interfaces/UserUpdateApi.ts new file mode 100644 index 000000000..065dbe745 --- /dev/null +++ b/packages/sprinkle-admin/app/assets/interfaces/UserUpdateApi.ts @@ -0,0 +1,19 @@ +/** Request payload for the user status endpoint. */ +export interface UserStatusRequest { + flag_enabled: '0' | '1' +} + +/** Request payload for the user verification endpoint. */ +export interface UserVerificationRequest { + flag_verified: '0' | '1' +} + +/** Request payload for the user group endpoint. */ +export interface UserGroupRequest { + group_id: number +} + +/** Request payload for the user roles endpoint. */ +export interface UserRolesRequest { + roles: number[] +} diff --git a/packages/sprinkle-admin/app/assets/interfaces/index.ts b/packages/sprinkle-admin/app/assets/interfaces/index.ts index 5b1264a37..43c6ac2fb 100644 --- a/packages/sprinkle-admin/app/assets/interfaces/index.ts +++ b/packages/sprinkle-admin/app/assets/interfaces/index.ts @@ -22,6 +22,7 @@ export type { RoleEditRequest, RoleEditResponse } from './RoleEditApi' export type { RoleDeleteResponse } from './RoleDeleteApi' export type { RolesSprunjeResponse } from './RolesApi' export type { RolePermissionsSprunjeResponse } from './RolePermissionsApi' +export type { RolePermissionsRequest } from './RoleUpdateApi' // Users export type { UserResponse } from './UserApi' @@ -31,3 +32,9 @@ export type { UserEditRequest, UserEditResponse } from './UserEditApi' export type { UserPasswordRequest } from './UserPasswordApi' export type { UsersSprunjerResponse } from './UsersApi' export type { UserRoleSprunjeResponse } from './UserRolesApi' +export type { + UserStatusRequest, + UserVerificationRequest, + UserGroupRequest, + UserRolesRequest +} from './UserUpdateApi' diff --git a/packages/sprinkle-admin/app/assets/tests/composables/useCrudApis.test.ts b/packages/sprinkle-admin/app/assets/tests/composables/useCrudApis.test.ts index 489f9e774..8cf2ae150 100644 --- a/packages/sprinkle-admin/app/assets/tests/composables/useCrudApis.test.ts +++ b/packages/sprinkle-admin/app/assets/tests/composables/useCrudApis.test.ts @@ -228,7 +228,6 @@ describe('CRUD admin composables', () => { expect(api.formData.value).toEqual({ user_name: '', - group_id: 0, first_name: '', last_name: '', email: '', @@ -243,7 +242,6 @@ describe('CRUD admin composables', () => { } as any) await api.createUser({ user_name: 'alice', - group_id: 1, first_name: 'Alice', last_name: 'Doe', email: 'alice@example.com', @@ -255,7 +253,6 @@ describe('CRUD admin composables', () => { } as any) await api.updateUser('alice', { user_name: 'alice', - group_id: 1, first_name: 'Alice', last_name: 'Doe', email: 'alice@example.com', @@ -287,7 +284,6 @@ describe('CRUD admin composables', () => { api.resetForm() expect(api.formData.value).toEqual({ user_name: '', - group_id: 0, first_name: '', last_name: '', email: '', @@ -305,7 +301,6 @@ describe('CRUD admin composables', () => { await expect( api.createUser({ user_name: 'x', - group_id: 1, first_name: 'x', last_name: 'x', email: 'x@x.com', @@ -319,7 +314,6 @@ describe('CRUD admin composables', () => { await expect( api.updateUser('alice', { user_name: 'alice', - group_id: 1, first_name: 'x', last_name: 'x', email: 'x@x.com', diff --git a/packages/sprinkle-admin/app/assets/tests/composables/useSimpleApis.test.ts b/packages/sprinkle-admin/app/assets/tests/composables/useSimpleApis.test.ts index a35243186..c88ca288a 100644 --- a/packages/sprinkle-admin/app/assets/tests/composables/useSimpleApis.test.ts +++ b/packages/sprinkle-admin/app/assets/tests/composables/useSimpleApis.test.ts @@ -192,18 +192,22 @@ describe('simple admin composables', () => { }) }) - test('useRoleUpdateApi submitRoleUpdate handles success and error', async () => { - const { submitRoleUpdate, apiError, apiLoading } = useRoleUpdateApi() + test('useRoleUpdateApi submitRolePermissions handles success and error', async () => { + const { submitRolePermissions, apiError, apiLoading } = useRoleUpdateApi() vi.spyOn(axios, 'put').mockResolvedValueOnce({ data: { title: 'Updated', description: 'ok' } } as any) - await expect(submitRoleUpdate('admin', 'name', { name: 'Admin' })).resolves.toEqual({ + await expect(submitRolePermissions('admin', { permissions: [1, 2] })).resolves.toEqual({ title: 'Updated', description: 'ok' }) + expect(axios.put).toHaveBeenCalledWith('/api/roles/r/admin/permissions', { + permissions: [1, 2] + }) + expect(mockPush).toHaveBeenCalledWith({ style: Severity.Success, title: 'Updated', @@ -213,18 +217,20 @@ describe('simple admin composables', () => { expect(apiLoading.value).toBe(false) vi.spyOn(axios, 'put').mockRejectedValueOnce({ response: { data: { title: 'Error' } } }) - await expect(submitRoleUpdate('admin', 'name', { name: 'Admin' })).resolves.toBeUndefined() + await expect(submitRolePermissions('admin', { permissions: [] })).rejects.toEqual({ + title: 'Error' + }) expect(apiError.value).toEqual({ title: 'Error' }) }) - test('useUserUpdateApi submitUserUpdate handles success and error', async () => { - const { submitUserUpdate, apiError, apiLoading } = useUserUpdateApi() + test('useUserUpdateApi submitUserStatus handles success and error', async () => { + const { submitUserStatus, apiError, apiLoading } = useUserUpdateApi() vi.spyOn(axios, 'put').mockResolvedValueOnce({ data: { title: 'Updated', description: 'ok' } } as any) - await expect(submitUserUpdate('alice', 'name', { first_name: 'Alice' })).resolves.toEqual({ + await expect(submitUserStatus('alice', { flag_enabled: '1' })).resolves.toEqual({ title: 'Updated', description: 'ok' }) @@ -238,12 +244,45 @@ describe('simple admin composables', () => { expect(apiLoading.value).toBe(false) vi.spyOn(axios, 'put').mockRejectedValueOnce({ response: { data: { title: 'Error' } } }) - await expect( - submitUserUpdate('alice', 'name', { first_name: 'Alice' }) - ).resolves.toBeUndefined() + await expect(submitUserStatus('alice', { flag_enabled: '1' })).rejects.toEqual({ + title: 'Error' + }) expect(apiError.value).toEqual({ title: 'Error' }) }) + test('useUserUpdateApi submits verification, group, roles, and password updates', async () => { + const { + submitUserVerification, + submitUserGroup, + submitUserRoles, + submitUserPassword, + apiLoading + } = useUserUpdateApi() + const response = { title: 'Updated', description: 'ok' } + const put = vi.spyOn(axios, 'put').mockResolvedValue({ data: response } as any) + + await expect(submitUserVerification('alice', { flag_verified: '1' })).resolves.toEqual( + response + ) + await expect(submitUserGroup('alice', { group_id: 3 })).resolves.toEqual(response) + await expect(submitUserRoles('alice', { roles: [1, 2] })).resolves.toEqual(response) + await expect( + submitUserPassword('alice', { password: 'new-password', passwordc: 'new-password' }) + ).resolves.toEqual(response) + + expect(put).toHaveBeenNthCalledWith(1, '/api/users/u/alice/verification', { + flag_verified: '1' + }) + expect(put).toHaveBeenNthCalledWith(2, '/api/users/u/alice/group', { group_id: 3 }) + expect(put).toHaveBeenNthCalledWith(3, '/api/users/u/alice/roles', { roles: [1, 2] }) + expect(put).toHaveBeenNthCalledWith(4, '/api/users/u/alice/password', { + password: 'new-password', + passwordc: 'new-password' + }) + expect(mockPush).toHaveBeenCalledTimes(4) + expect(apiLoading.value).toBe(false) + }) + test('useUserPasswordResetApi passwordReset handles success and error', async () => { const { passwordReset, apiError, apiLoading } = useUserPasswordResetApi() diff --git a/packages/sprinkle-admin/app/locale/en_US/messages.yaml b/packages/sprinkle-admin/app/locale/en_US/messages.yaml index 62c028692..1cfc32d64 100644 --- a/packages/sprinkle-admin/app/locale/en_US/messages.yaml +++ b/packages/sprinkle-admin/app/locale/en_US/messages.yaml @@ -7,6 +7,54 @@ ACTIVITY: PAGE: Activities PAGE_DESCRIPTION: A listing of user activities TIME: Activity Time +ACCOUNT: + ACTIVITY: + CREATE: Account created for {{subject.full_name}} + DELETE: Account deleted + UPDATE_INFO: Account information updated + ADD_TO_GROUP: Account added to group {{context.name}} + REMOVE_FROM_GROUP: Account removed from group {{context.name}} + UPDATE_ROLES: 'Account roles added: {{added_roles}}; removed: {{removed_roles}}' + UPDATE_FIELD: Account field {{field}} updated + ENABLE: Account enabled + DISABLE: Account disabled + VERIFY: Account verified + UNVERIFY: Account unverified + UPDATE_PROFILE_SETTINGS: Profile updated + UPDATE_ACCOUNT_SETTINGS: Account settings updated + UPDATE_EMAIL: Email updated + UPDATE_PASSWORD: Password changed + REGISTER: User registered + VERIFIED: User verified + PASSWORD_RESET: Password reset + LOGGED_IN: User signed in + LOGGED_OUT: User signed out + PASSWORD_UPGRADED: Password upgraded + LABEL: + CREATE: Account created + DELETE: Account deleted + UPDATE_INFO: Account updated + ADD_TO_GROUP: Account added to group + REMOVE_FROM_GROUP: Account removed from group + UPDATE_ROLES: Account roles changed + UPDATE_FIELD: Account field updated + ENABLE: Account enabled + DISABLE: Account disabled + VERIFY: Account verified + UNVERIFY: Account unverified + UPDATE_PROFILE_SETTINGS: Profile settings updated + UPDATE_ACCOUNT_SETTINGS: Account settings updated + UPDATE_EMAIL: Email updated + UPDATE_PASSWORD: Password changed + REGISTER: User registered + VERIFIED: User verified + PASSWORD_RESET: Password reset + LOGGED_IN: User signed in + LOGGED_OUT: User signed out + PASSWORD_UPGRADED: Password upgraded +ADMIN: + ACTIVITY: + CACHE_CLEARED: Cleared site cache ADMIN_PANEL: Admin Panel DASHBOARD: Dashboard DELETE_MASTER: You cannot delete the master account @@ -46,6 +94,14 @@ GROUP: for editing and deleting groups. UPDATE: Details updated for group {{name}} USERS: Users in this group + ACTIVITY: + CREATE: Group created + DELETE: Group deleted + UPDATE_INFO: Group information updated + LABEL: + CREATE: Group created + DELETE: Group deleted + UPDATE_INFO: Group updated MANUALLY_ACTIVATED: '{{user_name}}''s account has been manually activated' PERMISSION: '1': Permission @@ -56,6 +112,7 @@ PERMISSION: HOOK_CONDITION: Hook/Conditions ID: Permission ID INFO_PAGE: View and edit permission details. + NONE: No permission NOT_FOUND: Permission not found PAGE: Permissions PAGE_DESCRIPTION: A listing of the permissions for your site. Provides management @@ -83,6 +140,7 @@ ROLE: NAME: Name NAME_EXPLAIN: Please enter a name for the role NAME_IN_USE: A role named {{name}} already exist + NONE: No role NOT_FOUND: Role not found PAGE: Roles PAGE_DESCRIPTION: A listing of the roles for your site. Provides management tools @@ -92,6 +150,18 @@ ROLE: UPDATE: Update Roles UPDATED: Details updated for role {{name}} USERS: Users with this role + ACTIVITY: + CREATE: Role created + DELETE: Role deleted + UPDATE_INFO: Role information updated + UPDATE_PERMISSIONS: 'Role permissions added: {{added_permissions}}; removed: {{removed_permissions}}' + UPDATE_FIELD: Role field {{field}} updated + LABEL: + CREATE: Role created + DELETE: Role deleted + UPDATE_INFO: Role updated + UPDATE_PERMISSIONS: Role permissions changed + UPDATE_FIELD: Role field updated SITE_CONFIG: '@TRANSLATION': Site configuration CACHE: diff --git a/packages/sprinkle-admin/app/locale/fr_FR/messages.yaml b/packages/sprinkle-admin/app/locale/fr_FR/messages.yaml index fc740e69d..5a9bd9133 100644 --- a/packages/sprinkle-admin/app/locale/fr_FR/messages.yaml +++ b/packages/sprinkle-admin/app/locale/fr_FR/messages.yaml @@ -7,6 +7,54 @@ ACTIVITY: PAGE: Activités PAGE_DESCRIPTION: Une liste des activités des utilisateurs TIME: Date de l'activité +ACCOUNT: + ACTIVITY: + CREATE: Compte créé for {{subject.full_name}} + DELETE: Compte supprimé + UPDATE_INFO: Informations du compte mises à jour + ADD_TO_GROUP: Compte ajouté au groupe {{context.name}} + REMOVE_FROM_GROUP: Compte retiré du groupe {{context.name}} + UPDATE_ROLES: 'Rôles du compte ajoutés : {{added_roles}} ; supprimés : {{removed_roles}}' + UPDATE_FIELD: Champ du compte {{field}} mis à jour + ENABLE: Compte activé + DISABLE: Compte désactivé + VERIFY: Compte vérifié + UNVERIFY: Compte non vérifié + UPDATE_PROFILE_SETTINGS: Profil mis à jour + UPDATE_ACCOUNT_SETTINGS: Paramètres du compte mis à jour + UPDATE_EMAIL: Email mis à jour + UPDATE_PASSWORD: Mot de passe modifié + REGISTER: Utilisateur inscrit + VERIFIED: Utilisateur vérifié + PASSWORD_RESET: Mot de passe réinitialisé + LOGGED_IN: Utilisateur connecté + LOGGED_OUT: Utilisateur déconnecté + PASSWORD_UPGRADED: Mot de passe amélioré + LABEL: + CREATE: Compte créé + DELETE: Compte supprimé + UPDATE_INFO: Compte mis à jour + ADD_TO_GROUP: Compte ajouté au groupe + REMOVE_FROM_GROUP: Compte retiré du groupe + UPDATE_ROLES: Rôles du compte modifiés + UPDATE_FIELD: Champ du compte mis à jour + ENABLE: Compte activé + DISABLE: Compte désactivé + VERIFY: Compte vérifié + UNVERIFY: Compte non vérifié + UPDATE_PROFILE_SETTINGS: Paramètres du profil mis à jour + UPDATE_ACCOUNT_SETTINGS: Paramètres du compte mis à jour + UPDATE_EMAIL: Email mis à jour + UPDATE_PASSWORD: Mot de passe modifié + REGISTER: Utilisateur inscrit + VERIFIED: Utilisateur vérifié + PASSWORD_RESET: Mot de passe réinitialisé + LOGGED_IN: Utilisateur connecté + LOGGED_OUT: Utilisateur déconnecté + PASSWORD_UPGRADED: Mot de passe amélioré +ADMIN: + ACTIVITY: + CACHE_CLEARED: Cache du site vidé ADMIN_PANEL: Panneau d'admin DASHBOARD: Tableau de bord DELETE_MASTER: Vous ne pouvez pas supprimer le compte principal ! @@ -48,6 +96,14 @@ GROUP: pour éditer et supprimer des groupes. UPDATE: Les détails du groupe {{name}} ont été enregistrés USERS: Utilisateurs dans ce groupe + ACTIVITY: + CREATE: Groupe créé + DELETE: Groupe supprimé + UPDATE_INFO: Informations du groupe {{context.name}} mises à jour + LABEL: + CREATE: Groupe créé + DELETE: Groupe supprimé + UPDATE_INFO: Groupe mis à jour MANUALLY_ACTIVATED: Le compte de {{user_name}} a été activé manuellement PERMISSION: '1': Autorisation @@ -58,6 +114,7 @@ PERMISSION: HOOK_CONDITION: Hook/Conditions ID: ID de l'autorisation INFO_PAGE: Afficher et modifier les détails des autorisations. + NONE: Aucune autorisation NOT_FOUND: Autorisation non trouvée PAGE: Autorisations PAGE_DESCRIPTION: Une liste des autorisations pour votre site. Fournit des outils @@ -86,6 +143,7 @@ ROLE: NAME: Nom du rôle NAME_EXPLAIN: Spécifiez le nom du rôle NAME_IN_USE: Un rôle nommé {{name}} existe déjà + NONE: Aucun rôle NOT_FOUND: Rôle non trouvé PAGE: Rôles PAGE_DESCRIPTION: Une liste des rôles de votre site. Fournit des outils de gestion @@ -95,6 +153,18 @@ ROLE: UPDATE: Mettre à jour les rôles UPDATED: Détails mis à jour pour le rôle {{name}} USERS: Utilisateurs avec ce rôle + ACTIVITY: + CREATE: Rôle créé + DELETE: Rôle supprimé + UPDATE_INFO: Informations du rôle mises à jour + UPDATE_PERMISSIONS: 'Autorisations du rôle ajoutées : {{added_permissions}} ; supprimées : {{removed_permissions}}' + UPDATE_FIELD: Champ du rôle {{field}} mis à jour + LABEL: + CREATE: Rôle créé + DELETE: Rôle supprimé + UPDATE_INFO: Rôle mis à jour + UPDATE_PERMISSIONS: Autorisations du rôle modifiées + UPDATE_FIELD: Champ du rôle mis à jour SITE_CONFIG: '@TRANSLATION': Configuration du site CACHE: diff --git a/packages/sprinkle-admin/app/schema/requests/role/edit-field.yaml b/packages/sprinkle-admin/app/schema/requests/role/edit-field.yaml deleted file mode 100644 index a01527668..000000000 --- a/packages/sprinkle-admin/app/schema/requests/role/edit-field.yaml +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: - validators: - length: - label: "&NAME" - min: 1 - max: 255 - message: VALIDATE.LENGTH_RANGE - transformations: - - trim -slug: - validators: - length: - label: "&SLUG" - min: 1 - max: 255 - message: VALIDATE.LENGTH_RANGE - transformations: - - trim -description: -permissions: - validators: - array: - label: "permissions" - message: VALIDATE.ARRAY diff --git a/packages/sprinkle-admin/app/schema/requests/role/permissions.yaml b/packages/sprinkle-admin/app/schema/requests/role/permissions.yaml new file mode 100644 index 000000000..e7a393333 --- /dev/null +++ b/packages/sprinkle-admin/app/schema/requests/role/permissions.yaml @@ -0,0 +1,5 @@ +permissions: + validators: + array: + label: "permissions" + message: VALIDATE.ARRAY diff --git a/packages/sprinkle-admin/app/schema/requests/user/create.yaml b/packages/sprinkle-admin/app/schema/requests/user/create.yaml index 57391ee58..6fcf39ecc 100644 --- a/packages/sprinkle-admin/app/schema/requests/user/create.yaml +++ b/packages/sprinkle-admin/app/schema/requests/user/create.yaml @@ -64,9 +64,3 @@ locale: max: 10 domain: server message: VALIDATE.LENGTH_RANGE -group_id: - validators: - integer: - label: "&GROUP" - domain: server - message: VALIDATE.INTEGER diff --git a/packages/sprinkle-admin/app/schema/requests/user/edit-field.yaml b/packages/sprinkle-admin/app/schema/requests/user/edit-field.yaml deleted file mode 100644 index 9e7d6a18f..000000000 --- a/packages/sprinkle-admin/app/schema/requests/user/edit-field.yaml +++ /dev/null @@ -1,71 +0,0 @@ ---- -first_name: - validators: - length: - label: "&FIRST_NAME" - min: 1 - max: 20 - message: VALIDATE.LENGTH_RANGE -last_name: - validators: - length: - label: "&LAST_NAME" - min: 1 - max: 30 - message: VALIDATE.LENGTH_RANGE -email: - validators: - length: - label: "&EMAIL" - min: 1 - max: 150 - message: VALIDATE.LENGTH_RANGE - email: - message: VALIDATE.INVALID_EMAIL -locale: - validators: - length: - label: "&LOCALE" - min: 1 - max: 10 - message: VALIDATE.LENGTH_RANGE -group_id: - validators: - integer: - message: VALIDATE.INTEGER -flag_enabled: - validators: - member_of: - values: - - '0' - - '1' - message: VALIDATE.BOOLEAN -flag_verified: - validators: - member_of: - values: - - '0' - - '1' - message: VALIDATE.BOOLEAN -password: - validators: - length: - label: "&PASSWORD" - min: 12 - max: 100 - message: VALIDATE.LENGTH_RANGE -passwordc: - validators: - length: - label: "&PASSWORD" - min: 12 - max: 100 - message: VALIDATE.LENGTH_RANGE - matches: - field: password - label: "&PASSWORD.CONFIRM" - message: VALIDATE.PASSWORD_MISMATCH -roles: - validators: - array: - message: VALIDATE.ARRAY diff --git a/packages/sprinkle-admin/app/schema/requests/user/edit-info.yaml b/packages/sprinkle-admin/app/schema/requests/user/edit-info.yaml index 30ae9205e..c306a6a04 100644 --- a/packages/sprinkle-admin/app/schema/requests/user/edit-info.yaml +++ b/packages/sprinkle-admin/app/schema/requests/user/edit-info.yaml @@ -1,4 +1,25 @@ --- +user_name: + validators: + length: + label: "&USERNAME" + min: 1 + max: 50 + message: VALIDATE.LENGTH_RANGE + no_leading_whitespace: + label: "&USERNAME" + message: VALIDATE.NO_LEAD_WS + no_trailing_whitespace: + label: "&USERNAME" + message: VALIDATE.NO_TRAIL_WS + required: + label: "&USERNAME" + message: VALIDATE.REQUIRED + username: + label: "&USERNAME" + message: VALIDATE.USERNAME + transformations: + - trim first_name: validators: length: @@ -29,8 +50,3 @@ locale: min: 1 max: 10 message: VALIDATE.LENGTH_RANGE -group_id: - validators: - integer: - label: "&GROUP" - message: VALIDATE.INTEGER diff --git a/packages/sprinkle-admin/app/schema/requests/user/edit-password.yaml b/packages/sprinkle-admin/app/schema/requests/user/edit-password.yaml index 2b67f964a..2153754a3 100644 --- a/packages/sprinkle-admin/app/schema/requests/user/edit-password.yaml +++ b/packages/sprinkle-admin/app/schema/requests/user/edit-password.yaml @@ -2,11 +2,9 @@ password: validators: required: - domain: client label: "&PASSWORD" message: VALIDATE.REQUIRED length: - domain: client label: "&PASSWORD" min: 12 max: 100 @@ -14,16 +12,13 @@ password: passwordc: validators: required: - domain: client label: "&PASSWORD.CONFIRM" message: VALIDATE.REQUIRED matches: - domain: client field: password label: "&PASSWORD.CONFIRM" message: VALIDATE.PASSWORD_MISMATCH length: - domain: client label: "&PASSWORD.CONFIRM" min: 12 max: 100 diff --git a/packages/sprinkle-admin/app/schema/requests/user/group.yaml b/packages/sprinkle-admin/app/schema/requests/user/group.yaml new file mode 100644 index 000000000..f18f51350 --- /dev/null +++ b/packages/sprinkle-admin/app/schema/requests/user/group.yaml @@ -0,0 +1,9 @@ +--- +group_id: + validators: + required: + label: "&GROUP" + message: VALIDATE.REQUIRED + integer: + label: "&GROUP" + message: VALIDATE.INTEGER \ No newline at end of file diff --git a/packages/sprinkle-admin/app/schema/requests/user/roles.yaml b/packages/sprinkle-admin/app/schema/requests/user/roles.yaml new file mode 100644 index 000000000..d19f118ef --- /dev/null +++ b/packages/sprinkle-admin/app/schema/requests/user/roles.yaml @@ -0,0 +1,8 @@ +--- +roles: + validators: + required: + label: "&ROLE" + message: VALIDATE.REQUIRED + array: + message: VALIDATE.ARRAY \ No newline at end of file diff --git a/packages/sprinkle-admin/app/schema/requests/user/status.yaml b/packages/sprinkle-admin/app/schema/requests/user/status.yaml new file mode 100644 index 000000000..5c677e99b --- /dev/null +++ b/packages/sprinkle-admin/app/schema/requests/user/status.yaml @@ -0,0 +1,11 @@ +--- +flag_enabled: + validators: + required: + label: "&STATUS" + message: VALIDATE.REQUIRED + member_of: + values: + - '0' + - '1' + message: VALIDATE.BOOLEAN \ No newline at end of file diff --git a/packages/sprinkle-admin/app/schema/requests/user/verification.yaml b/packages/sprinkle-admin/app/schema/requests/user/verification.yaml new file mode 100644 index 000000000..1af9d5e8b --- /dev/null +++ b/packages/sprinkle-admin/app/schema/requests/user/verification.yaml @@ -0,0 +1,11 @@ +--- +flag_verified: + validators: + required: + label: "&VERIFICATION" + message: VALIDATE.REQUIRED + member_of: + values: + - '0' + - '1' + message: VALIDATE.BOOLEAN \ No newline at end of file diff --git a/packages/sprinkle-admin/app/src/Admin.php b/packages/sprinkle-admin/app/src/Admin.php index 40cfd4d88..17ca20a4f 100644 --- a/packages/sprinkle-admin/app/src/Admin.php +++ b/packages/sprinkle-admin/app/src/Admin.php @@ -11,6 +11,10 @@ namespace UserFrosting\Sprinkle\Admin; use UserFrosting\Sprinkle\Account\Account; +use UserFrosting\Sprinkle\Account\Sprinkle\Recipe\ActivityRecipe; +use UserFrosting\Sprinkle\Admin\Log\AdminAccountActivityTypes; +use UserFrosting\Sprinkle\Admin\Log\GroupActivityTypes; +use UserFrosting\Sprinkle\Admin\Log\RoleActivityTypes; use UserFrosting\Sprinkle\Admin\Routes\ActivitiesRoutes; use UserFrosting\Sprinkle\Admin\Routes\ConfigRoutes; use UserFrosting\Sprinkle\Admin\Routes\DashboardRoutes; @@ -21,7 +25,7 @@ use UserFrosting\Sprinkle\Core\Core; use UserFrosting\Sprinkle\SprinkleRecipe; -class Admin implements SprinkleRecipe +class Admin implements SprinkleRecipe, ActivityRecipe { /** * {@inheritdoc} @@ -50,6 +54,18 @@ public function getSprinkles(): array ]; } + /** + * {@inheritDoc} + */ + public function getActivityTypes(): array + { + return [ + AdminAccountActivityTypes::class, + GroupActivityTypes::class, + RoleActivityTypes::class, + ]; + } + /** * {@inheritDoc} */ diff --git a/packages/sprinkle-admin/app/src/Controller/Config/CacheApiAction.php b/packages/sprinkle-admin/app/src/Controller/Config/CacheApiAction.php index c4f4e751a..722a47fd2 100644 --- a/packages/sprinkle-admin/app/src/Controller/Config/CacheApiAction.php +++ b/packages/sprinkle-admin/app/src/Controller/Config/CacheApiAction.php @@ -18,6 +18,8 @@ use UserFrosting\I18n\Translator; use UserFrosting\Sprinkle\Account\Authenticate\Authenticator; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorder; +use UserFrosting\Sprinkle\Admin\Log\AdminActivityTypes; use UserFrosting\Sprinkle\Core\Bakery\ClearCacheCommand; use UserFrosting\Sprinkle\Core\Util\ApiResponse; @@ -33,6 +35,7 @@ public function __construct( protected Translator $translator, protected Authenticator $authenticator, protected ClearCacheCommand $clearCacheCommand, + protected ActivityRecorder $logger, ) { } @@ -50,6 +53,12 @@ public function __invoke(Request $request, Response $response): Response $this->clearCacheCommand->clearTwigCache(); $this->clearCacheCommand->clearRouterCache(); + // Log activity + $this->logger->record( + user: $this->authenticator->user(), + type: AdminActivityTypes::CACHE_CLEARED, + ); + // Message $message = $this->translator->translate('SITE_CONFIG.CACHE.CLEARED'); diff --git a/packages/sprinkle-admin/app/src/Controller/Group/GroupCreateAction.php b/packages/sprinkle-admin/app/src/Controller/Group/GroupCreateAction.php index 9af83760f..c330159ec 100644 --- a/packages/sprinkle-admin/app/src/Controller/Group/GroupCreateAction.php +++ b/packages/sprinkle-admin/app/src/Controller/Group/GroupCreateAction.php @@ -24,8 +24,9 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\GroupInterface; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Admin\Exceptions\GroupException; +use UserFrosting\Sprinkle\Admin\Log\GroupActivityTypes; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; use UserFrosting\Support\Message\UserMessage; @@ -54,7 +55,7 @@ public function __construct( protected Authenticator $authenticator, protected Connection $db, protected GroupInterface $groupModel, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, protected RequestDataTransformer $transformer, protected ServerSideValidator $validator, ) { @@ -110,15 +111,15 @@ protected function handle(Request $request): GroupInterface // All checks passed! log events/activities and create group // Begin transaction - DB will be rolled back if an exception occurs $group = $this->db->transaction(function () use ($data, $currentUser) { - // Create the group $group = new $this->groupModel($data); $group->save(); // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} created group {$group->name}.", [ - 'type' => 'group_create', - 'user_id' => $currentUser->id, - ]); + $this->logger->record( + user: $currentUser, + type: GroupActivityTypes::CREATE, + subject: $group + ); return $group; }); diff --git a/packages/sprinkle-admin/app/src/Controller/Group/GroupDeleteAction.php b/packages/sprinkle-admin/app/src/Controller/Group/GroupDeleteAction.php index 14c4b86fa..ef40f548b 100644 --- a/packages/sprinkle-admin/app/src/Controller/Group/GroupDeleteAction.php +++ b/packages/sprinkle-admin/app/src/Controller/Group/GroupDeleteAction.php @@ -21,8 +21,9 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\GroupInterface; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Admin\Exceptions\GroupException; +use UserFrosting\Sprinkle\Admin\Log\GroupActivityTypes; use UserFrosting\Sprinkle\Core\Util\ApiResponse; use UserFrosting\Support\Message\UserMessage; @@ -49,7 +50,7 @@ public function __construct( protected Authenticator $authenticator, protected Config $config, protected Connection $db, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, ) { } @@ -113,10 +114,11 @@ protected function handle(GroupInterface $group): UserMessage $group->delete(); // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} deleted group {$group->name}.", [ - 'type' => 'group_delete', - 'user_id' => $currentUser->id, - ]); + $this->logger->record( + user: $currentUser, + type: GroupActivityTypes::DELETE, + subject: $group + ); }); return new UserMessage('GROUP.DELETION_SUCCESSFUL', [ diff --git a/packages/sprinkle-admin/app/src/Controller/Group/GroupEditAction.php b/packages/sprinkle-admin/app/src/Controller/Group/GroupEditAction.php index f8e71d610..a20ef4362 100644 --- a/packages/sprinkle-admin/app/src/Controller/Group/GroupEditAction.php +++ b/packages/sprinkle-admin/app/src/Controller/Group/GroupEditAction.php @@ -25,8 +25,9 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\GroupInterface; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Admin\Exceptions\GroupException; +use UserFrosting\Sprinkle\Admin\Log\GroupActivityTypes; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; use UserFrosting\Support\Message\UserMessage; @@ -55,7 +56,7 @@ public function __construct( protected Authenticator $authenticator, protected Config $config, protected Connection $db, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, protected GroupInterface $groupModel, protected RequestDataTransformer $transformer, protected ServerSideValidator $validator, @@ -129,18 +130,19 @@ protected function handle(GroupInterface $group, Request $request): GroupInterfa // Begin transaction - DB will be rolled back if an exception occurs $this->db->transaction(function () use ($data, $group, $currentUser) { - // Update the user and generate success messages foreach ($data as $name => $value) { $group->setAttribute($name, $value); } - $group->save(); + // Create activity record while the subject still contains its dirty attributes. + $this->logger->record( + user: $currentUser, + type: GroupActivityTypes::UPDATE_INFO, + subject: $group, + withProperties: true, + ); - // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} updated details for group {$group->name}.", [ - 'type' => 'group_update_info', - 'user_id' => $currentUser->id, - ]); + $group->save(); }); return $group; diff --git a/packages/sprinkle-admin/app/src/Controller/Role/RoleCreateAction.php b/packages/sprinkle-admin/app/src/Controller/Role/RoleCreateAction.php index 95270443c..70a561b89 100644 --- a/packages/sprinkle-admin/app/src/Controller/Role/RoleCreateAction.php +++ b/packages/sprinkle-admin/app/src/Controller/Role/RoleCreateAction.php @@ -24,8 +24,9 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\RoleInterface; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Admin\Exceptions\RoleException; +use UserFrosting\Sprinkle\Admin\Log\RoleActivityTypes; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; use UserFrosting\Support\Message\UserMessage; @@ -54,7 +55,7 @@ public function __construct( protected Authenticator $authenticator, protected Connection $db, protected RoleInterface $roleModel, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, protected RequestDataTransformer $transformer, protected ServerSideValidator $validator, ) { @@ -117,10 +118,11 @@ protected function handle(Request $request): RoleInterface $role->save(); // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} created role {$role->name}.", [ - 'type' => 'role_create', - 'user_id' => $currentUser->id, - ]); + $this->logger->record( + user: $currentUser, + type: RoleActivityTypes::CREATE, + subject: $role + ); return $role; }); diff --git a/packages/sprinkle-admin/app/src/Controller/Role/RoleDeleteAction.php b/packages/sprinkle-admin/app/src/Controller/Role/RoleDeleteAction.php index ecf89bb18..d87e4bb56 100644 --- a/packages/sprinkle-admin/app/src/Controller/Role/RoleDeleteAction.php +++ b/packages/sprinkle-admin/app/src/Controller/Role/RoleDeleteAction.php @@ -21,8 +21,9 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\RoleInterface; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Admin\Exceptions\RoleException; +use UserFrosting\Sprinkle\Admin\Log\RoleActivityTypes; use UserFrosting\Sprinkle\Core\Util\ApiResponse; use UserFrosting\Support\Message\UserMessage; @@ -49,7 +50,7 @@ public function __construct( protected Authenticator $authenticator, protected Config $config, protected Connection $db, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, ) { } @@ -116,10 +117,11 @@ protected function handle(RoleInterface $role): void $role->delete(); // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} deleted role {$role->name}.", [ - 'type' => 'role_delete', - 'user_id' => $currentUser->id, - ]); + $this->logger->record( + user: $currentUser, + type: RoleActivityTypes::DELETE, + subject: $role + ); }); } diff --git a/packages/sprinkle-admin/app/src/Controller/Role/RoleEditAction.php b/packages/sprinkle-admin/app/src/Controller/Role/RoleEditAction.php index eab0d4e99..557bbbd7a 100644 --- a/packages/sprinkle-admin/app/src/Controller/Role/RoleEditAction.php +++ b/packages/sprinkle-admin/app/src/Controller/Role/RoleEditAction.php @@ -25,8 +25,9 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\RoleInterface; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Admin\Exceptions\RoleException; +use UserFrosting\Sprinkle\Admin\Log\RoleActivityTypes; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; use UserFrosting\Support\Message\UserMessage; @@ -55,7 +56,7 @@ public function __construct( protected Authenticator $authenticator, protected Config $config, protected Connection $db, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, protected RoleInterface $roleModel, protected RequestDataTransformer $transformer, protected ServerSideValidator $validator, @@ -129,18 +130,19 @@ protected function handle(RoleInterface $role, Request $request): RoleInterface // Begin transaction - DB will be rolled back if an exception occurs $role = $this->db->transaction(function () use ($data, $role, $currentUser) { - // Update the user and generate success messages foreach ($data as $name => $value) { $role->setAttribute($name, $value); } - $role->save(); + // Create activity record while the subject still contains its dirty attributes. + $this->logger->record( + user: $currentUser, + type: RoleActivityTypes::UPDATE_INFO, + subject: $role, + withProperties: true, + ); - // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} updated details for role {$role->name}.", [ - 'type' => 'role_update_info', - 'user_id' => $currentUser->id, - ]); + $role->save(); return $role; }); diff --git a/packages/sprinkle-admin/app/src/Controller/Role/RoleUpdateFieldAction.php b/packages/sprinkle-admin/app/src/Controller/Role/RolePermissionsAction.php similarity index 57% rename from packages/sprinkle-admin/app/src/Controller/Role/RoleUpdateFieldAction.php rename to packages/sprinkle-admin/app/src/Controller/Role/RolePermissionsAction.php index 285cbfa0e..e945480f9 100644 --- a/packages/sprinkle-admin/app/src/Controller/Role/RoleUpdateFieldAction.php +++ b/packages/sprinkle-admin/app/src/Controller/Role/RolePermissionsAction.php @@ -16,7 +16,6 @@ use Illuminate\Database\Connection; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; -use UserFrosting\Config\Config; use UserFrosting\Fortress\RequestSchema; use UserFrosting\Fortress\RequestSchema\RequestSchemaInterface; use UserFrosting\Fortress\Transformer\RequestDataTransformer; @@ -25,27 +24,22 @@ use UserFrosting\Sprinkle\Account\Authenticate\Authenticator; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\RoleInterface; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; +use UserFrosting\Sprinkle\Account\Database\Models\Permission; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Admin\Exceptions\MissingRequiredParamException; +use UserFrosting\Sprinkle\Admin\Log\RoleActivityTypes; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; use UserFrosting\Support\Message\UserMessage; /** - * Processes the request to update a specific field for an existing role, including permissions. - * - * Processes the request from the role update form, checking that: - * 1. The logged-in user has the necessary permissions to update the putted field(s); - * 2. The submitted data is valid. - * This route requires authentication. - * - * Request type: PUT + * Assigns permissions to an existing role. */ -class RoleUpdateFieldAction +class RolePermissionsAction { // Request schema for client side form validation - protected string $schema = 'schema://requests/role/edit-field.yaml'; + protected string $schema = 'schema://requests/role/permissions.yaml'; /** * Inject dependencies. @@ -53,10 +47,9 @@ class RoleUpdateFieldAction public function __construct( protected Translator $translator, protected Authenticator $authenticator, - protected Config $config, protected Cache $cache, protected Connection $db, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, protected RequestDataTransformer $transformer, protected ServerSideValidator $validator, ) { @@ -67,17 +60,15 @@ public function __construct( * the response. * * @param RoleInterface $role The role to update, injected by middleware. - * @param string $field The field to update. * @param Request $request * @param Response $response */ public function __invoke( RoleInterface $role, - string $field, Request $request, Response $response ): Response { - $message = $this->handle($role, $field, $request); + $message = $this->handle($role, $request); $message = $this->translator->translate($message->message, $message->parameters); $payload = new ApiResponse($message); $response->getBody()->write((string) $payload); @@ -89,84 +80,80 @@ public function __invoke( * Handle the request. * * @param RoleInterface $role - * @param string $fieldName * @param Request $request * * @return UserMessage The message to display to the user. */ protected function handle( RoleInterface $role, - string $fieldName, Request $request ): UserMessage { - // Access-controlled resource - check that current User has permission - // to edit the specified field for this user - $this->validateAccess($role, $fieldName); - - // Get current user. Won't be null, as AuthGuard prevent it - /** @var UserInterface */ - $currentUser = $this->authenticator->user(); + // Access-control - check that current User has permission, and get the + // current user + $currentUser = $this->authorize(); // Get PUT parameters: value $put = (array) $request->getParsedBody(); // Make sure data is part of $_PUT data. - // Except for roles, which we allows to be empty. - if (isset($put[$fieldName])) { - $fieldData = $put[$fieldName]; - } else { + if (!array_key_exists('permissions', $put)) { $e = new MissingRequiredParamException(); - $e->setParam($fieldName); + $e->setParam('permissions'); throw $e; } - // Create and validate key -> value pair - $params = [ - $fieldName => $fieldData, - ]; - // Load the request schema $schema = $this->getSchema(); // Whitelist and set parameter defaults - $data = $this->transformer->transform($schema, $params); + $data = $this->transformer->transform($schema, $put); // Validate request data $this->validateData($schema, $data); - // Get validated and transformed value - $fieldValue = $data[$fieldName]; - - // Begin transaction - DB will be rolled back if an exception occurs - $this->db->transaction(function () use ($fieldName, $fieldValue, $role, $currentUser) { - if ($fieldName === 'permissions') { - $role->permissions()->sync($fieldValue); - - // All user's permissions are cached. Clear cache. - $this->cache->clear(); - } else { - $role->$fieldName = $fieldValue; // @phpstan-ignore-line Variable property is ok here. - $role->save(); - } - - // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} updated property '$fieldName' for role {$role->name}.", [ - 'type' => 'role_update_field', - 'user_id' => $currentUser->id, - ]); + $permissionIds = $data['permissions']; + + $this->db->transaction(function () use ($role, $currentUser, $permissionIds): void { + // Prepare data for the activity record + $oldPermissions = $role->permissions()->get()->pluck('name', 'id')->all(); + $newPermissions = Permission::query()->whereKey($permissionIds)->pluck('name', 'id')->all(); + $addedPermissions = implode(', ', array_values(array_diff_key($newPermissions, $oldPermissions))); + $removedPermissions = implode(', ', array_values(array_diff_key($oldPermissions, $newPermissions))); + + // Change data in the database + $role->permissions()->sync($permissionIds); + + // All user's permissions are cached. Clear cache. + $this->cache->clear(); + + $this->logger->record( + user: $currentUser, + type: RoleActivityTypes::UPDATE_PERMISSIONS, + subject: $role, + metadata: [ + 'added_permissions' => $addedPermissions !== '' ? $addedPermissions : $this->translator->translate('PERMISSION.NONE'), + 'removed_permissions' => $removedPermissions !== '' ? $removedPermissions : $this->translator->translate('PERMISSION.NONE'), + ], + ); }); - // Add success messages - if ($fieldName === 'permissions') { - return new UserMessage('ROLE.PERMISSIONS_UPDATED', [ - 'name' => $role->name, - ]); - } else { - return new UserMessage('ROLE.UPDATED', [ - 'name' => $role->name, - ]); + return new UserMessage('ROLE.PERMISSIONS_UPDATED', ['name' => $role->name]); + } + + /** + * Authorize the user. + * + * @return UserInterface + */ + protected function authorize(): UserInterface + { + if (!$this->authenticator->checkAccess('update_role_field')) { + throw new ForbiddenException(); } + + /** @var UserInterface */ + return $this->authenticator->user(); } /** @@ -176,9 +163,7 @@ protected function handle( */ protected function getSchema(): RequestSchemaInterface { - $schema = new RequestSchema($this->schema); - - return $schema; + return new RequestSchema($this->schema); } /** @@ -197,16 +182,4 @@ protected function validateData(RequestSchemaInterface $schema, array $data): vo throw $e; } } - - /** - * Validate access to the page. - * - * @throws ForbiddenException - */ - protected function validateAccess(RoleInterface $role, string $fieldName): void - { - if (!$this->authenticator->checkAccess('update_role_field')) { - throw new ForbiddenException(); - } - } } diff --git a/packages/sprinkle-admin/app/src/Controller/User/UserCreateAction.php b/packages/sprinkle-admin/app/src/Controller/User/UserCreateAction.php index d1027b32c..935b19942 100644 --- a/packages/sprinkle-admin/app/src/Controller/User/UserCreateAction.php +++ b/packages/sprinkle-admin/app/src/Controller/User/UserCreateAction.php @@ -28,7 +28,8 @@ use UserFrosting\Sprinkle\Account\Event\UserCreatedEvent; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; use UserFrosting\Sprinkle\Account\Exceptions\LocaleNotFoundException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Validators\UserValidation; use UserFrosting\Sprinkle\Admin\Mail\UserCreatedEmail; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; @@ -67,7 +68,7 @@ public function __construct( protected EventDispatcherInterface $eventDispatcher, protected GroupInterface $groupModel, protected SiteLocaleInterface $siteLocale, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, protected UserCreatedEmail $userCreationEmail, protected UserInterface $userModel, protected UserValidation $userValidation, @@ -136,11 +137,6 @@ protected function handle(Request $request): UserInterface $data['flag_verified'] = true; $data['flag_enabled'] = true; - // If group id is zero, then it's no group - if (!isset($data['group_id']) || $data['group_id'] === 0) { - $data['group_id'] = null; - } - // Now that we check the form, we can try to register the actual user $user = new $this->userModel($data); @@ -159,10 +155,11 @@ protected function handle(Request $request): UserInterface $user = $this->eventDispatcher->dispatch($event)->user; // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} created a new account for {$user->user_name}.", [ - 'type' => 'account_create', // UserActivityLogger::TYPE_REGISTER, - 'user_id' => $user->id, - ]); + $this->logger->record( + user: $currentUser, + type: AccountActivityTypes::CREATE, + subject: $user + ); // Send an email to the user who's been created $this->userCreationEmail->send($user); diff --git a/packages/sprinkle-admin/app/src/Controller/User/UserDeleteAction.php b/packages/sprinkle-admin/app/src/Controller/User/UserDeleteAction.php index e99ac2eab..de5f52d41 100644 --- a/packages/sprinkle-admin/app/src/Controller/User/UserDeleteAction.php +++ b/packages/sprinkle-admin/app/src/Controller/User/UserDeleteAction.php @@ -21,7 +21,8 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\AccountException; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Admin\Exceptions\AccountNotFoundException; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; @@ -52,7 +53,7 @@ public function __construct( protected Authenticator $authenticator, protected Config $config, protected Connection $db, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, ) { } @@ -99,10 +100,12 @@ protected function handle(UserInterface $user): void $user->delete(); // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} deleted the account for {$username}.", [ - 'type' => 'account_delete', - 'user_id' => $currentUser->id, - ]); + $this->logger->record( + user: $currentUser, + type: AccountActivityTypes::DELETE, + subject: $user, + metadata: ['username' => $username] + ); }); } diff --git a/packages/sprinkle-admin/app/src/Controller/User/UserEditAction.php b/packages/sprinkle-admin/app/src/Controller/User/UserEditAction.php index 4bbb638b0..25d495d44 100644 --- a/packages/sprinkle-admin/app/src/Controller/User/UserEditAction.php +++ b/packages/sprinkle-admin/app/src/Controller/User/UserEditAction.php @@ -16,21 +16,18 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use UserFrosting\Config\Config; -use UserFrosting\Fortress\RequestSchema; -use UserFrosting\Fortress\RequestSchema\RequestSchemaInterface; use UserFrosting\Fortress\Transformer\RequestDataTransformer; use UserFrosting\Fortress\Validator\ServerSideValidator; use UserFrosting\I18n\Translator; use UserFrosting\Sprinkle\Account\Authenticate\Authenticator; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\EmailNotUniqueException; -use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; -use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; -use UserFrosting\Sprinkle\Core\Util\ApiResponse; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; +use UserFrosting\Sprinkle\Admin\Log\AdminAccountActivityTypes; +use UserFrosting\Support\Message\UserMessage; /** - * Processes the request to update an existing user's basic details (first_name, last_name, email, locale, group_id). + * Processes the request to update an existing user's basic details. * * Processes the request from the user update form, checking that: * 1. The target user's new email address, if specified, is not already in use; @@ -40,24 +37,25 @@ * This route requires authentication. * Request type: PUT */ -class UserEditAction +class UserEditAction extends UserUpdateAction { // Request schema for client side form validation - protected string $schema = 'schema://requests/user/create.yaml'; + protected string $schema = 'schema://requests/user/edit-info.yaml'; /** * Inject dependencies. */ public function __construct( - protected Translator $translator, - protected Authenticator $authenticator, - protected Config $config, + Translator $translator, + Authenticator $authenticator, + Config $config, protected Connection $db, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, protected UserInterface $userModel, - protected RequestDataTransformer $transformer, - protected ServerSideValidator $validator, + RequestDataTransformer $transformer, + ServerSideValidator $validator, ) { + parent::__construct($translator, $authenticator, $config, $transformer, $validator); } /** @@ -70,16 +68,10 @@ public function __construct( */ public function __invoke(UserInterface $user, Request $request, Response $response): Response { - $user = $this->handle($user, $request)->toArray(); - - // Message - $message = $this->translator->translate('DETAILS_UPDATED', $user); - - // Write response - $payload = new ApiResponse($message); - $response->getBody()->write((string) $payload); - - return $response->withHeader('Content-Type', 'application/json'); + return $this->respond( + $response, + new UserMessage('DETAILS_UPDATED', $this->handle($user, $request)->toArray()) + ); } /** @@ -92,48 +84,13 @@ public function __invoke(UserInterface $user, Request $request, Response $respon */ protected function handle(UserInterface $user, Request $request): UserInterface { - // Access-controlled resource. - // Verify the authenticated user has permission to edit the submitted - // fields for the target user. - if (!$this->authenticator->checkAccess('update_user_field')) { - throw new ForbiddenException(); - } - - // Get current user. Won't be null, as AuthGuard prevent it - /** @var UserInterface */ - $currentUser = $this->authenticator->user(); - - // Only the master account can edit the master account! - if ( - ($user->id === $this->config->get('reserved_user_ids.master')) && - ($currentUser->id !== $this->config->get('reserved_user_ids.master')) - ) { - throw new ForbiddenException(); - } - - // Get PUT parameters - $params = (array) $request->getParsedBody(); + $currentUser = $this->authorize($user, 'update_user_field'); // Load the request schema $schema = $this->getSchema(); // Whitelist and set parameter defaults - $data = $this->transformer->transform($schema, $params); - - // Validate request data - $this->validateData($schema, $data); - - // Determine targeted fields - $fieldNames = []; - foreach ($data as $name => $value) { - if ($name === 'first_name' || $name === 'last_name') { - $fieldNames[] = 'name'; - } elseif ($name === 'group_id') { - $fieldNames[] = 'group'; - } else { - $fieldNames[] = $name; - } - } + $data = $this->transform($schema, $request); // Check if email already exists if ( @@ -147,58 +104,28 @@ protected function handle(UserInterface $user, Request $request): UserInterface throw $e; } - // Unset group relation if group_id is 0 - if (isset($data['group_id']) && $data['group_id'] === 0) { - $data['group_id'] = null; - } - // Begin transaction - DB will be rolled back if an exception occurs $newUser = $this->db->transaction(function () use ($data, $user, $currentUser) { - // Update the user and generate success messages + // Update the user foreach ($data as $name => $value) { $user->setAttribute($name, $value); } - $user->save(); + // Create activity record if the subject contains dirty attributes. + if ($user->isDirty()) { + $this->logger->record( + user: $currentUser, + type: AdminAccountActivityTypes::UPDATE_INFO, + subject: $user, + withProperties: true, + ); + } - // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} updated basic account info for user {$user->user_name}.", [ - 'type' => 'account_update_info', - 'user_id' => $user->id, - ]); + $user->save(); return $user; }); return $newUser; } - - /** - * Load the request schema. - * - * @return RequestSchemaInterface - */ - protected function getSchema(): RequestSchemaInterface - { - $schema = new RequestSchema($this->schema); - - return $schema; - } - - /** - * Validate request POST data. - * - * @param RequestSchemaInterface $schema - * @param mixed[] $data - */ - protected function validateData(RequestSchemaInterface $schema, array $data): void - { - $errors = $this->validator->validate($schema, $data); - if (count($errors) !== 0) { - $e = new ValidationException(); - $e->addErrors($errors); - - throw $e; - } - } } diff --git a/packages/sprinkle-admin/app/src/Controller/User/UserGroupAction.php b/packages/sprinkle-admin/app/src/Controller/User/UserGroupAction.php new file mode 100644 index 000000000..17504b4d2 --- /dev/null +++ b/packages/sprinkle-admin/app/src/Controller/User/UserGroupAction.php @@ -0,0 +1,109 @@ +handle($user, $request); + + return $this->respond( + $response, + new UserMessage('DETAILS_UPDATED', ['user_name' => $user->user_name]) + ); + } + + /** + * Handle the request. + * + * @param UserInterface $user + * @param Request $request + * + * @return UserInterface + */ + protected function handle(UserInterface $user, Request $request): UserInterface + { + $currentUser = $this->authorize($user, 'update_user_field'); + $data = $this->transform($this->getSchema(), $request); + $groupId = $data['group_id'] === 0 ? null : $data['group_id']; + + $this->db->transaction(function () use ($user, $currentUser, $groupId): void { + // Skip if no changes + if ($user->group_id !== $groupId) { + $oldGroup = $user->group; + + // Update group + $user->group_id = $groupId; + $user->unsetRelation('group'); + $newGroup = $user->group; + + if ($oldGroup !== null) { + $this->logger->record( + user: $currentUser, + type: AdminAccountActivityTypes::REMOVE_FROM_GROUP, + context: $oldGroup, + subject: $user, + withProperties: false, + ); + } + + if ($newGroup !== null) { + $this->logger->record( + user: $currentUser, + type: AdminAccountActivityTypes::ADD_TO_GROUP, + context: $newGroup, + subject: $user, + withProperties: false, + ); + } + } + + $user->save(); + }); + + return $user; + } +} diff --git a/packages/sprinkle-admin/app/src/Controller/User/UserPasswordAction.php b/packages/sprinkle-admin/app/src/Controller/User/UserPasswordAction.php new file mode 100644 index 000000000..0e5407610 --- /dev/null +++ b/packages/sprinkle-admin/app/src/Controller/User/UserPasswordAction.php @@ -0,0 +1,89 @@ +handle($user, $request); + + return $this->respond( + $response, + new UserMessage('DETAILS_UPDATED', ['user_name' => $user->user_name]) + ); + } + + /** + * Handle the request. + * + * @param UserInterface $user + * @param Request $request + * + * @return UserInterface + */ + protected function handle(UserInterface $user, Request $request): UserInterface + { + $currentUser = $this->authorize($user, 'update_user_field'); + $schema = $this->getSchema(); + $schema->set('password.validators.length.min', $this->config->get('site.password.length.min')); + $schema->set('password.validators.length.max', $this->config->get('site.password.length.max')); + $schema->set('passwordc.validators.length.min', $this->config->get('site.password.length.min')); + $schema->set('passwordc.validators.length.max', $this->config->get('site.password.length.max')); + $data = $this->transform($schema, $request); + + $this->db->transaction(function () use ($user, $currentUser, $data): void { + $user->password = $data['password']; + $this->logger->record( + user: $currentUser, + type: AccountActivityTypes::UPDATE_PASSWORD, + subject: $user, + ); + $user->save(); + }); + + return $user; + } +} diff --git a/packages/sprinkle-admin/app/src/Controller/User/UserRolesAction.php b/packages/sprinkle-admin/app/src/Controller/User/UserRolesAction.php new file mode 100644 index 000000000..be97c4ea0 --- /dev/null +++ b/packages/sprinkle-admin/app/src/Controller/User/UserRolesAction.php @@ -0,0 +1,96 @@ +handle($user, $request); + + return $this->respond( + $response, + new UserMessage('DETAILS_UPDATED', ['user_name' => $user->user_name]) + ); + } + + /** + * Handle the request. + * + * @param UserInterface $user + * @param Request $request + * + * @return UserInterface + */ + protected function handle(UserInterface $user, Request $request): UserInterface + { + $currentUser = $this->authorize($user, 'update_user_role'); + $data = $this->transform($this->getSchema(), $request); + $roleIds = $data['roles']; + + $this->db->transaction(function () use ($user, $currentUser, $roleIds): void { + $oldRoles = $user->roles()->get()->pluck('name', 'id')->all(); + $newRoles = Role::query()->whereKey($roleIds)->pluck('name', 'id')->all(); + $addedRoles = implode(', ', array_values(array_diff_key($newRoles, $oldRoles))); + $removedRoles = implode(', ', array_values(array_diff_key($oldRoles, $newRoles))); + $user->roles()->sync($roleIds); + $user->forgetCache(); + + $this->logger->record( + user: $currentUser, + type: AdminAccountActivityTypes::UPDATE_ROLES, + subject: $user, + metadata: [ + 'added_roles' => $addedRoles !== '' ? $addedRoles : $this->translator->translate('ROLE.NONE'), + 'removed_roles' => $removedRoles !== '' ? $removedRoles : $this->translator->translate('ROLE.NONE'), + ], + withProperties: false, + ); + }); + + return $user; + } +} diff --git a/packages/sprinkle-admin/app/src/Controller/User/UserStatusAction.php b/packages/sprinkle-admin/app/src/Controller/User/UserStatusAction.php new file mode 100644 index 000000000..f1df393b8 --- /dev/null +++ b/packages/sprinkle-admin/app/src/Controller/User/UserStatusAction.php @@ -0,0 +1,104 @@ +handle($user, $request); + + $message = new UserMessage( + $user->flag_enabled ? 'ENABLE_SUCCESSFUL' : 'DISABLE_SUCCESSFUL', + ['user_name' => $user->user_name] + ); + + return $this->respond($response, $message); + } + + /** + * Handle the request. + * + * @param UserInterface $user + * @param Request $request + * + * @return UserInterface + */ + protected function handle(UserInterface $user, Request $request): UserInterface + { + $currentUser = $this->authorize($user, 'update_user_field'); + $data = $this->transform($this->getSchema(), $request); + $enabled = $data['flag_enabled']; + + if ($user->id === $this->config->getInt('reserved_user_ids.master') && $enabled === '0') { + $e = new AccountException(); + $e->setTitle('DISABLE_MASTER'); + + throw $e; + } + + if ($user->id === $currentUser->id && $enabled === '0') { + $e = new AccountException(); + $e->setTitle('DISABLE_SELF'); + + throw $e; + } + + $this->db->transaction(function () use ($user, $currentUser, $enabled): void { + $user->flag_enabled = $enabled; + $this->logger->record( + user: $currentUser, + type: $enabled === '1' + ? AdminAccountActivityTypes::ENABLE + : AdminAccountActivityTypes::DISABLE, + subject: $user, + ); + $user->save(); + }); + + return $user; + } +} diff --git a/packages/sprinkle-admin/app/src/Controller/User/UserUpdateAction.php b/packages/sprinkle-admin/app/src/Controller/User/UserUpdateAction.php new file mode 100644 index 000000000..298d7301b --- /dev/null +++ b/packages/sprinkle-admin/app/src/Controller/User/UserUpdateAction.php @@ -0,0 +1,107 @@ +authenticator->checkAccess($permission)) { + throw new ForbiddenException(); + } + + /** @var UserInterface */ + $currentUser = $this->authenticator->user(); + + if ( + $user->id === $this->config->getInt('reserved_user_ids.master') && + $currentUser->id !== $this->config->getInt('reserved_user_ids.master') + ) { + throw new ForbiddenException(); + } + + return $currentUser; + } + + /** + * Transform and validate a request against its dedicated schema. + * + * @return mixed[] + */ + protected function transform(RequestSchemaInterface $schema, Request $request): array + { + $data = $this->transformer->transform($schema, (array) $request->getParsedBody()); + $errors = $this->validator->validate($schema, $data); + + if (count($errors) !== 0) { + $e = new ValidationException(); + $e->addErrors($errors); + + throw $e; + } + + return $data; + } + + /** + * Load the request schema for this update action. + */ + protected function getSchema(): RequestSchemaInterface + { + return new RequestSchema($this->schema); + } + + /** + * Create the standard API response used by user update actions. + */ + protected function respond(Response $response, UserMessage $message): Response + { + $message = $this->translator->translate($message->message, $message->parameters); + $payload = new ApiResponse($message); + $response->getBody()->write((string) $payload); + + return $response->withHeader('Content-Type', 'application/json'); + } +} diff --git a/packages/sprinkle-admin/app/src/Controller/User/UserUpdateFieldAction.php b/packages/sprinkle-admin/app/src/Controller/User/UserUpdateFieldAction.php deleted file mode 100644 index 12ab7304a..000000000 --- a/packages/sprinkle-admin/app/src/Controller/User/UserUpdateFieldAction.php +++ /dev/null @@ -1,264 +0,0 @@ -handle($user, $field, $request); - $message = $this->translator->translate($message->message, $message->parameters); - $payload = new ApiResponse($message); - $response->getBody()->write((string) $payload); - - return $response->withHeader('Content-Type', 'application/json'); - } - - /** - * Handle the request. - * - * @param UserInterface $user - * @param string $fieldName - * @param Request $request - * - * @return UserMessage The message to display to the user. - */ - protected function handle( - UserInterface $user, - string $fieldName, - Request $request - ): UserMessage { - // Access-controlled resource - check that current User has permission - // to edit the specified field for this user - $this->validateAccess($user, $fieldName); - - // Get current user. Won't be null, as AuthGuard prevent it - /** @var UserInterface */ - $currentUser = $this->authenticator->user(); - - // Only the master account can edit the master account! - if ( - ($user->id === $this->config->get('reserved_user_ids.master')) && - ($currentUser->id !== $this->config->get('reserved_user_ids.master')) - ) { - throw new ForbiddenException(); - } - - // Get PUT parameters: value - $put = (array) $request->getParsedBody(); - - // Make sure data is part of $_PUT data. - // Except for roles, which we allows to be empty. - if (isset($put[$fieldName])) { - $fieldData = $put[$fieldName]; - } else { - $e = new MissingRequiredParamException(); - $e->setParam($fieldName); - - throw $e; - } - - // Create and validate key -> value pair - $params = [ - $fieldName => $fieldData, - ]; - - // Add password confirmation if needed - if ($fieldName === 'password') { - $params['passwordc'] = $put['passwordc']; - } - - // Load the request schema - $schema = $this->getSchema(); - - // Whitelist and set parameter defaults - $data = $this->transformer->transform($schema, $params); - - // Validate request data - $this->validateData($schema, $data); - - // Get validated and transformed value - $fieldValue = $data[$fieldName]; - - // Special checks and transformations for certain fields - if ($fieldName === 'flag_enabled') { - // Check that we are not disabling the master account - if ( - $user->id === $this->config->getInt('reserved_user_ids.master') && - $fieldValue === '0' - ) { - $e = new AccountException(); - $e->setTitle('DISABLE_MASTER'); - - throw $e; - } - - // Check that we are not disabling the current user - if ($user->id === $currentUser->id && $fieldValue === '0') { - $e = new AccountException(); - $e->setTitle('DISABLE_SELF'); - - throw $e; - } - } - - // Begin transaction - DB will be rolled back if an exception occurs - $this->db->transaction(function () use ($fieldName, $fieldValue, $user, $currentUser) { - if ($fieldName === 'roles') { - $user->roles()->sync($fieldValue); - $user->forgetCache(); - } else { - $user->$fieldName = $fieldValue; // @phpstan-ignore-line Variable property is ok here. - $user->save(); - } - - // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} updated property '$fieldName' for user {$user->user_name}.", [ - 'type' => 'account_update_field', - 'user_id' => $user->id, - ]); - }); - - // Return success messages - $message = new UserMessage(); - $message->parameters = ['user_name' => $user->user_name]; - - if ($fieldName === 'flag_enabled' && $fieldValue === '1') { - $message->message = 'ENABLE_SUCCESSFUL'; - } elseif ($fieldName === 'flag_enabled') { - $message->message = 'DISABLE_SUCCESSFUL'; - } elseif ($fieldName === 'flag_verified') { - $message->message = 'MANUALLY_ACTIVATED'; - } else { - $message->message = 'DETAILS_UPDATED'; - } - - return $message; - } - - /** - * Load the request schema. - * - * @return RequestSchemaInterface - */ - protected function getSchema(): RequestSchemaInterface - { - $schema = new RequestSchema($this->schema); - $schema->set('password.validators.length.min', $this->config->get('site.password.length.min')); - $schema->set('password.validators.length.max', $this->config->get('site.password.length.max')); - $schema->set('passwordc.validators.length.min', $this->config->get('site.password.length.min')); - $schema->set('passwordc.validators.length.max', $this->config->get('site.password.length.max')); - - return $schema; - } - - /** - * Validate request POST data. - * - * @param RequestSchemaInterface $schema - * @param mixed[] $data - */ - protected function validateData(RequestSchemaInterface $schema, array $data): void - { - $errors = $this->validator->validate($schema, $data); - if (count($errors) !== 0) { - $e = new ValidationException(); - $e->addErrors($errors); - - throw $e; - } - } - - /** - * Validate access to the page. - * - * @throws ForbiddenException - */ - protected function validateAccess(UserInterface $user, string $fieldName): void - { - // TODO Support `update_user_role` - if (!$this->authenticator->checkAccess('update_user_field')) { - throw new ForbiddenException(); - } - } -} diff --git a/packages/sprinkle-admin/app/src/Controller/User/UserVerificationAction.php b/packages/sprinkle-admin/app/src/Controller/User/UserVerificationAction.php new file mode 100644 index 000000000..e0fb54163 --- /dev/null +++ b/packages/sprinkle-admin/app/src/Controller/User/UserVerificationAction.php @@ -0,0 +1,89 @@ +handle($user, $request); + + $message = new UserMessage( + $user->flag_verified === true ? 'MANUALLY_ACTIVATED' : 'DETAILS_UPDATED', + ['user_name' => $user->user_name] + ); + + return $this->respond($response, $message); + } + + /** + * Handle the request. + * + * @param UserInterface $user + * @param Request $request + * + * @return UserInterface + */ + protected function handle(UserInterface $user, Request $request): UserInterface + { + $currentUser = $this->authorize($user, 'update_user_field'); + $data = $this->transform($this->getSchema(), $request); + $verified = $data['flag_verified']; + + $this->db->transaction(function () use ($user, $currentUser, $verified): void { + $user->flag_verified = $verified; + $this->logger->record( + user: $currentUser, + type: $verified === '1' + ? AdminAccountActivityTypes::VERIFY + : AdminAccountActivityTypes::UNVERIFY, + subject: $user, + ); + $user->save(); + }); + + return $user; + } +} diff --git a/packages/sprinkle-admin/app/src/Log/AdminAccountActivityTypes.php b/packages/sprinkle-admin/app/src/Log/AdminAccountActivityTypes.php new file mode 100644 index 000000000..45542c5d3 --- /dev/null +++ b/packages/sprinkle-admin/app/src/Log/AdminAccountActivityTypes.php @@ -0,0 +1,66 @@ + 'ACCOUNT.ACTIVITY.UPDATE_INFO', + self::ADD_TO_GROUP => 'ACCOUNT.ACTIVITY.ADD_TO_GROUP', + self::REMOVE_FROM_GROUP => 'ACCOUNT.ACTIVITY.REMOVE_FROM_GROUP', + self::UPDATE_ROLES => 'ACCOUNT.ACTIVITY.UPDATE_ROLES', + self::UPDATE_FIELD => 'ACCOUNT.ACTIVITY.UPDATE_FIELD', + self::ENABLE => 'ACCOUNT.ACTIVITY.ENABLE', + self::DISABLE => 'ACCOUNT.ACTIVITY.DISABLE', + self::VERIFY => 'ACCOUNT.ACTIVITY.VERIFY', + self::UNVERIFY => 'ACCOUNT.ACTIVITY.UNVERIFY', + default => null, + }; + } + + /** + * {@inheritDoc} + */ + public static function getLabelI18nKey(string $value): ?string + { + return match (self::tryFrom($value)) { + self::UPDATE_INFO => 'ACCOUNT.ACTIVITY.LABEL.UPDATE_INFO', + self::ADD_TO_GROUP => 'ACCOUNT.ACTIVITY.LABEL.ADD_TO_GROUP', + self::REMOVE_FROM_GROUP => 'ACCOUNT.ACTIVITY.LABEL.REMOVE_FROM_GROUP', + self::UPDATE_ROLES => 'ACCOUNT.ACTIVITY.LABEL.UPDATE_ROLES', + self::UPDATE_FIELD => 'ACCOUNT.ACTIVITY.LABEL.UPDATE_FIELD', + self::ENABLE => 'ACCOUNT.ACTIVITY.LABEL.ENABLE', + self::DISABLE => 'ACCOUNT.ACTIVITY.LABEL.DISABLE', + self::VERIFY => 'ACCOUNT.ACTIVITY.LABEL.VERIFY', + self::UNVERIFY => 'ACCOUNT.ACTIVITY.LABEL.UNVERIFY', + default => null, + }; + } +} diff --git a/packages/sprinkle-admin/app/src/Log/AdminActivityTypes.php b/packages/sprinkle-admin/app/src/Log/AdminActivityTypes.php new file mode 100644 index 000000000..55c2e9e5f --- /dev/null +++ b/packages/sprinkle-admin/app/src/Log/AdminActivityTypes.php @@ -0,0 +1,42 @@ + 'ADMIN.ACTIVITY.CACHE_CLEARED', + default => null, + }; + } + + /** + * {@inheritDoc} + */ + public static function getLabelI18nKey(string $value): ?string + { + return match (self::tryFrom($value)) { + self::CACHE_CLEARED => 'ADMIN.ACTIVITY.LABEL.CACHE_CLEARED', + default => null, + }; + } +} diff --git a/packages/sprinkle-admin/app/src/Log/GroupActivityTypes.php b/packages/sprinkle-admin/app/src/Log/GroupActivityTypes.php new file mode 100644 index 000000000..83b1ad02d --- /dev/null +++ b/packages/sprinkle-admin/app/src/Log/GroupActivityTypes.php @@ -0,0 +1,48 @@ + 'GROUP.ACTIVITY.CREATE', + self::DELETE => 'GROUP.ACTIVITY.DELETE', + self::UPDATE_INFO => 'GROUP.ACTIVITY.UPDATE_INFO', + default => null, + }; + } + + /** + * {@inheritDoc} + */ + public static function getLabelI18nKey(string $value): ?string + { + return match (self::tryFrom($value)) { + self::CREATE => 'GROUP.ACTIVITY.LABEL.CREATE', + self::DELETE => 'GROUP.ACTIVITY.LABEL.DELETE', + self::UPDATE_INFO => 'GROUP.ACTIVITY.LABEL.UPDATE_INFO', + default => null, + }; + } +} diff --git a/packages/sprinkle-admin/app/src/Log/RoleActivityTypes.php b/packages/sprinkle-admin/app/src/Log/RoleActivityTypes.php new file mode 100644 index 000000000..9fd9e9be9 --- /dev/null +++ b/packages/sprinkle-admin/app/src/Log/RoleActivityTypes.php @@ -0,0 +1,54 @@ + 'ROLE.ACTIVITY.CREATE', + self::DELETE => 'ROLE.ACTIVITY.DELETE', + self::UPDATE_INFO => 'ROLE.ACTIVITY.UPDATE_INFO', + self::UPDATE_PERMISSIONS => 'ROLE.ACTIVITY.UPDATE_PERMISSIONS', + self::UPDATE_FIELD => 'ROLE.ACTIVITY.UPDATE_FIELD', + default => null, + }; + } + + /** + * {@inheritDoc} + */ + public static function getLabelI18nKey(string $value): ?string + { + return match (self::tryFrom($value)) { + self::CREATE => 'ROLE.ACTIVITY.LABEL.CREATE', + self::DELETE => 'ROLE.ACTIVITY.LABEL.DELETE', + self::UPDATE_INFO => 'ROLE.ACTIVITY.LABEL.UPDATE_INFO', + self::UPDATE_PERMISSIONS => 'ROLE.ACTIVITY.LABEL.UPDATE_PERMISSIONS', + self::UPDATE_FIELD => 'ROLE.ACTIVITY.LABEL.UPDATE_FIELD', + default => null, + }; + } +} diff --git a/packages/sprinkle-admin/app/src/Routes/RolesRoutes.php b/packages/sprinkle-admin/app/src/Routes/RolesRoutes.php index 64bbe9785..e81b33479 100644 --- a/packages/sprinkle-admin/app/src/Routes/RolesRoutes.php +++ b/packages/sprinkle-admin/app/src/Routes/RolesRoutes.php @@ -20,9 +20,9 @@ use UserFrosting\Sprinkle\Admin\Controller\Role\RoleCreateAction; use UserFrosting\Sprinkle\Admin\Controller\Role\RoleDeleteAction; use UserFrosting\Sprinkle\Admin\Controller\Role\RoleEditAction; +use UserFrosting\Sprinkle\Admin\Controller\Role\RolePermissionsAction; use UserFrosting\Sprinkle\Admin\Controller\Role\RolePermissionsSprunje; use UserFrosting\Sprinkle\Admin\Controller\Role\RolesSprunje; -use UserFrosting\Sprinkle\Admin\Controller\Role\RoleUpdateFieldAction; use UserFrosting\Sprinkle\Admin\Controller\Role\RoleUsersSprunje; use UserFrosting\Sprinkle\Admin\Middlewares\RoleInjector; use UserFrosting\Sprinkle\Core\Middlewares\NoCache; @@ -47,7 +47,7 @@ public function register(App $app): void $group->post('', RoleCreateAction::class); $group->put('/r/{slug}', RoleEditAction::class) ->add(RoleInjector::class); - $group->put('/r/{slug}/{field}', RoleUpdateFieldAction::class) + $group->put('/r/{slug}/permissions', RolePermissionsAction::class) ->add(RoleInjector::class); })->add(AuthGuard::class)->add(NoCache::class); } diff --git a/packages/sprinkle-admin/app/src/Routes/UsersRoutes.php b/packages/sprinkle-admin/app/src/Routes/UsersRoutes.php index 08cfad114..0c9f59c95 100644 --- a/packages/sprinkle-admin/app/src/Routes/UsersRoutes.php +++ b/packages/sprinkle-admin/app/src/Routes/UsersRoutes.php @@ -21,11 +21,15 @@ use UserFrosting\Sprinkle\Admin\Controller\User\UserCreateAction; use UserFrosting\Sprinkle\Admin\Controller\User\UserDeleteAction; use UserFrosting\Sprinkle\Admin\Controller\User\UserEditAction; +use UserFrosting\Sprinkle\Admin\Controller\User\UserGroupAction; +use UserFrosting\Sprinkle\Admin\Controller\User\UserPasswordAction; use UserFrosting\Sprinkle\Admin\Controller\User\UserPasswordResetAction; use UserFrosting\Sprinkle\Admin\Controller\User\UserPermissionSprunje; +use UserFrosting\Sprinkle\Admin\Controller\User\UserRolesAction; use UserFrosting\Sprinkle\Admin\Controller\User\UserRoleSprunje; use UserFrosting\Sprinkle\Admin\Controller\User\UsersSprunjeAction; -use UserFrosting\Sprinkle\Admin\Controller\User\UserUpdateFieldAction; +use UserFrosting\Sprinkle\Admin\Controller\User\UserStatusAction; +use UserFrosting\Sprinkle\Admin\Controller\User\UserVerificationAction; use UserFrosting\Sprinkle\Admin\Middlewares\UserInjector; use UserFrosting\Sprinkle\Core\Middlewares\NoCache; @@ -59,9 +63,21 @@ public function register(App $app): void $group->put('/u/{user_name}', UserEditAction::class) ->add(UserInjector::class) ->setName('api.users.edit'); - $group->put('/u/{user_name}/{field}', UserUpdateFieldAction::class) + $group->put('/u/{user_name}/status', UserStatusAction::class) ->add(UserInjector::class) - ->setName('api.users.update-field'); + ->setName('api.users.status'); + $group->put('/u/{user_name}/verification', UserVerificationAction::class) + ->add(UserInjector::class) + ->setName('api.users.verification'); + $group->put('/u/{user_name}/group', UserGroupAction::class) + ->add(UserInjector::class) + ->setName('api.users.group'); + $group->put('/u/{user_name}/roles', UserRolesAction::class) + ->add(UserInjector::class) + ->setName('api.users.roles'); + $group->put('/u/{user_name}/password', UserPasswordAction::class) + ->add(UserInjector::class) + ->setName('api.users.password'); })->add(AuthGuard::class)->add(NoCache::class); } } diff --git a/packages/sprinkle-admin/app/src/Sprunje/ActivitySprunje.php b/packages/sprinkle-admin/app/src/Sprunje/ActivitySprunje.php index 802133347..33024dd0d 100644 --- a/packages/sprinkle-admin/app/src/Sprunje/ActivitySprunje.php +++ b/packages/sprinkle-admin/app/src/Sprunje/ActivitySprunje.php @@ -12,11 +12,16 @@ namespace UserFrosting\Sprinkle\Admin\Sprunje; +use BackedEnum; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Builder as QueryBuilder; +use Illuminate\Support\Collection; +use UserFrosting\I18n\DictionaryInterface; +use UserFrosting\I18n\Translator; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\ActivityInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityTypeRegistryInterface; use UserFrosting\Sprinkle\Core\Sprunje\Sprunje; /** @@ -29,29 +34,229 @@ class ActivitySprunje extends Sprunje protected array $sortable = [ 'occurred_at', 'user', - 'description', + 'label', + ]; + + protected array $listable = [ + 'label', ]; protected array $filterable = [ 'occurred_at', 'user', - 'description', + 'label', ]; + /** @var array|null */ + protected ?array $activityLabels = null; + + protected ?DictionaryInterface $dictionary = null; + public function __construct( protected ActivityInterface $activityModel, + protected ActivityTypeRegistryInterface $activityTypeRegistry, + protected Translator $translator, ) { parent::__construct(); } + /** + * Translate activity types after the database query has been executed. + * + * @param Collection $collection + * + * @return Collection + */ + protected function applyTransformations(Collection $collection): Collection + { + return $collection->each(function (Model $activity): void { + if ($activity->getAttribute('user_id') === null) { + $activity->setRelation('user', null); + } else { + $activity->load([ + 'user' => function ($query): void { + $query->withTrashed(); + }, + ]); + } + + $type = (string) $activity->getAttribute('type'); + $activity->setAttribute('label', $this->getActivityLabel($type)); + + $i18nKey = $this->activityTypeRegistry->getI18nKey($type); + $dictionary = $this->getDictionary(); + + if ($i18nKey === null || !$dictionary->has($i18nKey)) { + $conventionKey = 'ACTIVITY.TYPE.' . $type; + $i18nKey = $dictionary->has($conventionKey) + ? $conventionKey + : null; + } + + if ($i18nKey !== null) { + $placeholders = $activity->getAttribute('metadata'); + if (!is_array($placeholders)) { + $placeholders = []; + } + $placeholders['context'] = $activity->getRelationValue('context'); + $placeholders['subject'] = $activity->getRelationValue('subject'); + + $activity->setAttribute( + 'description', + $this->translator->translate($i18nKey, $placeholders) + ); + + return; + } + + $description = $activity->getAttribute('description'); + if (!is_string($description) || $description === '') { + $activity->setAttribute('description', $type); + } + }); + } + + /** + * Resolve a concise localized label for a persisted activity type value. + */ + protected function getActivityLabel(string $value): string + { + $i18nKey = $this->activityTypeRegistry->getLabelI18nKey($value); + if ($i18nKey === null || !$this->getDictionary()->has($i18nKey)) { + return $value; + } + + return $this->translator->translate($i18nKey); + } + + /** + * Return localized labels indexed by their persisted activity type values. + * + * @return array + */ + protected function getActivityLabels(): array + { + if ($this->activityLabels !== null) { + return $this->activityLabels; + } + + $activityLabels = []; + foreach ($this->activityTypeRegistry->all() as $activityType) { + if (!$activityType instanceof BackedEnum) { + continue; + } + + $value = (string) $activityType->value; + $activityLabels[$value] = $this->getActivityLabel($value); + } + + return $this->activityLabels = $activityLabels; + } + + /** + * Return the possible localized activity labels. + * + * @return array{value: string, text: string}[] + */ + protected function listLabel(): array + { + /** @var array $valuesByLabel */ + $valuesByLabel = []; + foreach ($this->getActivityLabels() as $value => $text) { + $valuesByLabel[$text][] = $value; + } + + /** @var array{value: string, text: string}[] $labels */ + $labels = []; + foreach ($valuesByLabel as $text => $values) { + $labels[] = [ + 'value' => implode($this->orSeparator, $values), + 'text' => $text, + ]; + } + + usort($labels, static function (array $left, array $right): int { + $textComparison = strcasecmp($left['text'], $right['text']); + + return $textComparison !== 0 + ? $textComparison + : strcmp($left['value'], $right['value']); + }); + + return $labels; + } + + /** + * Filter by persisted activity type values represented by localized labels. + * + * @param EloquentBuilder|QueryBuilder|Relation $query + * @param string $value + * + * @return static + */ + protected function filterLabel($query, string $value): static + { + $values = array_values(array_intersect( + explode($this->orSeparator, $value), + array_keys($this->getActivityLabels()) + )); + // @phpstan-ignore-next-line - Eloquent builders expose whereIn dynamically through this union. + $query->whereIn('activities.type', $values); + + return $this; + } + + /** + * Sort by localized activity labels using persisted type values. + * + * @param EloquentBuilder|QueryBuilder|Relation $query + * @param string $direction + * + * @return static + */ + protected function sortLabel($query, string $direction): static + { + $direction = strtolower($direction) === 'desc' ? 'desc' : 'asc'; + $cases = []; + $bindings = []; + + foreach ($this->getActivityLabels() as $value => $label) { + $cases[] = 'WHEN activities.type = ? THEN ?'; + $bindings[] = $value; + $bindings[] = $label; + } + + if ($cases !== []) { + // @phpstan-ignore-next-line - Eloquent builders expose orderByRaw dynamically through this union. + $query->orderByRaw( + 'CASE ' . implode(' ', $cases) . ' ELSE activities.type END ' . $direction, + $bindings + ); + } + + // @phpstan-ignore-next-line - Eloquent builders expose orderBy dynamically through this union. + $query->orderBy('activities.type', $direction) + ->orderBy('activities.id', $direction); + + return $this; + } + + protected function getDictionary(): DictionaryInterface + { + return $this->dictionary ??= $this->translator->getDictionary(); + } + /** * Set the initial query used by your Sprunje. * {@inheritDoc} */ - protected function baseQuery() + protected function baseQuery(): EloquentBuilder { - // @phpstan-ignore-next-line Activity interface mixin Model and non-static method. - return $this->activityModel->joinUser(); + // @phpstan-ignore-next-line staticMethod.dynamicCall + $query = $this->activityModel->joinUser(); + + return $query + ->with(['context', 'subject']); } /** diff --git a/packages/sprinkle-admin/app/tests/Controller/Role/RoleUpdateFieldActionTest.php b/packages/sprinkle-admin/app/tests/Controller/Role/RolePermissionsActionTest.php similarity index 60% rename from packages/sprinkle-admin/app/tests/Controller/Role/RoleUpdateFieldActionTest.php rename to packages/sprinkle-admin/app/tests/Controller/Role/RolePermissionsActionTest.php index 304fc0c36..220fc30a4 100644 --- a/packages/sprinkle-admin/app/tests/Controller/Role/RoleUpdateFieldActionTest.php +++ b/packages/sprinkle-admin/app/tests/Controller/Role/RolePermissionsActionTest.php @@ -13,22 +13,21 @@ namespace UserFrosting\Sprinkle\Admin\Tests\Controller\Role; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; +use UserFrosting\Sprinkle\Account\Database\Models\Activity; use UserFrosting\Sprinkle\Account\Database\Models\Permission; use UserFrosting\Sprinkle\Account\Database\Models\Role; use UserFrosting\Sprinkle\Account\Database\Models\User; use UserFrosting\Sprinkle\Account\Testing\WithTestUser; +use UserFrosting\Sprinkle\Admin\Log\RoleActivityTypes; use UserFrosting\Sprinkle\Admin\Tests\AdminTestCase; use UserFrosting\Sprinkle\Core\Testing\RefreshDatabase; -class RoleUpdateFieldActionTest extends AdminTestCase +class RolePermissionsActionTest extends AdminTestCase { use RefreshDatabase; use WithTestUser; use MockeryPHPUnitIntegration; - /** - * Setup test database for controller tests - */ public function setUp(): void { parent::setUp(); @@ -37,26 +36,22 @@ public function setUp(): void public function testPageForGuestUser(): void { - // Create request with method and url and fetch response $request = $this->createJsonRequest('PUT', '/api/roles/r/foo/permissions'); $response = $this->handleRequest($request); - // Assert response status & body $this->assertJsonResponse('Login Required', $response, 'title'); $this->assertResponseStatus(401, $response); } - public function testPageWithNotFoundUser(): void + public function testPageWithNotFoundRole(): void { /** @var User */ $user = User::factory()->create(); $this->actAsUser($user); - // Create request with method and url and fetch response $request = $this->createJsonRequest('PUT', '/api/roles/r/foo/permissions'); $response = $this->handleRequest($request); - // Assert response status & body $this->assertJsonResponse([ 'title' => 'Not Found', 'description' => 'Role not found', @@ -65,25 +60,6 @@ public function testPageWithNotFoundUser(): void $this->assertResponseStatus(404, $response); } - public function testPostForNoData(): void - { - /** @var User */ - $user = User::factory()->create(); - $this->actAsUser($user, isMaster: true); - - /** @var Role */ - $role = Role::factory()->create(); - - // Create request with method and url and fetch response - $request = $this->createJsonRequest('PUT', '/api/roles/r/' . $role->slug . '/name'); - $response = $this->handleRequest($request); - - // Assert response status & body - $this->assertJsonResponse('Validation error', $response, 'title'); - $this->assertJsonResponse('Please specify a value for name.', $response, 'description'); - $this->assertResponseStatus(400, $response); - } - public function testPageForNoPermissions(): void { /** @var User */ @@ -93,17 +69,18 @@ public function testPageForNoPermissions(): void /** @var Role */ $role = Role::factory()->create(); - // Create request with method and url and fetch response - $data = ['permissions' => []]; - $request = $this->createJsonRequest('PUT', '/api/roles/r/' . $role->slug . '/permissions', $data); + $request = $this->createJsonRequest( + 'PUT', + '/api/roles/r/' . $role->slug . '/permissions', + ['permissions' => []] + ); $response = $this->handleRequest($request); - // Assert response status & body $this->assertJsonResponse('Access Denied', $response, 'title'); $this->assertResponseStatus(403, $response); } - public function testPostForName(): void + public function testPostForPermission(): void { /** @var User */ $user = User::factory()->create(); @@ -112,26 +89,50 @@ public function testPostForName(): void /** @var Role */ $role = Role::factory()->create(); - // Create request with method and url and fetch response - $data = [ - 'name' => 'New Foo', - ]; - $request = $this->createJsonRequest('PUT', '/api/roles/r/' . $role->slug . '/name', $data); + /** @var Permission */ + $permissions = Permission::factory()->count(2)->create(); + + /* + * N.B.: Expected value format, passed from uf-collection: + * value[0][permission_id]: 1 + * value[1][permission_id]: 2 + * value[2][permission_id]: 3 + */ + // @phpstan-ignore-next-line + $ids = $permissions->map(function ($item) { + return ['permission_id' => $item->id]; + })->toArray(); + + $request = $this->createJsonRequest( + 'PUT', + '/api/roles/r/' . $role->slug . '/permissions', + ['permissions' => $ids] + ); $response = $this->handleRequest($request); - // Assert response status & body $this->assertResponseStatus(200, $response); $this->assertJsonResponse([ - 'title' => 'Details updated for role New Foo', + 'title' => 'Permissions updated for role ' . $role->name . '', 'description' => '', ], $response); - // Make sure the role has the new name. $role->refresh(); - $this->assertSame('New Foo', $role->name); + $this->assertCount(2, $role->permissions); + + /** @var Activity|null $activity */ + $activity = Activity::query() + ->where('type', RoleActivityTypes::UPDATE_PERMISSIONS->value) + ->where('subject_id', $role->id) + ->first(); + $this->assertNotNull($activity); + $metadata = $activity->metadata; + $this->assertIsArray($metadata); + $this->assertSame($permissions[0]->name . ', ' . $permissions[1]->name, $metadata['added_permissions']); + $this->assertSame('No permission', $metadata['removed_permissions']); + $this->assertNull($activity->properties); } - public function testPostForPermission(): void + public function testPostForNumericPermissionIds(): void { /** @var User */ $user = User::factory()->create(); @@ -142,37 +143,21 @@ public function testPostForPermission(): void /** @var Permission */ $permissions = Permission::factory()->count(2)->create(); + $ids = [$permissions[0]->id, $permissions[1]->id]; - /* - N.B.: Expected value format, passed from uf-collection : - value[0][permission_id]: 1 - value[1][permission_id]: 2 - value[2][permission_id]: 3 - */ - - // @phpstan-ignore-next-line - $ids = $permissions->map(function ($item) { - return ['permission_id' => $item->id]; - })->toArray(); - - // Create request with method and url and fetch response - $data = ['permissions' => $ids]; - $request = $this->createJsonRequest('PUT', '/api/roles/r/' . $role->slug . '/permissions', $data); + $request = $this->createJsonRequest( + 'PUT', + '/api/roles/r/' . $role->slug . '/permissions', + ['permissions' => $ids] + ); $response = $this->handleRequest($request); - // Assert response status & body $this->assertResponseStatus(200, $response); - $this->assertJsonResponse([ - 'title' => 'Permissions updated for role ' . $role->name . '', - 'description' => '', - ], $response); - - // Make sure the role has the new permissions. $role->refresh(); $this->assertCount(2, $role->permissions); } - public function testPostForRemovingAllRoles(): void + public function testPostForRemovingAllPermissions(): void { /** @var User */ $user = User::factory()->create(); @@ -180,24 +165,35 @@ public function testPostForRemovingAllRoles(): void /** @var Role */ $role = Role::factory()->has(Permission::factory())->create(); - $this->assertCount(1, $role->permissions); // Default role above. + $this->assertCount(1, $role->permissions); - // Create request with method and url and fetch response - // uf-collection will pass no data when removing all roles_id. - $data = ['permissions' => []]; - $request = $this->createJsonRequest('PUT', '/api/roles/r/' . $role->slug . '/permissions', $data); + $request = $this->createJsonRequest( + 'PUT', + '/api/roles/r/' . $role->slug . '/permissions', + ['permissions' => []] + ); $response = $this->handleRequest($request); - // Assert response status & body $this->assertResponseStatus(200, $response); $this->assertJsonResponse([ 'title' => 'Permissions updated for role ' . $role->name . '', 'description' => '', ], $response); - // Make sure the user has the new roles. $role->refresh(); $this->assertCount(0, $role->permissions); + + /** @var Activity|null $activity */ + $activity = Activity::query() + ->where('type', RoleActivityTypes::UPDATE_PERMISSIONS->value) + ->where('subject_id', $role->id) + ->first(); + $this->assertNotNull($activity); + $metadata = $activity->metadata; + $this->assertIsArray($metadata); + $this->assertSame('No permission', $metadata['added_permissions']); + $this->assertNotSame('No permission', $metadata['removed_permissions']); + $this->assertNull($activity->properties); } public function testPostForMissingValueArgument(): void @@ -208,14 +204,10 @@ public function testPostForMissingValueArgument(): void /** @var Role */ $role = Role::factory()->has(Permission::factory())->create(); - $this->assertCount(1, $role->permissions); // Default role above. - // Create request with method and url and fetch response - // uf-collection will pass no data when removing all roles_id. $request = $this->createJsonRequest('PUT', '/api/roles/r/' . $role->slug . '/permissions'); $response = $this->handleRequest($request); - // Assert response status & body $this->assertResponseStatus(400, $response); $this->assertJsonResponse('Please specify a value for permissions.', $response, 'description'); } @@ -229,12 +221,13 @@ public function testPageForFailedValidation(): void /** @var Role */ $role = Role::factory()->create(); - // Create request with method and url and fetch response - $data = ['permissions' => 'notAnArray']; - $request = $this->createJsonRequest('PUT', '/api/roles/r/' . $role->slug . '/permissions', $data); + $request = $this->createJsonRequest( + 'PUT', + '/api/roles/r/' . $role->slug . '/permissions', + ['permissions' => 'notAnArray'] + ); $response = $this->handleRequest($request); - // Assert response status & body $this->assertJsonResponse('The values for permissions must be in an array.', $response, 'description'); $this->assertResponseStatus(400, $response); } diff --git a/packages/sprinkle-admin/app/tests/Controller/User/UserCreateActionTest.php b/packages/sprinkle-admin/app/tests/Controller/User/UserCreateActionTest.php index d408679ae..6709e4ce2 100644 --- a/packages/sprinkle-admin/app/tests/Controller/User/UserCreateActionTest.php +++ b/packages/sprinkle-admin/app/tests/Controller/User/UserCreateActionTest.php @@ -15,7 +15,6 @@ use Mockery; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use UserFrosting\Config\Config; -use UserFrosting\Sprinkle\Account\Database\Models\Group; use UserFrosting\Sprinkle\Account\Database\Models\User; use UserFrosting\Sprinkle\Account\Testing\WithTestUser; use UserFrosting\Sprinkle\Admin\Tests\AdminTestCase; @@ -106,99 +105,7 @@ public function testPost(): void $user = User::where('email', 'foo@bar.com')->first(); $this->assertSame('foo', $user['user_name']); $this->assertSame('en_US', $user['locale']); - } - - public function testPostForGroup(): void - { - /** @var Group */ - $group = Group::factory()->create(); - - /** @var User */ - $user = User::factory()->for($group)->create(); - $this->actAsUser($user, permissions: ['create_user']); - - /** @var Config */ - $config = $this->getService(Config::class); - - // Force locale config. - $config->set('site.registration.user_defaults.locale', 'en_US'); - $config->set('site.locales.available', ['en_US' => true]); - - /** @var Mockery\MockInterface&Mailer */ - $mailer = Mockery::mock(Mailer::class) - ->makePartial() - ->shouldReceive('send')->once() - ->getMock(); - $this->getContainer()->set(Mailer::class, $mailer); - - // Set post payload - $data = [ - 'user_name' => 'foo', - 'first_name' => 'Foo', - 'last_name' => 'Bar', - 'email' => 'foo@bar.com', - 'group_id' => $group->id, - ]; - - // Create request with method and url and fetch response - $request = $this->createJsonRequest('POST', '/api/users', $data); - $response = $this->handleRequest($request); - - // Assert response status & body - $this->assertResponseStatus(200, $response); - $this->assertJsonStructure(['title', 'description'], $response); - - // Make sure the user is added to the db by querying it - /** @var User */ - $user = User::where('email', 'foo@bar.com')->first(); - $this->assertSame($group->id, $user->group?->id); - $this->assertSame('en_US', $user['locale']); // Locale will be default :) - } - - public function testPostForNoGroup(): void - { - /** @var User */ - $user = User::factory()->create(); - $this->actAsUser($user, isMaster: true); - - /** @var Config */ - $config = $this->getService(Config::class); - - // Force locale config. - $config->set('site.registration.user_defaults.locale', 'en_US'); - $config->set('site.locales.available', ['en_US' => true]); - - /** @var Mockery\MockInterface&Mailer */ - $mailer = Mockery::mock(Mailer::class) - ->makePartial() - ->shouldReceive('send')->once() - ->getMock(); - $this->getContainer()->set(Mailer::class, $mailer); - - // Set post payload - $data = [ - 'user_name' => 'foo', - 'first_name' => 'Foo', - 'last_name' => 'Bar', - 'email' => 'foo@bar.com', - 'locale' => 'en_US', - 'group_id' => 0, - ]; - - // Create request with method and url and fetch response - $request = $this->createJsonRequest('POST', '/api/users', $data); - $response = $this->handleRequest($request); - - // Assert response status & body - $this->assertResponseStatus(200, $response); - $this->assertJsonStructure(['title', 'description'], $response); - - // Make sure the user is added to the db by querying it - /** @var User */ - $user = User::where('email', 'foo@bar.com')->first(); - $this->assertSame('foo', $user['user_name']); - $this->assertSame('en_US', $user['locale']); - $this->assertNull($user->group?->id); + $this->assertNull($user->group_id); } public function testPostForFailedValidation(): void diff --git a/packages/sprinkle-admin/app/tests/Controller/User/UserEditActionTest.php b/packages/sprinkle-admin/app/tests/Controller/User/UserEditActionTest.php index c8d367f1e..7b514f881 100644 --- a/packages/sprinkle-admin/app/tests/Controller/User/UserEditActionTest.php +++ b/packages/sprinkle-admin/app/tests/Controller/User/UserEditActionTest.php @@ -14,8 +14,10 @@ use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use UserFrosting\Config\Config; +use UserFrosting\Sprinkle\Account\Database\Models\Activity; use UserFrosting\Sprinkle\Account\Database\Models\User; use UserFrosting\Sprinkle\Account\Testing\WithTestUser; +use UserFrosting\Sprinkle\Admin\Log\AdminAccountActivityTypes; use UserFrosting\Sprinkle\Admin\Tests\AdminTestCase; use UserFrosting\Sprinkle\Core\Testing\RefreshDatabase; @@ -95,7 +97,6 @@ public function testPage(): void // Create a second user, to be edited. /** @var User */ $userToEdit = User::factory()->create(); - // Set post payload $data = [ 'user_name' => 'foo', @@ -103,7 +104,6 @@ public function testPage(): void 'last_name' => 'Bar', 'email' => 'foo@bar.com', 'locale' => 'en_US', - 'group_id' => 0, ]; // Create request with method and url and fetch response @@ -123,7 +123,13 @@ public function testPage(): void $editedUser = User::find($userToEdit->id); $this->assertSame('foo', $editedUser->user_name); $this->assertSame('foo@bar.com', $editedUser->email); - $this->assertNull($editedUser->group_id); + /** @var Activity|null $infoActivity */ + $infoActivity = Activity::query() + ->where('type', AdminAccountActivityTypes::UPDATE_INFO->value) + ->where('subject_id', $userToEdit->id) + ->first(); + $this->assertNotNull($infoActivity); + $this->assertIsArray($infoActivity->properties); } public function testPageForEditMasterUser(): void diff --git a/packages/sprinkle-admin/app/tests/Controller/User/UserGroupActionTest.php b/packages/sprinkle-admin/app/tests/Controller/User/UserGroupActionTest.php new file mode 100644 index 000000000..7ff7044c6 --- /dev/null +++ b/packages/sprinkle-admin/app/tests/Controller/User/UserGroupActionTest.php @@ -0,0 +1,93 @@ +refreshDatabase(); + } + + public function testGroupCanBeAssignedAndRemoved(): void + { + /** @var User */ + $user = User::factory()->create(); + $this->actAsUser($user, permissions: ['update_user_field']); + + /** @var User */ + $userToEdit = User::factory()->create(); + /** @var Group */ + $oldGroup = Group::factory()->create(); + /** @var Group */ + $newGroup = Group::factory()->create(); + $userToEdit->group_id = $oldGroup->id; + $userToEdit->save(); + + $request = $this->createJsonRequest( + 'PUT', + '/api/users/u/' . $userToEdit->user_name . '/group', + ['group_id' => $newGroup->id] + ); + $response = $this->handleRequest($request); + + $this->assertResponseStatus(200, $response); + $userToEdit->refresh(); + $this->assertSame($newGroup->id, $userToEdit->group_id); + + /** @var Activity|null $removedActivity */ + $removedActivity = Activity::query() + ->where('type', AdminAccountActivityTypes::REMOVE_FROM_GROUP->value) + ->where('subject_id', $userToEdit->id) + ->where('context_id', $oldGroup->id) + ->first(); + $this->assertNotNull($removedActivity); + $this->assertEquals($oldGroup->id, $removedActivity->context_id); + $this->assertSame([], $removedActivity->metadata); + $this->assertNull($removedActivity->properties); + + /** @var Activity|null $addedActivity */ + $addedActivity = Activity::query() + ->where('type', AdminAccountActivityTypes::ADD_TO_GROUP->value) + ->where('subject_id', $userToEdit->id) + ->where('context_id', $newGroup->id) + ->first(); + $this->assertNotNull($addedActivity); + $this->assertEquals($newGroup->id, $addedActivity->context_id); + $this->assertSame([], $addedActivity->metadata); + $this->assertNull($addedActivity->properties); + + $request = $this->createJsonRequest( + 'PUT', + '/api/users/u/' . $userToEdit->user_name . '/group', + ['group_id' => 0] + ); + $response = $this->handleRequest($request); + + $this->assertResponseStatus(200, $response); + $userToEdit->refresh(); + $this->assertNull($userToEdit->group_id); + $this->assertCount(2, Activity::query() + ->where('type', AdminAccountActivityTypes::REMOVE_FROM_GROUP->value) + ->where('subject_id', $userToEdit->id) + ->get()); + $this->assertCount(1, Activity::query() + ->where('type', AdminAccountActivityTypes::ADD_TO_GROUP->value) + ->where('subject_id', $userToEdit->id) + ->get()); + } +} diff --git a/packages/sprinkle-admin/app/tests/Controller/User/UserUpdateFieldActionTest.php b/packages/sprinkle-admin/app/tests/Controller/User/UserUpdateFieldActionTest.php index 87299208d..43f3594e2 100644 --- a/packages/sprinkle-admin/app/tests/Controller/User/UserUpdateFieldActionTest.php +++ b/packages/sprinkle-admin/app/tests/Controller/User/UserUpdateFieldActionTest.php @@ -14,9 +14,12 @@ use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use UserFrosting\Config\Config; +use UserFrosting\Sprinkle\Account\Database\Models\Activity; use UserFrosting\Sprinkle\Account\Database\Models\Role; use UserFrosting\Sprinkle\Account\Database\Models\User; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; use UserFrosting\Sprinkle\Account\Testing\WithTestUser; +use UserFrosting\Sprinkle\Admin\Log\AdminAccountActivityTypes; use UserFrosting\Sprinkle\Admin\Tests\AdminTestCase; use UserFrosting\Sprinkle\Core\Testing\RefreshDatabase; @@ -77,7 +80,11 @@ public function testPostForNoData(): void // Assert response status & body $this->assertJsonResponse('Validation error', $response, 'title'); - $this->assertJsonResponse('Please specify a value for password.', $response, 'description'); + $this->assertJsonResponse( + 'Please specify a value for Password. Password must be between 8 and 25 characters in length. Please specify a value for Confirm password. Your password and confirmation password must match. Confirm password must be between 8 and 25 characters in length.', + $response, + 'description' + ); $this->assertResponseStatus(400, $response); } @@ -125,6 +132,16 @@ public function testPostForPassword(): void 'title' => 'Account details updated for user ' . $userToEdit->user_name . '', 'description' => '', ], $response); + + /** @var Activity|null $activity */ + $activity = Activity::query() + ->where('type', AccountActivityTypes::UPDATE_PASSWORD->value) + ->where('subject_id', $userToEdit->id) + ->first(); + $this->assertNotNull($activity); + $this->assertIsArray($activity->metadata); + $this->assertArrayNotHasKey('password', $activity->metadata); + $this->assertNull($activity->properties); } public function testPostForPasswordWithoutConfirmation(): void @@ -158,7 +175,7 @@ public function testPostForEnabled(): void // Create request with method and url and fetch response $data = ['flag_enabled' => '1']; - $request = $this->createJsonRequest('PUT', '/api/users/u/' . $user->user_name . '/flag_enabled', $data); + $request = $this->createJsonRequest('PUT', '/api/users/u/' . $user->user_name . '/status', $data); $response = $this->handleRequest($request); // Assert response status & body @@ -167,6 +184,11 @@ public function testPostForEnabled(): void 'title' => 'Account for user ' . $user->user_name . ' has been successfully enabled.', 'description' => '', ], $response); + + $this->assertNotNull(Activity::query() + ->where('type', AdminAccountActivityTypes::ENABLE->value) + ->where('subject_id', $user->id) + ->first()); } public function testPostForDisabled(): void @@ -181,7 +203,7 @@ public function testPostForDisabled(): void // Create request with method and url and fetch response $data = ['flag_enabled' => '0']; - $request = $this->createJsonRequest('PUT', '/api/users/u/' . $userToEdit->user_name . '/flag_enabled', $data); + $request = $this->createJsonRequest('PUT', '/api/users/u/' . $userToEdit->user_name . '/status', $data); $response = $this->handleRequest($request); // Assert response status & body @@ -200,7 +222,7 @@ public function testPostForVerified(): void // Create request with method and url and fetch response $data = ['flag_verified' => '1']; - $request = $this->createJsonRequest('PUT', '/api/users/u/' . $user->user_name . '/flag_verified', $data); + $request = $this->createJsonRequest('PUT', '/api/users/u/' . $user->user_name . '/verification', $data); $response = $this->handleRequest($request); // Assert response status & body @@ -209,13 +231,19 @@ public function testPostForVerified(): void 'title' => $user->user_name . "'s account has been manually activated", 'description' => '', ], $response); + + $this->assertNotNull(Activity::query() + ->where('type', AdminAccountActivityTypes::VERIFY->value) + ->where('subject_id', $user->id) + ->first()); } public function testPostForRole(): void { /** @var User */ $user = User::factory()->create(); - $this->actAsUser($user, permissions: ['update_user_field']); + $this->actAsUser($user, permissions: ['update_user_role']); + $oldRoles = $user->roles->pluck('name')->implode(', '); /** @var Role */ $roles = Role::factory()->count(2)->create(); @@ -247,14 +275,24 @@ public function testPostForRole(): void // Make sure the user has the new roles. $user->refresh(); $this->assertCount(2, $user->roles); + + /** @var Activity|null $activity */ + $activity = Activity::query()->where('type', AdminAccountActivityTypes::UPDATE_ROLES->value)->where('subject_id', $user->id)->first(); + $this->assertNotNull($activity); + $metadata = $activity->metadata; + $this->assertIsArray($metadata); + $this->assertSame($roles[0]->name . ', ' . $roles[1]->name, $metadata['added_roles']); + $this->assertSame($oldRoles, $metadata['removed_roles']); + $this->assertNull($activity->properties); } public function testPostForRemovingRoles(): void { /** @var User */ $user = User::factory()->create(); - $this->actAsUser($user, permissions: ['update_user_field']); + $this->actAsUser($user, permissions: ['update_user_role']); $this->assertCount(1, $user->roles); // Default role above. + $oldRoles = $user->roles->pluck('name')->implode(', '); // Create request with method and url and fetch response // uf-collection will pass no data when removing all roles_id. @@ -271,6 +309,15 @@ public function testPostForRemovingRoles(): void // Make sure the user has the new roles. $user->refresh(); $this->assertCount(0, $user->roles); + + /** @var Activity|null $activity */ + $activity = Activity::query()->where('type', AdminAccountActivityTypes::UPDATE_ROLES->value)->where('subject_id', $user->id)->first(); + $this->assertNotNull($activity); + $metadata = $activity->metadata; + $this->assertIsArray($metadata); + $this->assertSame('No role', $metadata['added_roles']); + $this->assertSame($oldRoles, $metadata['removed_roles']); + $this->assertNull($activity->properties); } public function testPageForFailedValidation(): void @@ -284,8 +331,14 @@ public function testPageForFailedValidation(): void $userToEdit = User::factory()->create(); // Create request with method and url and fetch response - $data = ['email' => 'notAndEmail']; - $request = $this->createJsonRequest('PUT', '/api/users/u/' . $userToEdit->user_name . '/email', $data); + $data = [ + 'user_name' => $userToEdit->user_name, + 'first_name' => $userToEdit->first_name, + 'last_name' => $userToEdit->last_name, + 'email' => 'notAndEmail', + 'locale' => $userToEdit->locale, + ]; + $request = $this->createJsonRequest('PUT', '/api/users/u/' . $userToEdit->user_name, $data); $response = $this->handleRequest($request); // Assert response status & body @@ -308,8 +361,14 @@ public function testPageForFailedToEditMasterUser(): void $config->set('reserved_user_ids.master', $userToEdit->id); // Create request with method and url and fetch response - $data = ['email' => 'notAndEmail']; - $request = $this->createJsonRequest('PUT', '/api/users/u/' . $userToEdit->user_name . '/email', $data); + $data = [ + 'user_name' => $userToEdit->user_name, + 'first_name' => $userToEdit->first_name, + 'last_name' => $userToEdit->last_name, + 'email' => 'notAndEmail', + 'locale' => $userToEdit->locale, + ]; + $request = $this->createJsonRequest('PUT', '/api/users/u/' . $userToEdit->user_name, $data); $response = $this->handleRequest($request); // Assert response status & body @@ -325,7 +384,7 @@ public function testPostForDisableMasterUser(): void // Create request with method and url and fetch response $data = ['flag_enabled' => '0']; - $request = $this->createJsonRequest('PUT', '/api/users/u/' . $user->user_name . '/flag_enabled', $data); + $request = $this->createJsonRequest('PUT', '/api/users/u/' . $user->user_name . '/status', $data); $response = $this->handleRequest($request); // Assert response status & body @@ -341,7 +400,7 @@ public function testPostForDisableSameUser(): void // Create request with method and url and fetch response $data = ['flag_enabled' => '0']; - $request = $this->createJsonRequest('PUT', '/api/users/u/' . $user->user_name . '/flag_enabled', $data); + $request = $this->createJsonRequest('PUT', '/api/users/u/' . $user->user_name . '/status', $data); $response = $this->handleRequest($request); // Assert response status & body diff --git a/packages/sprinkle-admin/app/tests/Log/ActivityTypesTest.php b/packages/sprinkle-admin/app/tests/Log/ActivityTypesTest.php new file mode 100644 index 000000000..e52648f6f --- /dev/null +++ b/packages/sprinkle-admin/app/tests/Log/ActivityTypesTest.php @@ -0,0 +1,41 @@ +assertSame($prefix . '.' . $case->name, $activityType::getI18nKey($case->value)); + $this->assertSame($prefix . '.LABEL.' . $case->name, $activityType::getLabelI18nKey($case->value)); + } + + $this->assertNull($activityType::getI18nKey('unknown_activity')); + $this->assertNull($activityType::getLabelI18nKey('unknown_activity')); + } + + /** @return array */ + public static function activityTypesProvider(): array + { + return [ + 'admin account' => [AdminAccountActivityTypes::class, 'ACCOUNT.ACTIVITY'], + 'admin' => [AdminActivityTypes::class, 'ADMIN.ACTIVITY'], + 'group' => [GroupActivityTypes::class, 'GROUP.ACTIVITY'], + 'role' => [RoleActivityTypes::class, 'ROLE.ACTIVITY'], + ]; + } +} diff --git a/packages/sprinkle-admin/app/tests/Sprunje/ActivitySprunjeTest.php b/packages/sprinkle-admin/app/tests/Sprunje/ActivitySprunjeTest.php index 88934914d..01edf2639 100644 --- a/packages/sprinkle-admin/app/tests/Sprunje/ActivitySprunjeTest.php +++ b/packages/sprinkle-admin/app/tests/Sprunje/ActivitySprunjeTest.php @@ -13,7 +13,19 @@ namespace UserFrosting\Sprinkle\Admin\Tests\Sprunje; use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; +use Mockery; +use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; +use stdClass; +use UserFrosting\I18n\DictionaryInterface; +use UserFrosting\I18n\Translator; +use UserFrosting\Sprinkle\Account\Database\Models\Activity; use UserFrosting\Sprinkle\Account\Database\Models\User; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; +use UserFrosting\Sprinkle\Account\Log\ActivityTypeRegistryInterface; +use UserFrosting\Sprinkle\Admin\Log\AdminAccountActivityTypes; +use UserFrosting\Sprinkle\Admin\Log\GroupActivityTypes; use UserFrosting\Sprinkle\Admin\Sprunje\ActivitySprunje; use UserFrosting\Sprinkle\Admin\Tests\AdminTestCase; use UserFrosting\Sprinkle\Core\Testing\RefreshDatabase; @@ -24,6 +36,7 @@ class ActivitySprunjeTest extends AdminTestCase { use RefreshDatabase; + use MockeryPHPUnitIntegration; /** @var EloquentCollection */ protected EloquentCollection $users; @@ -58,7 +71,13 @@ public function testBaseSprunje(): void $this->assertEquals(6, $data['count']); $this->assertEquals(6, $data['count_filtered']); $this->assertCount(6, $data['rows']); // @phpstan-ignore-line - $this->assertEquals([], $data['listable']); + $this->assertArrayHasKey('label', $data['listable']); // @phpstan-ignore-line + $this->assertContains([ + 'value' => AccountActivityTypes::CREATE->value, + 'text' => 'Account created', + ], $data['listable']['label']); // @phpstan-ignore-line + $this->assertContains('label', $data['sortable']); // @phpstan-ignore-line + $this->assertContains('label', $data['filterable']); // @phpstan-ignore-line } public function testWithPagination(): void @@ -88,23 +107,23 @@ public function testWithUserSort(): void $this->assertEquals(6, $data['count']); $this->assertEquals(6, $data['count_filtered']); $this->assertCount(6, $data['rows']); // @phpstan-ignore-line - $this->assertEquals($this->users[1]->id, $data['rows'][0]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[1]->id, $data['rows'][1]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[1]->id, $data['rows'][2]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[0]->id, $data['rows'][3]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[0]->id, $data['rows'][4]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[0]->id, $data['rows'][5]['user_id']); // @phpstan-ignore-line + $this->assertEquals($this->users[1]->id, $data['rows'][0]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[1]->id, $data['rows'][1]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[1]->id, $data['rows'][2]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[0]->id, $data['rows'][3]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[0]->id, $data['rows'][4]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[0]->id, $data['rows'][5]['user']['id']); // @phpstan-ignore-line $sprunje->setOptions([ 'sorts' => ['user' => 'asc'], ]); $data = $sprunje->getArray(); - $this->assertEquals($this->users[0]->id, $data['rows'][0]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[0]->id, $data['rows'][1]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[0]->id, $data['rows'][2]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[1]->id, $data['rows'][3]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[1]->id, $data['rows'][4]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[1]->id, $data['rows'][5]['user_id']); // @phpstan-ignore-line + $this->assertEquals($this->users[0]->id, $data['rows'][0]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[0]->id, $data['rows'][1]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[0]->id, $data['rows'][2]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[1]->id, $data['rows'][3]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[1]->id, $data['rows'][4]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[1]->id, $data['rows'][5]['user']['id']); // @phpstan-ignore-line } public function testWithOccurredAtSort(): void @@ -171,4 +190,363 @@ public function testWithUserFilter(): void ]); $this->assertEquals(6, $sprunje->getArray()['count_filtered']); } + + public function testWithLabelFilter(): void + { + $userId = $this->users[0]->id; + $created = Activity::factory()->create([ + 'user_id' => $userId, + 'type' => AccountActivityTypes::CREATE->value, + ]); + $deleted = Activity::factory()->create([ + 'user_id' => $userId, + 'type' => AccountActivityTypes::DELETE->value, + ]); + + /** @var ActivitySprunje */ + $sprunje = $this->getService(ActivitySprunje::class); + $sprunje->setOptions([ + 'filters' => [ + 'label' => AccountActivityTypes::CREATE->value . '||' . AccountActivityTypes::DELETE->value, + ], + ]); + $data = $sprunje->getArray(); + + $this->assertEquals(8, $data['count']); + $this->assertEquals(2, $data['count_filtered']); + $this->assertEqualsCanonicalizing([$created->id, $deleted->id], array_column($data['rows'], 'id')); // @phpstan-ignore-line + + $sprunje->setOptions([ + 'filters' => ['label' => 'Account created'], + ]); + $this->assertEquals(0, $sprunje->getArray()['count_filtered']); + } + + public function testWithLocalizedLabelSortAndPagination(): void + { + $userId = $this->users[0]->id; + Activity::factory()->create([ + 'user_id' => $userId, + 'type' => AccountActivityTypes::DELETE->value, + ]); + $groupCreated = Activity::factory()->create([ + 'user_id' => $userId, + 'type' => GroupActivityTypes::CREATE->value, + ]); + Activity::factory()->create([ + 'user_id' => $userId, + 'type' => 'sign_up', + ]); + $filter = implode('||', [ + AccountActivityTypes::DELETE->value, + GroupActivityTypes::CREATE->value, + 'sign_up', + ]); + + /** @var ActivitySprunje */ + $sprunje = $this->getService(ActivitySprunje::class); + $sprunje->setOptions([ + 'filters' => ['label' => $filter], + 'sorts' => ['label' => 'asc'], + ]); + $data = $sprunje->getArray(); + + $this->assertEquals(9, $data['count']); + $this->assertEquals(3, $data['count_filtered']); + $this->assertSame([ + AccountActivityTypes::DELETE->value, + GroupActivityTypes::CREATE->value, + 'sign_up', + ], array_column($data['rows'], 'type')); // @phpstan-ignore-line + $this->assertSame('Account deleted', $data['rows'][0]['label']); // @phpstan-ignore-line + + $sprunje->setOptions([ + 'sorts' => ['label' => 'desc'], + ]); + $data = $sprunje->getArray(); + $this->assertSame([ + 'sign_up', + GroupActivityTypes::CREATE->value, + AccountActivityTypes::DELETE->value, + ], array_column($data['rows'], 'type')); // @phpstan-ignore-line + + $sprunje->setOptions([ + 'sorts' => ['label' => 'asc'], + 'size' => 1, + 'page' => 1, + ]); + $data = $sprunje->getArray(); + $this->assertEquals(9, $data['count']); + $this->assertEquals(3, $data['count_filtered']); + $this->assertCount(1, $data['rows']); // @phpstan-ignore-line + $this->assertSame($groupCreated->id, $data['rows'][0]['id']); // @phpstan-ignore-line + } + + public function testLabelSortUsesIdAsDeterministicTieBreaker(): void + { + $userId = $this->users[0]->id; + $first = Activity::factory()->create([ + 'user_id' => $userId, + 'type' => AccountActivityTypes::CREATE->value, + ]); + $second = Activity::factory()->create([ + 'user_id' => $userId, + 'type' => AccountActivityTypes::CREATE->value, + ]); + + /** @var ActivitySprunje */ + $sprunje = $this->getService(ActivitySprunje::class); + $sprunje->setOptions([ + 'filters' => ['label' => AccountActivityTypes::CREATE->value], + 'sorts' => ['label' => 'asc'], + ]); + $data = $sprunje->getArray(); + $this->assertSame([$first->id, $second->id], array_column($data['rows'], 'id')); // @phpstan-ignore-line + + $sprunje->setOptions([ + 'sorts' => ['label' => 'desc'], + ]); + $data = $sprunje->getArray(); + $this->assertSame([$second->id, $first->id], array_column($data['rows'], 'id')); // @phpstan-ignore-line + } + + public function testActivityDescriptions(): void + { + $userId = $this->users[0]->id; + /** @var Activity $registered */ + $registered = Activity::factory()->create([ + 'user_id' => $userId, + 'type' => AdminAccountActivityTypes::UPDATE_FIELD->value, + 'metadata' => ['field' => 'email'], + 'description' => 'Legacy registered description', + ]); + /** @var Activity $legacy */ + $legacy = Activity::factory()->create([ + 'user_id' => $userId, + 'type' => 'unregistered_event', + 'description' => 'Legacy description', + ]); + /** @var Activity $raw */ + $raw = Activity::factory()->create([ + 'user_id' => $userId, + 'type' => 'unknown_event', + 'description' => null, + ]); + + /** @var ActivitySprunje */ + $sprunje = $this->getService(ActivitySprunje::class); + $rows = $sprunje->getArray()['rows']; + + $rowsById = []; + foreach ($rows as $row) { + $rowsById[$row['id']] = $row; + } + + $this->assertSame('Account field email updated', $rowsById[$registered->id]['description']); + $this->assertSame('Account field updated', $rowsById[$registered->id]['label']); + $this->assertSame('Legacy description', $rowsById[$legacy->id]['description']); + $this->assertSame('unregistered_event', $rowsById[$legacy->id]['label']); + $this->assertSame('unknown_event', $rowsById[$raw->id]['description']); + $this->assertSame('unknown_event', $rowsById[$raw->id]['label']); + $this->assertArrayNotHasKey('metadata', $rowsById[$registered->id]); + } + + public function testActorlessActivityIsVisible(): void + { + /** @var Activity */ + $activity = Activity::factory()->create([ + 'user_id' => null, + 'type' => 'actorless_event', + ]); + + /** @var ActivitySprunje */ + $sprunje = $this->getService(ActivitySprunje::class); + $rows = $sprunje->getArray()['rows']; + $rowsById = []; + foreach ($rows as $row) { + $rowsById[$row['id']] = $row; + } + + $this->assertArrayHasKey($activity->id, $rowsById); + $this->assertNull($rowsById[$activity->id]['user']); + + $activity->delete(); + } + + public function testConventionFallbackUsesMockedDictionary(): void + { + $activity = new Activity([ + 'type' => 'legacy_event', + 'description' => 'Legacy convention description', + ]); + + /** @var Mockery\MockInterface&ActivityTypeRegistryInterface $registry */ + $registry = Mockery::mock(ActivityTypeRegistryInterface::class) + ->shouldReceive('getLabelI18nKey')->with('legacy_event')->once()->andReturnNull() + ->shouldReceive('getI18nKey')->with('legacy_event')->once()->andReturnNull() + ->getMock(); + + /** @var Mockery\MockInterface&DictionaryInterface $dictionary */ + $dictionary = Mockery::mock(DictionaryInterface::class) + ->shouldReceive('has')->with('ACTIVITY.TYPE.legacy_event')->once()->andReturnTrue() + ->getMock(); + + /** @var Mockery\MockInterface&Translator $translator */ + $translator = Mockery::mock(Translator::class) + ->shouldReceive('getDictionary')->once()->andReturn($dictionary) + ->shouldReceive('translate')->once()->with('ACTIVITY.TYPE.legacy_event', [ + 'context' => null, + 'subject' => null, + ])->andReturn('Legacy event')->getMock(); + + $sprunje = new TestableActivitySprunje(new Activity(), $registry, $translator); + $sprunje->transform(new Collection([$activity])); + + $this->assertSame('Legacy event', $activity->getAttribute('description')); + } + + public function testActivityDescriptionPlaceholdersIncludeRelations(): void + { + $context = new stdClass(); + $subject = new stdClass(); + $activity = new Activity([ + 'type' => 'test_event', + 'metadata' => [ + 'field' => 'email', + 'context' => 'metadata context', + 'subject' => 'metadata subject', + ], + ]); + $activity->setRelation('context', $context); + $activity->setRelation('subject', $subject); + + /** @var Mockery\MockInterface&ActivityTypeRegistryInterface $registry */ + $registry = Mockery::mock(ActivityTypeRegistryInterface::class) + ->shouldReceive('getLabelI18nKey')->with('test_event')->once()->andReturnNull() + ->shouldReceive('getI18nKey')->with('test_event')->once()->andReturn('TEST.ACTIVITY') + ->getMock(); + + /** @var Mockery\MockInterface&DictionaryInterface $dictionary */ + $dictionary = Mockery::mock(DictionaryInterface::class) + ->shouldReceive('has')->with('TEST.ACTIVITY')->once()->andReturnTrue() + ->getMock(); + + /** @var Mockery\MockInterface&Translator $translator */ + $translator = Mockery::mock(Translator::class) + ->shouldReceive('getDictionary')->once()->andReturn($dictionary) + ->shouldReceive('translate')->once()->with('TEST.ACTIVITY', Mockery::on(function (array $placeholders) use ($context, $subject): bool { + return $placeholders === [ + 'field' => 'email', + 'context' => $context, + 'subject' => $subject, + ]; + }))->andReturn('translated')->getMock(); + + $sprunje = new TestableActivitySprunje(new Activity(), $registry, $translator); + $sprunje->transform(new Collection([$activity])); + + $this->assertSame('translated', $activity->getAttribute('description')); + } + + public function testMissingLabelTranslationFallsBackToPersistedType(): void + { + $activity = new Activity([ + 'type' => 'test_event', + 'description' => 'Legacy description', + ]); + + /** @var Mockery\MockInterface&ActivityTypeRegistryInterface $registry */ + $registry = Mockery::mock(ActivityTypeRegistryInterface::class) + ->shouldReceive('getLabelI18nKey')->with('test_event')->once()->andReturn('TEST.LABEL') + ->shouldReceive('getI18nKey')->with('test_event')->once()->andReturn('TEST.DESCRIPTION') + ->getMock(); + + /** @var Mockery\MockInterface&DictionaryInterface $dictionary */ + $dictionary = Mockery::mock(DictionaryInterface::class) + ->shouldReceive('has')->with('TEST.LABEL')->once()->andReturnFalse() + ->shouldReceive('has')->with('TEST.DESCRIPTION')->once()->andReturnTrue() + ->getMock(); + + /** @var Mockery\MockInterface&Translator $translator */ + $translator = Mockery::mock(Translator::class) + ->shouldReceive('getDictionary')->once()->andReturn($dictionary) + ->shouldReceive('translate')->once()->with('TEST.DESCRIPTION', [ + 'context' => null, + 'subject' => null, + ])->andReturn('Translated description')->getMock(); + + $sprunje = new TestableActivitySprunje(new Activity(), $registry, $translator); + $sprunje->transform(new Collection([$activity])); + + $this->assertSame('test_event', $activity->getAttribute('label')); + $this->assertSame('Translated description', $activity->getAttribute('description')); + } + + public function testCsvIncludesLabelAndTranslatedDescription(): void + { + Activity::factory()->create([ + 'user_id' => $this->users[0]->id, + 'type' => AdminAccountActivityTypes::UPDATE_FIELD->value, + 'metadata' => ['field' => 'email'], + 'description' => 'Legacy description', + ]); + + /** @var ActivitySprunje */ + $sprunje = $this->getService(ActivitySprunje::class); + $sprunje->setOptions([ + 'filters' => ['label' => AdminAccountActivityTypes::UPDATE_FIELD->value], + ]); + $csv = $sprunje->getCsv()->toString(); + + $this->assertStringContainsString('label', $csv); + $this->assertStringContainsString('Account field updated', $csv); + $this->assertStringContainsString('description', $csv); + $this->assertStringContainsString('Account field email updated', $csv); + } + + public function testActivityLabelsAreCached(): void + { + /** @var ActivityTypeRegistryInterface $registry */ + $registry = $this->getService(ActivityTypeRegistryInterface::class); + /** @var Translator $translator */ + $translator = $this->getService(Translator::class); + $sprunje = new TestableActivitySprunje(new Activity(), $registry, $translator); + + $this->assertSame($sprunje->labels(), $sprunje->labels()); + } + + public function testActivityLabelsSkipNonBackedEnums(): void + { + /** @var Mockery\MockInterface&ActivityTypeRegistryInterface $registry */ + $registry = Mockery::mock(ActivityTypeRegistryInterface::class) + ->shouldReceive('all')->once()->andReturn([new stdClass()]) + ->getMock(); + + $sprunje = new TestableActivitySprunje( + new Activity(), + $registry, + Mockery::mock(Translator::class) + ); + + $this->assertSame([], $sprunje->labels()); + } +} + +class TestableActivitySprunje extends ActivitySprunje +{ + /** + * @param Collection $collection + * + * @return Collection + */ + public function transform(Collection $collection): Collection + { + return $this->applyTransformations($collection); + } + + /** @return array */ + public function labels(): array + { + return $this->getActivityLabels(); + } } diff --git a/packages/sprinkle-core/CHANGELOG.md b/packages/sprinkle-core/CHANGELOG.md index 6153d8f3b..9717a3312 100644 --- a/packages/sprinkle-core/CHANGELOG.md +++ b/packages/sprinkle-core/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Validate throttling configuration before creating throttle rules. ### Changed +- Added `MorphableModelInterface` to the shared model contract. Used to represent an Eloquent model that can be used as a polymorphic relation target, i.e. a Model that provides `getKey` and `getMorphClass` methods. Required for type validation since Eloquent doesn't provide a shared `Model` interface, so our dynamic model interfaces (e.g. `UserInterface`, `GroupInterface`, etc.) don't extend the base Eloquent `Model` class. - Updated the `RefreshDatabase` testing trait to use the typed application container accessor. - Converted locale message files from PHP arrays to YAML. diff --git a/packages/sprinkle-core/app/src/Database/Models/Interfaces/MorphableModelInterface.php b/packages/sprinkle-core/app/src/Database/Models/Interfaces/MorphableModelInterface.php new file mode 100644 index 000000000..23d800587 --- /dev/null +++ b/packages/sprinkle-core/app/src/Database/Models/Interfaces/MorphableModelInterface.php @@ -0,0 +1,42 @@ +assertInstanceOf(MorphableModelInterface::class, $model); + } + } +} diff --git a/packages/theme-pink-cupcake/CHANGELOG.md b/packages/theme-pink-cupcake/CHANGELOG.md index 7e01ae5fe..013338738 100644 --- a/packages/theme-pink-cupcake/CHANGELOG.md +++ b/packages/theme-pink-cupcake/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Changed +- Updated role permission management to use a dedicated typed update API. +- Updated the admin user interface to use dedicated forms for profile, group, password, role, status, and verification changes. +- User creation no longer includes group assignment. + ## [6.0.2](https://github.com/userfrosting/theme-pink-cupcake/compare/6.0.1...6.0.2) - 2026-08-02 ### Fixed diff --git a/packages/theme-pink-cupcake/src/components/Activities/ActivityDescription.vue b/packages/theme-pink-cupcake/src/components/Activities/ActivityDescription.vue new file mode 100644 index 000000000..e54edde6a --- /dev/null +++ b/packages/theme-pink-cupcake/src/components/Activities/ActivityDescription.vue @@ -0,0 +1,48 @@ + + + diff --git a/packages/theme-pink-cupcake/src/components/Activities/index.ts b/packages/theme-pink-cupcake/src/components/Activities/index.ts new file mode 100644 index 000000000..91408f639 --- /dev/null +++ b/packages/theme-pink-cupcake/src/components/Activities/index.ts @@ -0,0 +1 @@ +export { default as ActivityDescription } from './ActivityDescription.vue' diff --git a/packages/theme-pink-cupcake/src/components/Pages/Admin/Dashboard/DashboardActivities.vue b/packages/theme-pink-cupcake/src/components/Pages/Admin/Dashboard/DashboardActivities.vue index 940c588d7..0516f9595 100644 --- a/packages/theme-pink-cupcake/src/components/Pages/Admin/Dashboard/DashboardActivities.vue +++ b/packages/theme-pink-cupcake/src/components/Pages/Admin/Dashboard/DashboardActivities.vue @@ -1,3 +1,7 @@ + + diff --git a/packages/theme-pink-cupcake/src/components/Pages/Admin/Role/RoleManagePermissionModal.vue b/packages/theme-pink-cupcake/src/components/Pages/Admin/Role/RoleManagePermissionModal.vue index 9c47f243f..d77f1a4fb 100644 --- a/packages/theme-pink-cupcake/src/components/Pages/Admin/Role/RoleManagePermissionModal.vue +++ b/packages/theme-pink-cupcake/src/components/Pages/Admin/Role/RoleManagePermissionModal.vue @@ -22,9 +22,9 @@ const { role } = defineProps<{ * Methods - Fetch permissions, selected, fetch method and submit the form. */ const { loading, selected, fetch } = useRolePermissionsApi() -const { submitRoleUpdate } = useRoleUpdateApi() +const { submitRolePermissions } = useRoleUpdateApi() const submitForm = () => { - submitRoleUpdate(role.slug, 'permissions', { permissions: selected.value }) + submitRolePermissions(role.slug, { permissions: selected.value }) .then(() => { // Emit the saved event emits('saved') diff --git a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserActivateModal.vue b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserActivateModal.vue deleted file mode 100644 index f70a0e713..000000000 --- a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserActivateModal.vue +++ /dev/null @@ -1,89 +0,0 @@ - - - diff --git a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserActivities.vue b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserActivities.vue index 2880cc275..ed118c026 100644 --- a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserActivities.vue +++ b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserActivities.vue @@ -1,4 +1,6 @@ diff --git a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserEditModal.vue b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserEditModal.vue index f731669e7..b9618871f 100644 --- a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserEditModal.vue +++ b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserEditModal.vue @@ -1,6 +1,5 @@ diff --git a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserForm.vue b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserForm.vue index fe0d07f98..5fba07e4c 100644 --- a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserForm.vue +++ b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserForm.vue @@ -3,14 +3,12 @@ import { computed, ref, watch } from 'vue' import { useUserApi } from '@userfrosting/sprinkle-admin/composables' import { useConfigStore } from '@userfrosting/sprinkle-core/stores' import type { UserCreateRequest, UserEditRequest } from '@userfrosting/sprinkle-admin/interfaces' -import type { GroupInterface } from '@userfrosting/sprinkle-account/interfaces' /** * Props - List of groups for the dropdown, plus optional user object for * editing. */ const props = defineProps<{ - groups: GroupInterface[] user?: UserCreateRequest | UserEditRequest }>() @@ -46,7 +44,6 @@ watch( (user) => { if (user) { formData.value.user_name = user.user_name - formData.value.group_id = user.group_id formData.value.first_name = user.first_name formData.value.last_name = user.last_name formData.value.email = user.email @@ -117,27 +114,6 @@ const submitForm = async () => { -
- -
- - - -
-
-
diff --git a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserGroupForm.vue b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserGroupForm.vue new file mode 100644 index 000000000..7c9ef10da --- /dev/null +++ b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserGroupForm.vue @@ -0,0 +1,67 @@ + + + diff --git a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserGroupModal.vue b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserGroupModal.vue new file mode 100644 index 000000000..b85fc18da --- /dev/null +++ b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserGroupModal.vue @@ -0,0 +1,29 @@ + + + diff --git a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserInfo.vue b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserInfo.vue index 7d2f66b91..931749bbb 100644 --- a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserInfo.vue +++ b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserInfo.vue @@ -4,9 +4,11 @@ import { Severity } from '@userfrosting/sprinkle-core/interfaces' import type { UserResponse } from '@userfrosting/sprinkle-admin/interfaces' import UserEditModal from './UserEditModal.vue' import UserDeleteModal from './UserDeleteModal.vue' -import UserActivateModal from './UserActivateModal.vue' +import UserStatusModal from './UserStatusModal.vue' +import UserVerificationModal from './UserVerificationModal.vue' import UserPasswordModal from './UserPasswordModal.vue' import UserPasswordResetModal from './UserPasswordResetModal.vue' +import UserGroupModal from './UserGroupModal.vue' const router = useRouter() const { user } = defineProps<{ @@ -72,6 +74,11 @@ const emits = defineEmits(['updated']) @saved="emits('updated')" v-if="$checkAccess('update_user_field')" class="uk-width-1-1 uk-margin-small-bottom uk-button uk-button-primary uk-button-small" /> + - + { - submitUserUpdate(user.user_name, 'roles', { roles: selected.value }).then(() => { + submitUserRoles(user.user_name, { roles: selected.value }).then(() => { // Emit the saved event emits('saved') diff --git a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserPasswordModal.vue b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserPasswordModal.vue index e741c9698..7bdb7a93b 100644 --- a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserPasswordModal.vue +++ b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserPasswordModal.vue @@ -39,13 +39,13 @@ watch( /** * API - Use the user edit API. */ -const { submitUserUpdate } = useUserUpdateApi() +const { submitUserPassword } = useUserUpdateApi() /** * Methods - Submit the form to the API and handle the response. */ const submitForm = () => { - submitUserUpdate(props.user.user_name, 'password', formData.value).then(() => { + submitUserPassword(props.user.user_name, formData.value).then(() => { // Close the modal UIkit.modal('#modal-user-password-' + props.user.id).hide() }) diff --git a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserStatusModal.vue b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserStatusModal.vue new file mode 100644 index 000000000..2776fc917 --- /dev/null +++ b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserStatusModal.vue @@ -0,0 +1,65 @@ + + + diff --git a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserVerificationModal.vue b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserVerificationModal.vue new file mode 100644 index 000000000..1924ab99e --- /dev/null +++ b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserVerificationModal.vue @@ -0,0 +1,38 @@ + + + diff --git a/packages/theme-pink-cupcake/src/components/Pages/Admin/index.ts b/packages/theme-pink-cupcake/src/components/Pages/Admin/index.ts index f128fb088..6406b87bb 100644 --- a/packages/theme-pink-cupcake/src/components/Pages/Admin/index.ts +++ b/packages/theme-pink-cupcake/src/components/Pages/Admin/index.ts @@ -16,7 +16,6 @@ import RoleInfo from './Role/RoleInfo.vue' import RoleManagePermissionModal from './Role/RoleManagePermissionModal.vue' import RolePermissions from './Role/RolePermissions.vue' import RoleUsers from './Role/RoleUsers.vue' -import UserActivateModal from './User/UserActivateModal.vue' import UserActivities from './User/UserActivities.vue' import UserCreateModal from './User/UserCreateModal.vue' import UserDeleteModal from './User/UserDeleteModal.vue' @@ -29,6 +28,8 @@ import UserPasswordModal from './User/UserPasswordModal.vue' import UserPasswordResetModal from './User/UserPasswordResetModal.vue' import UserPermissions from './User/UserPermissions.vue' import UserRoles from './User/UserRoles.vue' +import UserStatusModal from './User/UserStatusModal.vue' +import UserVerificationModal from './User/UserVerificationModal.vue' export { DashboardActivities, @@ -49,7 +50,6 @@ export { RoleManagePermissionModal, RolePermissions, RoleUsers, - UserActivateModal, UserActivities, UserCreateModal, UserDeleteModal, @@ -61,5 +61,7 @@ export { UserPasswordModal, UserPasswordResetModal, UserPermissions, - UserRoles + UserRoles, + UserStatusModal, + UserVerificationModal } diff --git a/packages/theme-pink-cupcake/src/components/Sprunjer/SprunjeFilters.vue b/packages/theme-pink-cupcake/src/components/Sprunjer/SprunjeFilters.vue index 875d67636..8a7b5be25 100644 --- a/packages/theme-pink-cupcake/src/components/Sprunjer/SprunjeFilters.vue +++ b/packages/theme-pink-cupcake/src/components/Sprunjer/SprunjeFilters.vue @@ -29,7 +29,7 @@ const listableOptions = (column: string) => {