Merge branch 'main' into staging
linter / quality (push) Successful in 1m51s
tests / ci (8.4) (push) Successful in 2m4s

This commit is contained in:
2026-06-24 22:55:55 +02:00
25 changed files with 802 additions and 57 deletions
+8 -1
View File
@@ -2,6 +2,7 @@
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Actions\Users\UpdateUserAvatar;
use App\Concerns\PasswordValidationRules; use App\Concerns\PasswordValidationRules;
use App\Concerns\ProfileValidationRules; use App\Concerns\ProfileValidationRules;
use App\Models\User; use App\Models\User;
@@ -12,6 +13,10 @@ class CreateNewUser implements CreatesNewUsers
{ {
use PasswordValidationRules, ProfileValidationRules; use PasswordValidationRules, ProfileValidationRules;
public function __construct(
private readonly UpdateUserAvatar $avatars,
) {}
/** /**
* Validate and create a newly registered user. * Validate and create a newly registered user.
* *
@@ -24,10 +29,12 @@ class CreateNewUser implements CreatesNewUsers
'password' => $this->passwordRules(), 'password' => $this->passwordRules(),
])->validate(); ])->validate();
return User::create([ $user = User::create([
'name' => $input['name'], 'name' => $input['name'],
'email' => $input['email'], 'email' => $input['email'],
'password' => $input['password'], '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; namespace App\Console\Commands;
use App\Actions\Users\UpdateUserAvatar;
use App\Enums\Permission; use App\Enums\Permission;
use App\Models\User; use App\Models\User;
use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Description;
@@ -13,10 +14,16 @@ use Illuminate\Support\Facades\Hash;
#[Description('Command description')] #[Description('Command description')]
class CreateAdminUser extends Command class CreateAdminUser extends Command
{ {
public function __construct(
private readonly UpdateUserAvatar $avatars,
) {
parent::__construct();
}
/** /**
* Execute the console command. * Execute the console command.
*/ */
public function handle() public function handle(): int
{ {
$user = User::create([ $user = User::create([
'name' => $this->argument('name'), 'name' => $this->argument('name'),
@@ -24,6 +31,8 @@ class CreateAdminUser extends Command
'password' => Hash::make($this->argument('password')), 'password' => Hash::make($this->argument('password')),
]); ]);
$this->avatars->generate($user);
$user->permissionAssignments()->create([ $user->permissionAssignments()->create([
'permission' => Permission::GlobalAdmin, 'permission' => Permission::GlobalAdmin,
]); ]);
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Actions\Permissions\PermissionService; use App\Actions\Permissions\PermissionService;
use App\Actions\Users\UpdateUserAvatar;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreManagedUserRequest; use App\Http\Requests\Admin\StoreManagedUserRequest;
use App\Http\Requests\Admin\SyncUserGlobalPermissionsRequest; use App\Http\Requests\Admin\SyncUserGlobalPermissionsRequest;
@@ -19,6 +20,7 @@ class UserManagementController extends Controller
{ {
public function __construct( public function __construct(
private readonly PermissionService $permissionService, private readonly PermissionService $permissionService,
private readonly UpdateUserAvatar $avatars,
) {} ) {}
public function index(): Response public function index(): Response
@@ -46,13 +48,15 @@ class UserManagementController extends Controller
{ {
$validated = $request->validated(); $validated = $request->validated();
User::query()->create([ $user = User::query()->create([
'name' => Arr::get($validated, 'name'), 'name' => Arr::get($validated, 'name'),
'email' => Arr::get($validated, 'email'), 'email' => Arr::get($validated, 'email'),
'password' => Arr::get($validated, 'password'), 'password' => Arr::get($validated, 'password'),
'must_change_password_on_next_login' => (bool) Arr::get($validated, 'must_change_password_on_next_login', true), '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') return to_route('admin.users.index')
->with('status', 'User was created.'); ->with('status', 'User was created.');
} }
@@ -2,18 +2,25 @@
namespace App\Http\Controllers\Settings; namespace App\Http\Controllers\Settings;
use App\Actions\Users\UpdateUserAvatar;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ProfileDeleteRequest; use App\Http\Requests\Settings\ProfileDeleteRequest;
use App\Http\Requests\Settings\ProfileUpdateRequest; use App\Http\Requests\Settings\ProfileUpdateRequest;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
class ProfileController extends Controller class ProfileController extends Controller
{ {
public function __construct(
private readonly UpdateUserAvatar $avatars,
) {}
/** /**
* Show the user's profile settings page. * Show the user's profile settings page.
*/ */
@@ -33,9 +40,14 @@ class ProfileController extends Controller
*/ */
public function update(ProfileUpdateRequest $request): RedirectResponse public function update(ProfileUpdateRequest $request): RedirectResponse
{ {
$validated = $request->validated();
$user = $request->user(); $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')) { if ($user->isDirty('email')) {
$user->email_verified_at = null; $user->email_verified_at = null;
@@ -43,6 +55,21 @@ class ProfileController extends Controller
$user->save(); $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'); return to_route('profile.edit');
} }
@@ -17,6 +17,9 @@ class ProfileUpdateRequest extends FormRequest
*/ */
public function rules(): array 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, 'id' => $this->id,
'name' => $this->name, 'name' => $this->name,
'email' => $this->email, '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(), 'email_verified_at' => $this->email_verified_at?->toISOString(),
'must_change_password_on_next_login' => (bool) $this->must_change_password_on_next_login, 'must_change_password_on_next_login' => (bool) $this->must_change_password_on_next_login,
'created_at' => $this->created_at?->toISOString(), 'created_at' => $this->created_at?->toISOString(),
+39 -2
View File
@@ -4,29 +4,48 @@ namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; // use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Actions\Permissions\PermissionService; use App\Actions\Permissions\PermissionService;
use App\Actions\Users\UpdateUserAvatar;
use App\Enums\Permission; use App\Enums\Permission;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Storage;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
#[Fillable(['name', 'email', 'password', 'must_change_password_on_next_login'])] #[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'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token', 'avatar_path'])]
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, TwoFactorAuthenticatable; 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 protected static function booted(): void
{ {
static::deleting(function (self $user): void { static::deleting(function (self $user): void {
app(UpdateUserAvatar::class)->delete($user);
$user->permissionAssignments()->delete(); $user->permissionAssignments()->delete();
}); });
} }
@@ -39,6 +58,7 @@ class User extends Authenticatable
protected function casts(): array protected function casts(): array
{ {
return [ return [
'avatar_is_generated' => 'bool',
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'must_change_password_on_next_login' => 'bool', 'must_change_password_on_next_login' => 'bool',
'password' => 'hashed', '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 public function permissionAssignments(): MorphMany
{ {
return $this->morphMany(PermissionAssignment::class, 'assignee'); return $this->morphMany(PermissionAssignment::class, 'assignee');
+3
View File
@@ -28,6 +28,9 @@ class UserFactory extends Factory
return [ return [
'name' => fake()->name(), 'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(), 'email' => fake()->unique()->safeEmail(),
'avatar_path' => null,
'avatar_color' => null,
'avatar_is_generated' => false,
'email_verified_at' => now(), 'email_verified_at' => now(),
'must_change_password_on_next_login' => false, 'must_change_password_on_next_login' => false,
'password' => static::$password ??= Hash::make('password'), 'password' => static::$password ??= Hash::make('password'),
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->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',
]);
});
}
};
@@ -0,0 +1,52 @@
<?php
use App\Actions\Users\UpdateUserAvatar;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Collection;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$avatars = app(UpdateUserAvatar::class);
User::query()
->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,
]);
}
};
+10 -1
View File
@@ -2,6 +2,7 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Actions\Users\UpdateUserAvatar;
use App\Enums\Permission; use App\Enums\Permission;
use App\Models\PermissionAssignment; use App\Models\PermissionAssignment;
use App\Models\User; use App\Models\User;
@@ -9,6 +10,10 @@ use Illuminate\Database\Seeder;
class UserSeeder extends Seeder class UserSeeder extends Seeder
{ {
public function __construct(
private readonly UpdateUserAvatar $avatars,
) {}
/** /**
* Run the database seeds. * Run the database seeds.
*/ */
@@ -18,6 +23,7 @@ class UserSeeder extends Seeder
'name' => 'Admin User', 'name' => 'Admin User',
'email' => 'admin@kanbangit.test', 'email' => 'admin@kanbangit.test',
]); ]);
$this->avatars->generate($admin);
PermissionAssignment::factory() PermissionAssignment::factory()
->forAssignee($admin) ->forAssignee($admin)
@@ -28,6 +34,7 @@ class UserSeeder extends Seeder
'name' => 'Test User', 'name' => 'Test User',
'email' => 'test@example.com', 'email' => 'test@example.com',
]); ]);
$this->avatars->generate($testUser);
PermissionAssignment::factory() PermissionAssignment::factory()
->forAssignee($testUser) ->forAssignee($testUser)
@@ -35,6 +42,8 @@ class UserSeeder extends Seeder
->permission(Permission::ProjectCreate) ->permission(Permission::ProjectCreate)
->create(); ->create();
User::factory(8)->create(); User::factory(8)
->create()
->each(fn (User $user) => $this->avatars->generate($user));
} }
} }
+2 -16
View File
@@ -5,7 +5,6 @@ import { computed } from 'vue';
import AppLogo from '@/components/AppLogo.vue'; import AppLogo from '@/components/AppLogo.vue';
import AppLogoIcon from '@/components/AppLogoIcon.vue'; import AppLogoIcon from '@/components/AppLogoIcon.vue';
import Breadcrumbs from '@/components/Breadcrumbs.vue'; import Breadcrumbs from '@/components/Breadcrumbs.vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
DropdownMenu, DropdownMenu,
@@ -33,10 +32,10 @@ import {
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import UserMenuContent from '@/components/UserMenuContent.vue'; import UserMenuContent from '@/components/UserMenuContent.vue';
import { useCurrentUrl } from '@/composables/useCurrentUrl'; import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { getInitials } from '@/composables/useInitials';
import { toUrl } from '@/lib/utils'; import { toUrl } from '@/lib/utils';
import { dashboard } from '@/routes/index'; import { dashboard } from '@/routes/index';
import type { BreadcrumbItem, NavItem } from '@/types'; import type { BreadcrumbItem, NavItem } from '@/types';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
type Props = { type Props = {
breadcrumbs?: BreadcrumbItem[]; breadcrumbs?: BreadcrumbItem[];
@@ -245,20 +244,7 @@ const rightNavItems: NavItem[] = [
size="icon" size="icon"
class="focus-within:ring-primary relative size-10 w-auto rounded-full p-1 focus-within:ring-2" class="focus-within:ring-primary relative size-10 w-auto rounded-full p-1 focus-within:ring-2"
> >
<Avatar <UserAvatar rounded />
class="size-8 overflow-hidden rounded-full"
>
<AvatarImage
v-if="auth.user.avatar"
:src="auth.user.avatar"
:alt="auth.user.name"
/>
<AvatarFallback
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white"
>
{{ getInitials(auth.user?.name) }}
</AvatarFallback>
</Avatar>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56"> <DropdownMenuContent align="end" class="w-56">
+2 -7
View File
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useInitials } from '@/composables/useInitials'; import { useInitials } from '@/composables/useInitials';
import type { User } from '@/types'; import type { User } from '@/types';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
type Props = { type Props = {
user: User; user: User;
@@ -22,12 +22,7 @@ const showAvatar = computed(
</script> </script>
<template> <template>
<Avatar class="h-8 w-8 overflow-hidden rounded-lg"> <UserAvatar />
<AvatarImage v-if="showAvatar" :src="user.avatar!" :alt="user.name" />
<AvatarFallback class="rounded-lg text-black dark:text-white">
{{ getInitials(user.name) }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight"> <div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.name }}</span> <span class="truncate font-medium">{{ user.name }}</span>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { getInitials } from "@/composables/useInitials";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar/index";
import { usePage } from "@inertiajs/vue3";
import { computed, PropType } from "vue";
import { UserModel } from "@/types/models/user-model";
const props = defineProps({
user: {
type: Object as PropType<UserModel>,
},
rounded: {
type: Boolean,
default: false,
},
})
const page = usePage();
const auth = computed(() => page.props.auth);
const user = computed(() => props.user ?? auth.value.user)
</script>
<template>
<Avatar class="size-8 overflow-hidden" :class="rounded ? 'rounded-full' : 'rounded-lg'">
<AvatarImage
v-if="user.avatar"
:src="user.avatar"
:alt="user.name"
/>
<AvatarFallback
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white"
>
{{ getInitials(auth.user?.name) }}
</AvatarFallback>
</Avatar>
</template>
+13 -4
View File
@@ -126,7 +126,9 @@ const submit = (): void => {
> >
<input <input
id="must_change_password_on_next_login" id="must_change_password_on_next_login"
v-model="form.must_change_password_on_next_login" v-model="
form.must_change_password_on_next_login
"
type="checkbox" type="checkbox"
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded" class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
/> />
@@ -134,13 +136,20 @@ const submit = (): void => {
<span class="block font-medium"> <span class="block font-medium">
Must change password on next login Must change password on next login
</span> </span>
<span class="text-muted-foreground block text-sm"> <span
Keep the user locked to the security settings page until they save a new password. class="text-muted-foreground block text-sm"
>
Keep the user locked to the security
settings page until they save a new
password.
</span> </span>
</span> </span>
</Label> </Label>
<InputError <InputError
:message="form.errors.must_change_password_on_next_login" :message="
form.errors
.must_change_password_on_next_login
"
/> />
</div> </div>
+42 -9
View File
@@ -1,6 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Form, Head, Link } from '@inertiajs/vue3';
import { FolderPlus, KeyRound, Pencil, Shield, UserPlus, Users } from 'lucide-vue-next'; import {
FolderPlus,
KeyRound,
Pencil,
Shield,
UserPlus,
Users,
} from 'lucide-vue-next';
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController'; import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -19,6 +26,7 @@ import {
index as adminUsers, index as adminUsers,
} from '@/routes/admin/users'; } from '@/routes/admin/users';
import type { AdminUsersPageProps, BreadcrumbItem } from '@/types'; import type { AdminUsersPageProps, BreadcrumbItem } from '@/types';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
const props = defineProps<AdminUsersPageProps>(); const props = defineProps<AdminUsersPageProps>();
@@ -111,8 +119,13 @@ const hasPermission = (actorId: number, permission: string) =>
> >
<td class="px-4 py-4"> <td class="px-4 py-4">
<div <div
class="text-foreground font-medium" class="text-foreground items-medium flex items-center"
> >
<UserAvatar
:user="user"
class="mr-2 size-8"
rounded
/>
{{ user.name }} {{ user.name }}
</div> </div>
</td> </td>
@@ -122,7 +135,9 @@ const hasPermission = (actorId: number, permission: string) =>
<td class="px-4 py-4"> <td class="px-4 py-4">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<Badge <Badge
v-if="user.must_change_password_on_next_login" v-if="
user.must_change_password_on_next_login
"
variant="outline" variant="outline"
class="gap-1.5" class="gap-1.5"
> >
@@ -170,10 +185,24 @@ const hasPermission = (actorId: number, permission: string) =>
</td> </td>
<td class="px-4 py-4"> <td class="px-4 py-4">
<div class="flex justify-end"> <div class="flex justify-end">
<div class="flex flex-wrap justify-end gap-3"> <div
<Button as-child size="sm" variant="outline"> class="flex flex-wrap justify-end gap-3"
<Link :href="editAdminUser(user.id)"> >
<Pencil class="size-4" /> <Button
as-child
size="sm"
variant="outline"
>
<Link
:href="
editAdminUser(
user.id,
)
"
>
<Pencil
class="size-4"
/>
Edit Edit
</Link> </Link>
</Button> </Button>
@@ -195,7 +224,9 @@ const hasPermission = (actorId: number, permission: string) =>
<input <input
type="checkbox" type="checkbox"
name="permissions[]" name="permissions[]"
:value="option.value" :value="
option.value
"
:checked=" :checked="
hasPermission( hasPermission(
user.id, user.id,
@@ -209,7 +240,9 @@ const hasPermission = (actorId: number, permission: string) =>
}}</span> }}</span>
</label> </label>
<div class="w-full sm:hidden" /> <div
class="w-full sm:hidden"
/>
<Button <Button
type="submit" type="submit"
+91 -11
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link, usePage } from '@inertiajs/vue3'; import { Form, Head, Link, useForm, usePage } from '@inertiajs/vue3';
import { computed } from 'vue'; import { Trash2 } from 'lucide-vue-next';
import { computed, onBeforeUnmount, ref } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import DeleteUser from '@/components/DeleteUser.vue'; import DeleteUser from '@/components/DeleteUser.vue';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
@@ -12,14 +13,10 @@ import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/profile/index'; import { edit } from '@/routes/profile/index';
import { send } from '@/routes/verification'; import { send } from '@/routes/verification';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem, SettingsProfilePageProps } from '@/types';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
type Props = { defineProps<SettingsProfilePageProps>();
mustVerifyEmail: boolean;
status?: string;
};
defineProps<Props>();
const breadcrumbItems: BreadcrumbItem[] = [ const breadcrumbItems: BreadcrumbItem[] = [
{ {
@@ -30,6 +27,43 @@ const breadcrumbItems: BreadcrumbItem[] = [
const page = usePage(); const page = usePage();
const user = computed(() => page.props.auth.user); const user = computed(() => page.props.auth.user);
const avatarPreview = ref<string | null>(null);
const avatarDeleteForm = useForm({});
const displayedAvatar = computed(
() => avatarPreview.value ?? user.value.avatar,
);
const canRemoveAvatar = computed(
() => user.value.avatar !== null && !user.value.avatar_is_generated,
);
const clearAvatarPreview = (): void => {
if (avatarPreview.value) {
URL.revokeObjectURL(avatarPreview.value);
avatarPreview.value = null;
}
};
const handleAvatarChange = (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
clearAvatarPreview();
if (file) {
avatarPreview.value = URL.createObjectURL(file);
}
};
const removeAvatar = (): void => {
avatarDeleteForm.delete(ProfileController.destroyAvatar.url(), {
preserveScroll: true,
onSuccess: clearAvatarPreview,
});
};
onBeforeUnmount(() => {
clearAvatarPreview();
});
</script> </script>
<template> <template>
@@ -43,14 +77,60 @@ const user = computed(() => page.props.auth.user);
<Heading <Heading
variant="small" variant="small"
title="Profile information" title="Profile information"
description="Update your name and email address" description="Update your name, email address, and profile picture"
/> />
<Form <Form
v-bind="ProfileController.update.form()" v-bind="ProfileController.update.form()"
class="space-y-6" class="space-y-6"
v-slot="{ errors, processing, recentlySuccessful }" v-slot="{
errors,
processing,
progress,
recentlySuccessful,
}"
> >
<div class="grid gap-3">
<Label for="avatar">Profile picture</Label>
<div class="flex items-center gap-4">
<UserAvatar class="size-16" />
<div class="grid flex-1 gap-2">
<div class="flex flex-col gap-2 sm:flex-row">
<Input
id="avatar"
name="avatar"
type="file"
accept="image/png,image/jpeg,image/webp"
@change="handleAvatarChange"
/>
<Button
v-if="canRemoveAvatar"
type="button"
variant="outline"
:disabled="avatarDeleteForm.processing"
data-test="delete-profile-picture-button"
@click="removeAvatar"
>
<Trash2 class="size-4" />
Remove
</Button>
</div>
<p class="text-muted-foreground text-sm">
PNG, JPG, or WebP up to 2 MB.
</p>
</div>
</div>
<progress
v-if="progress"
class="h-1 w-full overflow-hidden rounded-full"
:value="progress.percentage"
max="100"
>
{{ progress.percentage }}%
</progress>
<InputError class="mt-1" :message="errors.avatar" />
</div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="name">Name</Label> <Label for="name">Name</Label>
<Input <Input
+3 -1
View File
@@ -2,7 +2,9 @@ export type UserModel = {
id: number; id: number;
name: string; name: string;
email: string; email: string;
avatar?: string; avatar: string | null;
avatar_color: string | null;
avatar_is_generated: boolean;
email_verified_at: string | null; email_verified_at: string | null;
must_change_password_on_next_login: boolean; must_change_password_on_next_login: boolean;
created_at: string; created_at: string;
+5
View File
@@ -226,6 +226,11 @@ export type SettingsGitConnectionsPageProps = {
status?: string | null; status?: string | null;
}; };
export type SettingsProfilePageProps = {
mustVerifyEmail: boolean;
status?: string | null;
};
export type SettingsApplicationsPageProps = { export type SettingsApplicationsPageProps = {
applicationKey?: string | null; applicationKey?: string | null;
applications: UserApplicationModel[]; applications: UserApplicationModel[];
+1
View File
@@ -16,6 +16,7 @@ Route::middleware('auth')
->group(function (): void { ->group(function (): void {
Route::get('/', 'edit')->name('profile.edit'); Route::get('/', 'edit')->name('profile.edit');
Route::patch('/', 'update')->name('profile.update'); Route::patch('/', 'update')->name('profile.update');
Route::delete('avatar', 'destroyAvatar')->name('profile.avatar.destroy');
}); });
}); });
@@ -5,6 +5,7 @@ namespace Tests\Feature\Admin;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia as Assert; use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase; 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 public function test_admin_can_create_a_user_that_must_change_password_on_next_login(): void
{ {
Storage::fake('public');
$admin = User::factory()->admin()->create(); $admin = User::factory()->admin()->create();
$this->actingAs($admin) $this->actingAs($admin)
@@ -103,6 +106,12 @@ class UserManagementControllerTest extends TestCase
'email' => 'new-member@example.com', 'email' => 'new-member@example.com',
'must_change_password_on_next_login' => true, '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);
} }
/** /**
+13
View File
@@ -2,7 +2,9 @@
namespace Tests\Feature\Auth; namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Laravel\Fortify\Features; use Laravel\Fortify\Features;
use Tests\TestCase; use Tests\TestCase;
@@ -26,6 +28,8 @@ class RegistrationTest extends TestCase
public function test_new_users_can_register() public function test_new_users_can_register()
{ {
Storage::fake('public');
$response = $this->post(route('register.store'), [ $response = $this->post(route('register.store'), [
'name' => 'Test User', 'name' => 'Test User',
'email' => 'test@example.com', 'email' => 'test@example.com',
@@ -35,5 +39,14 @@ class RegistrationTest extends TestCase
$this->assertAuthenticated(); $this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false)); $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));
} }
} }
+90
View File
@@ -5,7 +5,11 @@ namespace Tests\Feature\Models;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use Database\Seeders\UserSeeder; use Database\Seeders\UserSeeder;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase; use Tests\TestCase;
class UserTest extends 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. * 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 public function test_user_seeder_creates_admin_and_regular_users(): void
{ {
Storage::fake('public');
$this->seed(UserSeeder::class); $this->seed(UserSeeder::class);
$admin = User::query()->where('email', 'admin@kanbangit.test')->firstOrFail(); $admin = User::query()->where('email', 'admin@kanbangit.test')->firstOrFail();
@@ -69,6 +150,15 @@ class UserTest extends TestCase
'scope_id' => null, 'scope_id' => null,
'scope_type' => 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);
});
} }
/** /**
@@ -4,6 +4,8 @@ namespace Tests\Feature\Settings;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase; use Tests\TestCase;
class ProfileUpdateTest extends TestCase class ProfileUpdateTest extends TestCase
@@ -23,6 +25,8 @@ class ProfileUpdateTest extends TestCase
public function test_profile_information_can_be_updated() public function test_profile_information_can_be_updated()
{ {
Storage::fake('public');
$user = User::factory()->create(); $user = User::factory()->create();
$response = $this $response = $this
@@ -41,10 +45,15 @@ class ProfileUpdateTest extends TestCase
$this->assertSame('Test User', $user->name); $this->assertSame('Test User', $user->name);
$this->assertSame('test@example.com', $user->email); $this->assertSame('test@example.com', $user->email);
$this->assertNull($user->email_verified_at); $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() public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged()
{ {
Storage::fake('public');
$user = User::factory()->create(); $user = User::factory()->create();
$response = $this $response = $this
@@ -61,9 +70,165 @@ class ProfileUpdateTest extends TestCase
$this->assertNotNull($user->refresh()->email_verified_at); $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, '<svg>OU</svg>');
$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, '<svg>OU</svg>');
$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() public function test_user_can_delete_their_account()
{ {
Storage::fake('public');
$user = User::factory()->create(); $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, '<svg>DU</svg>');
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
@@ -77,6 +242,7 @@ class ProfileUpdateTest extends TestCase
$this->assertGuest(); $this->assertGuest();
$this->assertNull($user->fresh()); $this->assertNull($user->fresh());
Storage::disk('public')->assertMissing('avatars/generated/delete-me.svg');
} }
public function test_correct_password_must_be_provided_to_delete_account() public function test_correct_password_must_be_provided_to_delete_account()