mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-07 21:33:18 +01:00
Add invite-only registration mode
This commit is contained in:
committed by
Constantin Graf
parent
3ec2abb309
commit
637475e669
@@ -5,8 +5,8 @@ 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
|
# Supported values: on, off, invite-only (true/false are supported for backwards compatibility)
|
||||||
APP_ENABLE_REGISTRATION=true
|
APP_ENABLE_REGISTRATION=on
|
||||||
SUPER_ADMINS=admin@example.com
|
SUPER_ADMINS=admin@example.com
|
||||||
PAGINATION_PER_PAGE_DEFAULT=500
|
PAGINATION_PER_PAGE_DEFAULT=500
|
||||||
|
|
||||||
|
|||||||
@@ -63,11 +63,18 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
],
|
],
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
if ($registrationMode === RegistrationMode::InviteOnly
|
if ($registrationMode === RegistrationMode::InviteOnly) {
|
||||||
&& ! app(InvitationService::class)->hasInvitationForEmail((string) $validated['email'])) {
|
$invitationService = app(InvitationService::class);
|
||||||
throw ValidationException::withMessages([
|
$email = (string) $validated['email'];
|
||||||
'email' => [__('Registration is only available to invited users.')],
|
if (! $invitationService->hasAcceptedInvitationForEmail($email)) {
|
||||||
]);
|
$message = $invitationService->hasPendingInvitationForEmail($email)
|
||||||
|
? __('Please accept the organization invitation sent to your email address before registering.')
|
||||||
|
: __('Registration is only available to invited users.');
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => [$message],
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$timezone = null;
|
$timezone = null;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ enum RegistrationMode: string
|
|||||||
|
|
||||||
return match (strtolower(trim((string) $value))) {
|
return match (strtolower(trim((string) $value))) {
|
||||||
'1', 'on', 'true' => self::On,
|
'1', 'on', 'true' => self::On,
|
||||||
'invite', 'invite-only' => self::InviteOnly,
|
'invite-only' => self::InviteOnly,
|
||||||
default => self::Off,
|
default => self::Off,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class OrganizationInvitationController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
return redirect(route('register'))
|
return redirect(route('register'))
|
||||||
|
->with('registration_email', $email)
|
||||||
->with('bannerText', __('Please create an account to finish joining the :organization organization.', [
|
->with('bannerText', __('Please create an account to finish joining the :organization organization.', [
|
||||||
'organization' => $organization->name,
|
'organization' => $organization->name,
|
||||||
]))
|
]))
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
Fortify::registerView(function () {
|
Fortify::registerView(function () {
|
||||||
return Inertia::render('Auth/Register', [
|
return Inertia::render('Auth/Register', [
|
||||||
|
'email' => session('registration_email', ''),
|
||||||
'terms_url' => config('auth.terms_url'),
|
'terms_url' => config('auth.terms_url'),
|
||||||
'privacy_policy_url' => config('auth.privacy_policy_url'),
|
'privacy_policy_url' => config('auth.privacy_policy_url'),
|
||||||
'newsletter_consent' => config('auth.newsletter_consent'),
|
'newsletter_consent' => config('auth.newsletter_consent'),
|
||||||
|
|||||||
@@ -18,10 +18,19 @@ use Illuminate\Support\Facades\Mail;
|
|||||||
|
|
||||||
class InvitationService
|
class InvitationService
|
||||||
{
|
{
|
||||||
public function hasInvitationForEmail(string $email): bool
|
public function hasAcceptedInvitationForEmail(string $email): bool
|
||||||
{
|
{
|
||||||
return OrganizationInvitation::query()
|
return OrganizationInvitation::query()
|
||||||
->whereRaw('lower(email) = ?', [strtolower($email)])
|
->whereRaw('lower(email) = ?', [strtolower($email)])
|
||||||
|
->whereNotNull('accepted_at')
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasPendingInvitationForEmail(string $email): bool
|
||||||
|
{
|
||||||
|
return OrganizationInvitation::query()
|
||||||
|
->whereRaw('lower(email) = ?', [strtolower($email)])
|
||||||
|
->whereNull('accepted_at')
|
||||||
->exists();
|
->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ return [
|
|||||||
|
|
||||||
'force_https' => (bool) env('APP_FORCE_HTTPS', false),
|
'force_https' => (bool) env('APP_FORCE_HTTPS', false),
|
||||||
|
|
||||||
'enable_registration' => env('APP_ENABLE_REGISTRATION', false),
|
'enable_registration' => env('APP_ENABLE_REGISTRATION', 'off'),
|
||||||
|
|
||||||
'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false),
|
'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false),
|
||||||
|
|
||||||
|
|||||||
32
e2e/invitation-registration-prefill.spec.ts
Normal file
32
e2e/invitation-registration-prefill.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { expect, test } from '../playwright/fixtures';
|
||||||
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
|
import { getInvitationAcceptUrl } from './utils/mailpit';
|
||||||
|
|
||||||
|
test('prefills the registration email after accepting an invitation', async ({ page, browser }) => {
|
||||||
|
const memberId = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||||
|
const memberEmail = `prefill-${memberId}@invitation.test`;
|
||||||
|
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||||
|
await page.getByRole('button', { name: 'Invite Member' }).click();
|
||||||
|
await page.getByPlaceholder('Member Email').fill(memberEmail);
|
||||||
|
await page.getByRole('button', { name: 'Employee' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/invitations') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const inviteeContext = await browser.newContext();
|
||||||
|
const inviteePage = await inviteeContext.newPage();
|
||||||
|
const acceptUrl = await getInvitationAcceptUrl(inviteePage.request, memberEmail);
|
||||||
|
await inviteePage.goto(acceptUrl);
|
||||||
|
await inviteePage.waitForURL(/\/register$/);
|
||||||
|
|
||||||
|
await expect(inviteePage.getByLabel('Email')).toHaveValue(memberEmail);
|
||||||
|
|
||||||
|
await inviteeContext.close();
|
||||||
|
});
|
||||||
@@ -8,9 +8,13 @@ import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
|
|||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
email: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: props.email,
|
||||||
password: '',
|
password: '',
|
||||||
password_confirmation: '',
|
password_confirmation: '',
|
||||||
terms: false,
|
terms: false,
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ use TiMacDonald\Log\LogEntry;
|
|||||||
|
|
||||||
class RegistrationTest extends TestCaseWithDatabase
|
class RegistrationTest extends TestCaseWithDatabase
|
||||||
{
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Config::set('app.enable_registration', 'on');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_registration_screen_can_be_rendered(): void
|
public function test_registration_screen_can_be_rendered(): void
|
||||||
{
|
{
|
||||||
if (! Features::enabled(Features::registration())) {
|
if (! Features::enabled(Features::registration())) {
|
||||||
@@ -109,7 +116,7 @@ class RegistrationTest extends TestCaseWithDatabase
|
|||||||
|
|
||||||
public function test_invited_user_can_register_if_registration_is_invite_only(): void
|
public function test_invited_user_can_register_if_registration_is_invite_only(): void
|
||||||
{
|
{
|
||||||
Config::set('app.enable_registration', 'invite');
|
Config::set('app.enable_registration', 'invite-only');
|
||||||
$user = $this->createUserWithPermission();
|
$user = $this->createUserWithPermission();
|
||||||
OrganizationInvitation::factory()
|
OrganizationInvitation::factory()
|
||||||
->forOrganization($user->organization)
|
->forOrganization($user->organization)
|
||||||
@@ -134,7 +141,7 @@ class RegistrationTest extends TestCaseWithDatabase
|
|||||||
$this->assertSame($user->organization->getKey(), $newUser->organizations()->firstOrFail()->getKey());
|
$this->assertSame($user->organization->getKey(), $newUser->organizations()->firstOrFail()->getKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_pending_invitation_allows_registration_if_registration_is_invite_only(): void
|
public function test_user_must_accept_pending_invitation_before_registration_if_registration_is_invite_only(): void
|
||||||
{
|
{
|
||||||
Config::set('app.enable_registration', 'invite-only');
|
Config::set('app.enable_registration', 'invite-only');
|
||||||
$user = $this->createUserWithPermission();
|
$user = $this->createUserWithPermission();
|
||||||
@@ -153,9 +160,11 @@ class RegistrationTest extends TestCaseWithDatabase
|
|||||||
'terms' => true,
|
'terms' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertValid();
|
$response->assertInvalid([
|
||||||
$this->assertAuthenticated();
|
'email' => 'Please accept the organization invitation sent to your email address before registering.',
|
||||||
$response->assertRedirect(RouteServiceProvider::HOME);
|
]);
|
||||||
|
$this->assertGuest();
|
||||||
|
$this->assertFalse(User::query()->where('email', 'test@example.com')->exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class OrganizationInvitationEndpointTest extends EndpointTestAbstract
|
|||||||
$response->assertRedirect(route('register'));
|
$response->assertRedirect(route('register'));
|
||||||
$response->assertSessionHas('bannerText', 'Please create an account to finish joining the '.$user->organization->name.' organization.');
|
$response->assertSessionHas('bannerText', 'Please create an account to finish joining the '.$user->organization->name.' organization.');
|
||||||
$response->assertSessionHas('bannerStyle', 'info');
|
$response->assertSessionHas('bannerStyle', 'info');
|
||||||
|
$response->assertSessionHas('registration_email', strtolower($invitation->email));
|
||||||
$invitation->refresh();
|
$invitation->refresh();
|
||||||
$this->assertNotNull($invitation->accepted_at);
|
$this->assertNotNull($invitation->accepted_at);
|
||||||
}
|
}
|
||||||
@@ -64,6 +65,7 @@ class OrganizationInvitationEndpointTest extends EndpointTestAbstract
|
|||||||
$response->assertRedirect(route('register'));
|
$response->assertRedirect(route('register'));
|
||||||
$response->assertSessionHas('bannerText', 'Please create an account to finish joining the '.$user->organization->name.' organization.');
|
$response->assertSessionHas('bannerText', 'Please create an account to finish joining the '.$user->organization->name.' organization.');
|
||||||
$response->assertSessionHas('bannerStyle', 'info');
|
$response->assertSessionHas('bannerStyle', 'info');
|
||||||
|
$response->assertSessionHas('registration_email', strtolower($invitation->email));
|
||||||
$invitation->refresh();
|
$invitation->refresh();
|
||||||
$this->assertNotNull($invitation->accepted_at);
|
$this->assertNotNull($invitation->accepted_at);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ class RegistrationModeTest extends TestCase
|
|||||||
'boolean true' => [true, RegistrationMode::On],
|
'boolean true' => [true, RegistrationMode::On],
|
||||||
'on' => ['on', RegistrationMode::On],
|
'on' => ['on', RegistrationMode::On],
|
||||||
'true' => ['true', RegistrationMode::On],
|
'true' => ['true', RegistrationMode::On],
|
||||||
'invite' => ['invite', RegistrationMode::InviteOnly],
|
|
||||||
'invite-only' => ['invite-only', RegistrationMode::InviteOnly],
|
'invite-only' => ['invite-only', RegistrationMode::InviteOnly],
|
||||||
'boolean false' => [false, RegistrationMode::Off],
|
'boolean false' => [false, RegistrationMode::Off],
|
||||||
'off' => ['off', RegistrationMode::Off],
|
'off' => ['off', RegistrationMode::Off],
|
||||||
'false' => ['false', RegistrationMode::Off],
|
'false' => ['false', RegistrationMode::Off],
|
||||||
|
'unsupported invite alias' => ['invite', RegistrationMode::Off],
|
||||||
'unknown' => ['unknown', RegistrationMode::Off],
|
'unknown' => ['unknown', RegistrationMode::Off],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user