Compare commits

..

1 Commits

Author SHA1 Message Date
Gregor Vostrak
bf2798c0f4 fix invitations not being respected during signup when email case differs 2026-07-23 20:01:32 +02:00
4 changed files with 487 additions and 718 deletions

View File

@@ -23,6 +23,10 @@ class InvitationService
*/
public function inviteUser(Organization $organization, string $email, Role $role, User $inviter): OrganizationInvitation
{
// Normalize the email so it matches how user emails are stored (see UserService::createUser),
// otherwise a mixed-case invite silently fails to link on registration.
$email = strtolower($email);
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
throw new UserIsAlreadyMemberOfOrganizationApiException;
}
@@ -55,7 +59,7 @@ class InvitationService
$organizations = new Collection;
$invitations = OrganizationInvitation::query()
->where('email', $user->email)
->whereRaw('lower(email) = ?', [strtolower($user->email)])
->whereNotNull('accepted_at')
->get();

1115
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,61 +25,61 @@
"watch:api": "npm run watch --workspace=@solidtime/api"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.6",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.39.4",
"@inertiajs/vue3": "^2.3.23",
"@playwright/test": "^1.61.1",
"@playwright/test": "^1.60.0",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/typography": "^0.5.19",
"@types/chroma-js": "^3.1.2",
"@types/node": "^22.19.19",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1",
"autoprefixer": "^10.5.4",
"axios": "^1.18.1",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.5.0",
"axios": "^1.16.0",
"eslint-plugin-unused-imports": "^4.4.1",
"happy-dom": "^20.11.0",
"happy-dom": "^20.8.9",
"laravel-vite-plugin": "^2.1.0",
"openapi-zod-client": "^1.18.3",
"postcss": "^8.5.20",
"postcss": "^8.5.14",
"postcss-import": "^15.1.0",
"postcss-nesting": "^12.1.5",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vite": "^7.3.3",
"vite-plugin-checker": "^0.14.4",
"vitest": "^4.1.10",
"vue": "^3.5.40",
"vue-tsc": "^3.3.7"
"vite-plugin-checker": "^0.12.0",
"vitest": "^4.1.4",
"vue": "^3.5.34",
"vue-tsc": "^3.2.8"
},
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/core": "^1.7.5",
"@floating-ui/vue": "^1.1.11",
"@heroicons/vue": "^2.2.0",
"@lucide/vue": "^1.25.0",
"@lucide/vue": "^1.14.0",
"@rushstack/eslint-patch": "^1.16.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/vue-form": "^1.33.2",
"@tanstack/vue-query": "^5.101.2",
"@tanstack/vue-form": "^1.32.0",
"@tanstack/vue-query": "^5.100.10",
"@tanstack/vue-query-devtools": "^5.91.0",
"@tanstack/vue-table": "^8.21.3",
"@tanstack/vue-virtual": "^3.13.32",
"@tanstack/vue-virtual": "^3.13.24",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.9.0",
"@vue/eslint-config-typescript": "^14.7.0",
"@vueuse/core": "^14.3.0",
"@vueuse/integrations": "^14.3.0",
"@zodios/core": "^10.9.6",
"chroma-js": "^3.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.21",
"echarts": "^6.1.0",
"focus-trap": "^8.2.2",
"parse-duration": "^2.1.8",
"dayjs": "^1.11.20",
"echarts": "^6.0.0",
"focus-trap": "^8.2.0",
"parse-duration": "^2.1.6",
"pinia": "^3.0.4",
"radix-vue": "^1.9.17",
"reka-ui": "^2.10.1",
"reka-ui": "^2.9.7",
"tailwind-merge": "^2.6.1",
"tailwindcss-animate": "^1.0.7",
"vue-draggable-plus": "^0.6.1",

View File

@@ -380,6 +380,40 @@ class RegistrationTest extends TestCaseWithDatabase
$this->assertSame($user->organization->id, $organizations->first()->id);
}
public function test_registration_joins_invited_organization_even_if_invitation_email_casing_differs(): void
{
// Arrange: invitation stored with a different casing than the registration email
$user = $this->createUserWithPermission();
OrganizationInvitation::factory()
->forOrganization($user->organization)
->role(Role::Employee)
->accepted()
->create([
'email' => 'Invited.User@example.com',
]);
// Act
$response = $this->post('/register', [
'name' => 'Invited User',
'email' => 'invited.user@example.com',
'password' => 'password',
'password_confirmation' => 'password',
'terms' => true,
]);
// Assert: joined the inviting organization, no extra personal organization, invitation consumed
$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);
$newUser = User::where('email', 'invited.user@example.com')->first();
$this->assertNotNull($newUser);
$this->assertDatabaseMissing(OrganizationInvitation::class, [
'email' => 'Invited.User@example.com',
]);
$organizations = $newUser->organizations;
$this->assertCount(1, $organizations);
$this->assertSame($user->organization->id, $organizations->first()->id);
}
public function test_registration_logs_and_skips_accepted_invitation_with_invalid_role(): void
{
// Arrange