#2: Implemented the user creation tool.

This commit is contained in:
Peter Deltchev 2016-06-11 22:55:27 -07:00
parent 84d51fee2f
commit 09effb6955
20 changed files with 359 additions and 17 deletions

View file

@ -30,11 +30,16 @@ class CommandResponse
private $_validator; private $_validator;
private $_response; private $_response;
private $_didFail; private $_didFail;
/**
* @var int Used for HTTP responses.
*/
private $_statusCode;
public static function fail($validatorOrMessages) public static function fail($validatorOrMessages, int $statusCode = 400)
{ {
$response = new CommandResponse(); $response = new CommandResponse();
$response->_didFail = true; $response->_didFail = true;
$response->_statusCode = $statusCode;
if (is_array($validatorOrMessages)) { if (is_array($validatorOrMessages)) {
$response->_messages = $validatorOrMessages; $response->_messages = $validatorOrMessages;
@ -47,11 +52,12 @@ class CommandResponse
return $response; return $response;
} }
public static function succeed($response = null) public static function succeed($response = null, int $statusCode = 200)
{ {
$cmdResponse = new CommandResponse(); $cmdResponse = new CommandResponse();
$cmdResponse->_didFail = false; $cmdResponse->_didFail = false;
$cmdResponse->_response = $response; $cmdResponse->_response = $response;
$cmdResponse->_statusCode = $statusCode;
return $cmdResponse; return $cmdResponse;
} }
@ -76,6 +82,14 @@ class CommandResponse
return $this->_response; return $this->_response;
} }
/**
* @return int
*/
public function getStatusCode():int
{
return $this->_statusCode;
}
/** /**
* @return Validator * @return Validator
*/ */

View file

@ -0,0 +1,93 @@
<?php
/**
* Pony.fm - A community for pony fan music.
* Copyright (C) 2016 Peter Deltchev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Poniverse\Ponyfm\Commands;
use Gate;
use Poniverse\Ponyfm\Models\User;
use Validator;
class CreateUserCommand extends CommandBase
{
private $username;
private $displayName;
private $email;
private $createArchivedUser;
public function __construct(
string $username,
string $displayName,
string $email = null,
bool $createArchivedUser = false
) {
$this->username = $username;
$this->displayName = $displayName;
$this->email = $email;
$this->createArchivedUser = $createArchivedUser;
}
/**
* @return bool
*/
public function authorize()
{
return Gate::allows('create-user');
}
/**
* @throws \Exception
* @return CommandResponse
*/
public function execute()
{
$rules = [
'username' => config('ponyfm.validation_rules.username'),
'display_name' => config('ponyfm.validation_rules.display_name'),
'email' => 'email',
'create_archived_user' => 'boolean',
];
$validator = Validator::make([
'username' => $this->username,
'display_name' => $this->displayName,
], $rules);
if ($validator->fails()) {
return CommandResponse::fail([
'message' => $validator->getMessageBag()->first(),
'user' => null
]);
}
// Attempt to create the user.
$user = User::findOrCreate($this->username, $this->displayName, $this->email, $this->createArchivedUser);
if ($user->wasRecentlyCreated) {
return CommandResponse::succeed([
'message' => 'New user successfully created!',
'user' => User::mapPublicUserSummary($user)
], 201);
} else {
return CommandResponse::fail([
'message' => 'A user with that username already exists.',
'user' => User::mapPublicUserSummary($user)
], 409);
}
}
}

View file

@ -89,7 +89,7 @@ class SaveAccountSettingsCommand extends CommandBase
'slug' => [ 'slug' => [
'required', 'required',
'unique:users,slug,'.$this->_user->id, 'unique:users,slug,'.$this->_user->id,
'min:3', 'min:'.config('ponyfm.user_slug_minimum_length'),
'regex:/^[a-z\d-]+$/', 'regex:/^[a-z\d-]+$/',
'is_not_reserved_slug' 'is_not_reserved_slug'
] ]

View file

@ -44,4 +44,9 @@ class AdminController extends Controller
{ {
return View::make('shared.null'); return View::make('shared.null');
} }
public function getUsers()
{
return View::make('shared.null');
}
} }

View file

