mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-07 21:33:18 +01:00
Compare commits
5 Commits
feature/er
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3161bbef4a | ||
|
|
303de4930d | ||
|
|
b2849ec04a | ||
|
|
bd69cf478c | ||
|
|
5197135d00 |
@@ -5,7 +5,8 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
||||
APP_DEBUG=true
|
||||
APP_URL=https://solidtime.test
|
||||
APP_FORCE_HTTPS=false
|
||||
APP_ENABLE_REGISTRATION=true
|
||||
# Supported values: on, off, invite-only (true/false are supported for backwards compatibility)
|
||||
APP_ENABLE_REGISTRATION=on
|
||||
SUPER_ADMINS=admin@example.com
|
||||
PAGINATION_PER_PAGE_DEFAULT=500
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Enums\RegistrationMode;
|
||||
use App\Enums\Weekday;
|
||||
use App\Events\NewsletterRegistered;
|
||||
use App\Models\User;
|
||||
use App\Service\InvitationService;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use App\Service\TimezoneService;
|
||||
use App\Service\UserService;
|
||||
@@ -31,13 +33,14 @@ class CreateNewUser implements CreatesNewUsers
|
||||
*/
|
||||
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([
|
||||
'email' => [__('Registration is disabled.')],
|
||||
]);
|
||||
}
|
||||
|
||||
Validator::make($input, [
|
||||
$validated = Validator::make($input, [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
@@ -60,6 +63,20 @@ class CreateNewUser implements CreatesNewUsers
|
||||
],
|
||||
])->validate();
|
||||
|
||||
if ($registrationMode === RegistrationMode::InviteOnly) {
|
||||
$invitationService = app(InvitationService::class);
|
||||
$email = (string) $validated['email'];
|
||||
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;
|
||||
if (array_key_exists('timezone', $input) && is_string($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-only' => self::InviteOnly,
|
||||
default => self::Off,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ class OrganizationInvitationController extends Controller
|
||||
}
|
||||
|
||||
return redirect(route('register'))
|
||||
->with('registration_email', $email)
|
||||
->with('bannerText', __('Please create an account to finish joining the :organization organization.', [
|
||||
'organization' => $organization->name,
|
||||
]))
|
||||
|
||||
@@ -45,6 +45,7 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
|
||||
Fortify::registerView(function () {
|
||||
return Inertia::render('Auth/Register', [
|
||||
'email' => session('registration_email', ''),
|
||||
'terms_url' => config('auth.terms_url'),
|
||||
'privacy_policy_url' => config('auth.privacy_policy_url'),
|
||||
'newsletter_consent' => config('auth.newsletter_consent'),
|
||||
|
||||
@@ -18,6 +18,22 @@ use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class InvitationService
|
||||
{
|
||||
public function hasAcceptedInvitationForEmail(string $email): bool
|
||||
{
|
||||
return OrganizationInvitation::query()
|
||||
->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();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
|
||||
*/
|
||||
|
||||
@@ -100,7 +100,7 @@ return [
|
||||
|
||||
'force_https' => (bool) env('APP_FORCE_HTTPS', false),
|
||||
|
||||
'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false),
|
||||
'enable_registration' => env('APP_ENABLE_REGISTRATION', 'off'),
|
||||
|
||||
'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();
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"Billing": {
|
||||
"repository": "solidtime-io/extension-billing",
|
||||
"ref": "v0.0.4"
|
||||
"ref": "v0.0.5"
|
||||
},
|
||||
"Services": {
|
||||
"repository": "solidtime-io/extension-services",
|
||||
"ref": "v0.0.1"
|
||||
"ref": "v0.0.2"
|
||||
},
|
||||
"Invoicing": {
|
||||
"repository": "solidtime-io/extension-invoicing",
|
||||
|
||||
@@ -8,9 +8,13 @@ import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
email: string;
|
||||
}>();
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
email: '',
|
||||
email: props.email,
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
terms: false,
|
||||
|
||||
@@ -27,7 +27,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
<DialogOverlay
|
||||
class="fixed top-0 left-0 z-50 w-screen h-screen [height:100dvh] backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0">
|
||||
<div class="absolute inset-0 bg-default-background opacity-30" />
|
||||
<div class="absolute inset-0 overflow-y-auto overscroll-contain flex items-start justify-center px-2">
|
||||
<div
|
||||
class="absolute inset-0 overflow-y-auto overscroll-contain flex items-start justify-center px-2">
|
||||
<DialogContent
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
|
||||
@@ -22,6 +22,13 @@ use TiMacDonald\Log\LogEntry;
|
||||
|
||||
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
|
||||
{
|
||||
if (! Features::enabled(Features::registration())) {
|
||||
@@ -89,6 +96,77 @@ class RegistrationTest extends TestCaseWithDatabase
|
||||
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-only');
|
||||
$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_user_must_accept_pending_invitation_before_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->assertInvalid([
|
||||
'email' => 'Please accept the organization invitation sent to your email address before registering.',
|
||||
]);
|
||||
$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
|
||||
{
|
||||
// Act
|
||||
|
||||
@@ -38,6 +38,7 @@ class OrganizationInvitationEndpointTest extends EndpointTestAbstract
|
||||
$response->assertRedirect(route('register'));
|
||||
$response->assertSessionHas('bannerText', 'Please create an account to finish joining the '.$user->organization->name.' organization.');
|
||||
$response->assertSessionHas('bannerStyle', 'info');
|
||||
$response->assertSessionHas('registration_email', strtolower($invitation->email));
|
||||
$invitation->refresh();
|
||||
$this->assertNotNull($invitation->accepted_at);
|
||||
}
|
||||
@@ -64,6 +65,7 @@ class OrganizationInvitationEndpointTest extends EndpointTestAbstract
|
||||
$response->assertRedirect(route('register'));
|
||||
$response->assertSessionHas('bannerText', 'Please create an account to finish joining the '.$user->organization->name.' organization.');
|
||||
$response->assertSessionHas('bannerStyle', 'info');
|
||||
$response->assertSessionHas('registration_email', strtolower($invitation->email));
|
||||
$invitation->refresh();
|
||||
$this->assertNotNull($invitation->accepted_at);
|
||||
}
|
||||
|
||||
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-only' => ['invite-only', RegistrationMode::InviteOnly],
|
||||
'boolean false' => [false, RegistrationMode::Off],
|
||||
'off' => ['off', RegistrationMode::Off],
|
||||
'false' => ['false', RegistrationMode::Off],
|
||||
'unsupported invite alias' => ['invite', 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