Merge branch 'main' into epic/kan-3
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Actions\Users\UpdateUserAvatar;
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use App\Concerns\ProfileValidationRules;
|
||||
use App\Models\User;
|
||||
@@ -12,6 +13,10 @@ class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
use PasswordValidationRules, ProfileValidationRules;
|
||||
|
||||
public function __construct(
|
||||
private readonly UpdateUserAvatar $avatars,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
@@ -24,10 +29,12 @@ class CreateNewUser implements CreatesNewUsers
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
return User::create([
|
||||
$user = User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'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;
|
||||
|
||||
use App\Actions\Users\UpdateUserAvatar;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
@@ -13,10 +14,16 @@ use Illuminate\Support\Facades\Hash;
|
||||
#[Description('Command description')]
|
||||
class CreateAdminUser extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UpdateUserAvatar $avatars,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
public function handle(): int
|
||||
{
|
||||
$user = User::create([
|
||||
'name' => $this->argument('name'),
|
||||
@@ -24,6 +31,8 @@ class CreateAdminUser extends Command
|
||||
'password' => Hash::make($this->argument('password')),
|
||||
]);
|
||||
|
||||
$this->avatars->generate($user);
|
||||
|
||||
$user->permissionAssignments()->create([
|
||||
'permission' => Permission::GlobalAdmin,
|
||||
]);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Users\UpdateUserAvatar;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\StoreManagedUserRequest;
|
||||
use App\Http\Requests\Admin\SyncUserGlobalPermissionsRequest;
|
||||
@@ -19,6 +20,7 @@ class UserManagementController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly UpdateUserAvatar $avatars,
|
||||
) {}
|
||||
|
||||
public function index(): Response
|
||||
@@ -46,13 +48,15 @@ class UserManagementController extends Controller
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
User::query()->create([
|
||||
$user = User::query()->create([
|
||||
'name' => Arr::get($validated, 'name'),
|
||||
'email' => Arr::get($validated, 'email'),
|
||||
'password' => Arr::get($validated, 'password'),
|
||||
'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')
|
||||
->with('status', 'User was created.');
|
||||
}
|
||||
|
||||
@@ -2,18 +2,25 @@
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Actions\Users\UpdateUserAvatar;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\ProfileDeleteRequest;
|
||||
use App\Http\Requests\Settings\ProfileUpdateRequest;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UpdateUserAvatar $avatars,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Show the user's profile settings page.
|
||||
*/
|
||||
@@ -33,9 +40,14 @@ class ProfileController extends Controller
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
|
||||
$validated = $request->validated();
|
||||
$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')) {
|
||||
$user->email_verified_at = null;
|
||||
@@ -43,6 +55,21 @@ class ProfileController extends Controller
|
||||
|
||||
$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');
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ class ProfileUpdateRequest extends FormRequest
|
||||
*/
|
||||
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,
|
||||
'name' => $this->name,
|
||||
'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(),
|
||||
'must_change_password_on_next_login' => (bool) $this->must_change_password_on_next_login,
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
|
||||
+39
-2
@@ -4,29 +4,48 @@ namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Users\UpdateUserAvatar;
|
||||
use App\Enums\Permission;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password', 'must_change_password_on_next_login'])]
|
||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||
#[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', 'avatar_path'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
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
|
||||
{
|
||||
static::deleting(function (self $user): void {
|
||||
app(UpdateUserAvatar::class)->delete($user);
|
||||
|
||||
$user->permissionAssignments()->delete();
|
||||
});
|
||||
}
|
||||
@@ -39,6 +58,7 @@ class User extends Authenticatable
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'avatar_is_generated' => 'bool',
|
||||
'email_verified_at' => 'datetime',
|
||||
'must_change_password_on_next_login' => 'bool',
|
||||
'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
|
||||
{
|
||||
return $this->morphMany(PermissionAssignment::class, 'assignee');
|
||||
|
||||
Reference in New Issue
Block a user