1
0
mirror of https://github.com/Kovah/LinkAce.git synced 2025-04-21 23:42:10 +02:00

WIP: Add blocking and deleting of users

This commit is contained in:
Kovah 2022-07-01 00:18:02 +02:00
parent 3668524b0c
commit 78ee6eabda
No known key found for this signature in database
GPG Key ID: AAAA031BA9830D7B
13 changed files with 281 additions and 12 deletions

View File

@ -4,6 +4,9 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Event;
use OwenIt\Auditing\Events\AuditCustom;
class UserManagementController extends Controller
{
@ -23,13 +26,71 @@ class UserManagementController extends Controller
//
}
public function deleteUser()
public function blockUser(User $user): RedirectResponse
{
//
$this->checkIfActionAllowed($user);
$user->blocked_at = now();
$user->save();
$this->addBlockingAuditEvent($user, User::AUDIT_BLOCK_EVENT, null, $user->blocked_at);
flash()->warning(trans('user.block_successful', ['username' => $user->name]));
return redirect()->back();
}
public function restoreUser()
public function unblockUser(User $user): RedirectResponse
{
//
$this->checkIfActionAllowed($user);
$blockedAt = $user->blocked_at;
$user->blocked_at = null;
$user->save();
$this->addBlockingAuditEvent($user, User::AUDIT_UNBLOCK_EVENT, $blockedAt, null);
flash()->warning(trans('user.unblock_successful', ['username' => $user->name]));
return redirect()->back();
}
public function deleteUser(User $user): RedirectResponse
{
$this->checkIfActionAllowed($user);
$user->delete();
flash()->warning(trans('user.delete_successful', ['username' => $user->name]));
return redirect()->back();
}
public function restoreUser(User $user): RedirectResponse
{
$this->checkIfActionAllowed($user);
$user->restore();
flash()->warning(trans('user.restore_successful', ['username' => $user->name]));
return redirect()->back();
}
protected function checkIfActionAllowed(User $user): void
{
if ($user->id === auth()->id()) {
abort(403, trans('user.action_not_allowed_on_user'));
}
}
protected function addBlockingAuditEvent(User $user, string $event, $oldValue, $newValue): void
{
$user->auditEvent = $event;
$user->isCustomEvent = true;
$user->auditCustomOld = [
'blocked_at' => $oldValue,
];
$user->auditCustomNew = [
'blocked_at' => $newValue,
];
Event::dispatch(AuditCustom::class, [$user]);
}
}

View File

@ -54,7 +54,7 @@ class Kernel extends HttpKernel
* @var array
*/
protected $routeMiddleware = [
'auth' => \Illuminate\Auth\Middleware\Authenticate::class,
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Auth\Middleware\Authenticate as IlluminateAuthenticate;
class Authenticate extends IlluminateAuthenticate
{
public function handle($request, Closure $next, ...$guards)
{
$this->authenticate($request, $guards);
if ($request->user()->isBlocked()) {
abort(403, trans('user.block_warning'));
}
return $next($request);
}
}

View File

@ -2,13 +2,12 @@
namespace App\Models;
use App\Audits\Modifiers\BlockedAtModifier;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Laravel\Fortify\TwoFactorAuthenticatable;
use OwenIt\Auditing\Auditable as AuditableTrait;
use OwenIt\Auditing\Contracts\Auditable;
@ -43,6 +42,7 @@ class User extends Authenticatable implements Auditable
'email',
'password',
'api_token',
'blocked_at',
];
protected $hidden = [
@ -51,6 +51,10 @@ class User extends Authenticatable implements Auditable
'api_token',
];
protected $casts = [
'blocked_at' => 'datetime',
];
public string $langBase = 'user';
/*
@ -58,6 +62,9 @@ class User extends Authenticatable implements Auditable
* AUDIT SETTINGS
*/
public const AUDIT_BLOCK_EVENT = 'blocked';
public const AUDIT_UNBLOCK_EVENT = 'unblocked';
protected array $auditEvents = [
'created',
'updated',
@ -71,4 +78,14 @@ class User extends Authenticatable implements Auditable
];
public array $auditModifiers = [];
/*
* ========================================================================
* METHODS
*/
public function isBlocked(): bool
{
return $this->blocked_at !== null;
}
}

View File

