mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
Added user-update API endpoint
- Required changing the docs generator to handle more complex object-style rules. Bit of a hack for some types (password). - Extracted core update logic to repo for sharing with API. - Moved user update language string to align with activity/logging system. - Added tests to cover.
This commit is contained in:
parent
2cd7a48044
commit
9e1c8ec82a
@ -3,11 +3,13 @@
|
|||||||
namespace BookStack\Api;
|
namespace BookStack\Api;
|
||||||
|
|
||||||
use BookStack\Http\Controllers\Api\ApiController;
|
use BookStack\Http\Controllers\Api\ApiController;
|
||||||
|
use Exception;
|
||||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
use ReflectionClass;
|
use ReflectionClass;
|
||||||
use ReflectionException;
|
use ReflectionException;
|
||||||
use ReflectionMethod;
|
use ReflectionMethod;
|
||||||
@ -100,11 +102,36 @@ class ApiDocsGenerator
|
|||||||
$this->controllerClasses[$className] = $class;
|
$this->controllerClasses[$className] = $class;
|
||||||
}
|
}
|
||||||
|
|
||||||
$rules = $class->getValdationRules()[$methodName] ?? [];
|
$rules = collect($class->getValidationRules()[$methodName] ?? [])->map(function($validations) {
|
||||||
|
return array_map(function($validation) {
|
||||||
|
return $this->getValidationAsString($validation);
|
||||||
|
}, $validations);
|
||||||
|
})->toArray();
|
||||||
|
|
||||||
return empty($rules) ? null : $rules;
|
return empty($rules) ? null : $rules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the given validation message to a readable string.
|
||||||
|
*/
|
||||||
|
protected function getValidationAsString($validation): string
|
||||||
|
{
|
||||||
|
if (is_string($validation)) {
|
||||||
|
return $validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_object($validation) && method_exists($validation, '__toString')) {
|
||||||
|
return strval($validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($validation instanceof Password) {
|
||||||
|
return 'min:8';
|
||||||
|
}
|
||||||
|
|
||||||
|
$class = get_class($validation);
|
||||||
|
throw new Exception("Cannot provide string representation of rule for class: {$class}");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse out the description text from a class method comment.
|
* Parse out the description text from a class method comment.
|
||||||
*/
|
*/
|
||||||
|
@ -64,6 +64,7 @@ class UserRepo
|
|||||||
->scopes('withLastActivityAt')
|
->scopes('withLastActivityAt')
|
||||||
->with(['avatar']);
|
->with(['avatar']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all the users with their permissions in a paginated format.
|
* Get all the users with their permissions in a paginated format.
|
||||||
* Note: Due to the use of email search this should only be used when
|
* Note: Due to the use of email search this should only be used when
|
||||||
@ -185,6 +186,44 @@ class UserRepo
|
|||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the given user with the given data.
|
||||||
|
* @param array{name: ?string, email: ?string, external_auth_id: ?string, password: ?string, roles: ?array<int>, language: ?string} $data
|
||||||
|
* @throws UserUpdateException
|
||||||
|
*/
|
||||||
|
public function update(User $user, array $data, bool $manageUsersAllowed): User
|
||||||
|
{
|
||||||
|
if (!empty($data['name'])) {
|
||||||
|
$user->name = $data['name'];
|
||||||
|
$user->refreshSlug();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($data['email']) && $manageUsersAllowed) {
|
||||||
|
$user->email = $data['email'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($data['external_auth_id']) && $manageUsersAllowed) {
|
||||||
|
$user->external_auth_id = $data['external_auth_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['roles']) && $manageUsersAllowed) {
|
||||||
|
$this->setUserRoles($user, $data['roles']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($data['password'])) {
|
||||||
|
$user->password = bcrypt($data['password']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($data['language'])) {
|
||||||
|
setting()->putUser($user, 'language', $data['language']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->save();
|
||||||
|
Activity::add(ActivityType::USER_UPDATE, $user);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the given user from storage, Delete all related content.
|
* Remove the given user from storage, Delete all related content.
|
||||||
*
|
*
|
||||||
|
@ -10,7 +10,6 @@ use Illuminate\Http\JsonResponse;
|
|||||||
abstract class ApiController extends Controller
|
abstract class ApiController extends Controller
|
||||||
{
|
{
|
||||||
protected $rules = [];
|
protected $rules = [];
|
||||||
protected $fieldsToExpose = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provide a paginated listing JSON response in a standard format
|
* Provide a paginated listing JSON response in a standard format
|
||||||
@ -31,7 +30,7 @@ abstract class ApiController extends Controller
|
|||||||
* Get the validation rules for this controller.
|
* Get the validation rules for this controller.
|
||||||
* Defaults to a $rules property but can be a rules() method.
|
* Defaults to a $rules property but can be a rules() method.
|
||||||
*/
|
*/
|
||||||
public function getValdationRules(): array
|
public function getValidationRules(): array
|
||||||
{
|
{
|
||||||
if (method_exists($this, 'rules')) {
|
if (method_exists($this, 'rules')) {
|
||||||
return $this->rules();
|
return $this->rules();
|
||||||
|
@ -6,6 +6,8 @@ use BookStack\Auth\User;
|
|||||||
use BookStack\Auth\UserRepo;
|
use BookStack\Auth\UserRepo;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
use Illuminate\Validation\Rules\Unique;
|
||||||
|
|
||||||
class UserApiController extends ApiController
|
class UserApiController extends ApiController
|
||||||
{
|
{
|
||||||
@ -15,19 +17,33 @@ class UserApiController extends ApiController
|
|||||||
'email', 'created_at', 'updated_at', 'last_activity_at', 'external_auth_id'
|
'email', 'created_at', 'updated_at', 'last_activity_at', 'external_auth_id'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $rules = [
|
public function __construct(UserRepo $userRepo)
|
||||||
|
{
|
||||||
|
$this->userRepo = $userRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function rules(int $userId = null): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
'create' => [
|
'create' => [
|
||||||
],
|
],
|
||||||
'update' => [
|
'update' => [
|
||||||
|
'name' => ['min:2'],
|
||||||
|
'email' => [
|
||||||
|
'min:2',
|
||||||
|
'email',
|
||||||
|
(new Unique('users', 'email'))->ignore($userId ?? null)
|
||||||
|
],
|
||||||
|
'external_auth_id' => ['string'],
|
||||||
|
'language' => ['string'],
|
||||||
|
'password' => [Password::default()],
|
||||||
|
'roles' => ['array'],
|
||||||
|
'roles.*' => ['integer'],
|
||||||
],
|
],
|
||||||
'delete' => [
|
'delete' => [
|
||||||
'migrate_ownership_id' => ['integer', 'exists:users,id'],
|
'migrate_ownership_id' => ['integer', 'exists:users,id'],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(UserRepo $userRepo)
|
|
||||||
{
|
|
||||||
$this->userRepo = $userRepo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -54,10 +70,26 @@ class UserApiController extends ApiController
|
|||||||
{
|
{
|
||||||
$this->checkPermission('users-manage');
|
$this->checkPermission('users-manage');
|
||||||
|
|
||||||
$singleUser = $this->userRepo->getById($id);
|
$user = $this->userRepo->getById($id);
|
||||||
$this->singleFormatter($singleUser);
|
$this->singleFormatter($user);
|
||||||
|
|
||||||
return response()->json($singleUser);
|
return response()->json($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing user in the system.
|
||||||
|
* @throws \BookStack\Exceptions\UserUpdateException
|
||||||
|
*/
|
||||||
|
public function update(Request $request, string $id)
|
||||||
|
{
|
||||||
|
$this->checkPermission('users-manage');
|
||||||
|
|
||||||
|
$data = $this->validate($request, $this->rules($id)['update']);
|
||||||
|
$user = $this->userRepo->getById($id);
|
||||||
|
$this->userRepo->update($user, $data, userCan('users-manage'));
|
||||||
|
$this->singleFormatter($user);
|
||||||
|
|
||||||
|
return response()->json($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -168,51 +168,19 @@ class UserController extends Controller
|
|||||||
$this->preventAccessInDemoMode();
|
$this->preventAccessInDemoMode();
|
||||||
$this->checkPermissionOrCurrentUser('users-manage', $id);
|
$this->checkPermissionOrCurrentUser('users-manage', $id);
|
||||||
|
|
||||||
$this->validate($request, [
|
$validated = $this->validate($request, [
|
||||||
'name' => ['min:2'],
|
'name' => ['min:2'],
|
||||||
'email' => ['min:2', 'email', 'unique:users,email,' . $id],
|
'email' => ['min:2', 'email', 'unique:users,email,' . $id],
|
||||||
'password' => ['required_with:password_confirm', Password::default()],
|
'password' => ['required_with:password_confirm', Password::default()],
|
||||||
'password-confirm' => ['same:password', 'required_with:password'],
|
'password-confirm' => ['same:password', 'required_with:password'],
|
||||||
'setting' => ['array'],
|
'language' => ['string'],
|
||||||
|
'roles' => ['array'],
|
||||||
|
'roles.*' => ['integer'],
|
||||||
'profile_image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
'profile_image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = $this->userRepo->getById($id);
|
$user = $this->userRepo->getById($id);
|
||||||
$user->fill($request->except(['email']));
|
$this->userRepo->update($user, $validated, userCan('users-manage'));
|
||||||
|
|
||||||
// Email updates
|
|
||||||
if (userCan('users-manage') && $request->filled('email')) {
|
|
||||||
$user->email = $request->get('email');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the slug if the user's name has changed
|
|
||||||
if ($user->isDirty('name')) {
|
|
||||||
$user->refreshSlug();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role updates
|
|
||||||
if (userCan('users-manage') && $request->filled('roles')) {
|
|
||||||
$roles = $request->get('roles');
|
|
||||||
$this->userRepo->setUserRoles($user, $roles);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Password updates
|
|
||||||
if ($request->filled('password')) {
|
|
||||||
$password = $request->get('password');
|
|
||||||
$user->password = bcrypt($password);
|
|
||||||
}
|
|
||||||
|
|
||||||
// External auth id updates
|
|
||||||
if (user()->can('users-manage') && $request->filled('external_auth_id')) {
|
|
||||||
$user->external_auth_id = $request->get('external_auth_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save user-specific settings
|
|
||||||
if ($request->filled('setting')) {
|
|
||||||
foreach ($request->get('setting') as $key => $value) {
|
|
||||||
setting()->putUser($user, $key, $value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save profile image if in request
|
// Save profile image if in request
|
||||||
if ($request->hasFile('profile_image')) {
|
if ($request->hasFile('profile_image')) {
|
||||||
@ -220,6 +188,7 @@ class UserController extends Controller
|
|||||||
$this->imageRepo->destroyImage($user->avatar);
|
$this->imageRepo->destroyImage($user->avatar);
|
||||||
$image = $this->imageRepo->saveNew($imageUpload, 'user', $user->id);
|
$image = $this->imageRepo->saveNew($imageUpload, 'user', $user->id);
|
||||||
$user->image_id = $image->id;
|
$user->image_id = $image->id;
|
||||||
|
$user->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the profile image if reset option is in request
|
// Delete the profile image if reset option is in request
|
||||||
@ -227,11 +196,7 @@ class UserController extends Controller
|
|||||||
$this->imageRepo->destroyImage($user->avatar);
|
$this->imageRepo->destroyImage($user->avatar);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->save();
|
$redirectUrl = userCan('users-manage') ? '/settings/users' : "/settings/users/{$user->id}";
|
||||||
$this->showSuccessNotification(trans('settings.users_edit_success'));
|
|
||||||
$this->logActivity(ActivityType::USER_UPDATE, $user);
|
|
||||||
|
|
||||||
$redirectUrl = userCan('users-manage') ? '/settings/users' : ('/settings/users/' . $user->id);
|
|
||||||
|
|
||||||
return redirect($redirectUrl);
|
return redirect($redirectUrl);
|
||||||
}
|
}
|
||||||
|
@ -23,6 +23,7 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
public function boot()
|
public function boot()
|
||||||
{
|
{
|
||||||
// Password Configuration
|
// Password Configuration
|
||||||
|
// Changes here must be reflected in ApiDocsGenerate@getValidationAsString.
|
||||||
Password::defaults(function () {
|
Password::defaults(function () {
|
||||||
return Password::min(8);
|
return Password::min(8);
|
||||||
});
|
});
|
||||||
|
@ -60,6 +60,7 @@ return [
|
|||||||
'webhook_delete_notification' => 'Webhook successfully deleted',
|
'webhook_delete_notification' => 'Webhook successfully deleted',
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
|
'user_update_notification' => 'User successfully updated',
|
||||||
'user_delete_notification' => 'User successfully removed',
|
'user_delete_notification' => 'User successfully removed',
|
||||||
|
|
||||||
// Other
|
// Other
|
||||||
|
@ -190,7 +190,6 @@ return [
|
|||||||
'users_none_selected' => 'No user selected',
|
'users_none_selected' => 'No user selected',
|
||||||
'users_edit' => 'Edit User',
|
'users_edit' => 'Edit User',
|
||||||
'users_edit_profile' => 'Edit Profile',
|
'users_edit_profile' => 'Edit Profile',
|
||||||
'users_edit_success' => 'User successfully updated',
|
|
||||||
'users_avatar' => 'User Avatar',
|
'users_avatar' => 'User Avatar',
|
||||||
'users_avatar_desc' => 'Select an image to represent this user. This should be approx 256px square.',
|
'users_avatar_desc' => 'Select an image to represent this user. This should be approx 256px square.',
|
||||||
'users_preferred_language' => 'Preferred Language',
|
'users_preferred_language' => 'Preferred Language',
|
||||||
|
@ -9,7 +9,7 @@ $value - Currently selected lanuage value
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<select name="setting[language]" id="user-language">
|
<select name="language" id="user-language">
|
||||||
@foreach(trans('settings.language_select') as $lang => $label)
|
@foreach(trans('settings.language_select') as $lang => $label)
|
||||||
<option @if($value === $lang) selected @endif value="{{ $lang }}">{{ $label }}</option>
|
<option @if($value === $lang) selected @endif value="{{ $lang }}">{{ $label }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
@ -69,4 +69,5 @@ Route::delete('shelves/{id}', [BookshelfApiController::class, 'delete']);
|
|||||||
|
|
||||||
Route::get('users', [UserApiController::class, 'list']);
|
Route::get('users', [UserApiController::class, 'list']);
|
||||||
Route::get('users/{id}', [UserApiController::class, 'read']);
|
Route::get('users/{id}', [UserApiController::class, 'read']);
|
||||||
|
Route::put('users/{id}', [UserApiController::class, 'update']);
|
||||||
Route::delete('users/{id}', [UserApiController::class, 'delete']);
|
Route::delete('users/{id}', [UserApiController::class, 'delete']);
|
@ -4,6 +4,8 @@ namespace Tests\Api;
|
|||||||
|
|
||||||
use BookStack\Auth\Role;
|
use BookStack\Auth\Role;
|
||||||
use BookStack\Auth\User;
|
use BookStack\Auth\User;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class UsersApiTest extends TestCase
|
class UsersApiTest extends TestCase
|
||||||
@ -70,6 +72,53 @@ class UsersApiTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_update_endpoint()
|
||||||
|
{
|
||||||
|
$this->actingAsApiAdmin();
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getAdmin();
|
||||||
|
$roles = Role::query()->pluck('id');
|
||||||
|
$resp = $this->putJson($this->baseEndpoint . "/{$user->id}", [
|
||||||
|
'name' => 'My updated user',
|
||||||
|
'email' => 'barrytest@example.com',
|
||||||
|
'roles' => $roles,
|
||||||
|
'external_auth_id' => 'btest',
|
||||||
|
'password' => 'barrytester',
|
||||||
|
'language' => 'fr',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resp->assertStatus(200);
|
||||||
|
$resp->assertJson([
|
||||||
|
'id' => $user->id,
|
||||||
|
'name' => 'My updated user',
|
||||||
|
'email' => 'barrytest@example.com',
|
||||||
|
'external_auth_id' => 'btest',
|
||||||
|
]);
|
||||||
|
$user->refresh();
|
||||||
|
$this->assertEquals('fr', setting()->getUser($user, 'language'));
|
||||||
|
$this->assertEquals(count($roles), $user->roles()->count());
|
||||||
|
$this->assertNotEquals('barrytester', $user->password);
|
||||||
|
$this->assertTrue(Hash::check('barrytester', $user->password));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_endpoint_does_not_remove_info_if_not_provided()
|
||||||
|
{
|
||||||
|
$this->actingAsApiAdmin();
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getAdmin();
|
||||||
|
$roleCount = $user->roles()->count();
|
||||||
|
$resp = $this->putJson($this->baseEndpoint . "/{$user->id}", []);
|
||||||
|
|
||||||
|
$resp->assertStatus(200);
|
||||||
|
$this->assertDatabaseHas('users', [
|
||||||
|
'id' => $user->id,
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'password' => $user->password,
|
||||||
|
]);
|
||||||
|
$this->assertEquals($roleCount, $user->roles()->count());
|
||||||
|
}
|
||||||
|
|
||||||
public function test_delete_endpoint()
|
public function test_delete_endpoint()
|
||||||
{
|
{
|
||||||
$this->actingAsApiAdmin();
|
$this->actingAsApiAdmin();
|
||||||
|
Loading…
Reference in New Issue
Block a user