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']);
+    }
+}