diff --git a/src/app/Console/Commands/User/SuspendCommand.php b/src/app/Console/Commands/User/SuspendCommand.php --- a/src/app/Console/Commands/User/SuspendCommand.php +++ b/src/app/Console/Commands/User/SuspendCommand.php @@ -11,7 +11,7 @@ * * @var string */ - protected $signature = 'user:suspend {user}'; + protected $signature = 'user:suspend {user} {--comment=}'; /** * The console command description. @@ -35,5 +35,7 @@ } $user->suspend(); + + \App\EventLog::createFor($user, \App\EventLog::TYPE_SUSPENDED, $this->option('comment')); } } diff --git a/src/app/Console/Commands/User/UnsuspendCommand.php b/src/app/Console/Commands/User/UnsuspendCommand.php --- a/src/app/Console/Commands/User/UnsuspendCommand.php +++ b/src/app/Console/Commands/User/UnsuspendCommand.php @@ -11,7 +11,7 @@ * * @var string */ - protected $signature = 'user:unsuspend {user}'; + protected $signature = 'user:unsuspend {user} {--comment=}'; /** * The console command description. @@ -35,5 +35,7 @@ } $user->unsuspend(); + + \App\EventLog::createFor($user, \App\EventLog::TYPE_UNSUSPENDED, $this->option('comment')); } } diff --git a/src/app/EventLog.php b/src/app/EventLog.php new file mode 100644 --- /dev/null +++ b/src/app/EventLog.php @@ -0,0 +1,127 @@ +<?php + +namespace App; + +use App\Traits\UuidStrKeyTrait; +use Dyrynda\Database\Support\NullableFields; +use Illuminate\Database\Eloquent\Model; + +/** + * The eloquent definition of an EventLog record. + * + * @property ?string $comment Optional event description + * @property ?array $data Optional event data + * @property string $id Log record identifier + * @property string $object_id Object identifier + * @property string $object_type Object type (class) + * @property int $type Event type (0-255) + * @property ?string $user_email Acting user email + */ +class EventLog extends Model +{ + use NullableFields; + use UuidStrKeyTrait; + + public const TYPE_SUSPENDED = 1; + public const TYPE_UNSUSPENDED = 2; + public const TYPE_COMMENT = 3; + + /** @var array<int, string> The attributes that are mass assignable */ + protected $fillable = [ + 'comment', + // extra event info (json) + 'data', + 'type', + // user, domain, etc. + 'object_id', + 'object_type', + // actor, if any + 'user_email', + ]; + + /** @var array<string, string> Casts properties as type */ + protected $casts = [ + 'created_at' => 'datetime:Y-m-d H:i:s', + 'data' => 'array', + 'type' => 'integer', + ]; + + /** @var array<int, string> The attributes that can be not set */ + protected $nullable = ['comment', 'data', 'user_email']; + + /** @var string Database table name */ + protected $table = 'eventlog'; + + /** @var bool Indicates if the model should be timestamped. */ + public $timestamps = false; + + + /** + * Create an eventlog object for a specified object. + * + * @param object $object Object (User, Domain, etc.) + * @param int $type Event type (use one of EventLog::TYPE_* consts) + * @param ?string $comment Event description + * @param ?array $data Extra information + * + * @return EventLog + */ + public static function createFor($object, int $type, string $comment = null, array $data = null): EventLog + { + $event = self::create([ + 'object_id' => $object->id, + 'object_type' => get_class($object), + 'type' => $type, + 'comment' => $comment, + 'data' => $data, + ]); + + return $event; + } + + /** + * Principally an object such as Domain, User, Group. + * Note that it may be trashed (soft-deleted). + * + * @return mixed + */ + public function object() + { + return $this->morphTo()->withTrashed(); // @phpstan-ignore-line + } + + /** + * Get an event type name. + * + * @return ?string Event type name + */ + public function eventName(): ?string + { + switch ($this->type) { + case self::TYPE_SUSPENDED: + return \trans('app.event-suspended'); + case self::TYPE_UNSUSPENDED: + return \trans('app.event-unsuspended'); + case self::TYPE_COMMENT: + return \trans('app.event-comment'); + default: + return null; + } + } + + /** + * Event type mutator + * + * @throws \Exception + */ + public function setTypeAttribute($type) + { + $type = (int) $type; + + if ($type < 0 || $type > 255) { + throw new \Exception("Expecting an event type between 0 and 255"); + } + + $this->attributes['type'] = $type; + } +} diff --git a/src/app/Http/Controllers/API/V4/Admin/EventLogController.php b/src/app/Http/Controllers/API/V4/Admin/EventLogController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Admin/EventLogController.php @@ -0,0 +1,67 @@ +<?php + +namespace App\Http\Controllers\API\V4\Admin; + +use App\EventLog; +use App\Http\Controllers\Controller; +use Illuminate\Http\Request; + +class EventLogController extends Controller +{ + /** + * Listing of eventlog entries. + * + * @param \Illuminate\Http\Request $request HTTP Request + * @param string $object_type Object type + * @param string $object_id Object id + * + * @return \Illuminate\Http\JsonResponse + */ + public function index(Request $request, string $object_type, string $object_id) + { + $object_type = "App\\" . ucfirst($object_type); + + if (!class_exists($object_type)) { + return $this->errorResponse(404); + } + + $object = (new $object_type)->find($object_id); + + if (!$this->checkTenant($object)) { + return $this->errorResponse(404); + } + + $page = intval($request->input('page')) ?: 1; + $pageSize = 20; + $hasMore = false; + + $result = EventLog::where('object_id', $object_id) + ->where('object_type', $object_type) + ->orderBy('created_at', 'desc') + ->limit($pageSize + 1) + ->offset($pageSize * ($page - 1)) + ->get(); + + if (count($result) > $pageSize) { + $result->pop(); + $hasMore = true; + } + + $result = $result->map(function ($event) { + return [ + 'id' => $event->id, + 'comment' => $event->comment, + 'createdAt' => $event->created_at->toDateTimeString(), + 'event' => $event->eventName(), + 'data' => $event->data, + 'user' => $event->user_email, + ]; + }); + + return response()->json([ + 'list' => $result, + 'count' => count($result), + 'hasMore' => $hasMore, + ]); + } +} diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\API\V4\Admin; use App\Domain; +use App\EventLog; use App\Sku; use App\User; use App\Wallet; @@ -321,8 +322,16 @@ return $this->errorResponse(403); } + $v = Validator::make($request->all(), ['comment' => 'nullable|string|max:1024']); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + $user->suspend(); + EventLog::createFor($user, EventLog::TYPE_SUSPENDED, $request->comment); + return response()->json([ 'status' => 'success', 'message' => self::trans('app.user-suspend-success'), @@ -349,8 +358,16 @@ return $this->errorResponse(403); } + $v = Validator::make($request->all(), ['comment' => 'nullable|string|max:1024']); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + $user->unsuspend(); + EventLog::createFor($user, EventLog::TYPE_UNSUSPENDED, $request->comment); + return response()->json([ 'status' => 'success', 'message' => self::trans('app.user-unsuspend-success'), diff --git a/src/app/Http/Controllers/API/V4/Reseller/EventLogController.php b/src/app/Http/Controllers/API/V4/Reseller/EventLogController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Reseller/EventLogController.php @@ -0,0 +1,7 @@ +<?php + +namespace App\Http\Controllers\API\V4\Reseller; + +class EventLogController extends \App\Http\Controllers\API\V4\Admin\EventLogController +{ +} diff --git a/src/app/Jobs/User/CreateJob.php b/src/app/Jobs/User/CreateJob.php --- a/src/app/Jobs/User/CreateJob.php +++ b/src/app/Jobs/User/CreateJob.php @@ -76,6 +76,8 @@ $code = \Artisan::call("user:abuse-check {$this->userId}"); if ($code == 2) { \Log::info("Suspending user due to suspected abuse: {$this->userId} {$user->email}"); + \App\EventLog::createFor($user, \App\EventLog::TYPE_SUSPENDED, "Suspected spammer"); + $user->status |= \App\User::STATUS_SUSPENDED; } } diff --git a/src/app/Observers/EventLogObserver.php b/src/app/Observers/EventLogObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/EventLogObserver.php @@ -0,0 +1,24 @@ +<?php + +namespace App\Observers; + +use App\EventLog; + +class EventLogObserver +{ + /** + * Ensure the event entry ID is a custom ID (uuid). + * + * @param \App\EventLog $eventlog The EventLog object + */ + public function creating(EventLog $eventlog): void + { + if (!isset($eventlog->user_email)) { + $eventlog->user_email = \App\Utils::userEmailOrNull(); + } + + if (!isset($eventlog->type)) { + throw new \Exception("Unset event type"); + } + } +} diff --git a/src/app/PackageSku.php b/src/app/PackageSku.php --- a/src/app/PackageSku.php +++ b/src/app/PackageSku.php @@ -37,9 +37,6 @@ 'cost', ]; - /** @var string Database table name */ - protected $table = 'package_skus'; - /** @var bool Indicates if the model should be timestamped. */ public $timestamps = false; diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -69,6 +69,7 @@ { \App\Domain::observe(\App\Observers\DomainObserver::class); \App\Entitlement::observe(\App\Observers\EntitlementObserver::class); + \App\EventLog::observe(\App\Observers\EventLogObserver::class); \App\Group::observe(\App\Observers\GroupObserver::class); \App\GroupSetting::observe(\App\Observers\GroupSettingObserver::class); \App\Meet\Room::observe(\App\Observers\Meet\RoomObserver::class); diff --git a/src/database/migrations/2023_06_06_100000_create_eventlog_table.php b/src/database/migrations/2023_06_06_100000_create_eventlog_table.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2023_06_06_100000_create_eventlog_table.php @@ -0,0 +1,43 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::create( + 'eventlog', + function (Blueprint $table) { + $table->string('id', 36)->primary(); + $table->string('object_id', 36); + $table->string('object_type', 36); + $table->tinyInteger('type')->unsigned(); + $table->string('user_email')->nullable(); + $table->string('comment', 1024)->nullable(); + $table->text('data')->nullable(); // json + $table->timestamp('created_at')->useCurrent(); + + $table->index(['object_id', 'object_type', 'type']); + $table->index('created_at'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('eventlog'); + } +}; diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -23,6 +23,10 @@ 'companion-create-success' => 'Companion app has been created.', 'companion-delete-success' => 'Companion app has been removed.', + 'event-suspended' => 'Suspended', + 'event-unsuspended' => 'Unsuspended', + 'event-comment' => 'Commented', + 'mandate-delete-success' => 'The auto-payment has been removed.', 'mandate-update-success' => 'The auto-payment has been updated.', diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -42,6 +42,12 @@ 'verify' => "Verify", ], + 'collection' => [ + 'create' => "Create collection", + 'new' => "New Collection", + 'name' => "Name", + ], + 'companion' => [ 'title' => "Companion Apps", 'companion' => "Companion App", @@ -167,12 +173,6 @@ . "to the file via a unique link.", ], - 'collection' => [ - 'create' => "Create collection", - 'new' => "New Collection", - 'name' => "Name", - ], - 'form' => [ 'acl' => "Access rights", 'acl-full' => "All", @@ -182,6 +182,7 @@ 'anyone' => "Anyone", 'code' => "Confirmation Code", 'config' => "Configuration", + 'comment' => "Comment", 'companion' => "Companion App", 'date' => "Date", 'description' => "Description", @@ -195,8 +196,10 @@ 'general' => "General", 'geolocation' => "Your current location: {location}", 'lastname' => "Last Name", + 'less' => "Less", 'name' => "Name", 'months' => "months", + 'more' => "More", 'none' => "none", 'norestrictions' => "No restrictions", 'or' => "or", @@ -242,6 +245,12 @@ 'it' => "Italian", ], + 'log' => [ + 'event' => "Event", + 'list-none' => "There's no events in the log", + 'history' => "History", + ], + 'login' => [ '2fa' => "Second factor code", '2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.", diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -205,6 +205,27 @@ width: 50px; } } + + &.eventlog { + .details, + .btn-less { + display: none; + } + + tr.open { + .btn-more { + display: none; + } + + .details { + display: block; + } + + .btn-less { + display: initial; + } + } + } } .table > :not(:first-child) { diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -77,8 +77,8 @@ </div> </form> <div class="mt-2 buttons"> - <btn :id="'button-' + (user.isSuspended ? 'unsuspend' : 'suspend')" class="btn-outline-primary" @click="setSuspendState"> - {{ $t(user.isSuspended ? 'btn.unsuspend' : 'btn.suspend') }} + <btn :id="`button-${suspendAction}`" class="btn-outline-primary" @click="setSuspendState"> + {{ $t(`btn.${suspendAction}`) }} </btn> <btn id="button-resync" class="btn-outline-primary" @click="resyncUser"> {{ $t('btn.resync') }} @@ -228,21 +228,26 @@ </div> </div> </div> + <div class="tab-pane" id="history" role="tabpanel" aria-labelledby="tab-history"> + <div class="card-body"> + <event-log v-if="loadEventLog" :object-id="user.id" object-type="user" ref="eventLog" class="card-text mb-0"></event-log> + </div> + </div> </div> <modal-dialog id="discount-dialog" ref="discountDialog" :title="$t('user.discount-title')" @click="submitDiscount()" :buttons="['submit']"> - <div> - <select v-model="wallet.discount_id" class="form-select"> - <option value="">- {{ $t('form.none') }} -</option> - <option v-for="item in discounts" :value="item.id" :key="item.id">{{ item.label }}</option> - </select> - </div> + <select v-model="wallet.discount_id" class="form-select"> + <option value="">- {{ $t('form.none') }} -</option> + <option v-for="item in discounts" :value="item.id" :key="item.id">{{ item.label }}</option> + </select> </modal-dialog> <modal-dialog id="email-dialog" ref="emailDialog" :title="$t('user.ext-email')" @click="submitEmail()" :buttons="['submit']"> - <div> - <input v-model="external_email" name="external_email" class="form-control"> - </div> + <input v-model="external_email" name="external_email" class="form-control"> + </modal-dialog> + + <modal-dialog id="suspend-dialog" ref="suspendDialog" :title="$t(`btn.${suspendAction}`)" @click="submitSuspend()" :buttons="['submit']"> + <textarea v-model="comment" name="comment" class="form-control" :placeholder="$t('form.comment')" rows="3"></textarea> </modal-dialog> <modal-dialog id="oneoff-dialog" ref="oneoffDialog" @click="submitOneOff()" :buttons="['submit']" @@ -274,6 +279,7 @@ <script> import ModalDialog from '../Widgets/ModalDialog' + import EventLog from '../Widgets/EventLog' import TransactionLog from '../Widgets/TransactionLog' import { ListTable } from '../Widgets/ListTools' import { default as DistlistList } from '../Distlist/ListWidget' @@ -295,6 +301,7 @@ components: { DistlistList, DomainList, + EventLog, ListTable, ModalDialog, ResourceList, @@ -320,9 +327,7 @@ ], footLabel: 'user.aliases-none' }, - oneoff_amount: '', - oneoff_description: '', - oneoff_negative: false, + comment: '', discount: 0, discount_description: '', discounts: [], @@ -330,6 +335,10 @@ folders: [], has2FA: false, hasBeta: false, + loadEventLog: false, + oneoff_amount: '', + oneoff_description: '', + oneoff_negative: false, wallet: {}, walletReload: false, distlists: [], @@ -361,7 +370,8 @@ { label: 'user.distlists', count: 0 }, { label: 'user.resources', count: 0 }, { label: 'dashboard.shared-folders', count: 0 }, - { label: 'form.settings' } + { label: 'form.settings' }, + { label: 'log.history' } ], users: [], user: { @@ -372,6 +382,11 @@ } } }, + computed: { + suspendAction() { + return this.user.isSuspended ? 'unsuspend' : 'suspend' + } + }, created() { const user_id = this.$route.params.user @@ -476,6 +491,7 @@ .catch(this.$root.errorHandler) }, mounted() { + this.$refs.tabs.clickHandler('history', () => { this.loadEventLog = true }) this.$refs.discountDialog.events({ shown: () => { // Note: Vue v-model is strict, convert null to a string @@ -633,11 +649,23 @@ }) }, setSuspendState() { - axios.post('/api/v4/users/' + this.user.id + '/' + (this.user.isSuspended ? 'unsuspend' : 'suspend')) + this.$root.clearFormValidation($('#suspend-dialog')) + this.$refs.suspendDialog.show() + }, + submitSuspend() { + const post = { comment: this.comment } + + axios.post(`/api/v4/users/${this.user.id}/${this.suspendAction}`, post) .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) this.user = Object.assign({}, this.user, { isSuspended: !this.user.isSuspended }) + this.$refs.suspendDialog.hide() + this.comment = '' + + if (this.loadEventLog) { + this.$refs.eventLog.loadLog({ reset: true }) + } } }) } diff --git a/src/resources/vue/Widgets/EventLog.vue b/src/resources/vue/Widgets/EventLog.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Widgets/EventLog.vue @@ -0,0 +1,67 @@ +<template> + <div> + <table class="table table-sm m-0 eventlog"> + <thead> + <tr> + <th scope="col">{{ $t('form.date') }}</th> + <th scope="col">{{ $t('log.event') }}</th> + <th scope="col">{{ $t('form.comment') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="event in log" :id="'log' + event.id" :key="event.id"> + <td class="datetime">{{ event.createdAt }}</td> + <td>{{ event.event }}</td> + <td class="description"> + <btn class="btn-link btn-action btn-more" icon="angle-right" :title="$t('form.more')" @click="loadDetails"></btn> + <btn class="btn-link btn-action btn-less" icon="angle-down" :title="$t('form.less')" @click="hideDetails"></btn> + {{ event.comment }} + <pre v-if="event.data" class="details text-monospace p-1 m-1 ms-3">{{ JSON.stringify(event.data, null, 2) }}</pre> + <div v-if="event.user" class="details email text-nowrap text-secondary ms-3"><svg-icon icon="user" class="me-1"></svg-icon>{{ event.user }}</div> + </td> + </tr> + </tbody> + <list-foot :text="$t('log.list-none')" :colspan="3"></list-foot> + </table> + <list-more v-if="hasMore" :on-click="loadLog"></list-more> + </div> +</template> + +<script> + import ListTools from './ListTools' + import { library } from '@fortawesome/fontawesome-svg-core' + + library.add( + require('@fortawesome/free-solid-svg-icons/faAngleDown').definition, + require('@fortawesome/free-solid-svg-icons/faAngleRight').definition + ) + + export default { + mixins: [ ListTools ], + props: { + objectId: { type: [ String, Number ], default: null }, + objectType: { type: String, default: null }, + }, + data() { + return { + log: [] + } + }, + mounted() { + this.loadLog({ reset: true }) + }, + methods: { + loadDetails(event) { + $(event.target).closest('tr').addClass('open') + }, + hideDetails(event) { + $(event.target).closest('tr').removeClass('open') + }, + loadLog(params) { + if (this.objectId && this.objectType) { + this.listSearch('log', `/api/v4/eventlog/${this.objectType}/${this.objectId}`, params) + } + } + } + } +</script> diff --git a/src/resources/vue/Widgets/ListTools.vue b/src/resources/vue/Widgets/ListTools.vue --- a/src/resources/vue/Widgets/ListTools.vue +++ b/src/resources/vue/Widgets/ListTools.vue @@ -125,7 +125,6 @@ get.search = this.currentSearch } - if ('parent' in params) { get.parent = params.parent this.currentParent = params.parent diff --git a/src/resources/vue/Widgets/ModalDialog.vue b/src/resources/vue/Widgets/ModalDialog.vue --- a/src/resources/vue/Widgets/ModalDialog.vue +++ b/src/resources/vue/Widgets/ModalDialog.vue @@ -62,7 +62,7 @@ if (this.cancelFocus) { $(event.target).find('button.modal-cancel').focus() } else { - $(event.target).find('input,select').first().focus() + $(event.target).find('input,select,textarea').first().focus() } }) diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -235,6 +235,8 @@ Route::post('domains/{id}/suspend', [API\V4\Admin\DomainsController::class, 'suspend']); Route::post('domains/{id}/unsuspend', [API\V4\Admin\DomainsController::class, 'unsuspend']); + Route::get('eventlog/{type}/{id}', [API\V4\Admin\EventLogController::class, 'index']); + Route::apiResource('groups', API\V4\Admin\GroupsController::class); Route::post('groups/{id}/suspend', [API\V4\Admin\GroupsController::class, 'suspend']); Route::post('groups/{id}/unsuspend', [API\V4\Admin\GroupsController::class, 'unsuspend']); @@ -275,6 +277,8 @@ Route::post('domains/{id}/suspend', [API\V4\Reseller\DomainsController::class, 'suspend']); Route::post('domains/{id}/unsuspend', [API\V4\Reseller\DomainsController::class, 'unsuspend']); + Route::get('eventlog/{type}/{id}', [API\V4\Reseller\EventLogController::class, 'index']); + Route::apiResource('groups', API\V4\Reseller\GroupsController::class); Route::post('groups/{id}/suspend', [API\V4\Reseller\GroupsController::class, 'suspend']); Route::post('groups/{id}/unsuspend', [API\V4\Reseller\GroupsController::class, 'unsuspend']); diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php --- a/src/tests/Browser/Admin/UserTest.php +++ b/src/tests/Browser/Admin/UserTest.php @@ -5,6 +5,7 @@ use App\Auth\SecondFactor; use App\Discount; use App\Entitlement; +use App\EventLog; use App\Sku; use App\User; use Tests\Browser; @@ -39,6 +40,8 @@ $wallet->save(); Entitlement::where('cost', '>=', 5000)->delete(); + Eventlog::query()->delete(); + $this->deleteTestGroup('group-test@kolab.org'); $this->deleteTestUser('userstest1@kolabnow.com'); } @@ -61,6 +64,8 @@ $wallet->save(); Entitlement::where('cost', '>=', 5000)->delete(); + Eventlog::query()->delete(); + $this->deleteTestGroup('group-test@kolab.org'); $this->deleteTestUser('userstest1@kolabnow.com'); @@ -89,6 +94,15 @@ $jack->setSetting('limit_geo', null); $jack->setSetting('guam_enabled', null); + $event1 = EventLog::createFor($jack, EventLog::TYPE_SUSPENDED, 'Event 1'); + $event2 = EventLog::createFor($jack, EventLog::TYPE_UNSUSPENDED, 'Event 2', ['test' => 'test-data']); + $event2->refresh(); + $event1->created_at = (clone $event2->created_at)->subDay(); + $event1->user_email = 'jeroen@jeroen.jeroen'; + $event1->save(); + $event2->user_email = 'test@test.com'; + $event2->save(); + $page = new UserPage($jack->id); $browser->visit(new Home()) @@ -120,7 +134,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 9); + ->assertElementsCount('@nav a', 10); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); @@ -202,6 +216,35 @@ ->assertSeeIn('.row:nth-child(3) #limit_geo', 'No restrictions') ->assertMissing('#limit_geo + button'); }); + + // Assert History tab + $browser->assertSeeIn('@nav #tab-history', 'History') + ->click('@nav #tab-history') + ->whenAvailable('@user-history table', function (Browser $browser) use ($event1, $event2) { + $browser->waitFor('tbody tr')->assertElementsCount('tbody tr', 2) + // row 1 + ->assertSeeIn('tr:nth-child(1) td:nth-child(1)', $event2->created_at->toDateTimeString()) + ->assertSeeIn('tr:nth-child(1) td:nth-child(2)', 'Unsuspended') + ->assertSeeIn('tr:nth-child(1) td:nth-child(3)', $event2->comment) + ->assertMissing('tr:nth-child(1) td:nth-child(3) div') + ->assertMissing('tr:nth-child(1) td:nth-child(3) pre') + ->assertMissing('tr:nth-child(1) td:nth-child(3) .btn-less') + ->click('tr:nth-child(1) td:nth-child(3) .btn-more') + ->assertSeeIn('tr:nth-child(1) td:nth-child(3) div.email', $event2->user_email) + ->assertSeeIn('tr:nth-child(1) td:nth-child(3) pre', 'test-data') + ->assertMissing('tr:nth-child(1) td:nth-child(3) .btn-more') + ->click('tr:nth-child(1) td:nth-child(3) .btn-less') + ->assertMissing('tr:nth-child(1) td:nth-child(3) div') + ->assertMissing('tr:nth-child(1) td:nth-child(3) pre') + ->assertMissing('tr:nth-child(1) td:nth-child(3) .btn-less') + // row 2 + ->assertSeeIn('tr:nth-child(2) td:nth-child(1)', $event1->created_at->toDateTimeString()) + ->assertSeeIn('tr:nth-child(2) td:nth-child(2)', 'Suspended') + ->assertSeeIn('tr:nth-child(2) td:nth-child(3)', $event1->comment) + ->click('tr:nth-child(2) td:nth-child(3) .btn-more') + ->assertSeeIn('tr:nth-child(2) td:nth-child(3) div.email', $event1->user_email) + ->assertMissing('tr:nth-child(2) td:nth-child(3) pre'); + }); }); } @@ -257,7 +300,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 9); + ->assertElementsCount('@nav a', 10); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); @@ -348,6 +391,14 @@ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'folder-contact@kolab.org') ->assertMissing('table tfoot'); }); + + // Assert History tab + $browser->assertSeeIn('@nav #tab-history', 'History') + ->click('@nav #tab-history') + ->whenAvailable('@user-history table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 0) + ->assertSeeIn('tfoot tr td', "There's no events in the log"); + }); }); // Now we go to Ned's info page, he's a controller on John's wallet @@ -389,7 +440,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 9); + ->assertElementsCount('@nav a', 10); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); @@ -543,14 +594,34 @@ ->assertVisible('@user-info #button-suspend') ->assertMissing('@user-info #button-unsuspend') ->click('@user-info #button-suspend') + ->with(new Dialog('#suspend-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Suspend') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->type('textarea', 'test suspend') + ->click('@button-action'); + }) ->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.') ->assertSeeIn('@user-info #status span.text-warning', 'Suspended') - ->assertMissing('@user-info #button-suspend') - ->click('@user-info #button-unsuspend') + ->assertMissing('@user-info #button-suspend'); + + $event = EventLog::where('type', EventLog::TYPE_SUSPENDED)->first(); + $this->assertSame('test suspend', $event->comment); + + $browser->click('@user-info #button-unsuspend') + ->with(new Dialog('#suspend-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Unsuspend') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->click('@button-action'); + }) ->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.') ->assertSeeIn('@user-info #status span.text-success', 'Active') ->assertVisible('@user-info #button-suspend') ->assertMissing('@user-info #button-unsuspend'); + + $event = EventLog::where('type', EventLog::TYPE_UNSUSPENDED)->first(); + $this->assertSame(null, $event->comment); }); } diff --git a/src/tests/Browser/Pages/Admin/User.php b/src/tests/Browser/Pages/Admin/User.php --- a/src/tests/Browser/Pages/Admin/User.php +++ b/src/tests/Browser/Pages/Admin/User.php @@ -58,6 +58,7 @@ '@user-subscriptions' => '#subscriptions', '@user-distlists' => '#distlists', '@user-domains' => '#domains', + '@user-history' => '#history', '@user-resources' => '#resources', '@user-folders' => '#folders', '@user-users' => '#users', diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php --- a/src/tests/Browser/Reseller/UserTest.php +++ b/src/tests/Browser/Reseller/UserTest.php @@ -4,6 +4,7 @@ use App\Auth\SecondFactor; use App\Discount; +use App\EventLog; use App\Sku; use App\User; use Tests\Browser; @@ -114,7 +115,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 9); + ->assertElementsCount('@nav a', 10); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); @@ -250,7 +251,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 9); + ->assertElementsCount('@nav a', 10); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); @@ -341,6 +342,9 @@ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'folder-contact@kolab.org') ->assertMissing('table tfoot'); }); + + // Assert History tab + $browser->assertSeeIn('@nav #tab-history', 'History'); }); // Now we go to Ned's info page, he's a controller on John's wallet @@ -362,7 +366,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 9); + ->assertElementsCount('@nav a', 10); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); @@ -447,6 +451,9 @@ ->assertSeeIn('.row:nth-child(3) label', 'Geo-lockin') ->assertSeeIn('.row:nth-child(3) #limit_geo', 'No restrictions'); }); + + // Assert History tab + $browser->assertSeeIn('@nav #tab-history', 'History'); }); } @@ -510,6 +517,8 @@ */ public function testSuspendAndUnsuspend(): void { + EventLog::query()->delete(); + $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); @@ -517,14 +526,34 @@ ->assertVisible('@user-info #button-suspend') ->assertMissing('@user-info #button-unsuspend') ->click('@user-info #button-suspend') + ->with(new Dialog('#suspend-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Suspend') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->type('textarea', 'test suspend') + ->click('@button-action'); + }) ->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.') ->assertSeeIn('@user-info #status span.text-warning', 'Suspended') - ->assertMissing('@user-info #button-suspend') - ->click('@user-info #button-unsuspend') + ->assertMissing('@user-info #button-suspend'); + + $event = EventLog::where('type', EventLog::TYPE_SUSPENDED)->first(); + $this->assertSame('test suspend', $event->comment); + + $browser->click('@user-info #button-unsuspend') + ->with(new Dialog('#suspend-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Unsuspend') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->click('@button-action'); + }) ->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.') ->assertSeeIn('@user-info #status span.text-success', 'Active') ->assertVisible('@user-info #button-suspend') ->assertMissing('@user-info #button-unsuspend'); + + $event = EventLog::where('type', EventLog::TYPE_UNSUSPENDED)->first(); + $this->assertSame(null, $event->comment); }); } diff --git a/src/tests/Feature/Controller/Admin/EventLogTest.php b/src/tests/Feature/Controller/Admin/EventLogTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/Admin/EventLogTest.php @@ -0,0 +1,97 @@ +<?php + +namespace Tests\Feature\Controller\Admin; + +use App\EventLog; +use Tests\TestCase; + +class EventLogTest extends TestCase +{ + /** + * {@inheritDoc} + */ + public function setUp(): void + { + parent::setUp(); + self::useAdminUrl(); + + EventLog::query()->delete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + EventLog::query()->delete(); + + parent::tearDown(); + } + + /** + * Test listing events for a user (GET /api/v4/eventlog/user/{user}) + */ + public function testUserLog(): void + { + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + + // Non-admin user + $response = $this->actingAs($user)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(403); + + // Admin user (unknown object type) + $response = $this->actingAs($admin)->get("api/v4/eventlog/eeee/{$user->id}"); + $response->assertStatus(404); + + // Admin user (empty list) + $response = $this->actingAs($admin)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + $this->assertFalse($json['hasMore']); + + // Non-empty list + $event1 = EventLog::createFor($user, EventLog::TYPE_SUSPENDED, "Event 1", ['test' => 'test1']); + $event1->created_at = now(); + $event1->save(); + $event2 = EventLog::createFor($user, EventLog::TYPE_UNSUSPENDED, "Event 2", ['test' => 'test2']); + $event2->created_at = (clone now())->subDay(); + $event2->save(); + + $response = $this->actingAs($admin)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(2, $json['count']); + $this->assertCount(2, $json['list']); + $this->assertFalse($json['hasMore']); + $this->assertSame($event1->id, $json['list'][0]['id']); + $this->assertSame($event1->comment, $json['list'][0]['comment']); + $this->assertSame($event1->data, $json['list'][0]['data']); + $this->assertSame($admin->email, $json['list'][0]['user']); + $this->assertSame('Suspended', $json['list'][0]['event']); + $this->assertSame($event2->id, $json['list'][1]['id']); + $this->assertSame($event2->comment, $json['list'][1]['comment']); + $this->assertSame($event2->data, $json['list'][1]['data']); + $this->assertSame($admin->email, $json['list'][1]['user']); + $this->assertSame('Unsuspended', $json['list'][1]['event']); + + // A user in another tenant + $user = $this->getTestUser('user@sample-tenant.dev-local'); + $event3 = EventLog::createFor($user, EventLog::TYPE_SUSPENDED, "Event 3"); + + $response = $this->actingAs($admin)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($event3->id, $json['list'][0]['id']); + } +} diff --git a/src/tests/Feature/Controller/Reseller/EventLogTest.php b/src/tests/Feature/Controller/Reseller/EventLogTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/EventLogTest.php @@ -0,0 +1,106 @@ +<?php + +namespace Tests\Feature\Controller\Reseller; + +use App\EventLog; +use Tests\TestCase; + +class EventLogTest extends TestCase +{ + /** + * {@inheritDoc} + */ + public function setUp(): void + { + parent::setUp(); + self::useResellerUrl(); + + EventLog::query()->delete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + EventLog::query()->delete(); + + parent::tearDown(); + } + + /** + * Test listing events for a user (GET /api/v4/eventlog/user/{user}) + */ + public function testUserLog(): void + { + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); + $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); + + // Non-admin user + $response = $this->actingAs($user)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(403); + + // Admin user + $response = $this->actingAs($admin)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(403); + + // Reseller user (unknown object type) + $response = $this->actingAs($reseller1)->get("api/v4/eventlog/eeee/{$user->id}"); + $response->assertStatus(404); + + // Reseller user (empty list) + $response = $this->actingAs($reseller1)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + $this->assertFalse($json['hasMore']); + + // Non-empty list + $event1 = EventLog::createFor($user, EventLog::TYPE_SUSPENDED, "Event 1", ['test' => 'test1']); + $event1->created_at = now(); + $event1->save(); + $event2 = EventLog::createFor($user, EventLog::TYPE_UNSUSPENDED, "Event 2", ['test' => 'test2']); + $event2->created_at = (clone now())->subDay(); + $event2->save(); + + $response = $this->actingAs($reseller1)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(2, $json['count']); + $this->assertCount(2, $json['list']); + $this->assertFalse($json['hasMore']); + $this->assertSame($event1->id, $json['list'][0]['id']); + $this->assertSame($event1->comment, $json['list'][0]['comment']); + $this->assertSame($event1->data, $json['list'][0]['data']); + $this->assertSame($reseller1->email, $json['list'][0]['user']); + $this->assertSame('Suspended', $json['list'][0]['event']); + $this->assertSame($event2->id, $json['list'][1]['id']); + $this->assertSame($event2->comment, $json['list'][1]['comment']); + $this->assertSame($event2->data, $json['list'][1]['data']); + $this->assertSame($reseller1->email, $json['list'][1]['user']); + $this->assertSame('Unsuspended', $json['list'][1]['event']); + + // A user in another tenant + $user = $this->getTestUser('user@sample-tenant.dev-local'); + $event3 = EventLog::createFor($user, EventLog::TYPE_SUSPENDED, "Event 3"); + + $response = $this->actingAs($reseller1)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(404); + + $response = $this->actingAs($reseller2)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($event3->id, $json['list'][0]['id']); + } +}