Merge pull request 'Add profile avatar uploads' (#104) from codex/profile-avatars into main
Reviewed-on: OpenSource/KanbanGit#104
This commit was merged in pull request #104.
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Actions\Users\UpdateUserAvatar;
|
||||||
use App\Concerns\PasswordValidationRules;
|
use App\Concerns\PasswordValidationRules;
|
||||||
use App\Concerns\ProfileValidationRules;
|
use App\Concerns\ProfileValidationRules;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -12,6 +13,10 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
{
|
{
|
||||||
use PasswordValidationRules, ProfileValidationRules;
|
use PasswordValidationRules, ProfileValidationRules;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly UpdateUserAvatar $avatars,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and create a newly registered user.
|
* Validate and create a newly registered user.
|
||||||
*
|
*
|
||||||
@@ -24,10 +29,12 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
'password' => $this->passwordRules(),
|
'password' => $this->passwordRules(),
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
return User::create([
|
$user = User::create([
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
'email' => $input['email'],
|
'email' => $input['email'],
|
||||||
'password' => $input['password'],
|
'password' => $input['password'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
return $this->avatars->generate($user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Users;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class UpdateUserAvatar
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const COLORS = [
|
||||||
|
'#F97316',
|
||||||
|
'#22C55E',
|
||||||
|
'#06B6D4',
|
||||||
|
'#EC4899',
|
||||||
|
'#8B5CF6',
|
||||||
|
'#EAB308',
|
||||||
|
'#EF4444',
|
||||||
|
'#14B8A6',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function generate(User $user): User
|
||||||
|
{
|
||||||
|
$oldPath = $user->avatar_path;
|
||||||
|
$color = $this->colorFor($user);
|
||||||
|
$path = sprintf('avatars/generated/%d-%s.svg', $user->id, (string) Str::uuid());
|
||||||
|
|
||||||
|
$stored = Storage::disk('public')->put(
|
||||||
|
$path,
|
||||||
|
$this->svg($this->initials($user->name), $color),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $stored) {
|
||||||
|
throw new RuntimeException('Generated avatar could not be stored.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'avatar_path' => $path,
|
||||||
|
'avatar_color' => $color,
|
||||||
|
'avatar_is_generated' => true,
|
||||||
|
])->saveQuietly();
|
||||||
|
|
||||||
|
$this->deletePath($oldPath);
|
||||||
|
|
||||||
|
return $user->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upload(User $user, UploadedFile $avatar): User
|
||||||
|
{
|
||||||
|
$oldPath = $user->avatar_path;
|
||||||
|
$path = $avatar->store('avatars/uploads', 'public');
|
||||||
|
|
||||||
|
if (! is_string($path)) {
|
||||||
|
throw new RuntimeException('Uploaded avatar could not be stored.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'avatar_path' => $path,
|
||||||
|
'avatar_color' => $this->colorFor($user),
|
||||||
|
'avatar_is_generated' => false,
|
||||||
|
])->saveQuietly();
|
||||||
|
|
||||||
|
$this->deletePath($oldPath);
|
||||||
|
|
||||||
|
return $user->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user): void
|
||||||
|
{
|
||||||
|
$this->deletePath($user->avatar_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function colorFor(User $user): string
|
||||||
|
{
|
||||||
|
if (is_string($user->avatar_color) && in_array($user->avatar_color, self::COLORS, true)) {
|
||||||
|
return $user->avatar_color;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::COLORS[random_int(0, count(self::COLORS) - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deletePath(?string $path): void
|
||||||
|
{
|
||||||
|
if (blank($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Storage::disk('public')->delete($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function initials(string $name): string
|
||||||
|
{
|
||||||
|
$parts = preg_split('/\s+/u', trim($name)) ?: [];
|
||||||
|
|
||||||
|
if ($parts === []) {
|
||||||
|
return '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
$first = mb_substr($parts[0], 0, 1);
|
||||||
|
$last = count($parts) > 1 ? mb_substr($parts[count($parts) - 1], 0, 1) : '';
|
||||||
|
|
||||||
|
return mb_strtoupper($first.$last);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function svg(string $initials, string $color): string
|
||||||
|
{
|
||||||
|
$escapedInitials = htmlspecialchars($initials, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||||
|
|
||||||
|
return <<<SVG
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192" role="img" aria-label="Profile avatar">
|
||||||
|
<rect width="192" height="192" rx="42" fill="{$color}"/>
|
||||||
|
<text x="50%" y="52%" dominant-baseline="middle" text-anchor="middle" fill="#ffffff" font-family="Inter, ui-sans-serif, system-ui, sans-serif" font-size="72" font-weight="700">{$escapedInitials}</text>
|
||||||
|
</svg>
|
||||||
|
SVG;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Actions\Users\UpdateUserAvatar;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Console\Attributes\Description;
|
use Illuminate\Console\Attributes\Description;
|
||||||
@@ -13,10 +14,16 @@ use Illuminate\Support\Facades\Hash;
|
|||||||
#[Description('Command description')]
|
#[Description('Command description')]
|
||||||
class CreateAdminUser extends Command
|
class CreateAdminUser extends Command
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UpdateUserAvatar $avatars,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the console command.
|
* Execute the console command.
|
||||||
*/
|
*/
|
||||||
public function handle()
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
'name' => $this->argument('name'),
|
'name' => $this->argument('name'),
|
||||||
@@ -24,6 +31,8 @@ class CreateAdminUser extends Command
|
|||||||
'password' => Hash::make($this->argument('password')),
|
'password' => Hash::make($this->argument('password')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->avatars->generate($user);
|
||||||
|
|
||||||
$user->permissionAssignments()->create([
|
$user->permissionAssignments()->create([
|
||||||
'permission' => Permission::GlobalAdmin,
|
'permission' => Permission::GlobalAdmin,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
|
use App\Actions\Users\UpdateUserAvatar;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\StoreManagedUserRequest;
|
use App\Http\Requests\Admin\StoreManagedUserRequest;
|
||||||
use App\Http\Requests\Admin\SyncUserGlobalPermissionsRequest;
|
use App\Http\Requests\Admin\SyncUserGlobalPermissionsRequest;
|
||||||
@@ -19,6 +20,7 @@ class UserManagementController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PermissionService $permissionService,
|
private readonly PermissionService $permissionService,
|
||||||
|
private readonly UpdateUserAvatar $avatars,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(): Response
|
public function index(): Response
|
||||||
@@ -46,13 +48,15 @@ class UserManagementController extends Controller
|
|||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
|
|
||||||
User::query()->create([
|
$user = User::query()->create([
|
||||||
'name' => Arr::get($validated, 'name'),
|
'name' => Arr::get($validated, 'name'),
|
||||||
'email' => Arr::get($validated, 'email'),
|
'email' => Arr::get($validated, 'email'),
|
||||||
'password' => Arr::get($validated, 'password'),
|
'password' => Arr::get($validated, 'password'),
|
||||||
'must_change_password_on_next_login' => (bool) Arr::get($validated, 'must_change_password_on_next_login', true),
|
'must_change_password_on_next_login' => (bool) Arr::get($validated, 'must_change_password_on_next_login', true),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->avatars->generate($user);
|
||||||
|
|
||||||
return to_route('admin.users.index')
|
return to_route('admin.users.index')
|
||||||
->with('status', 'User was created.');
|
->with('status', 'User was created.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,25 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Settings;
|
namespace App\Http\Controllers\Settings;
|
||||||
|
|
||||||
|
use App\Actions\Users\UpdateUserAvatar;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Settings\ProfileDeleteRequest;
|
use App\Http\Requests\Settings\ProfileDeleteRequest;
|
||||||
use App\Http\Requests\Settings\ProfileUpdateRequest;
|
use App\Http\Requests\Settings\ProfileUpdateRequest;
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class ProfileController extends Controller
|
class ProfileController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UpdateUserAvatar $avatars,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the user's profile settings page.
|
* Show the user's profile settings page.
|
||||||
*/
|
*/
|
||||||
@@ -33,9 +40,14 @@ class ProfileController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
|
$validated = $request->validated();
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$user->fill($request->validated());
|
$user->fill([
|
||||||
|
'name' => Arr::get($validated, 'name'),
|
||||||
|
'email' => Arr::get($validated, 'email'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$nameChanged = $user->isDirty('name');
|
||||||
|
|
||||||
if ($user->isDirty('email')) {
|
if ($user->isDirty('email')) {
|
||||||
$user->email_verified_at = null;
|
$user->email_verified_at = null;
|
||||||
@@ -43,6 +55,21 @@ class ProfileController extends Controller
|
|||||||
|
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
|
$avatar = Arr::get($validated, 'avatar');
|
||||||
|
|
||||||
|
if ($avatar instanceof UploadedFile) {
|
||||||
|
$this->avatars->upload($user, $avatar);
|
||||||
|
} elseif (! $user->hasUploadedAvatar() && ($nameChanged || blank($user->avatar_path))) {
|
||||||
|
$this->avatars->generate($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return to_route('profile.edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroyAvatar(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->avatars->generate($request->user());
|
||||||
|
|
||||||
return to_route('profile.edit');
|
return to_route('profile.edit');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class ProfileUpdateRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return $this->profileRules($this->user()->id);
|
return [
|
||||||
|
...$this->profileRules($this->user()->id),
|
||||||
|
'avatar' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ class UserResource extends JsonResource
|
|||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'email' => $this->email,
|
'email' => $this->email,
|
||||||
|
'avatar' => $this->avatar,
|
||||||
|
'avatar_color' => $this->avatar_color,
|
||||||
|
'avatar_is_generated' => (bool) $this->avatar_is_generated,
|
||||||
'email_verified_at' => $this->email_verified_at?->toISOString(),
|
'email_verified_at' => $this->email_verified_at?->toISOString(),
|
||||||
'must_change_password_on_next_login' => (bool) $this->must_change_password_on_next_login,
|
'must_change_password_on_next_login' => (bool) $this->must_change_password_on_next_login,
|
||||||
'created_at' => $this->created_at?->toISOString(),
|
'created_at' => $this->created_at?->toISOString(),
|
||||||
|
|||||||
+39
-2
@@ -4,29 +4,48 @@ namespace App\Models;
|
|||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
|
use App\Actions\Users\UpdateUserAvatar;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
|
||||||
#[Fillable(['name', 'email', 'password', 'must_change_password_on_next_login'])]
|
#[Fillable(['name', 'email', 'password', 'must_change_password_on_next_login', 'avatar_path', 'avatar_color', 'avatar_is_generated'])]
|
||||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token', 'avatar_path'])]
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, Notifiable, TwoFactorAuthenticatable;
|
use HasFactory, Notifiable, TwoFactorAuthenticatable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $appends = [
|
||||||
|
'avatar',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected $attributes = [
|
||||||
|
'avatar_is_generated' => false,
|
||||||
|
];
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
static::deleting(function (self $user): void {
|
static::deleting(function (self $user): void {
|
||||||
|
app(UpdateUserAvatar::class)->delete($user);
|
||||||
|
|
||||||
$user->permissionAssignments()->delete();
|
$user->permissionAssignments()->delete();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -39,6 +58,7 @@ class User extends Authenticatable
|
|||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'avatar_is_generated' => 'bool',
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'must_change_password_on_next_login' => 'bool',
|
'must_change_password_on_next_login' => 'bool',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
@@ -46,6 +66,23 @@ class User extends Authenticatable
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasUploadedAvatar(): bool
|
||||||
|
{
|
||||||
|
return filled($this->avatar_path) && ! $this->avatar_is_generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Attribute<?string, never>
|
||||||
|
*/
|
||||||
|
protected function avatar(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::get(
|
||||||
|
fn (): ?string => filled($this->avatar_path)
|
||||||
|
? Storage::disk('public')->url($this->avatar_path)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function permissionAssignments(): MorphMany
|
public function permissionAssignments(): MorphMany
|
||||||
{
|
{
|
||||||
return $this->morphMany(PermissionAssignment::class, 'assignee');
|
return $this->morphMany(PermissionAssignment::class, 'assignee');
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ class UserFactory extends Factory
|
|||||||
return [
|
return [
|
||||||
'name' => fake()->name(),
|
'name' => fake()->name(),
|
||||||
'email' => fake()->unique()->safeEmail(),
|
'email' => fake()->unique()->safeEmail(),
|
||||||
|
'avatar_path' => null,
|
||||||
|
'avatar_color' => null,
|
||||||
|
'avatar_is_generated' => false,
|
||||||
'email_verified_at' => now(),
|
'email_verified_at' => now(),
|
||||||
'must_change_password_on_next_login' => false,
|
'must_change_password_on_next_login' => false,
|
||||||
'password' => static::$password ??= Hash::make('password'),
|
'password' => static::$password ??= Hash::make('password'),
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->string('avatar_path')
|
||||||
|
->nullable()
|
||||||
|
->after('password');
|
||||||
|
$table->string('avatar_color', 7)
|
||||||
|
->nullable()
|
||||||
|
->after('avatar_path');
|
||||||
|
$table->boolean('avatar_is_generated')
|
||||||
|
->default(false)
|
||||||
|
->after('avatar_color');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn([
|
||||||
|
'avatar_path',
|
||||||
|
'avatar_color',
|
||||||
|
'avatar_is_generated',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Actions\Users\UpdateUserAvatar;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$avatars = app(UpdateUserAvatar::class);
|
||||||
|
|
||||||
|
User::query()
|
||||||
|
->whereNull('avatar_path')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(100, function (Collection $users) use ($avatars): void {
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$avatars->generate($user);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$avatars = app(UpdateUserAvatar::class);
|
||||||
|
|
||||||
|
User::query()
|
||||||
|
->where('avatar_is_generated', true)
|
||||||
|
->whereNotNull('avatar_path')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(100, function (Collection $users) use ($avatars): void {
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$avatars->delete($user);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
User::query()
|
||||||
|
->where('avatar_is_generated', true)
|
||||||
|
->update([
|
||||||
|
'avatar_path' => null,
|
||||||
|
'avatar_color' => null,
|
||||||
|
'avatar_is_generated' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Actions\Users\UpdateUserAvatar;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Models\PermissionAssignment;
|
use App\Models\PermissionAssignment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -9,6 +10,10 @@ use Illuminate\Database\Seeder;
|
|||||||
|
|
||||||
class UserSeeder extends Seeder
|
class UserSeeder extends Seeder
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UpdateUserAvatar $avatars,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the database seeds.
|
* Run the database seeds.
|
||||||
*/
|
*/
|
||||||
@@ -18,6 +23,7 @@ class UserSeeder extends Seeder
|
|||||||
'name' => 'Admin User',
|
'name' => 'Admin User',
|
||||||
'email' => 'admin@kanbangit.test',
|
'email' => 'admin@kanbangit.test',
|
||||||
]);
|
]);
|
||||||
|
$this->avatars->generate($admin);
|
||||||
|
|
||||||
PermissionAssignment::factory()
|
PermissionAssignment::factory()
|
||||||
->forAssignee($admin)
|
->forAssignee($admin)
|
||||||
@@ -28,6 +34,7 @@ class UserSeeder extends Seeder
|
|||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
'email' => 'test@example.com',
|
'email' => 'test@example.com',
|
||||||
]);
|
]);
|
||||||
|
$this->avatars->generate($testUser);
|
||||||
|
|
||||||
PermissionAssignment::factory()
|
PermissionAssignment::factory()
|
||||||
->forAssignee($testUser)
|
->forAssignee($testUser)
|
||||||
@@ -35,6 +42,8 @@ class UserSeeder extends Seeder
|
|||||||
->permission(Permission::ProjectCreate)
|
->permission(Permission::ProjectCreate)
|
||||||
->create();
|
->create();
|
||||||
|
|
||||||
User::factory(8)->create();
|
User::factory(8)
|
||||||
|
->create()
|
||||||
|
->each(fn (User $user) => $this->avatars->generate($user));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { computed } from 'vue';
|
|||||||
import AppLogo from '@/components/AppLogo.vue';
|
import AppLogo from '@/components/AppLogo.vue';
|
||||||
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||||
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -33,10 +32,10 @@ import {
|
|||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import UserMenuContent from '@/components/UserMenuContent.vue';
|
import UserMenuContent from '@/components/UserMenuContent.vue';
|
||||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
import { getInitials } from '@/composables/useInitials';
|
|
||||||
import { toUrl } from '@/lib/utils';
|
import { toUrl } from '@/lib/utils';
|
||||||
import { dashboard } from '@/routes/index';
|
import { dashboard } from '@/routes/index';
|
||||||
import type { BreadcrumbItem, NavItem } from '@/types';
|
import type { BreadcrumbItem, NavItem } from '@/types';
|
||||||
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
breadcrumbs?: BreadcrumbItem[];
|
breadcrumbs?: BreadcrumbItem[];
|
||||||
@@ -245,20 +244,7 @@ const rightNavItems: NavItem[] = [
|
|||||||
size="icon"
|
size="icon"
|
||||||
class="focus-within:ring-primary relative size-10 w-auto rounded-full p-1 focus-within:ring-2"
|
class="focus-within:ring-primary relative size-10 w-auto rounded-full p-1 focus-within:ring-2"
|
||||||
>
|
>
|
||||||
<Avatar
|
<UserAvatar rounded />
|
||||||
class="size-8 overflow-hidden rounded-full"
|
|
||||||
>
|
|
||||||
<AvatarImage
|
|
||||||
v-if="auth.user.avatar"
|
|
||||||
:src="auth.user.avatar"
|
|
||||||
:alt="auth.user.name"
|
|
||||||
/>
|
|
||||||
<AvatarFallback
|
|
||||||
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white"
|
|
||||||
>
|
|
||||||
{{ getInitials(auth.user?.name) }}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" class="w-56">
|
<DropdownMenuContent align="end" class="w-56">
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
||||||
import { useInitials } from '@/composables/useInitials';
|
import { useInitials } from '@/composables/useInitials';
|
||||||
import type { User } from '@/types';
|
import type { User } from '@/types';
|
||||||
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -22,12 +22,7 @@ const showAvatar = computed(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Avatar class="h-8 w-8 overflow-hidden rounded-lg">
|
<UserAvatar />
|
||||||
<AvatarImage v-if="showAvatar" :src="user.avatar!" :alt="user.name" />
|
|
||||||
<AvatarFallback class="rounded-lg text-black dark:text-white">
|
|
||||||
{{ getInitials(user.name) }}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span class="truncate font-medium">{{ user.name }}</span>
|
<span class="truncate font-medium">{{ user.name }}</span>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { getInitials } from "@/composables/useInitials";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@/components/ui/avatar/index";
|
||||||
|
import { usePage } from "@inertiajs/vue3";
|
||||||
|
import { computed, PropType } from "vue";
|
||||||
|
import { UserModel } from "@/types/models/user-model";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
user: {
|
||||||
|
type: Object as PropType<UserModel>,
|
||||||
|
},
|
||||||
|
rounded: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const page = usePage();
|
||||||
|
const auth = computed(() => page.props.auth);
|
||||||
|
|
||||||
|
const user = computed(() => props.user ?? auth.value.user)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Avatar class="size-8 overflow-hidden" :class="rounded ? 'rounded-full' : 'rounded-lg'">
|
||||||
|
<AvatarImage
|
||||||
|
v-if="user.avatar"
|
||||||
|
:src="user.avatar"
|
||||||
|
:alt="user.name"
|
||||||
|
/>
|
||||||
|
<AvatarFallback
|
||||||
|
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white"
|
||||||
|
>
|
||||||
|
{{ getInitials(auth.user?.name) }}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</template>
|
||||||
@@ -40,7 +40,7 @@ const form = useForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const submit = (): void => {
|
const submit = (): void => {
|
||||||
form.patch(save(props.user.id), {
|
form.patch(save.url(props.user.id), {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -126,7 +126,9 @@ const submit = (): void => {
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id="must_change_password_on_next_login"
|
id="must_change_password_on_next_login"
|
||||||
v-model="form.must_change_password_on_next_login"
|
v-model="
|
||||||
|
form.must_change_password_on_next_login
|
||||||
|
"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
|
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
|
||||||
/>
|
/>
|
||||||
@@ -134,13 +136,20 @@ const submit = (): void => {
|
|||||||
<span class="block font-medium">
|
<span class="block font-medium">
|
||||||
Must change password on next login
|
Must change password on next login
|
||||||
</span>
|
</span>
|
||||||
<span class="text-muted-foreground block text-sm">
|
<span
|
||||||
Keep the user locked to the security settings page until they save a new password.
|
class="text-muted-foreground block text-sm"
|
||||||
|
>
|
||||||
|
Keep the user locked to the security
|
||||||
|
settings page until they save a new
|
||||||
|
password.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<InputError
|
<InputError
|
||||||
:message="form.errors.must_change_password_on_next_login"
|
:message="
|
||||||
|
form.errors
|
||||||
|
.must_change_password_on_next_login
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||||
import { FolderPlus, KeyRound, Pencil, Shield, UserPlus, Users } from 'lucide-vue-next';
|
import {
|
||||||
|
FolderPlus,
|
||||||
|
KeyRound,
|
||||||
|
Pencil,
|
||||||
|
Shield,
|
||||||
|
UserPlus,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController';
|
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -19,6 +26,7 @@ import {
|
|||||||
index as adminUsers,
|
index as adminUsers,
|
||||||
} from '@/routes/admin/users';
|
} from '@/routes/admin/users';
|
||||||
import type { AdminUsersPageProps, BreadcrumbItem } from '@/types';
|
import type { AdminUsersPageProps, BreadcrumbItem } from '@/types';
|
||||||
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
|
|
||||||
const props = defineProps<AdminUsersPageProps>();
|
const props = defineProps<AdminUsersPageProps>();
|
||||||
|
|
||||||
@@ -111,8 +119,13 @@ const hasPermission = (actorId: number, permission: string) =>
|
|||||||
>
|
>
|
||||||
<td class="px-4 py-4">
|
<td class="px-4 py-4">
|
||||||
<div
|
<div
|
||||||
class="text-foreground font-medium"
|
class="text-foreground items-medium flex items-center"
|
||||||
>
|
>
|
||||||
|
<UserAvatar
|
||||||
|
:user="user"
|
||||||
|
class="mr-2 size-8"
|
||||||
|
rounded
|
||||||
|
/>
|
||||||
{{ user.name }}
|
{{ user.name }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -122,7 +135,9 @@ const hasPermission = (actorId: number, permission: string) =>
|
|||||||
<td class="px-4 py-4">
|
<td class="px-4 py-4">
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
v-if="user.must_change_password_on_next_login"
|
v-if="
|
||||||
|
user.must_change_password_on_next_login
|
||||||
|
"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="gap-1.5"
|
class="gap-1.5"
|
||||||
>
|
>
|
||||||
@@ -170,10 +185,24 @@ const hasPermission = (actorId: number, permission: string) =>
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-4">
|
<td class="px-4 py-4">
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<div class="flex flex-wrap justify-end gap-3">
|
<div
|
||||||
<Button as-child size="sm" variant="outline">
|
class="flex flex-wrap justify-end gap-3"
|
||||||
<Link :href="editAdminUser(user.id)">
|
>
|
||||||
<Pencil class="size-4" />
|
<Button
|
||||||
|
as-child
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
:href="
|
||||||
|
editAdminUser(
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Pencil
|
||||||
|
class="size-4"
|
||||||
|
/>
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -195,7 +224,9 @@ const hasPermission = (actorId: number, permission: string) =>
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="permissions[]"
|
name="permissions[]"
|
||||||
:value="option.value"
|
:value="
|
||||||
|
option.value
|
||||||
|
"
|
||||||
:checked="
|
:checked="
|
||||||
hasPermission(
|
hasPermission(
|
||||||
user.id,
|
user.id,
|
||||||
@@ -209,7 +240,9 @@ const hasPermission = (actorId: number, permission: string) =>
|
|||||||
}}</span>
|
}}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="w-full sm:hidden" />
|
<div
|
||||||
|
class="w-full sm:hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link, usePage } from '@inertiajs/vue3';
|
import { Form, Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
||||||
import { computed } from 'vue';
|
import { Trash2 } from 'lucide-vue-next';
|
||||||
|
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||||
import DeleteUser from '@/components/DeleteUser.vue';
|
import DeleteUser from '@/components/DeleteUser.vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
@@ -12,14 +13,10 @@ import AppLayout from '@/layouts/AppLayout.vue';
|
|||||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
import { edit } from '@/routes/profile/index';
|
import { edit } from '@/routes/profile/index';
|
||||||
import { send } from '@/routes/verification';
|
import { send } from '@/routes/verification';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { BreadcrumbItem, SettingsProfilePageProps } from '@/types';
|
||||||
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
|
|
||||||
type Props = {
|
defineProps<SettingsProfilePageProps>();
|
||||||
mustVerifyEmail: boolean;
|
|
||||||
status?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
defineProps<Props>();
|
|
||||||
|
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
const breadcrumbItems: BreadcrumbItem[] = [
|
||||||
{
|
{
|
||||||
@@ -30,6 +27,43 @@ const breadcrumbItems: BreadcrumbItem[] = [
|
|||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const user = computed(() => page.props.auth.user);
|
const user = computed(() => page.props.auth.user);
|
||||||
|
const avatarPreview = ref<string | null>(null);
|
||||||
|
const avatarDeleteForm = useForm({});
|
||||||
|
const displayedAvatar = computed(
|
||||||
|
() => avatarPreview.value ?? user.value.avatar,
|
||||||
|
);
|
||||||
|
const canRemoveAvatar = computed(
|
||||||
|
() => user.value.avatar !== null && !user.value.avatar_is_generated,
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearAvatarPreview = (): void => {
|
||||||
|
if (avatarPreview.value) {
|
||||||
|
URL.revokeObjectURL(avatarPreview.value);
|
||||||
|
avatarPreview.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarChange = (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
|
||||||
|
clearAvatarPreview();
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
avatarPreview.value = URL.createObjectURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAvatar = (): void => {
|
||||||
|
avatarDeleteForm.delete(ProfileController.destroyAvatar.url(), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: clearAvatarPreview,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearAvatarPreview();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -43,14 +77,60 @@ const user = computed(() => page.props.auth.user);
|
|||||||
<Heading
|
<Heading
|
||||||
variant="small"
|
variant="small"
|
||||||
title="Profile information"
|
title="Profile information"
|
||||||
description="Update your name and email address"
|
description="Update your name, email address, and profile picture"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
v-bind="ProfileController.update.form()"
|
v-bind="ProfileController.update.form()"
|
||||||
class="space-y-6"
|
class="space-y-6"
|
||||||
v-slot="{ errors, processing, recentlySuccessful }"
|
v-slot="{
|
||||||
|
errors,
|
||||||
|
processing,
|
||||||
|
progress,
|
||||||
|
recentlySuccessful,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<Label for="avatar">Profile picture</Label>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<UserAvatar class="size-16" />
|
||||||
|
<div class="grid flex-1 gap-2">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
id="avatar"
|
||||||
|
name="avatar"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
@change="handleAvatarChange"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="canRemoveAvatar"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="avatarDeleteForm.processing"
|
||||||
|
data-test="delete-profile-picture-button"
|
||||||
|
@click="removeAvatar"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
PNG, JPG, or WebP up to 2 MB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<progress
|
||||||
|
v-if="progress"
|
||||||
|
class="h-1 w-full overflow-hidden rounded-full"
|
||||||
|
:value="progress.percentage"
|
||||||
|
max="100"
|
||||||
|
>
|
||||||
|
{{ progress.percentage }}%
|
||||||
|
</progress>
|
||||||
|
<InputError class="mt-1" :message="errors.avatar" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="name">Name</Label>
|
<Label for="name">Name</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ export type UserModel = {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
avatar?: string;
|
avatar: string | null;
|
||||||
|
avatar_color: string | null;
|
||||||
|
avatar_is_generated: boolean;
|
||||||
email_verified_at: string | null;
|
email_verified_at: string | null;
|
||||||
must_change_password_on_next_login: boolean;
|
must_change_password_on_next_login: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -172,6 +172,11 @@ export type SettingsGitConnectionsPageProps = {
|
|||||||
status?: string | null;
|
status?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SettingsProfilePageProps = {
|
||||||
|
mustVerifyEmail: boolean;
|
||||||
|
status?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type SettingsApplicationsPageProps = {
|
export type SettingsApplicationsPageProps = {
|
||||||
applicationKey?: string | null;
|
applicationKey?: string | null;
|
||||||
applications: UserApplicationModel[];
|
applications: UserApplicationModel[];
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Route::middleware('auth')
|
|||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('/', 'edit')->name('profile.edit');
|
Route::get('/', 'edit')->name('profile.edit');
|
||||||
Route::patch('/', 'update')->name('profile.update');
|
Route::patch('/', 'update')->name('profile.update');
|
||||||
|
Route::delete('avatar', 'destroyAvatar')->name('profile.avatar.destroy');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Tests\Feature\Admin;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -85,6 +86,8 @@ class UserManagementControllerTest extends TestCase
|
|||||||
*/
|
*/
|
||||||
public function test_admin_can_create_a_user_that_must_change_password_on_next_login(): void
|
public function test_admin_can_create_a_user_that_must_change_password_on_next_login(): void
|
||||||
{
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
$admin = User::factory()->admin()->create();
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
$this->actingAs($admin)
|
$this->actingAs($admin)
|
||||||
@@ -103,6 +106,12 @@ class UserManagementControllerTest extends TestCase
|
|||||||
'email' => 'new-member@example.com',
|
'email' => 'new-member@example.com',
|
||||||
'must_change_password_on_next_login' => true,
|
'must_change_password_on_next_login' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$user = User::query()->where('email', 'new-member@example.com')->firstOrFail();
|
||||||
|
|
||||||
|
$this->assertTrue($user->avatar_is_generated);
|
||||||
|
$this->assertNotNull($user->avatar_path);
|
||||||
|
Storage::disk('public')->assertExists($user->avatar_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Auth;
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -26,6 +28,8 @@ class RegistrationTest extends TestCase
|
|||||||
|
|
||||||
public function test_new_users_can_register()
|
public function test_new_users_can_register()
|
||||||
{
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
$response = $this->post(route('register.store'), [
|
$response = $this->post(route('register.store'), [
|
||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
'email' => 'test@example.com',
|
'email' => 'test@example.com',
|
||||||
@@ -35,5 +39,14 @@ class RegistrationTest extends TestCase
|
|||||||
|
|
||||||
$this->assertAuthenticated();
|
$this->assertAuthenticated();
|
||||||
$response->assertRedirect(route('dashboard', absolute: false));
|
$response->assertRedirect(route('dashboard', absolute: false));
|
||||||
|
|
||||||
|
$user = User::query()->where('email', 'test@example.com')->firstOrFail();
|
||||||
|
|
||||||
|
$this->assertTrue($user->avatar_is_generated);
|
||||||
|
$this->assertNotNull($user->avatar_color);
|
||||||
|
$this->assertNotNull($user->avatar_path);
|
||||||
|
|
||||||
|
Storage::disk('public')->assertExists($user->avatar_path);
|
||||||
|
$this->assertStringContainsString('TU', Storage::disk('public')->get($user->avatar_path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ namespace Tests\Feature\Models;
|
|||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Database\Seeders\UserSeeder;
|
use Database\Seeders\UserSeeder;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class UserTest extends TestCase
|
class UserTest extends TestCase
|
||||||
@@ -28,6 +32,81 @@ class UserTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_user_avatar_attribute_returns_public_storage_url(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'avatar_path' => 'avatars/generated/example.svg',
|
||||||
|
'avatar_color' => '#F97316',
|
||||||
|
'avatar_is_generated' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
Storage::disk('public')->url('avatars/generated/example.svg'),
|
||||||
|
$user->avatar,
|
||||||
|
);
|
||||||
|
$this->assertArrayNotHasKey('avatar_path', $user->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_can_identify_uploaded_avatars(): void
|
||||||
|
{
|
||||||
|
$generated = User::factory()->make([
|
||||||
|
'avatar_path' => 'avatars/generated/example.svg',
|
||||||
|
'avatar_is_generated' => true,
|
||||||
|
]);
|
||||||
|
$uploaded = User::factory()->make([
|
||||||
|
'avatar_path' => 'avatars/uploads/example.png',
|
||||||
|
'avatar_is_generated' => false,
|
||||||
|
]);
|
||||||
|
$missing = User::factory()->make([
|
||||||
|
'avatar_path' => null,
|
||||||
|
'avatar_is_generated' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertFalse($generated->hasUploadedAvatar());
|
||||||
|
$this->assertTrue($uploaded->hasUploadedAvatar());
|
||||||
|
$this->assertFalse($missing->hasUploadedAvatar());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_avatar_migration_backfills_existing_users(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn([
|
||||||
|
'avatar_path',
|
||||||
|
'avatar_color',
|
||||||
|
'avatar_is_generated',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('users')->insert([
|
||||||
|
'name' => 'Legacy User',
|
||||||
|
'email' => 'legacy@example.com',
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'password' => 'password',
|
||||||
|
'must_change_password_on_next_login' => false,
|
||||||
|
'remember_token' => null,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$avatarFieldsMigration = require database_path('migrations/2026_06_24_114831_add_avatar_fields_to_users_table.php');
|
||||||
|
$avatarFieldsMigration->up();
|
||||||
|
|
||||||
|
$backfillMigration = require database_path('migrations/2026_06_24_123911_backfill_existing_user_avatars.php');
|
||||||
|
$backfillMigration->up();
|
||||||
|
|
||||||
|
$user = DB::table('users')->where('email', 'legacy@example.com')->first();
|
||||||
|
|
||||||
|
$this->assertNotNull($user->avatar_path);
|
||||||
|
$this->assertNotNull($user->avatar_color);
|
||||||
|
$this->assertTrue((bool) $user->avatar_is_generated);
|
||||||
|
Storage::disk('public')->assertExists($user->avatar_path);
|
||||||
|
$this->assertStringContainsString('LU', Storage::disk('public')->get($user->avatar_path));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure the admin factory state creates admin users.
|
* Ensure the admin factory state creates admin users.
|
||||||
*/
|
*/
|
||||||
@@ -49,6 +128,8 @@ class UserTest extends TestCase
|
|||||||
*/
|
*/
|
||||||
public function test_user_seeder_creates_admin_and_regular_users(): void
|
public function test_user_seeder_creates_admin_and_regular_users(): void
|
||||||
{
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
$this->seed(UserSeeder::class);
|
$this->seed(UserSeeder::class);
|
||||||
|
|
||||||
$admin = User::query()->where('email', 'admin@kanbangit.test')->firstOrFail();
|
$admin = User::query()->where('email', 'admin@kanbangit.test')->firstOrFail();
|
||||||
@@ -69,6 +150,15 @@ class UserTest extends TestCase
|
|||||||
'scope_id' => null,
|
'scope_id' => null,
|
||||||
'scope_type' => null,
|
'scope_type' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
User::query()
|
||||||
|
->get()
|
||||||
|
->each(function (User $user): void {
|
||||||
|
$this->assertTrue($user->avatar_is_generated);
|
||||||
|
$this->assertNotNull($user->avatar_path);
|
||||||
|
$this->assertNotNull($user->avatar_color);
|
||||||
|
Storage::disk('public')->assertExists($user->avatar_path);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace Tests\Feature\Settings;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class ProfileUpdateTest extends TestCase
|
class ProfileUpdateTest extends TestCase
|
||||||
@@ -23,6 +25,8 @@ class ProfileUpdateTest extends TestCase
|
|||||||
|
|
||||||
public function test_profile_information_can_be_updated()
|
public function test_profile_information_can_be_updated()
|
||||||
{
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
$response = $this
|
$response = $this
|
||||||
@@ -41,10 +45,15 @@ class ProfileUpdateTest extends TestCase
|
|||||||
$this->assertSame('Test User', $user->name);
|
$this->assertSame('Test User', $user->name);
|
||||||
$this->assertSame('test@example.com', $user->email);
|
$this->assertSame('test@example.com', $user->email);
|
||||||
$this->assertNull($user->email_verified_at);
|
$this->assertNull($user->email_verified_at);
|
||||||
|
$this->assertTrue($user->avatar_is_generated);
|
||||||
|
$this->assertNotNull($user->avatar_path);
|
||||||
|
Storage::disk('public')->assertExists($user->avatar_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged()
|
public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged()
|
||||||
{
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
$response = $this
|
$response = $this
|
||||||
@@ -61,9 +70,165 @@ class ProfileUpdateTest extends TestCase
|
|||||||
$this->assertNotNull($user->refresh()->email_verified_at);
|
$this->assertNotNull($user->refresh()->email_verified_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_user_can_upload_a_profile_picture()
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'avatar_path' => 'avatars/generated/old.svg',
|
||||||
|
'avatar_color' => '#F97316',
|
||||||
|
'avatar_is_generated' => true,
|
||||||
|
]);
|
||||||
|
Storage::disk('public')->put($user->avatar_path, '<svg>OU</svg>');
|
||||||
|
|
||||||
|
$response = $this
|
||||||
|
->actingAs($user)
|
||||||
|
->post(route('profile.update'), [
|
||||||
|
'_method' => 'PATCH',
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'avatar' => UploadedFile::fake()->image('avatar.png'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertSessionHasNoErrors()
|
||||||
|
->assertRedirect(route('profile.edit'));
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
|
||||||
|
$this->assertFalse($user->avatar_is_generated);
|
||||||
|
$this->assertSame('#F97316', $user->avatar_color);
|
||||||
|
$this->assertNotSame('avatars/generated/old.svg', $user->avatar_path);
|
||||||
|
$this->assertStringStartsWith('avatars/uploads/', $user->avatar_path);
|
||||||
|
Storage::disk('public')->assertMissing('avatars/generated/old.svg');
|
||||||
|
Storage::disk('public')->assertExists($user->avatar_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_can_delete_their_uploaded_profile_picture()
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'name' => 'Upload User',
|
||||||
|
'avatar_path' => 'avatars/uploads/current.png',
|
||||||
|
'avatar_color' => '#06B6D4',
|
||||||
|
'avatar_is_generated' => false,
|
||||||
|
]);
|
||||||
|
Storage::disk('public')->put($user->avatar_path, 'uploaded image');
|
||||||
|
|
||||||
|
$response = $this
|
||||||
|
->actingAs($user)
|
||||||
|
->delete(route('profile.avatar.destroy'));
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertSessionHasNoErrors()
|
||||||
|
->assertRedirect(route('profile.edit'));
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
|
||||||
|
$this->assertTrue($user->avatar_is_generated);
|
||||||
|
$this->assertSame('#06B6D4', $user->avatar_color);
|
||||||
|
$this->assertNotSame('avatars/uploads/current.png', $user->avatar_path);
|
||||||
|
$this->assertStringStartsWith('avatars/generated/', $user->avatar_path);
|
||||||
|
Storage::disk('public')->assertMissing('avatars/uploads/current.png');
|
||||||
|
Storage::disk('public')->assertExists($user->avatar_path);
|
||||||
|
$this->assertStringContainsString('UU', Storage::disk('public')->get($user->avatar_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_name_change_regenerates_the_default_profile_picture()
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'name' => 'Old User',
|
||||||
|
'email' => 'old-user@example.com',
|
||||||
|
'avatar_path' => 'avatars/generated/old.svg',
|
||||||
|
'avatar_color' => '#22C55E',
|
||||||
|
'avatar_is_generated' => true,
|
||||||
|
]);
|
||||||
|
Storage::disk('public')->put($user->avatar_path, '<svg>OU</svg>');
|
||||||
|
|
||||||
|
$response = $this
|
||||||
|
->actingAs($user)
|
||||||
|
->patch(route('profile.update'), [
|
||||||
|
'name' => 'Fresh Person',
|
||||||
|
'email' => 'old-user@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertSessionHasNoErrors()
|
||||||
|
->assertRedirect(route('profile.edit'));
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
|
||||||
|
$this->assertTrue($user->avatar_is_generated);
|
||||||
|
$this->assertSame('#22C55E', $user->avatar_color);
|
||||||
|
$this->assertNotSame('avatars/generated/old.svg', $user->avatar_path);
|
||||||
|
Storage::disk('public')->assertMissing('avatars/generated/old.svg');
|
||||||
|
Storage::disk('public')->assertExists($user->avatar_path);
|
||||||
|
$this->assertStringContainsString('FP', Storage::disk('public')->get($user->avatar_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_name_change_keeps_an_uploaded_profile_picture()
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'name' => 'Old User',
|
||||||
|
'email' => 'old-user@example.com',
|
||||||
|
'avatar_path' => 'avatars/uploads/current.png',
|
||||||
|
'avatar_color' => '#06B6D4',
|
||||||
|
'avatar_is_generated' => false,
|
||||||
|
]);
|
||||||
|
Storage::disk('public')->put($user->avatar_path, 'uploaded image');
|
||||||
|
|
||||||
|
$response = $this
|
||||||
|
->actingAs($user)
|
||||||
|
->patch(route('profile.update'), [
|
||||||
|
'name' => 'Fresh Person',
|
||||||
|
'email' => 'old-user@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertSessionHasNoErrors()
|
||||||
|
->assertRedirect(route('profile.edit'));
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
|
||||||
|
$this->assertFalse($user->avatar_is_generated);
|
||||||
|
$this->assertSame('avatars/uploads/current.png', $user->avatar_path);
|
||||||
|
Storage::disk('public')->assertExists('avatars/uploads/current.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_profile_picture_must_be_an_image()
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this
|
||||||
|
->actingAs($user)
|
||||||
|
->post(route('profile.update'), [
|
||||||
|
'_method' => 'PATCH',
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'avatar' => UploadedFile::fake()->create('avatar.pdf', 10, 'application/pdf'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasErrors('avatar');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_user_can_delete_their_account()
|
public function test_user_can_delete_their_account()
|
||||||
{
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
$user->forceFill([
|
||||||
|
'avatar_path' => 'avatars/generated/delete-me.svg',
|
||||||
|
'avatar_color' => '#EC4899',
|
||||||
|
'avatar_is_generated' => true,
|
||||||
|
])->save();
|
||||||
|
Storage::disk('public')->put($user->avatar_path, '<svg>DU</svg>');
|
||||||
|
|
||||||
$response = $this
|
$response = $this
|
||||||
->actingAs($user)
|
->actingAs($user)
|
||||||
@@ -77,6 +242,7 @@ class ProfileUpdateTest extends TestCase
|
|||||||
|
|
||||||
$this->assertGuest();
|
$this->assertGuest();
|
||||||
$this->assertNull($user->fresh());
|
$this->assertNull($user->fresh());
|
||||||
|
Storage::disk('public')->assertMissing('avatars/generated/delete-me.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_correct_password_must_be_provided_to_delete_account()
|
public function test_correct_password_must_be_provided_to_delete_account()
|
||||||
|
|||||||
Reference in New Issue
Block a user