Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117951416
D4400.1775490476.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
46 KB
Referenced Files
None
Subscribers
None
D4400.1775490476.diff
View Options
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']);
+ }
+}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Mon, Apr 6, 3:47 PM (18 h, 5 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18838155
Default Alt Text
D4400.1775490476.diff (46 KB)
Attached To
Mode
D4400: Event Log (History)
Attached
Detach File
Event Timeline