Merge branch 'main' into epic/kan-3
linter / quality (pull_request) Successful in 1m30s
tests / ci (8.4) (pull_request) Successful in 1m54s

This commit is contained in:
2026-06-25 01:25:53 +02:00
25 changed files with 802 additions and 57 deletions
+8 -1
View File
@@ -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);
}
}
+121
View File
@@ -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;
}
}
+10 -1
View File
@@ -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'],
];
}
}
+3
View File
@@ -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
View File
@@ -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');