1
0
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:
Kovah 2022-07-01 15:51:27 +02:00
parent 95cc1bac48
commit 2b80d9a8a2
No known key found for this signature in database
GPG Key ID: AAAA031BA9830D7B
14 changed files with 332 additions and 40 deletions

View File

@ -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;
}
}

View 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),
]);
}
}

View File

@ -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.');
}
}

View File

@ -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();

View 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');
}
}

View 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(),
];
}
}

View File

@ -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;
}
}

View File

@ -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]
);
}
}

View File

@ -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',
],

View File

@ -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.',

View File

@ -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 }}"

View 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

View File

@ -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');

View 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',
]);
}
}