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..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 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/admin/users/Index.vue b/resources/js/pages/admin/users/Index.vue index 9c1ce93..754e0ae 100644 --- a/resources/js/pages/admin/users/Index.vue +++ b/resources/js/pages/admin/users/Index.vue @@ -1,6 +1,13 @@ @@ -43,14 +77,60 @@ const user = computed(() => page.props.auth.user); + + Profile picture + + + + + + + + Remove + + + + PNG, JPG, or WebP up to 2 MB. + + + + + {{ progress.percentage }}% + + + + Name group(function (): void { Route::get('/', 'edit')->name('profile.edit'); Route::patch('/', 'update')->name('profile.update'); + Route::delete('avatar', 'destroyAvatar')->name('profile.avatar.destroy'); }); }); diff --git a/tests/Feature/Admin/UserManagementControllerTest.php b/tests/Feature/Admin/UserManagementControllerTest.php index 9376183..98c9322 100644 --- a/tests/Feature/Admin/UserManagementControllerTest.php +++ b/tests/Feature/Admin/UserManagementControllerTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Admin; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Storage; use Inertia\Testing\AssertableInertia as Assert; 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 { + Storage::fake('public'); + $admin = User::factory()->admin()->create(); $this->actingAs($admin) @@ -103,6 +106,12 @@ class UserManagementControllerTest extends TestCase 'email' => 'new-member@example.com', '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); } /** diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index e25a94b..a67bd9a 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -2,7 +2,9 @@ namespace Tests\Feature\Auth; +use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Storage; use Laravel\Fortify\Features; use Tests\TestCase; @@ -26,6 +28,8 @@ class RegistrationTest extends TestCase public function test_new_users_can_register() { + Storage::fake('public'); + $response = $this->post(route('register.store'), [ 'name' => 'Test User', 'email' => 'test@example.com', @@ -35,5 +39,14 @@ class RegistrationTest extends TestCase $this->assertAuthenticated(); $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)); } } diff --git a/tests/Feature/Models/UserTest.php b/tests/Feature/Models/UserTest.php index b74f6db..c81dfb7 100644 --- a/tests/Feature/Models/UserTest.php +++ b/tests/Feature/Models/UserTest.php @@ -5,7 +5,11 @@ namespace Tests\Feature\Models; use App\Models\Team; use App\Models\User; use Database\Seeders\UserSeeder; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Facades\Storage; use Tests\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. */ @@ -49,6 +128,8 @@ class UserTest extends TestCase */ public function test_user_seeder_creates_admin_and_regular_users(): void { + Storage::fake('public'); + $this->seed(UserSeeder::class); $admin = User::query()->where('email', 'admin@kanbangit.test')->firstOrFail(); @@ -69,6 +150,15 @@ class UserTest extends TestCase 'scope_id' => 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); + }); } /** diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php index e6c95ce..72cee36 100644 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -4,6 +4,8 @@ namespace Tests\Feature\Settings; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use Tests\TestCase; class ProfileUpdateTest extends TestCase @@ -23,6 +25,8 @@ class ProfileUpdateTest extends TestCase public function test_profile_information_can_be_updated() { + Storage::fake('public'); + $user = User::factory()->create(); $response = $this @@ -41,10 +45,15 @@ class ProfileUpdateTest extends TestCase $this->assertSame('Test User', $user->name); $this->assertSame('test@example.com', $user->email); $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() { + Storage::fake('public'); + $user = User::factory()->create(); $response = $this @@ -61,9 +70,165 @@ class ProfileUpdateTest extends TestCase $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, 'OU'); + + $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, 'OU'); + + $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() { + Storage::fake('public'); + $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, 'DU'); $response = $this ->actingAs($user) @@ -77,6 +242,7 @@ class ProfileUpdateTest extends TestCase $this->assertGuest(); $this->assertNull($user->fresh()); + Storage::disk('public')->assertMissing('avatars/generated/delete-me.svg'); } public function test_correct_password_must_be_provided_to_delete_account()
+ PNG, JPG, or WebP up to 2 MB. +