@ -21,6 +21,7 @@
namespace Poniverse\Ponyfm\Http\Controllers\Api\Web; namespace Poniverse\Ponyfm\Http\Controllers\Api\Web;
use Gate; use Gate;
use Poniverse\Ponyfm\Commands\CreateUserCommand;
use Poniverse\Ponyfm\Models\Album; use Poniverse\Ponyfm\Models\Album;
use Poniverse\Ponyfm\Models\Comment; use Poniverse\Ponyfm\Models\Comment;
use Poniverse\Ponyfm\Models\Favourite; use Poniverse\Ponyfm\Models\Favourite;
@ -29,9 +30,9 @@ use Poniverse\Ponyfm\Models\Image;
use Poniverse\Ponyfm\Models\Track; use Poniverse\Ponyfm\Models\Track;
use Poniverse\Ponyfm\Models\User; use Poniverse\Ponyfm\Models\User;
use Poniverse\Ponyfm\Models\Follower; use Poniverse\Ponyfm\Models\Follower;
use Illuminate\Support\Facades\App; use App;
use Illuminate\Support\Facades\Input; use Input;
use Illuminate\Support\Facades\Response; use Response;
use ColorThief\ColorThief; use ColorThief\ColorThief;
use Helpers; use Helpers;
@ -228,4 +229,9 @@ class ArtistsController extends ApiControllerBase
return Response::json(["artists" => $users, "current_page" => $page, "total_pages" => ceil($count / $perPage)], return Response::json(["artists" => $users, "current_page" => $page, "total_pages" => ceil($count / $perPage)],
200); 200);
} }
public function postIndex() {
$name = Input::json('username');
return $this->execute(new CreateUserCommand($name, $name, null, true));
}
} }

View file

@ -43,10 +43,10 @@ abstract class ApiControllerBase extends Controller
return Response::json([ return Response::json([
'message' => 'Validation failed', 'message' => 'Validation failed',
'errors' => $result->getMessages() 'errors' => $result->getMessages()
], 400); ], $result->getStatusCode());
} }
return Response::json($result->getResponse(), 200); return Response::json($result->getResponse(), $result->getStatusCode());
} }
public function notAuthorized() public function notAuthorized()

View file

@ -101,6 +101,7 @@ Route::group(['prefix' => 'api/web'], function() {
Route::get('/comments/{type}/{id}', 'Api\Web\CommentsController@getIndex')->where('id', '\d+'); Route::get('/comments/{type}/{id}', 'Api\Web\CommentsController@getIndex')->where('id', '\d+');
Route::get('/artists', 'Api\Web\ArtistsController@getIndex'); Route::get('/artists', 'Api\Web\ArtistsController@getIndex');
Route::post('/artists', 'Api\Web\ArtistsController@postIndex');
Route::get('/artists/{slug}', 'Api\Web\ArtistsController@getShow'); Route::get('/artists/{slug}', 'Api\Web\ArtistsController@getShow');
Route::get('/artists/{slug}/content', 'Api\Web\ArtistsController@getContent'); Route::get('/artists/{slug}/content', 'Api\Web\ArtistsController@getContent');
Route::get('/artists/{slug}/favourites', 'Api\Web\ArtistsController@getFavourites'); Route::get('/artists/{slug}/favourites', 'Api\Web\ArtistsController@getFavourites');
@ -175,6 +176,7 @@ Route::group(['prefix' => 'admin', 'middleware' => ['auth', 'can:access-admin-ar
Route::get('/genres', 'AdminController@getGenres'); Route::get('/genres', 'AdminController@getGenres');
Route::get('/tracks', 'AdminController@getTracks'); Route::get('/tracks', 'AdminController@getTracks');
Route::get('/show-songs', 'AdminController@getShowSongs'); Route::get('/show-songs', 'AdminController@getShowSongs');
Route::get('/users', 'AdminController@getUsers');
Route::get('/', 'AdminController@getIndex'); Route::get('/', 'AdminController@getIndex');
}); });

View file

@ -20,6 +20,7 @@
namespace Poniverse\Ponyfm\Models; namespace Poniverse\Ponyfm\Models;
use DB;
use Gravatar; use Gravatar;
use Illuminate\Auth\Authenticatable; use Illuminate\Auth\Authenticatable;
use Illuminate\Auth\Passwords\CanResetPassword; use Illuminate\Auth\Passwords\CanResetPassword;
@ -114,6 +115,12 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/ */
private static function getUniqueSlugForName(string $name):string { private static function getUniqueSlugForName(string $name):string {
$baseSlug = Str::slug($name); $baseSlug = Str::slug($name);
// Ensure that the slug we generate is long enough.
for ($i = Str::length($baseSlug); $i < config('ponyfm.user_slug_minimum_length'); $i++) {
$baseSlug = $baseSlug.'-';
}
$slugBeingTried = $baseSlug; $slugBeingTried = $baseSlug;
$counter = 2; $counter = 2;
@ -135,15 +142,23 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
} }
/** /**
* @param string $username * @param string $username used to perform the search
* @param string $displayName * @param string $displayName
* @param string $email * @param string|null $email set to null if creating an archived user
* @param bool $createArchivedUser if true, includes archived users in the search and creates an archived user
* @return User * @return User
*/ */
public static function findOrCreate(string $username, string $displayName, string $email) { public static function findOrCreate(
$user = static::where('username', $username) string $username,
->where('is_archived', false) string $displayName,
->first(); string $email = null,
bool $createArchivedUser = false
) {
$user = static::where(DB::raw('LOWER(username)'), Str::lower($username));
if (false === $createArchivedUser) {
$user = $user->where('is_archived', false);
}
$user = $user->first();
if (null !== $user) { if (null !== $user) {
return $user; return $user;
@ -156,7 +171,10 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
$user->slug = self::getUniqueSlugForName($displayName); $user->slug = self::getUniqueSlugForName($displayName);
$user->email = $email; $user->email = $email;
$user->uses_gravatar = true; $user->uses_gravatar = true;
$user->is_archived = $createArchivedUser;
$user->save(); $user->save();
$user = $user->fresh();
$user->wasRecentlyCreated = true;
return $user; return $user;
} }

View file

@ -68,6 +68,10 @@ class AuthServiceProvider extends ServiceProvider
return $user->hasRole('admin'); return $user->hasRole('admin');
}); });
$gate->define('create-user', function(User $user) {
return $user->hasRole('admin');
});
$this->registerPolicies($gate); $this->registerPolicies($gate);
} }
} }

