mirror of
https://github.com/Kovah/LinkAce.git
synced 2025-04-21 23:42:10 +02:00
WIP: Add user registration with invitation
This commit is contained in:
parent
95cc1bac48
commit
2b80d9a8a2
@ -2,10 +2,11 @@
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Actions\Settings\SetDefaultSettingsForUser;
|
||||
use App\Models\User;
|
||||
use App\Settings\UserSettings;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
@ -35,10 +36,15 @@ class CreateNewUser implements CreatesNewUsers
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
return User::create([
|
||||
$user = User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
'api_token' => Str::random(32),
|
||||
]);
|
||||
|
||||
(new SetDefaultSettingsForUser($user))->up();
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
19
app/Actions/Fortify/CreateUserInvitation.php
Normal file
19
app/Actions/Fortify/CreateUserInvitation.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\UserInvitation;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CreateUserInvitation
|
||||
{
|
||||
public static function run(string $email)
|
||||
{
|
||||
return UserInvitation::create([
|
||||
'token' => Str::random(32),
|
||||
'email' => $email,
|
||||
'inviter_id' => auth()->id(),
|
||||
'valid_until' => now()->addDays(3),
|
||||
]);
|
||||
}
|
||||
}
|
@ -33,15 +33,13 @@ class RegisterUserCommand extends Command
|
||||
|
||||
$password = $this->secret('Please enter a password for ' . $name);
|
||||
|
||||
$user = (new CreateNewUser)->create([
|
||||
(new CreateNewUser)->create([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'password_confirmation' => $password,
|
||||
]);
|
||||
|
||||
(new SetDefaultSettingsForUser($user))->up();
|
||||
|
||||
$this->info('User ' . $name . ' registered.');
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Actions\Fortify\CreateUserInvitation;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\InviteUserRequest;
|
||||
use App\Models\User;
|
||||
@ -9,7 +10,6 @@ use App\Models\UserInvitation;
|
||||
use App\Notifications\UserInviteNotification;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Str;
|
||||
use OwenIt\Auditing\Events\AuditCustom;
|
||||
|
||||
class UserManagementController extends Controller
|
||||
@ -26,27 +26,13 @@ class UserManagementController extends Controller
|
||||
|
||||
public function inviteUser(InviteUserRequest $request): RedirectResponse
|
||||
{
|
||||
$invitation = UserInvitation::create([
|
||||
'token' => Str::random(32),
|
||||
'email' => $request->input('email'),
|
||||
'inviter_id' => $request->user()->id,
|
||||
'valid_until' => now()->addDays(3),
|
||||
]);
|
||||
|
||||
$invitation = CreateUserInvitation::run($request->input('email'));
|
||||
$invitation->notify(new UserInviteNotification());
|
||||
|
||||
flash()->warning(trans('admin.user_management.invite_successful'));
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
public function acceptInvitation()
|
||||
{
|
||||
// @TODO
|
||||
// check if request is valid, check if invitation with token was found
|
||||
// present view with registration form and pre-filled email
|
||||
// handle user registration
|
||||
}
|
||||
|
||||
public function deleteInvitation(UserInvitation $invitation): RedirectResponse
|
||||
{
|
||||
$invitation->delete();
|
||||
|
55
app/Http/Controllers/RegistrationController.php
Normal file
55
app/Http/Controllers/RegistrationController.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Http\Requests\Auth\RegisterRequest;
|
||||
use App\Models\UserInvitation;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class RegistrationController extends Controller
|
||||
{
|
||||
public function acceptInvitation(Request $request)
|
||||
{
|
||||
if (!$request->hasValidSignature()) {
|
||||
abort(401, trans('admin.user_management.invite_link_invalid'));
|
||||
}
|
||||
|
||||
$token = $request->input('token');
|
||||
$invitation = UserInvitation::where('token', $token)->first();
|
||||
|
||||
if ($invitation === null) {
|
||||
abort(401, trans('admin.user_management.invite_token_invalid'));
|
||||
}
|
||||
|
||||
if (!$invitation->isValid()) {
|
||||
abort(401, trans('admin.user_management.invite_expired'));
|
||||
}
|
||||
|
||||
return view('auth.register', [
|
||||
'invitation' => $invitation,
|
||||
]);
|
||||
}
|
||||
|
||||
public function register(RegisterRequest $request)
|
||||
{
|
||||
$invitation = UserInvitation::where('token', $request->input('token'))->first();
|
||||
|
||||
if ($invitation === null) {
|
||||
abort(401, trans('admin.user_management.invite_token_invalid'));
|
||||
}
|
||||
|
||||
if (!$invitation->isValid()) {
|
||||
abort(401, trans('admin.user_management.invite_expired'));
|
||||
}
|
||||
|
||||
$newUser = (new CreateNewUser())->create($request->input());
|
||||
Auth::login($newUser, true);
|
||||
|
||||
$invitation->created_user_id = $newUser->id;
|
||||
$invitation->save();
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
}
|
30
app/Http/Requests/Auth/RegisterRequest.php
Normal file
30
app/Http/Requests/Auth/RegisterRequest.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use App\Actions\Fortify\PasswordValidationRules;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class RegisterRequest extends FormRequest
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
public function rules(Request $request): array
|
||||
{
|
||||
return [
|
||||
'token' => ['required'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class),
|
||||
],
|
||||
'password' => $this->passwordRules(),
|
||||
];
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use OwenIt\Auditing\Auditable as AuditableTrait;
|
||||
use OwenIt\Auditing\Contracts\Auditable;
|
||||
use OwenIt\Auditing\Redactors\RightRedactor;
|
||||
@ -27,6 +28,10 @@ class UserInvitation extends Model implements Auditable
|
||||
'token',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'valid_until' => 'datetime',
|
||||
];
|
||||
|
||||
/*
|
||||
* ========================================================================
|
||||
* AUDIT SETTINGS
|
||||
@ -65,4 +70,24 @@ class UserInvitation extends Model implements Auditable
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_user_id');
|
||||
}
|
||||
|
||||
/*
|
||||
* ========================================================================
|
||||
* METHODS
|
||||
*/
|
||||
|
||||
public function inviteUrl(): string
|
||||
{
|
||||
return URL::temporarySignedRoute('auth.accept-invite', $this->valid_until, ['token' => $this->token]);
|
||||
}
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
return $this->valid_until->gt(now()) && $this->created_user_id === null;
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->created_user_id !== null;
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,6 @@ use App\Models\UserInvitation;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class UserInviteNotification extends Notification
|
||||
{
|
||||
@ -26,7 +25,7 @@ class UserInviteNotification extends Notification
|
||||
return (new MailMessage)
|
||||
->subject(trans('admin.user_management.invite_notification_title'))
|
||||
->line(trans('admin.user_management.invite_notification'))
|
||||
->action(trans('admin.user_management.invite_accept'), $this->inviteUrl($invitation))
|
||||
->action(trans('admin.user_management.invite_accept'), $invitation->inviteUrl())
|
||||
->line(trans('admin.user_management.invite_valid_until_info', ['datetime' => $invitation->valid_until]));
|
||||
}
|
||||
|
||||
@ -34,16 +33,7 @@ class UserInviteNotification extends Notification
|
||||
{
|
||||
return [
|
||||
'invitation' => $invitation,
|
||||
'inviteUrl' => $this->inviteUrl($invitation),
|
||||
'inviteUrl' => $invitation->inviteUrl(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function inviteUrl(UserInvitation $invitation): string
|
||||
{
|
||||
return URL::temporarySignedRoute(
|
||||
'user-management-accept-invite',
|
||||
$invitation->valid_until,
|
||||
['token' => $invitation->token]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -9,11 +9,16 @@ return [
|
||||
'invite_delete_confirmation' => 'Do you really want to delete this Invitation?',
|
||||
'invite_successful' => 'The invitation was sent successfully.',
|
||||
'invite_accept' => 'Accept invitation',
|
||||
'invite_accepted_by' => 'Invitation was accepted by :user (ID :id)',
|
||||
'invite_delete_successful' => 'Invitation to :email was deleted successfully.',
|
||||
|
||||
'invite_notification_title' => 'You have been invited to join LinkAce!',
|
||||
'invite_notification' => 'You have been invited to join LinkAce, a social bookmarking tool. Click the button below to set up your user account. If you did not request an invitation or do not expect one, please ignore this email or contact your administrator.',
|
||||
|
||||
'invite_link_invalid' => 'The invitation is expired or the link is incorrect. Please contact your administrator.',
|
||||
'invite_token_invalid' => 'The invitation link is invalid or the invitation was deleted.',
|
||||
'invite_expired' => 'The invitation is expired or was already used. Please contact your administrator to receive a new invitation.',
|
||||
|
||||
'invite_valid_until' => 'Valid until :datetime',
|
||||
'invite_valid_until_info' => 'This invitation is valid until :datetime',
|
||||
],
|
||||
|
@ -13,6 +13,9 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'register' => 'Register',
|
||||
'register_welcome' => 'Welcome to LinkAce! You have been invited to join this social bookmarking tool. Please select a user name and a password. After the successful registration, you will be redirected to the dashboard.',
|
||||
|
||||
'failed' => 'These credentials do not match our records.',
|
||||
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
|
||||
|
||||
|
@ -31,11 +31,15 @@
|
||||
<div class="list-group list-group-flush">
|
||||
@foreach($invitations as $invite)
|
||||
<div class="list-group-item d-md-flex justify-content-between">
|
||||
<div>
|
||||
{{ $invite->email }} <br>
|
||||
<span class="text-muted">
|
||||
@lang('admin.user_management.invite_valid_until', ['datetime' => $invite->valid_until])
|
||||
</span>
|
||||
<div @class(['text-muted' => $invite->isCompleted()])>
|
||||
{{ $invite->email }}
|
||||
<div class="small text-muted">
|
||||
@if($invite->isCompleted())
|
||||
@lang('admin.user_management.invite_accepted_by', ['user' => $invite->createdUser->name, 'id' => $invite->createdUser->id])
|
||||
@else
|
||||
@lang('admin.user_management.invite_valid_until', ['datetime' => $invite->valid_until])
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div @class(['mt-1 mt-md-0'])>
|
||||
<button type="submit" form="delete-invite-{{ $invite->id }}"
|
||||
|
81
resources/views/auth/register.blade.php
Normal file
81
resources/views/auth/register.blade.php
Normal file
@ -0,0 +1,81 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
@lang('auth.register')
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>@lang('auth.register_welcome')</p>
|
||||
|
||||
<form method="POST" action="{{ route('auth.register') }}">
|
||||
@csrf
|
||||
<input type="hidden" name="token" value="{{ $invitation->token }}">
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="input-group mb-3">
|
||||
<div class="input-group-text">
|
||||
<x-icon.envelope/>
|
||||
</div>
|
||||
<input type="email" name="email" id="email" class="form-control" readonly
|
||||
value="{{ $invitation->email }}" aria-label="@lang('user.email')">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="input-group mb-3">
|
||||
<div class="input-group-text">
|
||||
<x-icon.info/>
|
||||
</div>
|
||||
<input type="text" name="name" id="name" class="form-control" value="{{ old('name') }}"
|
||||
aria-label="@lang('user.username')" placeholder="@lang('user.username')">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="input-group mb-3">
|
||||
<div class="input-group-text">
|
||||
<x-icon.lock/>
|
||||
</div>
|
||||
<input type="password" name="password" id="password" class="form-control"
|
||||
placeholder="@lang('placeholder.password')" aria-label="@lang('linkace.password')">
|
||||
</div>
|
||||
@if ($errors->has('password'))
|
||||
<p class="invalid-feedback" role="alert">
|
||||
{{ $errors->first('password') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="input-group mb-3">
|
||||
<div class="input-group-text">
|
||||
<x-icon.lock/>
|
||||
</div>
|
||||
<input type="password" name="password_confirmation" id="password_confirmation"
|
||||
class="form-control" placeholder="@lang('placeholder.password_confirmed')"
|
||||
aria-label="@lang('linkace.password_confirm')">
|
||||
</div>
|
||||
@if ($errors->has('password_confirmation'))
|
||||
<p class="invalid-feedback" role="alert">
|
||||
{{ $errors->first('password_confirmation') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
@lang('auth.register')
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@endsection
|
@ -22,6 +22,7 @@ use App\Http\Controllers\Models\LinkController;
|
||||
use App\Http\Controllers\Models\ListController;
|
||||
use App\Http\Controllers\Models\NoteController;
|
||||
use App\Http\Controllers\Models\TagController;
|
||||
use App\Http\Controllers\RegistrationController;
|
||||
use App\Http\Controllers\Setup\AccountController;
|
||||
use App\Http\Controllers\Setup\DatabaseController;
|
||||
use App\Http\Controllers\Setup\MetaController;
|
||||
@ -59,8 +60,10 @@ Route::prefix('bookmarklet')->group(function () {
|
||||
|
||||
Route::get('cron/{token}', CronController::class)->name('cron');
|
||||
|
||||
Route::post('system/users/accept-invite', [UserManagementController::class, 'acceptInvitation'])
|
||||
->name('user-management-accept-invite');
|
||||
Route::get('auth/accept-invite', [RegistrationController::class, 'acceptInvitation'])
|
||||
->name('auth.accept-invite');
|
||||
Route::post('auth/register', [RegistrationController::class, 'register'])
|
||||
->name('auth.register');
|
||||
|
||||
Route::group(['middleware' => 'auth:api'], function () {
|
||||
Route::get('links/feed', [FeedController::class, 'links'])->name('links.feed');
|
||||
|
87
tests/Controller/Auth/RegisterControllerTest.php
Normal file
87
tests/Controller/Auth/RegisterControllerTest.php
Normal file
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Controller\Auth;
|
||||
|
||||
use App\Actions\Fortify\CreateUserInvitation;
|
||||
use App\Enums\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RegisterControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole(Role::ADMIN);
|
||||
|
||||
$this->actingAs($admin);
|
||||
}
|
||||
|
||||
public function testInvitationLink(): void
|
||||
{
|
||||
// Create user invitation and logout admin
|
||||
$invitation = CreateUserInvitation::run('invitation@linkace.org');
|
||||
Auth::logout();
|
||||
|
||||
$url = $invitation->inviteUrl();
|
||||
|
||||
$response = $this->get($url);
|
||||
$response->assertOk()->assertSee('Register')->assertSee('invitation@linkace.org');
|
||||
|
||||
// Mess with the invitation token > invitation invalid
|
||||
$url = str_replace('token=', 'token=abcd', $url);
|
||||
|
||||
$response = $this->get($url);
|
||||
$response->assertStatus(401)->assertSee('The invitation is expired or the link is incorrect.');
|
||||
|
||||
// Jump into the future > invitation expired
|
||||
Carbon::setTestNow(now()->addDays(4));
|
||||
$url = $invitation->inviteUrl();
|
||||
|
||||
$response = $this->get($url);
|
||||
$response->assertStatus(401)->assertSee('The invitation is expired or the link is incorrect.');
|
||||
Carbon::setTestNow();
|
||||
|
||||
// Invitation was already used
|
||||
$invitation->created_user_id = 5;
|
||||
$invitation->saveQuietly();
|
||||
|
||||
$response = $this->get($url);
|
||||
$response->assertStatus(401)->assertSee('The invitation is expired or was already used.');
|
||||
|
||||
// Delete the invitation before it can be used
|
||||
$invitation->delete();
|
||||
|
||||
$response = $this->get($url);
|
||||
$response->assertStatus(401)->assertSee('The invitation link is invalid or the invitation was deleted.');
|
||||
}
|
||||
|
||||
public function testRegistrationForUser(): void
|
||||
{
|
||||
// Create user invitation and logout admin
|
||||
$invitation = CreateUserInvitation::run('invitation@linkace.org');
|
||||
Auth::logout();
|
||||
|
||||
$response = $this->post('auth/register', [
|
||||
'token' => $invitation->token,
|
||||
'email' => 'invitation@linkace.org',
|
||||
'name' => 'testuser',
|
||||
'password' => 'sometestpassword',
|
||||
'password_confirmation' => 'sometestpassword',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('dashboard');
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'invitation@linkace.org',
|
||||
'name' => 'testuser',
|
||||
]);
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user