From 72ff4c7e59628282927945d574f81a961aeaa531 Mon Sep 17 00:00:00 2001 From: Tajim Date: Mon, 24 Aug 2026 22:41:25 +0600 Subject: [PATCH 1/5] feat: add user profile editing endpoint and isolate admin features --- app/Http/Controllers/Admin/UserController.php | 7 +- app/Http/Controllers/ProfileController.php | 39 ++ app/Http/Middleware/HandleInertiaRequests.php | 1 + .../Requests/Profile/UpdateProfileRequest.php | 47 ++ resources/js/components/NavBar.vue | 12 + resources/js/layouts/AdminLayout.vue | 2 - resources/js/pages/Profile.vue | 480 ++++++++++++++++++ routes/web.php | 18 +- tests/Feature/AuthorizationTest.php | 49 ++ 9 files changed, 642 insertions(+), 13 deletions(-) create mode 100644 app/Http/Controllers/ProfileController.php create mode 100644 app/Http/Requests/Profile/UpdateProfileRequest.php create mode 100644 resources/js/pages/Profile.vue diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index c09159f..aff2d25 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -59,16 +59,13 @@ public function edit(User $user) return Inertia::render('admin/users/CreateOrEdit', [ 'user' => $user->load(['roles', 'permissions']), 'permissions' => Permission::select('name')->get(), - 'shouldHideOptions' => (Auth::id() === $user->id && !Auth::user()->can('manage users')), //hide options when showing own profile && don't have manage user permission. + 'shouldHideOptions' => false, ]); } public function update(UpdateUserRequest $request, User $user) { - if ( - ($request->user()->id !== $user->id && - !$request->user()->can('manage users')) || $user->email === "check@example.com" - ) { + if (!$request->user()->can('manage users') || $user->email === "check@example.com") { throw UnauthorizedException::forPermissions(['manage users']); } diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php new file mode 100644 index 0000000..33bafbf --- /dev/null +++ b/app/Http/Controllers/ProfileController.php @@ -0,0 +1,39 @@ + $request->user(), + ]); + } + + public function update(UpdateProfileRequest $request) + { + $user = $request->user(); + $validated = $request->validated(); + + if ($request->hasFile('file')) { + $path = $request->file('file')->store('users/profile-images'); + + if ($user->image_path) { + Storage::delete($user->image_path); + } + + $validated['image_path'] = $path; + } + + $user->update($validated); + + return redirect()->route('profile.edit') + ->with('success', 'Profile updated successfully.'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 36e3e02..1f8a9ce 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -40,6 +40,7 @@ public function share(Request $request): array 'name' => config('app.name'), 'auth' => [ 'user' => $request->user(), + 'can_access_admin' => $request->user()?->can('view admin') ?? false, ], 'flash' => [ 'success' => fn() => $request->session()->get('success'), diff --git a/app/Http/Requests/Profile/UpdateProfileRequest.php b/app/Http/Requests/Profile/UpdateProfileRequest.php new file mode 100644 index 0000000..a75292b --- /dev/null +++ b/app/Http/Requests/Profile/UpdateProfileRequest.php @@ -0,0 +1,47 @@ +user() !== null; + } + + protected function prepareForValidation(): void + { + if (! $this->filled('password')) { + $this->replace($this->except('password')); + } + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + $userId = $this->user()->id; + + return [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'unique:users,email,'.$userId], + 'password' => ['sometimes', 'nullable', 'string', 'min:6'], + 'file' => ['sometimes', 'nullable', 'image', 'max:2048'], + 'about' => ['sometimes', 'nullable', 'string'], + 'title' => ['sometimes', 'nullable', 'string', 'max:255'], + 'institution' => ['sometimes', 'nullable', 'string', 'max:255'], + 'facebook' => ['sometimes', 'nullable', 'string', 'max:255'], + 'instagram' => ['sometimes', 'nullable', 'string', 'max:255'], + 'github' => ['sometimes', 'nullable', 'string', 'max:255'], + ]; + } +} diff --git a/resources/js/components/NavBar.vue b/resources/js/components/NavBar.vue index 0d7593c..034f0af 100644 --- a/resources/js/components/NavBar.vue +++ b/resources/js/components/NavBar.vue @@ -8,6 +8,7 @@ import { Moon, Sun, Monitor, + User, } from 'lucide-vue-next'; import { computed, ref, onMounted, onBeforeUnmount } from 'vue'; import { useDarkMode } from '@/lib/useDarkMode'; @@ -23,6 +24,7 @@ defineProps({ const { theme, toggle } = useDarkMode(); const user = computed(() => usePage().props.auth?.user); +const canAccessAdmin = computed(() => usePage().props.auth?.can_access_admin); const dropdownOpen = ref(false); const dropdownRef = ref(null); @@ -149,6 +151,16 @@ onBeforeUnmount(() => { + + Profile + + + { diff --git a/resources/js/pages/Profile.vue b/resources/js/pages/Profile.vue new file mode 100644 index 0000000..ad4893a --- /dev/null +++ b/resources/js/pages/Profile.vue @@ -0,0 +1,480 @@ + + + diff --git a/routes/web.php b/routes/web.php index cd6af30..714d412 100644 --- a/routes/web.php +++ b/routes/web.php @@ -21,6 +21,13 @@ + use App\Http\Controllers\ProfileController; + + Route::middleware(['throttle:60,1', 'auth'])->group(function () { + Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); + Route::put('/profile', [ProfileController::class, 'update'])->name('profile.update'); + }); + Route::prefix('admin')->middleware(['throttle:45,1', 'auth', 'verified', 'permission:view admin'])->name('admin.')->group(function () { Route::get('/', [DashboardController::class, 'index'])->name('index'); @@ -41,10 +48,6 @@ Route::get('/resources/create/bulk/videos', [AdminResourceController::class, 'createBulkVideos']); Route::get('/resources/edit/{resource}', [AdminResourceController::class, 'edit']); - Route::get('/users', [AdminUserController::class, 'index'])->name("users.index"); - Route::get('/users/create', [AdminUserController::class, 'create']); - Route::get('/users/edit/{user}', [AdminUserController::class, 'edit']); - Route::get('/notice', [AdminNoticeController::class, 'edit'])->name('notice.edit'); Route::get('/subjects/{subject:slug}/nodes/{path?}', [AdminNodeController::class, 'show'])->name('nodes.index')->where('path', '.*'); @@ -75,10 +78,13 @@ Route::delete('/nodes/{node}', [AdminNodeController::class, 'destroy'])->middleware("permission:delete nodes"); Route::middleware('permission:manage users')->group(function () { - Route::delete('/users/{user}', [AdminUserController::class, 'destroy']); + Route::get('/users', [AdminUserController::class, 'index'])->name("users.index"); + Route::get('/users/create', [AdminUserController::class, 'create'])->name("users.create"); + Route::get('/users/edit/{user}', [AdminUserController::class, 'edit'])->name("users.edit"); Route::post('/users', [AdminUserController::class, 'store'])->name("users.store"); + Route::patch('/users/{user}', [AdminUserController::class, 'update'])->name("users.update"); + Route::delete('/users/{user}', [AdminUserController::class, 'destroy'])->name("users.destroy"); }); - Route::patch('/users/{user}', [AdminUserController::class, 'update'])->name("users.update"); }); Route::post('/login', [AuthController::class, 'login']) diff --git a/tests/Feature/AuthorizationTest.php b/tests/Feature/AuthorizationTest.php index 48d4d70..aff3f38 100644 --- a/tests/Feature/AuthorizationTest.php +++ b/tests/Feature/AuthorizationTest.php @@ -53,3 +53,52 @@ $response->assertStatus(200); }); + +test('authenticated user can view their profile', function () { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get('/profile'); + + $response->assertStatus(200); +}); + +test('authenticated user can update their profile', function () { + $user = User::factory()->create([ + 'name' => 'Old Name', + ]); + + $response = $this->actingAs($user)->put('/profile', [ + 'name' => 'New Name', + 'email' => $user->email, + 'title' => 'Engineer', + 'institution' => 'Tech Corp', + 'facebook' => 'https://facebook.com/new', + 'github' => 'https://github.com/new', + 'instagram' => 'https://instagram.com/new', + 'about' => 'Hello world bio', + ]); + + $response->assertRedirect(route('profile.edit')); + $response->assertSessionHas('success', 'Profile updated successfully.'); + expect($user->fresh()->name)->toBe('New Name') + ->and($user->fresh()->title)->toBe('Engineer'); +}); + +test('non-manage-users cannot access admin user edit route', function () { + $admin = adminUserWithPermissions(['view admin']); + $targetUser = User::factory()->create(); + + $response = $this->actingAs($admin)->get("/admin/users/edit/{$targetUser->id}"); + + $response->assertStatus(302); + $response->assertSessionHas('error', 'You do not have permission to perform this action.'); +}); + +test('users with manage users permission can access admin user edit route', function () { + $admin = adminUserWithPermissions(['view admin', 'manage users']); + $targetUser = User::factory()->create(); + + $response = $this->actingAs($admin)->get("/admin/users/edit/{$targetUser->id}"); + + $response->assertStatus(200); +}); From dd26765fffb30b9ec557d166127db527d932ceaf Mon Sep 17 00:00:00 2001 From: Tajim Date: Mon, 24 Aug 2026 22:43:42 +0600 Subject: [PATCH 2/5] fix: make email field immutable on user profile updates --- .../Requests/Profile/UpdateProfileRequest.php | 1 - resources/js/pages/Profile.vue | 23 ++++++------------- tests/Feature/AuthorizationTest.php | 8 ++++--- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/app/Http/Requests/Profile/UpdateProfileRequest.php b/app/Http/Requests/Profile/UpdateProfileRequest.php index a75292b..3d6f805 100644 --- a/app/Http/Requests/Profile/UpdateProfileRequest.php +++ b/app/Http/Requests/Profile/UpdateProfileRequest.php @@ -33,7 +33,6 @@ public function rules(): array return [ 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'email', 'unique:users,email,'.$userId], 'password' => ['sometimes', 'nullable', 'string', 'min:6'], 'file' => ['sometimes', 'nullable', 'image', 'max:2048'], 'about' => ['sometimes', 'nullable', 'string'], diff --git a/resources/js/pages/Profile.vue b/resources/js/pages/Profile.vue index ad4893a..dd9ba59 100644 --- a/resources/js/pages/Profile.vue +++ b/resources/js/pages/Profile.vue @@ -16,7 +16,6 @@ const user = computed(() => page.props.auth?.user); const form = useForm({ _method: 'PUT', name: user.value?.name || '', - email: user.value?.email || '', password: '', file: null as File | null, about: user.value?.about || '', @@ -112,26 +111,18 @@ const submitForm = () => { class="mb-1.5 block text-xs font-semibold text-slate-700 dark:text-gray-300" > Email Address + (Cannot be changed) -

- {{ form.errors.email }} -

diff --git a/tests/Feature/AuthorizationTest.php b/tests/Feature/AuthorizationTest.php index aff3f38..c48ce4c 100644 --- a/tests/Feature/AuthorizationTest.php +++ b/tests/Feature/AuthorizationTest.php @@ -62,14 +62,15 @@ $response->assertStatus(200); }); -test('authenticated user can update their profile', function () { +test('authenticated user can update their profile without changing email', function () { $user = User::factory()->create([ 'name' => 'Old Name', + 'email' => 'original@example.com', ]); $response = $this->actingAs($user)->put('/profile', [ 'name' => 'New Name', - 'email' => $user->email, + 'email' => 'changed@example.com', 'title' => 'Engineer', 'institution' => 'Tech Corp', 'facebook' => 'https://facebook.com/new', @@ -81,7 +82,8 @@ $response->assertRedirect(route('profile.edit')); $response->assertSessionHas('success', 'Profile updated successfully.'); expect($user->fresh()->name)->toBe('New Name') - ->and($user->fresh()->title)->toBe('Engineer'); + ->and($user->fresh()->title)->toBe('Engineer') + ->and($user->fresh()->email)->toBe('original@example.com'); }); test('non-manage-users cannot access admin user edit route', function () { From 75dac486665e06125641a71f973f8ccc2f67465c Mon Sep 17 00:00:00 2001 From: Tajim Date: Mon, 24 Aug 2026 22:44:29 +0600 Subject: [PATCH 3/5] refactor: enforce manage users permission strictly at route level --- app/Http/Controllers/Admin/UserController.php | 4 ++-- app/Http/Requests/User/UpdateUserRequest.php | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index aff2d25..ac67825 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -65,8 +65,8 @@ public function edit(User $user) public function update(UpdateUserRequest $request, User $user) { - if (!$request->user()->can('manage users') || $user->email === "check@example.com") { - throw UnauthorizedException::forPermissions(['manage users']); + if ($user->email === 'check@example.com') { + abort(403, 'This demo user cannot be modified.'); } $validated = $request->validated(); diff --git a/app/Http/Requests/User/UpdateUserRequest.php b/app/Http/Requests/User/UpdateUserRequest.php index e211892..593a0cf 100644 --- a/app/Http/Requests/User/UpdateUserRequest.php +++ b/app/Http/Requests/User/UpdateUserRequest.php @@ -42,14 +42,11 @@ public function rules(): array 'facebook' => ['sometimes', 'nullable', 'string', 'max:255'], 'instagram' => ['sometimes', 'nullable', 'string', 'max:255'], 'github' => ['sometimes', 'nullable', 'string', 'max:255'], + 'role' => ['sometimes', 'string'], + 'permissions' => ['sometimes', 'array'], + 'permissions.*' => ['string', 'exists:permissions,name'], ]; - if ($this->user()->can('manage users')) { - $rules['role'] = ['sometimes', 'string']; - $rules['permissions'] = ['sometimes', 'array']; - $rules['permissions.*'] = ['string', 'exists:permissions,name']; - } - return $rules; } } From b215490d388cb55df6224f730233ebd12284210a Mon Sep 17 00:00:00 2001 From: Tajim Date: Mon, 24 Aug 2026 22:47:47 +0600 Subject: [PATCH 4/5] refactor: remove shouldHideOptions logic from user editing --- app/Http/Controllers/Admin/UserController.php | 1 - resources/js/pages/admin/users/CreateOrEdit.vue | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index ac67825..3580f1c 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -59,7 +59,6 @@ public function edit(User $user) return Inertia::render('admin/users/CreateOrEdit', [ 'user' => $user->load(['roles', 'permissions']), 'permissions' => Permission::select('name')->get(), - 'shouldHideOptions' => false, ]); } diff --git a/resources/js/pages/admin/users/CreateOrEdit.vue b/resources/js/pages/admin/users/CreateOrEdit.vue index 9244823..86226d1 100644 --- a/resources/js/pages/admin/users/CreateOrEdit.vue +++ b/resources/js/pages/admin/users/CreateOrEdit.vue @@ -6,7 +6,6 @@ import { watch } from 'vue'; const props = defineProps({ user: Object, permissions: Array, - shouldHideOptions: Boolean, }); const roles = [ @@ -182,7 +181,7 @@ const submitForm = () => {

-
+