Merge pull request 'Add admin user edit flow' (#77) from koda/137-user-edit-flow into main
Reviewed-on: OpenSource/KanbanGit#77 Reviewed-by: Twyco <twyco@icloud.com>
This commit was merged in pull request #77.
This commit is contained in:
@@ -6,6 +6,7 @@ use App\Actions\Permissions\PermissionService;
|
|||||||
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;
|
||||||
|
use App\Http\Requests\Admin\UpdateManagedUserRequest;
|
||||||
use App\Http\Resources\PermissionOptionResource;
|
use App\Http\Resources\PermissionOptionResource;
|
||||||
use App\Http\Resources\UserResource;
|
use App\Http\Resources\UserResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -56,6 +57,33 @@ class UserManagementController extends Controller
|
|||||||
->with('status', 'User was created.');
|
->with('status', 'User was created.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function edit(User $user): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('admin/users/Edit', [
|
||||||
|
'user' => UserResource::make($user)->resolve(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(UpdateManagedUserRequest $request, User $user): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
$user->fill([
|
||||||
|
'name' => Arr::get($validated, 'name'),
|
||||||
|
'email' => Arr::get($validated, 'email'),
|
||||||
|
'must_change_password_on_next_login' => (bool) Arr::get($validated, 'must_change_password_on_next_login', true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (filled(Arr::get($validated, 'password'))) {
|
||||||
|
$user->password = Arr::get($validated, 'password');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
return to_route('admin.users.index')
|
||||||
|
->with('status', "{$user->name} was updated.");
|
||||||
|
}
|
||||||
|
|
||||||
public function update(SyncUserGlobalPermissionsRequest $request, User $user): RedirectResponse
|
public function update(SyncUserGlobalPermissionsRequest $request, User $user): RedirectResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use App\Concerns\ProfileValidationRules;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
class UpdateManagedUserRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use ProfileValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('access-admin') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
...$this->profileRules($this->route('user')?->id),
|
||||||
|
'must_change_password_on_next_login' => ['required', 'boolean'],
|
||||||
|
'password' => ['nullable', 'string', Password::default(), 'confirmed'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ const props = defineProps<{
|
|||||||
class?: HTMLAttributes['class'];
|
class?: HTMLAttributes['class'];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const model = defineModel<string | number | null>();
|
||||||
|
|
||||||
const showPassword = ref(false);
|
const showPassword = ref(false);
|
||||||
const inputRef = useTemplateRef('inputRef');
|
const inputRef = useTemplateRef('inputRef');
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ defineExpose({
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Input
|
<Input
|
||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
|
v-model="model"
|
||||||
:type="showPassword ? 'text' : 'password'"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
:class="cn('pr-10', props.class)"
|
:class="cn('pr-10', props.class)"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
|
import Heading from '@/components/Heading.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import { edit, index, save } from '@/routes/admin/users';
|
||||||
|
import type { AdminUserEditPageProps, BreadcrumbItem } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps<AdminUserEditPageProps>();
|
||||||
|
|
||||||
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
|
{
|
||||||
|
title: 'User management',
|
||||||
|
href: index(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Edit user',
|
||||||
|
href: edit(props.user.id),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
name: props.user.name,
|
||||||
|
email: props.user.email,
|
||||||
|
password: '',
|
||||||
|
password_confirmation: '',
|
||||||
|
must_change_password_on_next_login:
|
||||||
|
props.user.must_change_password_on_next_login,
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
form.patch(save(props.user.id), {
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
|
<Head :title="`Edit ${user.name}`" />
|
||||||
|
|
||||||
|
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||||
|
<Heading
|
||||||
|
:title="`Edit ${user.name}`"
|
||||||
|
description="Update user details, reset the password if needed, and control whether the user must change it on next login."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>User details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Leave the password fields empty if the current password
|
||||||
|
should stay unchanged.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<form class="space-y-6" @submit.prevent="submit">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
v-model="form.name"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
autocomplete="name"
|
||||||
|
placeholder="Jane Admin"
|
||||||
|
/>
|
||||||
|
<InputError :message="form.errors.name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
v-model="form.email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="jane@example.com"
|
||||||
|
/>
|
||||||
|
<InputError :message="form.errors.email" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="password">New password</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="password"
|
||||||
|
v-model="form.password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="Leave blank to keep current password"
|
||||||
|
/>
|
||||||
|
<InputError :message="form.errors.password" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="password_confirmation"
|
||||||
|
>Confirm new password</Label
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
id="password_confirmation"
|
||||||
|
v-model="form.password_confirmation"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="form.errors.password_confirmation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label
|
||||||
|
for="must_change_password_on_next_login"
|
||||||
|
class="flex items-start gap-3 rounded-lg border px-4 py-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="must_change_password_on_next_login"
|
||||||
|
v-model="form.must_change_password_on_next_login"
|
||||||
|
type="checkbox"
|
||||||
|
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
|
||||||
|
/>
|
||||||
|
<span class="space-y-1">
|
||||||
|
<span class="block font-medium">
|
||||||
|
Must change password on next login
|
||||||
|
</span>
|
||||||
|
<span class="text-muted-foreground block text-sm">
|
||||||
|
Keep the user locked to the security settings page until they save a new password.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<InputError
|
||||||
|
:message="form.errors.must_change_password_on_next_login"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Button type="submit" :disabled="form.processing">
|
||||||
|
Save user
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button as-child variant="outline">
|
||||||
|
<Link :href="index()">Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<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, 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';
|
||||||
@@ -13,7 +13,11 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { create as createAdminUser, index as adminUsers } from '@/routes/admin/users';
|
import {
|
||||||
|
create as createAdminUser,
|
||||||
|
edit as editAdminUser,
|
||||||
|
index as adminUsers,
|
||||||
|
} from '@/routes/admin/users';
|
||||||
import type { AdminUsersPageProps, BreadcrumbItem } from '@/types';
|
import type { AdminUsersPageProps, BreadcrumbItem } from '@/types';
|
||||||
|
|
||||||
const props = defineProps<AdminUsersPageProps>();
|
const props = defineProps<AdminUsersPageProps>();
|
||||||
@@ -166,48 +170,57 @@ 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">
|
||||||
<Form
|
<div class="flex flex-wrap justify-end gap-3">
|
||||||
v-bind="
|
<Button as-child size="sm" variant="outline">
|
||||||
UserManagementController.update.form(
|
<Link :href="editAdminUser(user.id)">
|
||||||
user.id,
|
<Pencil class="size-4" />
|
||||||
)
|
Edit
|
||||||
"
|
</Link>
|
||||||
class="flex flex-wrap justify-end gap-3"
|
|
||||||
v-slot="{ processing }"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
v-for="option in permissionOptions"
|
|
||||||
:key="`${user.id}-${option.value}`"
|
|
||||||
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="permissions[]"
|
|
||||||
:value="option.value"
|
|
||||||
:checked="
|
|
||||||
hasPermission(
|
|
||||||
user.id,
|
|
||||||
option.value,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
|
||||||
/>
|
|
||||||
<span>{{
|
|
||||||
option.label
|
|
||||||
}}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="w-full sm:hidden" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
|
||||||
|
<Form
|
||||||
|
v-bind="
|
||||||
|
UserManagementController.update.form(
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="flex flex-wrap justify-end gap-3"
|
||||||
|
v-slot="{ processing }"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
v-for="option in permissionOptions"
|
||||||
|
:key="`${user.id}-${option.value}`"
|
||||||
|
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="permissions[]"
|
||||||
|
:value="option.value"
|
||||||
|
:checked="
|
||||||
|
hasPermission(
|
||||||
|
user.id,
|
||||||
|
option.value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="border-input text-primary focus:ring-ring size-4 rounded"
|
||||||
|
/>
|
||||||
|
<span>{{
|
||||||
|
option.label
|
||||||
|
}}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="w-full sm:hidden" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="processing"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export type AdminUsersPageProps = {
|
|||||||
status?: string | null;
|
status?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminUserEditPageProps = {
|
||||||
|
user: UserModel;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProjectsIndexPageProps = {
|
export type ProjectsIndexPageProps = {
|
||||||
projects: ProjectModel[];
|
projects: ProjectModel[];
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ Route::middleware('can:access-admin')
|
|||||||
Route::get('/', 'index')->name('index');
|
Route::get('/', 'index')->name('index');
|
||||||
Route::get('create', 'create')->name('create');
|
Route::get('create', 'create')->name('create');
|
||||||
Route::post('/', 'store')->name('store');
|
Route::post('/', 'store')->name('store');
|
||||||
|
Route::get('{user}/edit', 'edit')->name('edit');
|
||||||
|
Route::patch('{user}', 'save')->name('save');
|
||||||
Route::patch('{user}/permissions', 'update')->name('update');
|
Route::patch('{user}/permissions', 'update')->name('update');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,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 Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -61,6 +62,24 @@ class UserManagementControllerTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure admins can view the edit user page.
|
||||||
|
*/
|
||||||
|
public function test_admin_can_view_the_edit_user_page(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$member = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get(route('admin.users.edit', $member))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('admin/users/Edit')
|
||||||
|
->where('user.id', $member->id)
|
||||||
|
->where('user.email', $member->email),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure admins can create managed users.
|
* Ensure admins can create managed users.
|
||||||
*/
|
*/
|
||||||
@@ -120,6 +139,37 @@ class UserManagementControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure admins can update managed users, including password and forced password changes.
|
||||||
|
*/
|
||||||
|
public function test_admin_can_update_a_managed_user(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$member = User::factory()->create([
|
||||||
|
'name' => 'Regular User',
|
||||||
|
'email' => 'member@example.com',
|
||||||
|
'must_change_password_on_next_login' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->patch(route('admin.users.save', $member), [
|
||||||
|
'name' => 'Updated User',
|
||||||
|
'email' => 'updated-user@example.com',
|
||||||
|
'password' => 'fresh-password',
|
||||||
|
'password_confirmation' => 'fresh-password',
|
||||||
|
'must_change_password_on_next_login' => true,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('admin.users.index'))
|
||||||
|
->assertSessionHas('status', 'Updated User was updated.');
|
||||||
|
|
||||||
|
$member->refresh();
|
||||||
|
|
||||||
|
$this->assertSame('Updated User', $member->name);
|
||||||
|
$this->assertSame('updated-user@example.com', $member->email);
|
||||||
|
$this->assertTrue($member->must_change_password_on_next_login);
|
||||||
|
$this->assertTrue(Hash::check('fresh-password', $member->password));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure admins can also clear a user's global permissions.
|
* Ensure admins can also clear a user's global permissions.
|
||||||
*/
|
*/
|
||||||
@@ -188,4 +238,27 @@ class UserManagementControllerTest extends TestCase
|
|||||||
'email' => 'blocked-user@example.com',
|
'email' => 'blocked-user@example.com',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure non-admin users cannot edit managed users.
|
||||||
|
*/
|
||||||
|
public function test_non_admin_cannot_edit_a_user(): void
|
||||||
|
{
|
||||||
|
$member = User::factory()->create();
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($member)
|
||||||
|
->patch(route('admin.users.save', $otherUser), [
|
||||||
|
'name' => 'Blocked User',
|
||||||
|
'email' => 'blocked-user@example.com',
|
||||||
|
'password' => 'fresh-password',
|
||||||
|
'password_confirmation' => 'fresh-password',
|
||||||
|
'must_change_password_on_next_login' => true,
|
||||||
|
])
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('users', [
|
||||||
|
'email' => 'blocked-user@example.com',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user