@ -27,6 +27,10 @@ class UserEntry extends Component
$this->changes[] = trans('user.history_restored', ['name' => $this->entry->getModified()['name']['new']]);
} elseif ($this->entry->event === 'created') {
$this->changes[] = trans('user.history_created', ['name' => $this->entry->getModified()['name']['new']]);
} elseif ($this->entry->event === 'blocked') {
$this->changes[] = trans('user.history_blocked', ['name' => $this->entry->auditable->name]);
} elseif ($this->entry->event === 'unblocked') {
$this->changes[] = trans('user.history_unblocked', ['name' => $this->entry->auditable->name]);
} else {
foreach ($this->entry->getModified() as $field => $change) {
$this->processChange($field, $change);

View File

@ -113,6 +113,7 @@ class MigrateUserData extends Migration
\Illuminate\Support\Facades\Artisan::call('db:seed', ['--class' => 'RolesAndPermissionsSeeder']);
Schema::table('users', function (Blueprint $table) {
$table->timestamp('blocked_at')->nullable()->after('api_token');
$table->softDeletes();
});

View File

@ -21,6 +21,9 @@ return [
'added_at' => 'Added at',
'updated_at' => 'Updated at',
'last_update' => 'Last Update',
'blocked' => 'Blocked',
'blocked_at' => 'Blocked at',
'deleted' => 'Deleted',
'deleted_at' => 'Deleted at',
'add' => 'Add',
@ -28,6 +31,10 @@ return [
'edit' => 'Edit',
'update' => 'Update',
'delete' => 'Delete',
'restore' => 'Restore',
'block' => 'Block',
'unblock' => 'Unblock',
'unblocked' => 'Unblocked',
'menu' => 'Menu',
'entries' => 'Entries',

View File

@ -6,10 +6,29 @@ return [
'username' => 'Username',
'name' => 'Username',
'email' => 'Email',
'blocked_at' => 'Blocked at',
'block' => 'Block User',
'block_confirmation' => 'Do you really want to block this User?',
'block_warning' => 'Your user account is currently blocked. Please contact your administrator for help.',
'block_successful' => 'The user :username was blocked successfully.',
'unblock' => 'Unblock User',
'unblock_confirmation' => 'Do you really want to unblock this User?',
'unblock_successful' => 'The user :username was unblocked successfully.',
'delete' => 'Delete User',
'delete_confirmation' => 'Do you really want to delete this User?',
'delete_successful' => 'The user :username was deleted successfully.',
'restore' => 'Restore User',
'restore_confirmation' => 'Do you really want to restore this User?',
'restore_successful' => 'The user :username was restored successfully.',
'action_not_allowed_on_user' => 'This action cannot be performed on the selected user.',
'history_deleted' => 'User <code>:name</code> was deleted',
'history_restored' => 'User <code>:name</code> was restored',
'history_created' => 'User <code>:name</code> was created',
'history_blocked' => 'User <code>:name</code> was blocked',
'history_unblocked' => 'User <code>:name</code> was unblocked',
'hello' => 'Hello :user!',
'for_user' => 'for User',

View File

@ -7,8 +7,12 @@ export default class LoadingButton {
this.$btn.addEventListener('click', this.onClick.bind(this));
}
onClick () {
onClick (event) {
if (this.$form.checkValidity()) {
if (typeof this.$form.dataset.confirmation !== 'undefined' && confirm(this.$form.dataset.confirmation) === false) {
event.preventDefault();
return;
}
this.$btn.disabled = true;
this.$form.submit();
}

View File

@ -6,12 +6,68 @@
<div class="card-header">
@lang('admin.user_management.title')
</div>
<div class="card-body">
<div class="card-body p-0">
<div class="d-grid row-cols-1 gap-2">
<div class="list-group list-group-flush">
@foreach($users as $user)
<div @class(['text-danger' => $user->trashed()])>
{{ $user->name }} <span class="ms-2 text-muted">{{ $user->email }}</span>
<div class="list-group-item d-md-flex justify-content-between">
<div>
{{ $user->name }} <span class="ms-2 text-muted">{{ $user->email }}</span>
@if($user->isBlocked())
<span class="badge bg-warning">@lang('linkace.blocked')</span>
@endif
@if($user->trashed())
<span class="badge bg-danger">@lang('linkace.deleted')</span>
@endif
</div>
<div @class(['d-none' => $user->id === auth()->id()])>
@if($user->isBlocked())
<button type="submit" form="unblock-user-{{ $user->id }}"
class="btn btn-sm btn-outline-warning">
@lang('linkace.unblock')
</button>
@else
<button type="submit" form="block-user-{{ $user->id }}"
class="btn btn-sm btn-outline-warning">
@lang('linkace.block')
</button>
@endif
@if($user->trashed())
<button type="submit" form="restore-user-{{ $user->id }}"
class="btn btn-sm btn-outline-danger">
@lang('linkace.restore')
</button>
@else
<button type="submit" form="delete-user-{{ $user->id }}"
class="btn btn-sm btn-outline-danger">
@lang('linkace.delete')
</button>
@endif
<form action="{{ route('user-management-block', ['user' => $user]) }}"
id="block-user-{{ $user->id }}"
method="post" class="d-none" data-confirmation="@lang('user.block_confirmation')">
@csrf
@method('PATCH')
</form>
<form action="{{ route('user-management-unblock', ['user' => $user]) }}"
id="unblock-user-{{ $user->id }}"
method="post" class="d-none" data-confirmation="@lang('user.unblock_confirmation')">
@csrf
@method('PATCH')
</form>
<form action="{{ route('user-management-delete', ['user' => $user]) }}"
id="delete-user-{{ $user->id }}"
method="post" class="d-none" data-confirmation="@lang('user.delete_confirmation')">
@csrf
@method('DELETE')
</form>
<form action="{{ route('user-management-restore', ['user' => $user]) }}"
id="restore-user-{{ $user->id }}"
method="post" class="d-none" data-confirmation="@lang('user.restore_confirmation')">
@csrf
@method('PATCH')
</form>
</div>
</div>
@endforeach
</div>

View File

@ -59,6 +59,9 @@ Route::prefix('bookmarklet')->group(function () {
Route::get('cron/{token}', CronController::class)->name('cron');
Route::post('system/users/accept-invite', [UserManagementController::class, 'acceptInvitation'])
->name('user-management-accept-invite');
Route::group(['middleware' => 'auth:api'], function () {
Route::get('links/feed', [FeedController::class, 'links'])->name('links.feed');
Route::get('lists/feed', [FeedController::class, 'lists'])->name('lists.feed');
@ -139,6 +142,15 @@ Route::group(['middleware' => ['auth', 'role:admin']], function () {
->name('generate-cron-token');
Route::get('system/users', [UserManagementController::class, 'index'])->name('user-management');
Route::post('system/users/invite', [UserManagementController::class, 'index'])->name('user-management-invite');
Route::patch('system/users/{user}/block', [UserManagementController::class, 'blockUser'])
->name('user-management-block')->withTrashed();
Route::patch('system/users/{user}/unblock', [UserManagementController::class, 'unblockUser'])
->name('user-management-unblock')->withTrashed();
Route::delete('system/users/{user}/delete', [UserManagementController::class, 'deleteUser'])
->name('user-management-delete')->withTrashed();
Route::patch('system/users/{user}/restore', [UserManagementController::class, 'restoreUser'])
->name('user-management-restore')->withTrashed();
Route::get('system/logs', [LogViewerController::class, 'index'])->name('system-logs');
Route::get('system/audit', AuditController::class)->name('system-audit');

View File

@ -2,6 +2,7 @@
namespace Tests\Components\History;
use App\Enums\Role;
use App\Models\User;
use App\View\Components\History\UserEntry;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -39,5 +40,35 @@ class UserEntryTest extends TestCase
$output = (new UserEntry($historyEntries[1]))->render();
$this->assertStringContainsString('User <code>TestUser</code> was deleted', $output);
$user->restore();
$historyEntries = $user->audits()->latest()->get();
$output = (new UserEntry($historyEntries[2]))->render();
$this->assertStringContainsString('User <code>TestUser</code> was restored', $output);
}
public function testModelBlocking(): void
{
$admin = User::factory()->create();
$admin->assignRole(Role::ADMIN);
$this->actingAs($admin);
$user = User::factory()->create(['name' => 'TestUser']);
$this->patch('system/users/2/block');
$historyEntries = $user->audits()->latest()->get();
$output = (new UserEntry($historyEntries[1]))->render();
$this->assertStringContainsString('User <code>TestUser</code> was blocked', $output);
$this->patch('system/users/2/unblock');
$historyEntries = $user->audits()->latest()->get();
$output = (new UserEntry($historyEntries[2]))->render();
$this->assertStringContainsString('User <code>TestUser</code> was unblocked', $output);
}
}

View File

@ -45,4 +45,41 @@ class UserManagementControllerTest extends TestCase
$response->assertOk()->assertSee('User Management')
->assertSee($this->user->name);
}
public function testUserBlocking(): void
{
$otherUser = User::factory()->create();
$response = $this->patch('system/users/2/block');
$response->assertRedirect();
$this->assertTrue($otherUser->refresh()->isBlocked());
$this->actingAs($otherUser);
$response = $this->get('dashboard');
$response->assertForbidden();
$this->actingAs($this->user);
$response = $this->patch('system/users/2/unblock');
$response->assertRedirect();
$this->assertFalse($otherUser->refresh()->isBlocked());
}
public function testUserDeletion(): void
{
$otherUser = User::factory()->create();
$response = $this->delete('system/users/2/delete');
$response->assertRedirect();
$this->assertTrue($otherUser->refresh()->trashed());
$response = $this->patch('system/users/2/restore');
$response->assertRedirect();
$this->assertFalse($otherUser->refresh()->trashed());
}
}