diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 3c7c00c..50a6bd1 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -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); } } diff --git a/app/Actions/Users/UpdateUserAvatar.php b/app/Actions/Users/UpdateUserAvatar.php new file mode 100644 index 0000000..3d315f4 --- /dev/null +++ b/app/Actions/Users/UpdateUserAvatar.php @@ -0,0 +1,121 @@ + + */ + 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 << + + {$escapedInitials} + +SVG; + } +} diff --git a/app/Console/Commands/CreateAdminUser.php b/app/Console/Commands/CreateAdminUser.php index 21d56ed..c35d67e 100644 --- a/app/Console/Commands/CreateAdminUser.php +++ b/app/Console/Commands/CreateAdminUser.php @@ -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, ]); diff --git a/app/Http/Controllers/Admin/UserManagementController.php b/app/Http/Controllers/Admin/UserManagementController.php index 2f0d53a..43c9337 100644 --- a/app/Http/Controllers/Admin/UserManagementController.php +++ b/app/Http/Controllers/Admin/UserManagementController.php @@ -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.'); } diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php index 9c57bcf..9d38f31 100644 --- a/app/Http/Controllers/Settings/ProfileController.php +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -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,14 @@ 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'); } diff --git a/app/Http/Requests/Settings/ProfileUpdateRequest.php b/app/Http/Requests/Settings/ProfileUpdateRequest.php index e4eb8d8..2b0022c 100644 --- a/app/Http/Requests/Settings/ProfileUpdateRequest.php +++ b/app/Http/Requests/Settings/ProfileUpdateRequest.php @@ -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'], + ]; } } diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 353d589..1b9a82a 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -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(), diff --git a/app/Models/User.php b/app/Models/User.php index 9ff2172..87af0fb 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 */ use HasFactory, Notifiable, TwoFactorAuthenticatable; + /** + * @var list + */ + protected $appends = [ + 'avatar', + ]; + + /** + * @var array + */ + 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 + */ + 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'); diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 1c93e9b..4e74ceb 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -28,6 +28,9 @@ class UserFactory extends Factory return [ 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), + 'avatar_path' => null, + 'avatar_color' => null, + 'avatar_is_generated' => false, 'email_verified_at' => now(), 'must_change_password_on_next_login' => false, 'password' => static::$password ??= Hash::make('password'), diff --git a/database/migrations/2026_06_24_114831_add_avatar_fields_to_users_table.php b/database/migrations/2026_06_24_114831_add_avatar_fields_to_users_table.php new file mode 100644 index 0000000..d016a19 --- /dev/null +++ b/database/migrations/2026_06_24_114831_add_avatar_fields_to_users_table.php @@ -0,0 +1,40 @@ +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', + ]); + }); + } +}; diff --git a/resources/js/pages/admin/users/Edit.vue b/resources/js/pages/admin/users/Edit.vue index 7b170e9..7619318 100644 --- a/resources/js/pages/admin/users/Edit.vue +++ b/resources/js/pages/admin/users/Edit.vue @@ -40,7 +40,7 @@ const form = useForm({ }); const submit = (): void => { - form.patch(save(props.user.id), { + form.patch(save.url(props.user.id), { preserveScroll: true, }); }; @@ -126,7 +126,9 @@ const submit = (): void => { > @@ -134,13 +136,20 @@ const submit = (): void => { Must change password on next login - - Keep the user locked to the security settings page until they save a new password. + + Keep the user locked to the security + settings page until they save a new + password. diff --git a/resources/js/pages/settings/Profile.vue b/resources/js/pages/settings/Profile.vue index 884600a..89e6027 100644 --- a/resources/js/pages/settings/Profile.vue +++ b/resources/js/pages/settings/Profile.vue @@ -1,10 +1,11 @@