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 <<
+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..5398878 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,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');
}
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/database/migrations/2026_06_24_123911_backfill_existing_user_avatars.php b/database/migrations/2026_06_24_123911_backfill_existing_user_avatars.php
new file mode 100644
index 0000000..5be8b53
--- /dev/null
+++ b/database/migrations/2026_06_24_123911_backfill_existing_user_avatars.php
@@ -0,0 +1,52 @@
+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,
+ ]);
+ }
+};
diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php
index 960bdf0..e8c2c0d 100644
--- a/database/seeders/UserSeeder.php
+++ b/database/seeders/UserSeeder.php
@@ -2,6 +2,7 @@
namespace Database\Seeders;
+use App\Actions\Users\UpdateUserAvatar;
use App\Enums\Permission;
use App\Models\PermissionAssignment;
use App\Models\User;
@@ -9,6 +10,10 @@ use Illuminate\Database\Seeder;
class UserSeeder extends Seeder
{
+ public function __construct(
+ private readonly UpdateUserAvatar $avatars,
+ ) {}
+
/**
* Run the database seeds.
*/
@@ -18,6 +23,7 @@ class UserSeeder extends Seeder
'name' => 'Admin User',
'email' => 'admin@kanbangit.test',
]);
+ $this->avatars->generate($admin);
PermissionAssignment::factory()
->forAssignee($admin)
@@ -28,6 +34,7 @@ class UserSeeder extends Seeder
'name' => 'Test User',
'email' => 'test@example.com',
]);
+ $this->avatars->generate($testUser);
PermissionAssignment::factory()
->forAssignee($testUser)
@@ -35,6 +42,8 @@ class UserSeeder extends Seeder
->permission(Permission::ProjectCreate)
->create();
- User::factory(8)->create();
+ User::factory(8)
+ ->create()
+ ->each(fn (User $user) => $this->avatars->generate($user));
}
}
diff --git a/resources/js/components/AppHeader.vue b/resources/js/components/AppHeader.vue
index ca3cb1c..bce4a1d 100644
--- a/resources/js/components/AppHeader.vue
+++ b/resources/js/components/AppHeader.vue
@@ -5,7 +5,6 @@ import { computed } from 'vue';
import AppLogo from '@/components/AppLogo.vue';
import AppLogoIcon from '@/components/AppLogoIcon.vue';
import Breadcrumbs from '@/components/Breadcrumbs.vue';
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -33,10 +32,10 @@ import {
} from '@/components/ui/tooltip';
import UserMenuContent from '@/components/UserMenuContent.vue';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
-import { getInitials } from '@/composables/useInitials';
import { toUrl } from '@/lib/utils';
import { dashboard } from '@/routes/index';
import type { BreadcrumbItem, NavItem } from '@/types';
+import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
type Props = {
breadcrumbs?: BreadcrumbItem[];
@@ -245,20 +244,7 @@ const rightNavItems: NavItem[] = [
size="icon"
class="focus-within:ring-primary relative size-10 w-auto rounded-full p-1 focus-within:ring-2"
>
-
-
-
- {{ getInitials(auth.user?.name) }}
-
-
+
diff --git a/resources/js/components/UserInfo.vue b/resources/js/components/UserInfo.vue
index bdae50b..598390b 100644
--- a/resources/js/components/UserInfo.vue
+++ b/resources/js/components/UserInfo.vue
@@ -1,8 +1,8 @@
-
-
-
- {{ getInitials(user.name) }}
-
-
+
{{ user.name }}
diff --git a/resources/js/components/ui/avatar/UserAvatar.vue b/resources/js/components/ui/avatar/UserAvatar.vue
new file mode 100644
index 0000000..3412c64
--- /dev/null
+++ b/resources/js/components/ui/avatar/UserAvatar.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+ {{ getInitials(auth.user?.name) }}
+
+
+
diff --git a/resources/js/pages/admin/users/Edit.vue b/resources/js/pages/admin/users/Edit.vue
index 7748351..7619318 100644
--- a/resources/js/pages/admin/users/Edit.vue
+++ b/resources/js/pages/admin/users/Edit.vue
@@ -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.