View file

@ -89,4 +89,31 @@ return [
'indexing_queue' => 'indexing', 'indexing_queue' => 'indexing',
/*
|--------------------------------------------------------------------------
| Global validation rules
|--------------------------------------------------------------------------
|
| Data fields that are validated in multiple places have their validation
| rules centralized here.
|
*/
'validation_rules' => [
'username' => ['required', 'min:3', 'max:26'],
'display_name' => ['required', 'min:3', 'max:26'],
],
/*
|--------------------------------------------------------------------------
| Minimum length of a user slug
|--------------------------------------------------------------------------
|
| No profile slugs shorter than this will be generated. This setting is
| intended to pre-emptively avoid collisions with very short URL's that may
| be desirable for future site functionality.
|
*/
'user_slug_minimum_length' => 3
]; ];

View file

@ -9,7 +9,7 @@
"packages": {}, "packages": {},
"dependencies": {}, "dependencies": {},
"devDependencies": { "devDependencies": {
"angular": "^1.5.0", "angular": "^1.5.6",
"angular-chart.js": "^1.0.0-alpha6", "angular-chart.js": "^1.0.0-alpha6",
"angular-strap": "^2.3.8", "angular-strap": "^2.3.8",
"angular-ui-router": "^0.2.18", "angular-ui-router": "^0.2.18",

View file

@ -2,6 +2,7 @@
<li ng-class="{active: stateIncludes('admin.tracks')}"><a href="/admin/tracks">All Tracks</a></li> <li ng-class="{active: stateIncludes('admin.tracks')}"><a href="/admin/tracks">All Tracks</a></li>
<li ng-class="{active: stateIncludes('admin.genres')}"><a href="/admin/genres">Genres</a></li> <li ng-class="{active: stateIncludes('admin.genres')}"><a href="/admin/genres">Genres</a></li>
<li ng-class="{active: stateIncludes('admin.showsongs')}"><a href="/admin/show-songs">Show Songs</a></li> <li ng-class="{active: stateIncludes('admin.showsongs')}"><a href="/admin/show-songs">Show Songs</a></li>
<li ng-class="{active: stateIncludes('admin.users')}"><a ui-sref="admin.users">Users</a></li>
</ul> </ul>
<ui-view></ui-view> <ui-view></ui-view>

View file

@ -0,0 +1,21 @@
<h1>User Management</h1>
<div class="stretch-to-bottom">
<section class="user-creator">
<h2>Create an archived artist profile</h2>
<p>Archived profiles are for organizing music by artists who aren&#39;t on Pony.fm themselves. They can always be claimed by the artist and converted to a &quot;proper&quot; profile later.</p>
<pfm-user-creator></pfm-user-creator>
</section>
<section class="user-search">
<h2>Search for a user by name</h2>
<p>Use the universal search in the sidebar. /)</p>
</section>
<section class="user-merger">
<h2>Merge profiles</h2>
<p>Ask a Pony.fm developer or sysadmin to run the following for you: <pre>./artisan accounts:merge</pre></p>
</section>
</div>

View file

@ -0,0 +1,32 @@
<form
class="user-creator form-inline"
ng-submit="$ctrl.createUser()"
>
<input
type="text"
class="-usernameField form-control x-large"
ng-class="{'x-saving': $ctrl.isCreating, 'x-error': !$ctrl.creationSucceeded && $ctrl.creationMessage}"
ng-model="$ctrl.usernameToCreate"
placeholder="Username"
/>
<input class="-submitButton form-control" type="submit" value="Create!"/>
<div
class="-createdUser"
ng-if="$ctrl.creationMessage"
ng-class="{'alert-success': $ctrl.creationSucceeded, 'alert-warning': !$ctrl.creationSucceeded}"
>
<strong>{{ $ctrl.creationMessage }}</strong>
<ul ng-if="$ctrl.user">
<li ng-if="!$ctrl.user.is_archived"><span class="label label-warning">Caution</span> This is <strong>NOT</strong> an archived profile.</li>
<li ng-if="$ctrl.user.is_archived">This is an archived profile.</li>
<li><a class="-profile-link"
ui-sref="content.artist.account.uploader({slug: $ctrl.user.slug})">
Go to <strong>{{$ctrl.user.name}}&#39;s</strong> track uploader!</a>
</li>
<li><a ng-href="{{$ctrl.user.url}}">Visit <strong>{{$ctrl.user.name}}&#39;s</strong> profile!</a></li>
</ul>
</div>
</form>

View file

@ -283,6 +283,11 @@ ponyfm.config [
controller: 'admin-tracks' controller: 'admin-tracks'
templateUrl: '/templates/admin/tracks.html' templateUrl: '/templates/admin/tracks.html'
state.state 'admin.users',
url: '/users'
controller: 'admin-users'
templateUrl: '/templates/admin/users.html'
# Homepage # Homepage
if window.pfm.auth.isLogged if window.pfm.auth.isLogged

View file

@ -0,0 +1,22 @@
# Pony.fm - A community for pony fan music.
# Copyright (C) 2016 Peter Deltchev
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
module.exports = angular.module('ponyfm').controller 'admin-users', [
'meta'
(meta) ->
meta.setTitle('🔒 User management')
]

View file

@ -0,0 +1,51 @@
# Pony.fm - A community for pony fan music.
# Copyright (C) 2016 Peter Deltchev
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
pfmUserCreatorController = [
'artists',
(artists)->
ctrl = this
ctrl.usernameToCreate = ''
ctrl.isCreating = false
ctrl.creationMessage = null
ctrl.creationSucceeded = false
ctrl.user = null
ctrl.createUser = ->
ctrl.isCreating = true
ctrl.creationMessage = null
ctrl.creationSucceeded = false
artists.create(ctrl.usernameToCreate)
.then (response)->
ctrl.creationSucceeded = true
ctrl.user = response.data.user
ctrl.creationMessage = response.data.message
.catch (response)->
ctrl.creationSucceeded = false
ctrl.user = response.data.errors.user
ctrl.creationMessage = response.data.errors.message
.finally ->
ctrl.isCreating = false
return ctrl
]
module.exports = angular.module('ponyfm').component('pfmUserCreator', {
templateUrl: '/templates/directives/user-creator.html',
controller: pfmUserCreatorController,
})

View file

@ -15,8 +15,8 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
module.exports = angular.module('ponyfm').factory('artists', [ module.exports = angular.module('ponyfm').factory('artists', [
'$rootScope', '$http' '$rootScope', '$http', '$q'
($rootScope, $http) -> ($rootScope, $http, $q) ->
artistPage = [] artistPage = []
artists = {} artists = {}
artistContent = {} artistContent = {}
@ -72,5 +72,8 @@ module.exports = angular.module('ponyfm').factory('artists', [
artistFavourites[slug] = artistsDef.promise() artistFavourites[slug] = artistsDef.promise()
create: (username) ->
$http.post('/api/web/artists', {username: username})
self self
]) ])

View file

@ -35,4 +35,5 @@
@import 'components/uploader'; @import 'components/uploader';
@import 'components/search'; @import 'components/search';
@import 'components/track-editor'; @import 'components/track-editor';
@import 'components/user-creator';
@import 'mobile'; @import 'mobile';

View file

@ -0,0 +1,37 @@
/**
* Pony.fm - A community for pony fan music.
* Copyright (C) 2016 Peter Deltchev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@import '../base/bootstrap/bootstrap';
@import '../mixins';
.user-creator {
margin-bottom: 1em;
.-usernameField {
}
.-submitButton {
.btn;
.btn-primary;
}
.-createdUser {
.alert;
margin-top: 1em;
}
}