mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-07 21:33:18 +01:00
Add invite-only registration mode
Support configurable on, invite-only, and off registration modes, including case-insensitive invitation checks and test coverage.
This commit is contained in:
committed by
Constantin Graf
parent
3e36b1cc01
commit
3ec2abb309
@@ -5,6 +5,7 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
|||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=https://solidtime.test
|
APP_URL=https://solidtime.test
|
||||||
APP_FORCE_HTTPS=false
|
APP_FORCE_HTTPS=false
|
||||||
|
# Supported values: on/true, invite/invite-only, off/false
|
||||||
APP_ENABLE_REGISTRATION=true
|
APP_ENABLE_REGISTRATION=true
|
||||||
SUPER_ADMINS=admin@example.com
|
SUPER_ADMINS=admin@example.com
|
||||||
PAGINATION_PER_PAGE_DEFAULT=500
|
PAGINATION_PER_PAGE_DEFAULT=500
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Enums\RegistrationMode;
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Events\NewsletterRegistered;
|
use App\Events\NewsletterRegistered;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\InvitationService;
|
||||||
use App\Service\IpLookup\IpLookupServiceContract;
|
use App\Service\IpLookup\IpLookupServiceContract;
|
||||||
use App\Service\TimezoneService;
|
use App\Service\TimezoneService;
|
||||||
use App\Service\UserService;
|
use App\Service\UserService;
|
||||||
@@ -31,13 +33,14 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
*/
|
*/
|
||||||
public function create(array $input): User
|
public function create(array $input): User
|
||||||
{
|
{
|
||||||
if (! config('app.enable_registration')) {
|
$registrationMode = RegistrationMode::fromConfig(config('app.enable_registration'));
|
||||||
|
if ($registrationMode === RegistrationMode::Off) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'email' => [__('Registration is disabled.')],
|
'email' => [__('Registration is disabled.')],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Validator::make($input, [
|
$validated = Validator::make($input, [
|
||||||
'name' => [
|
'name' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
@@ -60,6 +63,13 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
],
|
],
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
|
if ($registrationMode === RegistrationMode::InviteOnly
|
||||||
|
&& ! app(InvitationService::class)->hasInvitationForEmail((string) $validated['email'])) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => [__('Registration is only available to invited users.')],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$timezone = null;
|
$timezone = null;
|
||||||
if (array_key_exists('timezone', $input) && is_string($input['timezone'])) {
|
if (array_key_exists('timezone', $input) && is_string($input['timezone'])) {
|
||||||
if (app(TimezoneService::class)->isValid($input['timezone'])) {
|
if (app(TimezoneService::class)->isValid($input['timezone'])) {
|
||||||
|
|||||||
29
app/Enums/RegistrationMode.php
Normal file
29
app/Enums/RegistrationMode.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum RegistrationMode: string
|
||||||
|
{
|
||||||
|
case On = 'on';
|
||||||
|
case InviteOnly = 'invite-only';
|
||||||
|
case Off = 'off';
|
||||||
|
|
||||||
|
public static function fromConfig(mixed $value): self
|
||||||
|
{
|
||||||
|
if ($value === true) {
|
||||||
|
return self::On;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === false || $value === null) {
|
||||||
|
return self::Off;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match (strtolower(trim((string) $value))) {
|
||||||
|
'1', 'on', 'true' => self::On,
|
||||||
|
'invite', 'invite-only' => self::InviteOnly,
|
||||||
|
default => self::Off,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,13 @@ use Illuminate\Support\Facades\Mail;
|
|||||||
|
|
||||||
class InvitationService
|
class InvitationService
|
||||||
{
|
{
|
||||||
|
public function hasInvitationForEmail(string $email): bool
|
||||||
|
{
|
||||||
|
return OrganizationInvitation::query()
|
||||||
|
->whereRaw('lower(email) = ?', [strtolower($email)])
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
|
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ return [
|
|||||||
|
|
||||||
'force_https' => (bool) env('APP_FORCE_HTTPS', false),
|
'force_https' => (bool) env('APP_FORCE_HTTPS', false),
|
||||||
|
|
||||||
'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false),
|
'enable_registration' => env('APP_ENABLE_REGISTRATION', false),
|
||||||
|
|
||||||
'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false),
|
'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false),
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,75 @@ class RegistrationTest extends TestCaseWithDatabase
|
|||||||
Event::assertNotDispatched(NewsletterRegistered::class);
|
Event::assertNotDispatched(NewsletterRegistered::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_user_registration_fails_without_an_invitation_if_registration_is_invite_only(): void
|
||||||
|
{
|
||||||
|
Config::set('app.enable_registration', 'invite-only');
|
||||||
|
|
||||||
|
$response = $this->post('/register', [
|
||||||
|
'name' => 'Test User',
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
'password' => 'password',
|
||||||
|
'password_confirmation' => 'password',
|
||||||
|
'terms' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertInvalid([
|
||||||
|
'email' => 'Registration is only available to invited users.',
|
||||||
|
]);
|
||||||
|
$this->assertFalse(User::query()->where('email', 'test@example.com')->exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invited_user_can_register_if_registration_is_invite_only(): void
|
||||||
|
{
|
||||||
|
Config::set('app.enable_registration', 'invite');
|
||||||
|
$user = $this->createUserWithPermission();
|
||||||
|
OrganizationInvitation::factory()
|
||||||
|
->forOrganization($user->organization)
|
||||||
|
->role(Role::Employee)
|
||||||
|
->accepted()
|
||||||
|
->create([
|
||||||
|
'email' => 'Invited.User@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->post('/register', [
|
||||||
|
'name' => 'Invited User',
|
||||||
|
'email' => 'invited.user@example.com',
|
||||||
|
'password' => 'password',
|
||||||
|
'password_confirmation' => 'password',
|
||||||
|
'terms' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertValid();
|
||||||
|
$this->assertAuthenticated();
|
||||||
|
$response->assertRedirect(RouteServiceProvider::HOME);
|
||||||
|
$newUser = User::query()->where('email', 'invited.user@example.com')->firstOrFail();
|
||||||
|
$this->assertSame($user->organization->getKey(), $newUser->organizations()->firstOrFail()->getKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_pending_invitation_allows_registration_if_registration_is_invite_only(): void
|
||||||
|
{
|
||||||
|
Config::set('app.enable_registration', 'invite-only');
|
||||||
|
$user = $this->createUserWithPermission();
|
||||||
|
OrganizationInvitation::factory()
|
||||||
|
->forOrganization($user->organization)
|
||||||
|
->role(Role::Employee)
|
||||||
|
->create([
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->post('/register', [
|
||||||
|
'name' => 'Test User',
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
'password' => 'password',
|
||||||
|
'password_confirmation' => 'password',
|
||||||
|
'terms' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertValid();
|
||||||
|
$this->assertAuthenticated();
|
||||||
|
$response->assertRedirect(RouteServiceProvider::HOME);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_new_user_can_not_register_with_likely_invalid_domain(): void
|
public function test_new_user_can_not_register_with_likely_invalid_domain(): void
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
|
|||||||
38
tests/Unit/Enums/RegistrationModeTest.php
Normal file
38
tests/Unit/Enums/RegistrationModeTest.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Enums;
|
||||||
|
|
||||||
|
use App\Enums\RegistrationMode;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
#[CoversClass(RegistrationMode::class)]
|
||||||
|
class RegistrationModeTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, array{mixed, RegistrationMode}>
|
||||||
|
*/
|
||||||
|
public static function configValueProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'boolean true' => [true, RegistrationMode::On],
|
||||||
|
'on' => ['on', RegistrationMode::On],
|
||||||
|
'true' => ['true', RegistrationMode::On],
|
||||||
|
'invite' => ['invite', RegistrationMode::InviteOnly],
|
||||||
|
'invite-only' => ['invite-only', RegistrationMode::InviteOnly],
|
||||||
|
'boolean false' => [false, RegistrationMode::Off],
|
||||||
|
'off' => ['off', RegistrationMode::Off],
|
||||||
|
'false' => ['false', RegistrationMode::Off],
|
||||||
|
'unknown' => ['unknown', RegistrationMode::Off],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('configValueProvider')]
|
||||||
|
public function test_registration_mode_is_created_from_config_value(mixed $value, RegistrationMode $expected): void
|
||||||
|
{
|
||||||
|
$this->assertSame($expected, RegistrationMode::fromConfig($value));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user