better_auth 0.8.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +142 -95
  5. data/lib/better_auth/adapters/base.rb +64 -1
  6. data/lib/better_auth/adapters/internal_adapter.rb +197 -11
  7. data/lib/better_auth/adapters/join_support.rb +10 -0
  8. data/lib/better_auth/adapters/memory.rb +303 -54
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +390 -63
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +48 -47
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +119 -15
  15. data/lib/better_auth/core.rb +0 -2
  16. data/lib/better_auth/crypto.rb +4 -3
  17. data/lib/better_auth/doctor.rb +108 -0
  18. data/lib/better_auth/endpoint.rb +88 -5
  19. data/lib/better_auth/error.rb +3 -1
  20. data/lib/better_auth/http_client.rb +46 -0
  21. data/lib/better_auth/logger.rb +1 -1
  22. data/lib/better_auth/middleware/origin_check.rb +9 -1
  23. data/lib/better_auth/migration_plan.rb +15 -0
  24. data/lib/better_auth/oauth2.rb +1 -1
  25. data/lib/better_auth/oauth_state.rb +147 -0
  26. data/lib/better_auth/plugin_loader.rb +220 -0
  27. data/lib/better_auth/plugins/access.rb +10 -13
  28. data/lib/better_auth/plugins/admin.rb +85 -29
  29. data/lib/better_auth/plugins/anonymous.rb +2 -0
  30. data/lib/better_auth/plugins/api_key.rb +4 -7
  31. data/lib/better_auth/plugins/bearer.rb +8 -22
  32. data/lib/better_auth/plugins/captcha.rb +24 -6
  33. data/lib/better_auth/plugins/device_authorization.rb +77 -5
  34. data/lib/better_auth/plugins/dub.rb +10 -2
  35. data/lib/better_auth/plugins/email_otp.rb +30 -47
  36. data/lib/better_auth/plugins/generic_oauth.rb +162 -140
  37. data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
  38. data/lib/better_auth/plugins/i18n.rb +215 -0
  39. data/lib/better_auth/plugins/jwt.rb +44 -19
  40. data/lib/better_auth/plugins/magic_link.rb +18 -21
  41. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  42. data/lib/better_auth/plugins/oauth_protocol.rb +233 -46
  43. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  44. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  45. data/lib/better_auth/plugins/one_tap.rb +78 -65
  46. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  47. data/lib/better_auth/plugins/open_api.rb +77 -9
  48. data/lib/better_auth/plugins/organization.rb +546 -102
  49. data/lib/better_auth/plugins/passkey.rb +4 -7
  50. data/lib/better_auth/plugins/phone_number.rb +25 -27
  51. data/lib/better_auth/plugins/scim.rb +4 -7
  52. data/lib/better_auth/plugins/siwe.rb +126 -16
  53. data/lib/better_auth/plugins/sso.rb +4 -7
  54. data/lib/better_auth/plugins/stripe.rb +4 -7
  55. data/lib/better_auth/plugins/two_factor.rb +205 -19
  56. data/lib/better_auth/plugins/username.rb +56 -13
  57. data/lib/better_auth/plugins.rb +57 -0
  58. data/lib/better_auth/rate_limiter.rb +300 -133
  59. data/lib/better_auth/request_ip.rb +123 -13
  60. data/lib/better_auth/router.rb +3 -3
  61. data/lib/better_auth/routes/account.rb +44 -8
  62. data/lib/better_auth/routes/email_verification.rb +93 -6
  63. data/lib/better_auth/routes/password.rb +6 -6
  64. data/lib/better_auth/routes/session.rb +7 -2
  65. data/lib/better_auth/routes/sign_in.rb +10 -1
  66. data/lib/better_auth/routes/sign_up.rb +45 -22
  67. data/lib/better_auth/routes/social.rb +197 -54
  68. data/lib/better_auth/routes/user.rb +17 -10
  69. data/lib/better_auth/schema/sql.rb +189 -34
  70. data/lib/better_auth/schema.rb +159 -5
  71. data/lib/better_auth/session.rb +29 -10
  72. data/lib/better_auth/session_store.rb +1 -1
  73. data/lib/better_auth/social_providers/apple.rb +6 -1
  74. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  75. data/lib/better_auth/social_providers/base.rb +45 -23
  76. data/lib/better_auth/social_providers/cognito.rb +75 -5
  77. data/lib/better_auth/social_providers/facebook.rb +95 -1
  78. data/lib/better_auth/social_providers/figma.rb +2 -0
  79. data/lib/better_auth/social_providers/github.rb +2 -0
  80. data/lib/better_auth/social_providers/google.rb +12 -1
  81. data/lib/better_auth/social_providers/line.rb +35 -1
  82. data/lib/better_auth/social_providers/linear.rb +1 -0
  83. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  84. data/lib/better_auth/social_providers/naver.rb +24 -1
  85. data/lib/better_auth/social_providers/notion.rb +1 -0
  86. data/lib/better_auth/social_providers/paybin.rb +25 -1
  87. data/lib/better_auth/social_providers/paypal.rb +118 -2
  88. data/lib/better_auth/social_providers/reddit.rb +25 -4
  89. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  90. data/lib/better_auth/social_providers/twitch.rb +24 -1
  91. data/lib/better_auth/social_providers/twitter.rb +36 -1
  92. data/lib/better_auth/social_providers/vk.rb +34 -1
  93. data/lib/better_auth/social_providers/wechat.rb +6 -4
  94. data/lib/better_auth/sql_migration.rb +566 -0
  95. data/lib/better_auth/version.rb +1 -1
  96. data/lib/better_auth.rb +14 -41
  97. metadata +14 -18
  98. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  99. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  100. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  101. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  102. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  103. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  104. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  105. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  106. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  107. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  108. data/lib/better_auth/plugins/mcp.rb +0 -215
  109. data/lib/better_auth/plugins/oidc_provider.rb +0 -710
@@ -13,6 +13,7 @@ module BetterAuth
13
13
  "USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION" => "User is not a member of the organization",
14
14
  "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION" => "You are not allowed to update this organization",
15
15
  "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION" => "You are not allowed to delete this organization",
16
+ "ORGANIZATION_DELETION_DISABLED" => "Organization deletion is disabled",
16
17
  "NO_ACTIVE_ORGANIZATION" => "No active organization",
17
18
  "USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION" => "User is already a member of this organization",
18
19
  "MEMBER_NOT_FOUND" => "Member not found",
@@ -20,6 +21,7 @@ module BetterAuth
20
21
  "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM" => "You are not allowed to create a new team",
21
22
  "TEAM_ALREADY_EXISTS" => "Team already exists",
22
23
  "TEAM_NOT_FOUND" => "Team not found",
24
+ "INVALID_TEAM_ID" => "Team IDs cannot contain commas",
23
25
  "YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER" => "You cannot leave the organization as the only owner",
24
26
  "YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER" => "You cannot leave the organization without an owner",
25
27
  "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER" => "You are not allowed to delete this member",
@@ -28,6 +30,7 @@ module BetterAuth
28
30
  "INVITATION_NOT_FOUND" => "Invitation not found",
29
31
  "YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION" => "You are not the recipient of the invitation",
30
32
  "EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION" => "Email verification required before accepting or rejecting invitation",
33
+ "EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION" => "Email verification required to view or list invitations for the session email",
31
34
  "YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION" => "You are not allowed to cancel this invitation",
32
35
  "INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION" => "Inviter is no longer a member of the organization",
33
36
  "YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE" => "You are not allowed to invite a user with this role",
@@ -88,7 +91,7 @@ module BetterAuth
88
91
  cancel_invitation: organization_cancel_invitation_endpoint(config),
89
92
  accept_invitation: organization_accept_invitation_endpoint(config),
90
93
  reject_invitation: organization_reject_invitation_endpoint(config),
91
- get_invitation: organization_get_invitation_endpoint,
94
+ get_invitation: organization_get_invitation_endpoint(config),
92
95
  list_invitations: organization_list_invitations_endpoint(config),
93
96
  list_user_invitations: organization_list_user_invitations_endpoint,
94
97
  add_member: organization_add_member_endpoint(config),
@@ -231,7 +234,7 @@ module BetterAuth
231
234
  data = normalize_hash(body[:data] || body)
232
235
  organization = organization_by_id(ctx, id) || organization_by_slug(ctx, body[:organization_slug])
233
236
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
234
- require_org_permission!(ctx, config, session, organization["id"], {organization: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION"))
237
+ member = require_org_permission!(ctx, config, session, organization["id"], {organization: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION"))
235
238
  if data[:slug] && data[:slug].to_s.empty?
236
239
  raise APIError.new("BAD_REQUEST", message: "slug is required")
237
240
  end
@@ -242,29 +245,37 @@ module BetterAuth
242
245
  raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_SLUG_ALREADY_TAKEN")) if existing && existing["id"] != organization["id"]
243
246
  update = additional_input(data, :organization_id, :organizationId, :organization_slug, :data)
244
247
  update[:metadata] = serialize_metadata(update[:metadata]) if update.key?(:metadata)
248
+ merge_hook_data!(update, run_org_hook(config, :before_update_organization, {organization: update, user: session[:user], member: member_wire(ctx, member)}, ctx))
245
249
  updated = ctx.context.adapter.update(model: "organization", where: [{field: "id", value: organization["id"]}], update: update)
246
- run_org_hook(config, :after_update_organization, {organization: organization_wire(ctx, updated), user: session[:user]}, ctx)
250
+ run_org_hook(config, :after_update_organization, {organization: organization_wire(ctx, updated), user: session[:user], member: member_wire(ctx, member)}, ctx)
247
251
  ctx.json(organization_wire(ctx, updated))
248
252
  end
249
253
  end
250
254
 
251
255
  def organization_delete_endpoint(config)
252
256
  Endpoint.new(path: "/organization/delete", method: "POST", metadata: organization_openapi("deleteOrganization", "Delete an organization", response: OpenAPI.status_response_schema)) do |ctx|
257
+ if org_truthy?(config[:disable_organization_deletion])
258
+ raise APIError.new("NOT_FOUND", code: "ORGANIZATION_DELETION_DISABLED", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_DELETION_DISABLED"))
259
+ end
253
260
  session = Routes.current_session(ctx)
254
261
  body = normalize_hash(ctx.body)
255
262
  organization = organization_by_id(ctx, body[:organization_id]) || organization_by_slug(ctx, body[:organization_slug])
256
263
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
257
264
  require_org_permission!(ctx, config, session, organization["id"], {organization: ["delete"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION"))
258
265
  run_org_hook(config, :before_delete_organization, {organization: organization_wire(ctx, organization), user: session[:user]}, ctx)
259
- if org_truthy?(config.dig(:teams, :enabled))
260
- team_ids = ctx.context.adapter.find_many(model: "team", where: [{field: "organizationId", value: organization["id"]}]).map { |team| team["id"] }
261
- ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "teamId", value: team_ids, operator: "in"}]) if team_ids.any?
262
- ctx.context.adapter.delete_many(model: "team", where: [{field: "organizationId", value: organization["id"]}])
266
+ organization_transaction(ctx) do |adapter|
267
+ if org_truthy?(config.dig(:teams, :enabled))
268
+ team_ids = adapter.find_many(model: "team", where: [{field: "organizationId", value: organization["id"]}]).map { |team| team["id"] }
269
+ adapter.delete_many(model: "teamMember", where: [{field: "teamId", value: team_ids, operator: "in"}]) if team_ids.any?
270
+ adapter.delete_many(model: "team", where: [{field: "organizationId", value: organization["id"]}])
271
+ end
272
+ adapter.delete_many(model: "invitation", where: [{field: "organizationId", value: organization["id"]}])
273
+ adapter.delete_many(model: "member", where: [{field: "organizationId", value: organization["id"]}])
274
+ adapter.delete_many(model: "organizationRole", where: [{field: "organizationId", value: organization["id"]}]) if org_truthy?(config.dig(:dynamic_access_control, :enabled))
275
+ adapter.delete(model: "organization", where: [{field: "id", value: organization["id"]}])
263
276
  end
264
- ctx.context.adapter.delete_many(model: "invitation", where: [{field: "organizationId", value: organization["id"]}])
265
- ctx.context.adapter.delete_many(model: "member", where: [{field: "organizationId", value: organization["id"]}])
266
- ctx.context.adapter.delete_many(model: "organizationRole", where: [{field: "organizationId", value: organization["id"]}]) if org_truthy?(config.dig(:dynamic_access_control, :enabled))
267
- ctx.context.adapter.delete(model: "organization", where: [{field: "id", value: organization["id"]}])
277
+ clear_active_organization_session(ctx, session, organization["id"])
278
+ run_org_hook(config, :after_delete_organization, {organization: organization_wire(ctx, organization), user: session[:user]}, ctx)
268
279
  ctx.json({status: true})
269
280
  end
270
281
  end
@@ -332,21 +343,8 @@ module BetterAuth
332
343
  end
333
344
  existing_member = find_member_by_email(ctx, organization["id"], email)
334
345
  raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION")) if existing_member
335
- pending = ctx.context.adapter.find_many(model: "invitation", where: [{field: "organizationId", value: organization["id"]}, {field: "email", value: email}, {field: "status", value: "pending"}])
336
- if pending.any?
337
- if config[:cancel_pending_invitations_on_re_invite]
338
- pending.each { |entry| ctx.context.adapter.update(model: "invitation", where: [{field: "id", value: entry["id"]}], update: {status: "canceled"}) }
339
- else
340
- raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION"))
341
- end
342
- end
343
- pending_count = ctx.context.adapter.count(model: "invitation", where: [{field: "organizationId", value: organization["id"]}, {field: "status", value: "pending"}])
344
- limit = config[:invitation_limit]
345
- if limit && pending_count >= limit.to_i
346
- raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_LIMIT_REACHED"))
347
- end
348
346
  team_ids = organization_team_ids(body[:team_id] || body[:team_ids])
349
- ensure_team_member_capacity!(ctx, config, team_ids)
347
+ validate_invitation_team_ids!(ctx, config, organization["id"], body[:team_id] || body[:team_ids], team_ids)
350
348
  invitation_data = {
351
349
  organizationId: organization["id"],
352
350
  email: email,
@@ -358,11 +356,25 @@ module BetterAuth
358
356
  createdAt: Time.now
359
357
  }.merge(additional_input(body, :organization_id, :organization_slug, :email, :role, :team_id, :team_ids))
360
358
  merge_hook_data!(invitation_data, run_org_hook(config, :before_create_invitation, {invitation: invitation_data, inviter: session[:user], organization: organization_wire(ctx, organization)}, ctx))
361
- invitation = ctx.context.adapter.create(
362
- model: "invitation",
363
- data: invitation_data,
364
- force_allow_id: true
365
- )
359
+ invitation = organization_transaction(ctx) do |adapter|
360
+ lock_organization_capacity!(adapter, organization)
361
+ pending = adapter.find_many(model: "invitation", where: [{field: "organizationId", value: organization["id"]}, {field: "email", value: email}, {field: "status", value: "pending"}])
362
+ if pending.any?
363
+ if config[:cancel_pending_invitations_on_re_invite]
364
+ pending.each { |entry| adapter.update(model: "invitation", where: [{field: "id", value: entry["id"]}], update: {status: "canceled"}) }
365
+ else
366
+ raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION"))
367
+ end
368
+ end
369
+ pending_count = adapter.count(model: "invitation", where: [{field: "organizationId", value: organization["id"]}, {field: "status", value: "pending"}])
370
+ limit = config[:invitation_limit]
371
+ if limit && pending_count >= limit.to_i
372
+ raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_LIMIT_REACHED"))
373
+ end
374
+ validate_stored_invitation_teams!(adapter, organization["id"], team_ids)
375
+ ensure_team_member_capacity!(ctx, config, team_ids, adapter: adapter)
376
+ adapter.create(model: "invitation", data: invitation_data, force_allow_id: true)
377
+ end
366
378
  sender = config[:send_invitation_email]
367
379
  sender.call({id: invitation["id"], role: role, email: email, organization: organization_wire(ctx, organization), invitation: invitation_wire(ctx, invitation), inviter: require_member!(ctx, session[:user]["id"], organization["id"])}, ctx.request) if sender.respond_to?(:call)
368
380
  run_org_hook(config, :after_create_invitation, {invitation: invitation_wire(ctx, invitation), inviter: session[:user], organization: organization_wire(ctx, organization)}, ctx)
@@ -375,32 +387,56 @@ module BetterAuth
375
387
  session = Routes.current_session(ctx)
376
388
  body = normalize_hash(ctx.body)
377
389
  invitation = invitation_by_id(ctx, body[:invitation_id] || body[:id])
378
- raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless invitation
379
- raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION")) unless invitation["email"].to_s.downcase == session[:user]["email"].to_s.downcase
380
- raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless invitation["status"] == "pending"
381
- raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) if invitation["expiresAt"] && Time.parse(invitation["expiresAt"].to_s) < Time.now
382
- if config[:require_email_verification_on_invitation] && !session[:user]["emailVerified"]
383
- raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION"))
384
- end
385
- ensure_team_member_capacity!(ctx, config, organization_team_ids(invitation["teamId"]))
386
- member = ctx.context.adapter.create(model: "member", data: {organizationId: invitation["organizationId"], userId: session[:user]["id"], role: invitation["role"], createdAt: Time.now})
387
- organization_team_ids(invitation["teamId"]).each do |team_id|
388
- ctx.context.adapter.create(model: "teamMember", data: {teamId: team_id, userId: session[:user]["id"], createdAt: Time.now})
389
- end
390
- updated = ctx.context.adapter.update(model: "invitation", where: [{field: "id", value: invitation["id"]}], update: {status: "accepted"})
390
+ require_pending_invitation!(invitation)
391
+ require_invitation_recipient!(invitation, session)
392
+ require_verified_invitation_recipient!(ctx, config, session, "EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION")
391
393
  organization = organization_by_id(ctx, invitation["organizationId"])
394
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
395
+
396
+ run_org_hook(config, :before_accept_invitation, {invitation: invitation_wire(ctx, invitation), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
397
+ member = nil
398
+ updated = nil
399
+ organization_transaction(ctx) do |adapter|
400
+ lock_organization_capacity!(adapter, organization)
401
+ accepted = adapter.increment_one(
402
+ model: "invitation",
403
+ where: [{field: "id", value: invitation["id"]}, {field: "status", value: "pending"}],
404
+ increment: {},
405
+ set: {status: "accepted"}
406
+ )
407
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless accepted
408
+
409
+ validate_organization_roles!(ctx, config, invitation["organizationId"], normalized_role_names(invitation["role"]), adapter: adapter)
410
+ ensure_organization_member_capacity!(ctx, config, session[:user], organization, adapter: adapter)
411
+ team_ids = organization_team_ids(invitation["teamId"])
412
+ validate_stored_invitation_teams!(adapter, invitation["organizationId"], team_ids)
413
+ ensure_team_member_capacity!(ctx, config, team_ids, adapter: adapter, lock: true)
414
+ if adapter.find_one(model: "member", where: [{field: "userId", value: session[:user]["id"]}, {field: "organizationId", value: invitation["organizationId"]}])
415
+ raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION"))
416
+ end
417
+ member = adapter.create(model: "member", data: {organizationId: invitation["organizationId"], userId: session[:user]["id"], role: invitation["role"], createdAt: Time.now})
418
+ team_ids.each do |team_id|
419
+ adapter.create(model: "teamMember", data: {teamId: team_id, userId: session[:user]["id"], createdAt: Time.now})
420
+ end
421
+ updated = accepted
422
+ end
392
423
  run_org_hook(config, :after_accept_invitation, {invitation: invitation_wire(ctx, updated), member: member_wire(ctx, member), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
393
424
  ctx.json({invitation: invitation_wire(ctx, updated), member: member_wire(ctx, member)})
394
425
  end
395
426
  end
396
427
 
397
- def organization_reject_invitation_endpoint(_config)
428
+ def organization_reject_invitation_endpoint(config)
398
429
  Endpoint.new(path: "/organization/reject-invitation", method: "POST", metadata: organization_openapi("rejectOrganizationInvitation", "Reject an organization invitation", response: organization_ref_schema("Invitation"))) do |ctx|
399
430
  session = Routes.current_session(ctx)
400
431
  invitation = invitation_by_id(ctx, normalize_hash(ctx.body)[:invitation_id])
401
- raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless invitation
402
- raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION")) unless invitation["email"].to_s.downcase == session[:user]["email"].to_s.downcase
432
+ require_pending_invitation!(invitation, check_expiration: false)
433
+ require_invitation_recipient!(invitation, session)
434
+ require_verified_invitation_recipient!(ctx, config, session, "EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION")
435
+ organization = organization_by_id(ctx, invitation["organizationId"])
436
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
437
+ run_org_hook(config, :before_reject_invitation, {invitation: invitation_wire(ctx, invitation), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
403
438
  updated = ctx.context.adapter.update(model: "invitation", where: [{field: "id", value: invitation["id"]}], update: {status: "rejected"})
439
+ run_org_hook(config, :after_reject_invitation, {invitation: invitation_wire(ctx, updated), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
404
440
  ctx.json(invitation_wire(ctx, updated))
405
441
  end
406
442
  end
@@ -411,16 +447,28 @@ module BetterAuth
411
447
  invitation = invitation_by_id(ctx, normalize_hash(ctx.body)[:invitation_id])
412
448
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless invitation
413
449
  require_org_permission!(ctx, config, session, invitation["organizationId"], {invitation: ["cancel"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION"))
450
+ organization = organization_by_id(ctx, invitation["organizationId"])
451
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
452
+ run_org_hook(config, :before_cancel_invitation, {invitation: invitation_wire(ctx, invitation), cancelled_by: session[:user], organization: organization_wire(ctx, organization)}, ctx)
414
453
  updated = ctx.context.adapter.update(model: "invitation", where: [{field: "id", value: invitation["id"]}], update: {status: "canceled"})
454
+ run_org_hook(config, :after_cancel_invitation, {invitation: invitation_wire(ctx, updated), cancelled_by: session[:user], organization: organization_wire(ctx, organization)}, ctx)
415
455
  ctx.json(invitation_wire(ctx, updated))
416
456
  end
417
457
  end
418
458
 
419
- def organization_get_invitation_endpoint
459
+ def organization_get_invitation_endpoint(config)
420
460
  Endpoint.new(path: "/organization/get-invitation", method: "GET", metadata: organization_openapi("getOrganizationInvitation", "Get an organization invitation", response: organization_ref_schema("Invitation"))) do |ctx|
461
+ session = Routes.current_session(ctx)
421
462
  invitation = invitation_by_id(ctx, normalize_hash(ctx.query)[:id] || normalize_hash(ctx.query)[:invitation_id])
422
- raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless invitation
423
- ctx.json(invitation_wire(ctx, invitation))
463
+ require_pending_invitation!(invitation)
464
+ require_invitation_recipient!(invitation, session)
465
+ require_verified_invitation_recipient!(ctx, config, session, "EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION")
466
+ organization = organization_by_id(ctx, invitation["organizationId"])
467
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
468
+ inviter = require_member(ctx, invitation["inviterId"], invitation["organizationId"])
469
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION")) unless inviter
470
+ inviter_user = ctx.context.internal_adapter.find_user_by_id(invitation["inviterId"])
471
+ ctx.json(invitation_wire(ctx, invitation).merge(organizationName: organization["name"], organizationSlug: organization["slug"], inviterEmail: inviter_user && inviter_user["email"]))
424
472
  end
425
473
  end
426
474
 
@@ -438,6 +486,9 @@ module BetterAuth
438
486
  def organization_list_user_invitations_endpoint
439
487
  Endpoint.new(path: "/organization/list-user-invitations", method: "GET", metadata: organization_openapi("listUserInvitations", "List user invitations", response: organization_array_schema("Invitation"))) do |ctx|
440
488
  session = Routes.current_session(ctx)
489
+ unless session[:user]["emailVerified"]
490
+ raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION"))
491
+ end
441
492
  invitations = ctx.context.adapter.find_many(model: "invitation", where: [{field: "email", value: session[:user]["email"].to_s.downcase}, {field: "status", value: "pending"}])
442
493
  ctx.json(invitations.map { |entry| invitation_wire(ctx, entry) })
443
494
  end
@@ -447,18 +498,47 @@ module BetterAuth
447
498
  Endpoint.new(path: "/organization/add-member", method: "POST", metadata: organization_openapi("addOrganizationMember", "Add an organization member", response: organization_ref_schema("Member"))) do |ctx|
448
499
  session = Routes.current_session(ctx)
449
500
  body = normalize_hash(ctx.body)
450
- organization_id = body[:organization_id]
501
+ organization_id = body[:organization_id] || session[:session]["activeOrganizationId"]
502
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
503
+
504
+ organization = organization_by_id(ctx, organization_id)
505
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
506
+
451
507
  require_org_permission!(ctx, config, session, organization_id, {member: ["create"]}, ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_MEMBERSHIP_LIMIT_REACHED"))
452
508
  user_id = body[:user_id].to_s
453
509
  raise APIError.new("BAD_REQUEST", message: "userId is required") if user_id.empty?
510
+
511
+ user = ctx.context.internal_adapter.find_user_by_id(user_id)
512
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless user
513
+
454
514
  if require_member(ctx, user_id, organization_id)
455
515
  raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION"))
456
516
  end
457
- organization = organization_by_id(ctx, organization_id)
458
- user = ctx.context.internal_adapter.find_user_by_id(user_id)
459
- member_data = {organizationId: organization_id, userId: user_id, role: parse_roles(body[:role] || "member"), createdAt: Time.now}.merge(additional_input(body, :organization_id, :user_id, :role))
517
+ team_id = body[:team_id]
518
+ if team_id
519
+ unless org_truthy?(config.dig(:teams, :enabled))
520
+ raise APIError.new("BAD_REQUEST", message: "Teams are not enabled")
521
+ end
522
+ team = team_by_id(ctx, team_id)
523
+ unless team && team["organizationId"] == organization_id
524
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND"))
525
+ end
526
+ end
527
+
528
+ member_data = {organizationId: organization_id, userId: user_id, role: parse_roles(body[:role] || "member"), createdAt: Time.now}.merge(additional_input(body, :organization_id, :user_id, :role, :team_id))
460
529
  merge_hook_data!(member_data, run_org_hook(config, :before_add_member, {member: member_data, user: user, organization: organization_wire(ctx, organization)}, ctx))
461
- member = ctx.context.adapter.create(model: "member", data: member_data)
530
+ member = organization_transaction(ctx) do |adapter|
531
+ lock_organization_capacity!(adapter, organization)
532
+ validate_organization_roles!(ctx, config, organization_id, normalized_role_names(member_data[:role]), adapter: adapter)
533
+ ensure_organization_member_capacity!(ctx, config, user, organization, adapter: adapter)
534
+ ensure_team_member_capacity!(ctx, config, [team_id], adapter: adapter, lock: true) if team_id
535
+ if adapter.find_one(model: "member", where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}])
536
+ raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION"))
537
+ end
538
+ created = adapter.create(model: "member", data: member_data)
539
+ adapter.create(model: "teamMember", data: {teamId: team_id, userId: user_id, createdAt: Time.now}) if team_id
540
+ created
541
+ end
462
542
  run_org_hook(config, :after_add_member, {member: member_wire(ctx, member), user: user, organization: organization_wire(ctx, organization)}, ctx)
463
543
  ctx.json(member_wire(ctx, member))
464
544
  end
@@ -471,11 +551,18 @@ module BetterAuth
471
551
  member = member_by_id(ctx, body[:member_id]) || require_member(ctx, body[:user_id], body[:organization_id])
472
552
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("MEMBER_NOT_FOUND")) unless member
473
553
  require_org_permission!(ctx, config, session, member["organizationId"], {member: ["delete"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER"))
474
- ensure_not_last_owner!(ctx, member)
475
554
  organization = organization_by_id(ctx, member["organizationId"])
476
555
  user = ctx.context.internal_adapter.find_user_by_id(member["userId"])
477
- ctx.context.adapter.delete(model: "member", where: [{field: "id", value: member["id"]}])
478
- ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "userId", value: member["userId"]}]) if org_truthy?(config.dig(:teams, :enabled))
556
+ run_org_hook(config, :before_remove_member, {member: member_wire(ctx, member), user: user, organization: organization_wire(ctx, organization)}, ctx)
557
+ organization_transaction(ctx) do |adapter|
558
+ lock_organization_capacity!(adapter, organization)
559
+ ensure_not_last_owner!(ctx, member, adapter: adapter)
560
+ adapter.delete(model: "member", where: [{field: "id", value: member["id"]}])
561
+ if org_truthy?(config.dig(:teams, :enabled))
562
+ team_ids = adapter.find_many(model: "team", where: [{field: "organizationId", value: member["organizationId"]}]).map { |team| team["id"] }
563
+ adapter.delete_many(model: "teamMember", where: [{field: "userId", value: member["userId"]}, {field: "teamId", value: team_ids, operator: "in"}]) if team_ids.any?
564
+ end
565
+ end
479
566
  run_org_hook(config, :after_remove_member, {member: member_wire(ctx, member), user: user, organization: organization_wire(ctx, organization)}, ctx)
480
567
  ctx.json({status: true})
481
568
  end
@@ -485,10 +572,57 @@ module BetterAuth
485
572
  Endpoint.new(path: "/organization/update-member-role", method: "POST", metadata: organization_openapi("updateOrganizationMemberRole", "Update an organization member role", response: organization_ref_schema("Member"))) do |ctx|
486
573
  session = Routes.current_session(ctx)
487
574
  body = normalize_hash(ctx.body)
488
- member = member_by_id(ctx, body[:member_id]) || require_member(ctx, body[:user_id], body[:organization_id])
575
+ organization_id = body[:organization_id] || session[:session]["activeOrganizationId"]
576
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
577
+
578
+ role_names = normalized_role_names(body[:role])
579
+ raise APIError.new("BAD_REQUEST") if role_names.empty?
580
+
581
+ validate_organization_roles!(ctx, config, organization_id, role_names)
582
+
583
+ updating_member = require_member(ctx, session[:user]["id"], organization_id)
584
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("MEMBER_NOT_FOUND")) unless updating_member
585
+
586
+ member = if body[:member_id]
587
+ (body[:member_id].to_s == updating_member["id"]) ? updating_member : member_by_id(ctx, body[:member_id])
588
+ else
589
+ require_member(ctx, body[:user_id], organization_id)
590
+ end
489
591
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("MEMBER_NOT_FOUND")) unless member
490
- require_org_permission!(ctx, config, session, member["organizationId"], {member: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER"))
491
- updated = ctx.context.adapter.update(model: "member", where: [{field: "id", value: member["id"]}], update: {role: parse_roles(body[:role])})
592
+ unless member["organizationId"] == organization_id
593
+ raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER"))
594
+ end
595
+
596
+ creator_role = config[:creator_role]
597
+ updating_member_roles = normalized_role_names(updating_member["role"])
598
+ member_roles = normalized_role_names(member["role"])
599
+ updater_is_creator = updating_member_roles.include?(creator_role)
600
+ updating_creator = member_roles.include?(creator_role)
601
+ setting_creator_role = role_names.include?(creator_role)
602
+
603
+ if (updating_creator && !updater_is_creator) || (setting_creator_role && !updater_is_creator)
604
+ raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER"))
605
+ end
606
+
607
+ if updater_is_creator && updating_member["id"] == member["id"] && !setting_creator_role
608
+ creator_count = count_members_with_role(ctx, organization_id, creator_role)
609
+ if creator_count <= 1
610
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER"))
611
+ end
612
+ end
613
+
614
+ unless updater_is_creator
615
+ require_org_permission!(ctx, config, session, organization_id, {member: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER"))
616
+ end
617
+ organization = organization_by_id(ctx, organization_id)
618
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
619
+ user = ctx.context.internal_adapter.find_user_by_id(member["userId"])
620
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless user
621
+ previous_role = member["role"]
622
+ update = {role: parse_roles(role_names)}
623
+ merge_hook_data!(update, run_org_hook(config, :before_update_member_role, {member: member_wire(ctx, member), new_role: update[:role], user: user, organization: organization_wire(ctx, organization)}, ctx))
624
+ updated = ctx.context.adapter.update(model: "member", where: [{field: "id", value: member["id"]}], update: update)
625
+ run_org_hook(config, :after_update_member_role, {member: member_wire(ctx, updated), previous_role: previous_role, user: user, organization: organization_wire(ctx, organization)}, ctx)
492
626
  ctx.json(member_wire(ctx, updated))
493
627
  end
494
628
  end
@@ -520,21 +654,29 @@ module BetterAuth
520
654
  session = Routes.current_session(ctx)
521
655
  organization_id = normalize_hash(ctx.body)[:organization_id]
522
656
  member = require_member!(ctx, session[:user]["id"], organization_id)
523
- ensure_not_last_owner!(ctx, member)
524
- ctx.context.adapter.delete(model: "member", where: [{field: "id", value: member["id"]}])
525
- ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "userId", value: session[:user]["id"]}]) if org_truthy?(config.dig(:teams, :enabled))
657
+ organization = organization_by_id(ctx, organization_id)
658
+ organization_transaction(ctx) do |adapter|
659
+ lock_organization_capacity!(adapter, organization)
660
+ ensure_not_last_owner!(ctx, member, adapter: adapter)
661
+ adapter.delete(model: "member", where: [{field: "id", value: member["id"]}])
662
+ if org_truthy?(config.dig(:teams, :enabled))
663
+ team_ids = adapter.find_many(model: "team", where: [{field: "organizationId", value: organization_id}]).map { |team| team["id"] }
664
+ adapter.delete_many(model: "teamMember", where: [{field: "userId", value: session[:user]["id"]}, {field: "teamId", value: team_ids, operator: "in"}]) if team_ids.any?
665
+ end
666
+ end
667
+ clear_active_organization_session(ctx, session, organization_id)
526
668
  ctx.json({status: true})
527
669
  end
528
670
  end
529
671
 
530
- def organization_list_members_endpoint(_config)
672
+ def organization_list_members_endpoint(config)
531
673
  Endpoint.new(path: "/organization/list-members", method: "GET", metadata: organization_openapi("listOrganizationMembers", "List organization members", response: organization_members_response_schema)) do |ctx|
532
674
  session = Routes.current_session(ctx)
533
675
  query = normalize_hash(ctx.query)
534
676
  organization_id = query[:organization_id] || organization_by_slug(ctx, query[:organization_slug])&.fetch("id") || session[:session]["activeOrganizationId"]
535
677
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
536
678
  require_member!(ctx, session[:user]["id"], organization_id)
537
- ctx.json(list_members_for(ctx, organization_id, query))
679
+ ctx.json(list_members_for(ctx, organization_id, query, config, session[:user]))
538
680
  end
539
681
  end
540
682
 
@@ -557,14 +699,18 @@ module BetterAuth
557
699
  organization_id = body[:organization_id] || session[:session]["activeOrganizationId"]
558
700
  require_org_permission!(ctx, config, session, organization_id, {team: ["create"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION"))
559
701
  organization = organization_by_id(ctx, organization_id)
560
- max_teams = config.dig(:teams, :maximum_teams)
561
- if max_teams && ctx.context.adapter.count(model: "team", where: [{field: "organizationId", value: organization_id}]) >= max_teams.to_i
562
- raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS"))
563
- end
564
702
  team_data = {organizationId: organization_id, name: body[:name].to_s, createdAt: Time.now}.merge(additional_input(body, :organization_id, :name))
565
703
  merge_hook_data!(team_data, run_org_hook(config, :before_create_team, {team: team_data, user: session[:user], organization: organization_wire(ctx, organization)}, ctx))
566
- team = ctx.context.adapter.create(model: "team", data: team_data, force_allow_id: true)
567
- ctx.context.adapter.create(model: "teamMember", data: {teamId: team["id"], userId: session[:user]["id"], createdAt: Time.now})
704
+ team = organization_transaction(ctx) do |adapter|
705
+ lock_organization_capacity!(adapter, organization)
706
+ max_teams = config.dig(:teams, :maximum_teams)
707
+ if max_teams && adapter.count(model: "team", where: [{field: "organizationId", value: organization_id}]) >= max_teams.to_i
708
+ raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS"))
709
+ end
710
+ created = adapter.create(model: "team", data: team_data, force_allow_id: true)
711
+ adapter.create(model: "teamMember", data: {teamId: created["id"], userId: session[:user]["id"], createdAt: Time.now})
712
+ created
713
+ end
568
714
  run_org_hook(config, :after_create_team, {team: team_wire(ctx, team), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
569
715
  ctx.json(team_wire(ctx, team))
570
716
  end
@@ -587,7 +733,11 @@ module BetterAuth
587
733
  team = team_by_id(ctx, body[:team_id])
588
734
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team
589
735
  require_org_permission!(ctx, config, session, team["organizationId"], {team: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM"))
590
- updated = ctx.context.adapter.update(model: "team", where: [{field: "id", value: team["id"]}], update: additional_input(body, :team_id, :organization_id))
736
+ organization = organization_by_id(ctx, team["organizationId"])
737
+ update = additional_input(body[:data].is_a?(Hash) ? body[:data] : body, :team_id, :organization_id)
738
+ merge_hook_data!(update, run_org_hook(config, :before_update_team, {team: team_wire(ctx, team), updates: update, user: session[:user], organization: organization_wire(ctx, organization)}, ctx))
739
+ updated = ctx.context.adapter.update(model: "team", where: [{field: "id", value: team["id"]}], update: update)
740
+ run_org_hook(config, :after_update_team, {team: team_wire(ctx, updated), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
591
741
  ctx.json(team_wire(ctx, updated))
592
742
  end
593
743
  end
@@ -598,12 +748,26 @@ module BetterAuth
598
748
  team = team_by_id(ctx, normalize_hash(ctx.body)[:team_id])
599
749
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team
600
750
  require_org_permission!(ctx, config, session, team["organizationId"], {team: ["delete"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM"))
601
- teams = ctx.context.adapter.find_many(model: "team", where: [{field: "organizationId", value: team["organizationId"]}])
602
- if teams.length <= 1 && config.dig(:teams, :allow_removing_all_teams) != true
603
- raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("UNABLE_TO_REMOVE_LAST_TEAM"))
751
+ organization = organization_by_id(ctx, team["organizationId"])
752
+ run_org_hook(config, :before_delete_team, {team: team_wire(ctx, team), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
753
+ organization_transaction(ctx) do |adapter|
754
+ lock_organization_capacity!(adapter, organization)
755
+ teams = adapter.find_many(model: "team", where: [{field: "organizationId", value: team["organizationId"]}])
756
+ if teams.length <= 1 && config.dig(:teams, :allow_removing_all_teams) != true
757
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("UNABLE_TO_REMOVE_LAST_TEAM"))
758
+ end
759
+ adapter.delete_many(model: "teamMember", where: [{field: "teamId", value: team["id"]}])
760
+ adapter.delete(model: "team", where: [{field: "id", value: team["id"]}])
761
+ pending = adapter.find_many(model: "invitation", where: [{field: "organizationId", value: team["organizationId"]}, {field: "status", value: "pending"}])
762
+ pending.each do |invitation|
763
+ team_ids = organization_team_ids(invitation["teamId"])
764
+ next unless team_ids.include?(team["id"])
765
+
766
+ remaining = team_ids.reject { |team_id| team_id == team["id"] }
767
+ adapter.update(model: "invitation", where: [{field: "id", value: invitation["id"]}], update: {teamId: remaining.any? ? remaining.join(",") : nil})
768
+ end
604
769
  end
605
- ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "teamId", value: team["id"]}])
606
- ctx.context.adapter.delete(model: "team", where: [{field: "id", value: team["id"]}])
770
+ run_org_hook(config, :after_delete_team, {team: team_wire(ctx, team), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
607
771
  ctx.json({status: true})
608
772
  end
609
773
  end
@@ -619,9 +783,14 @@ module BetterAuth
619
783
  end
620
784
  team = team_by_id(ctx, body[:team_id])
621
785
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team
786
+ active_organization_id = session[:session]["activeOrganizationId"]
787
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless active_organization_id
788
+ unless team["organizationId"] == active_organization_id
789
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND"))
790
+ end
622
791
  require_team_member!(ctx, session[:user]["id"], team["id"])
623
- updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], {activeOrganizationId: team["organizationId"], activeTeamId: team["id"]})
624
- Cookies.set_session_cookie(ctx, {session: updated_session || session[:session].merge("activeOrganizationId" => team["organizationId"], "activeTeamId" => team["id"]), user: session[:user]})
792
+ updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], {activeTeamId: team["id"]})
793
+ Cookies.set_session_cookie(ctx, {session: updated_session || session[:session].merge("activeTeamId" => team["id"]), user: session[:user]})
625
794
  ctx.json(team_wire(ctx, team))
626
795
  end
627
796
  end
@@ -655,14 +824,18 @@ module BetterAuth
655
824
  require_org_permission!(ctx, config, session, team["organizationId"], {team: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER"))
656
825
  user_id = body[:user_id].to_s
657
826
  require_member!(ctx, user_id, team["organizationId"])
658
- max_members = config.dig(:teams, :maximum_members_per_team)
659
- if max_members && ctx.context.adapter.count(model: "teamMember", where: [{field: "teamId", value: team["id"]}]) >= max_members.to_i
660
- raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_MEMBER_LIMIT_REACHED"))
827
+ user = ctx.context.internal_adapter.find_user_by_id(user_id)
828
+ organization = organization_by_id(ctx, team["organizationId"])
829
+ member_data = {teamId: team["id"], userId: user_id, createdAt: Time.now}
830
+ run_org_hook(config, :before_add_team_member, {team_member: member_data, team: team_wire(ctx, team), user: user, organization: organization_wire(ctx, organization)}, ctx)
831
+ member = organization_transaction(ctx) do |adapter|
832
+ existing = adapter.find_one(model: "teamMember", where: [{field: "teamId", value: team["id"]}, {field: "userId", value: user_id}])
833
+ next existing if existing
834
+
835
+ ensure_team_member_capacity!(ctx, config, [team["id"]], adapter: adapter, lock: true)
836
+ adapter.create(model: "teamMember", data: member_data)
661
837
  end
662
- existing = ctx.context.adapter.find_one(model: "teamMember", where: [{field: "teamId", value: team["id"]}, {field: "userId", value: user_id}])
663
- next ctx.json(team_member_wire(ctx, existing)) if existing
664
-
665
- member = ctx.context.adapter.create(model: "teamMember", data: {teamId: team["id"], userId: user_id, createdAt: Time.now})
838
+ run_org_hook(config, :after_add_team_member, {team_member: team_member_wire(ctx, member), team: team_wire(ctx, team), user: user, organization: organization_wire(ctx, organization)}, ctx)
666
839
  ctx.json(team_member_wire(ctx, member))
667
840
  end
668
841
  end
@@ -674,7 +847,13 @@ module BetterAuth
674
847
  team = team_by_id(ctx, body[:team_id])
675
848
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team
676
849
  require_org_permission!(ctx, config, session, team["organizationId"], {team: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER"))
850
+ member = ctx.context.adapter.find_one(model: "teamMember", where: [{field: "teamId", value: team["id"]}, {field: "userId", value: body[:user_id]}])
851
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_NOT_A_MEMBER_OF_THE_TEAM")) unless member
852
+ user = ctx.context.internal_adapter.find_user_by_id(body[:user_id])
853
+ organization = organization_by_id(ctx, team["organizationId"])
854
+ run_org_hook(config, :before_remove_team_member, {team_member: team_member_wire(ctx, member), team: team_wire(ctx, team), user: user, organization: organization_wire(ctx, organization)}, ctx)
677
855
  ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "teamId", value: team["id"]}, {field: "userId", value: body[:user_id]}])
856
+ run_org_hook(config, :after_remove_team_member, {team_member: team_member_wire(ctx, member), team: team_wire(ctx, team), user: user, organization: organization_wire(ctx, organization)}, ctx)
678
857
  ctx.json({status: true})
679
858
  end
680
859
  end
@@ -689,7 +868,7 @@ module BetterAuth
689
868
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ROLE_NAME_IS_ALREADY_TAKEN")) if organization_roles(config).key?(role_name) || organization_role_by_name(ctx, organization_id, role_name)
690
869
  permission = stringify_permission(body[:permission] || body[:permissions])
691
870
  validate_permission_resources!(config, permission)
692
- unless organization_permission?(ctx, config, require_member!(ctx, session[:user]["id"], organization_id)["role"], permission, organization_id)
871
+ unless organization_can_delegate_permissions?(ctx, config, require_member!(ctx, session[:user]["id"], organization_id)["role"], permission, organization_id)
693
872
  raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE"))
694
873
  end
695
874
  role = ctx.context.adapter.create(model: "organizationRole", data: {organizationId: organization_id, role: role_name, permission: JSON.generate(permission), createdAt: Time.now}.merge(additional_input(body, :organization_id, :role, :role_name, :permission, :permissions)))
@@ -735,7 +914,7 @@ module BetterAuth
735
914
  if permission
736
915
  permission = stringify_permission(permission)
737
916
  validate_permission_resources!(config, permission)
738
- unless organization_permission?(ctx, config, require_member!(ctx, session[:user]["id"], organization_id)["role"], permission, organization_id)
917
+ unless organization_can_delegate_permissions?(ctx, config, require_member!(ctx, session[:user]["id"], organization_id)["role"], permission, organization_id)
739
918
  raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE"))
740
919
  end
741
920
  update[:permission] = JSON.generate(permission)
@@ -768,6 +947,7 @@ module BetterAuth
768
947
  end
769
948
 
770
949
  def organization_openapi(operation_id, description, response:, response_description: "Success", request: nil, required: [], parameters: nil)
950
+ request ||= organization_request_schema(operation_id)
771
951
  openapi = {
772
952
  operationId: operation_id,
773
953
  description: description,
@@ -781,6 +961,60 @@ module BetterAuth
781
961
  {openapi: openapi}
782
962
  end
783
963
 
964
+ def organization_request_schema(operation_id)
965
+ string = {type: "string"}
966
+ boolean = {type: "boolean"}
967
+ array = ->(items = string) { {type: "array", items: items} }
968
+ object = {type: "object", additionalProperties: true}
969
+ {
970
+ "createOrganization" => {
971
+ name: string,
972
+ slug: string,
973
+ logo: string,
974
+ metadata: object,
975
+ userId: string,
976
+ keepCurrentActiveOrganization: boolean
977
+ },
978
+ "checkOrganizationSlug" => {slug: string},
979
+ "updateOrganization" => {
980
+ organizationId: string,
981
+ organizationSlug: string,
982
+ data: object,
983
+ name: string,
984
+ slug: string,
985
+ logo: string,
986
+ metadata: object
987
+ },
988
+ "deleteOrganization" => {organizationId: string, organizationSlug: string},
989
+ "setActiveOrganization" => {organizationId: string, organizationSlug: string},
990
+ "createOrganizationInvitation" => {
991
+ organizationId: string,
992
+ organizationSlug: string,
993
+ email: {type: "string", format: "email"},
994
+ role: {oneOf: [string, array.call]},
995
+ teamId: string,
996
+ teamIds: array.call
997
+ },
998
+ "acceptOrganizationInvitation" => {invitationId: string, id: string},
999
+ "rejectOrganizationInvitation" => {invitationId: string},
1000
+ "cancelOrganizationInvitation" => {invitationId: string},
1001
+ "addOrganizationMember" => {organizationId: string, userId: string, role: {oneOf: [string, array.call]}},
1002
+ "removeOrganizationMember" => {memberId: string, userId: string, organizationId: string},
1003
+ "updateOrganizationMemberRole" => {memberId: string, userId: string, organizationId: string, role: {oneOf: [string, array.call]}},
1004
+ "leaveOrganization" => {organizationId: string},
1005
+ "hasOrganizationPermission" => {organizationId: string, permission: object, permissions: object},
1006
+ "createOrganizationTeam" => {organizationId: string, name: string},
1007
+ "updateOrganizationTeam" => {teamId: string, name: string},
1008
+ "removeOrganizationTeam" => {teamId: string},
1009
+ "setActiveOrganizationTeam" => {teamId: string},
1010
+ "addTeamMember" => {teamId: string, userId: string},
1011
+ "removeTeamMember" => {teamId: string, userId: string},
1012
+ "createOrganizationRole" => {organizationId: string, role: string, roleName: string, permission: object, permissions: object},
1013
+ "updateOrganizationRole" => {organizationId: string, roleId: string, role: string, roleName: string, permission: object, permissions: object, data: object},
1014
+ "deleteOrganizationRole" => {organizationId: string, roleId: string, role: string, roleName: string}
1015
+ }[operation_id]
1016
+ end
1017
+
784
1018
  def organization_ref_schema(name)
785
1019
  {
786
1020
  type: "object",
@@ -869,6 +1103,35 @@ module BetterAuth
869
1103
  Array(roles).join(",")
870
1104
  end
871
1105
 
1106
+ def normalized_role_names(roles)
1107
+ Array(roles).flat_map { |role| role.to_s.split(",") }.map(&:strip).reject(&:empty?)
1108
+ end
1109
+
1110
+ def validate_organization_roles!(ctx, config, organization_id, role_names, adapter: ctx.context.adapter)
1111
+ valid_static_roles = (organization_default_roles(config).keys + organization_roles(config).keys).uniq
1112
+ unknown_roles = role_names.reject { |role| valid_static_roles.include?(role) }.uniq
1113
+ return if unknown_roles.empty?
1114
+
1115
+ if org_truthy?(config.dig(:dynamic_access_control, :enabled))
1116
+ found_roles = adapter.find_many(
1117
+ model: "organizationRole",
1118
+ where: [{field: "organizationId", value: organization_id}, {field: "role", value: unknown_roles, operator: "in"}]
1119
+ ).map { |role| role["role"] }
1120
+ unknown_roles -= found_roles
1121
+ end
1122
+ return if unknown_roles.empty?
1123
+
1124
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ROLE_NOT_FOUND"))
1125
+ end
1126
+
1127
+ def count_members_with_role(ctx, organization_id, role)
1128
+ count = 0
1129
+ organization_each_adapter_record(ctx.context.adapter, "member", where: [{field: "organizationId", value: organization_id}]) do |member|
1130
+ count += 1 if normalized_role_names(member["role"]).include?(role)
1131
+ end
1132
+ count
1133
+ end
1134
+
872
1135
  def organization_roles(config)
873
1136
  (config[:roles] || organization_default_roles(config)).transform_keys(&:to_s)
874
1137
  end
@@ -889,6 +1152,14 @@ module BetterAuth
889
1152
  end
890
1153
  end
891
1154
 
1155
+ def organization_can_delegate_permissions?(ctx, config, role_string, permissions, organization_id)
1156
+ permissions.all? do |resource, actions|
1157
+ actions.all? do |action|
1158
+ organization_permission?(ctx, config, role_string, {resource => [action]}, organization_id)
1159
+ end
1160
+ end
1161
+ end
1162
+
892
1163
  def require_org_permission!(ctx, config, session, organization_id, permissions, message)
893
1164
  member = require_member!(ctx, session[:user]["id"], organization_id)
894
1165
  return member if organization_permission?(ctx, config, member["role"], permissions, organization_id)
@@ -969,7 +1240,7 @@ module BetterAuth
969
1240
  ctx.context.adapter.find_one(model: "organizationRole", where: [{field: "organizationId", value: organization_id}, {field: "role", value: role}])
970
1241
  end
971
1242
 
972
- def list_members_for(ctx, organization_id, query = {})
1243
+ def list_members_for(ctx, organization_id, query = {}, config = nil, user = nil)
973
1244
  where = [{field: "organizationId", value: organization_id}]
974
1245
  if query[:filter_field]
975
1246
  where << {field: query[:filter_field], value: query[:filter_value], operator: query[:filter_operator]}
@@ -977,34 +1248,79 @@ module BetterAuth
977
1248
  filter = normalize_hash(query[:filter])
978
1249
  where << {field: filter[:field], value: filter[:value], operator: filter[:operator]}
979
1250
  end
1251
+ limit = member_list_limit(ctx, organization_id, query, config, user)
980
1252
  members = ctx.context.adapter.find_many(
981
1253
  model: "member",
982
1254
  where: where,
983
- limit: query[:limit],
1255
+ limit: limit,
984
1256
  offset: query[:offset],
985
1257
  sort_by: query[:sort_by] ? {field: query[:sort_by], direction: query[:sort_direction] || query[:sort_order] || "asc"} : nil
986
1258
  )
1259
+ users_by_id = member_users_by_id(ctx, members)
987
1260
  {
988
- members: members.map { |entry| member_wire(ctx, entry) },
1261
+ members: members.map { |entry| member_wire(ctx, entry, users_by_id: users_by_id) },
989
1262
  total: ctx.context.adapter.count(model: "member", where: where)
990
1263
  }
991
1264
  end
992
1265
 
993
- def ensure_team_member_capacity!(ctx, config, team_ids)
1266
+ def member_list_limit(ctx, organization_id, query, config, user)
1267
+ configured = config && config[:membership_limit]
1268
+ configured = 100 if configured.nil?
1269
+ default = numeric_member_limit(configured)
1270
+ default = 100 unless default.positive?
1271
+ requested = query[:limit].to_i if query.key?(:limit) && !query[:limit].to_s.empty?
1272
+ return default unless requested&.positive?
1273
+
1274
+ [requested, default].min
1275
+ end
1276
+
1277
+ def ensure_organization_member_capacity!(ctx, config, user, organization, adapter: ctx.context.adapter)
1278
+ limit = organization_membership_capacity_limit(ctx, config, user, organization)
1279
+ count = adapter.count(model: "member", where: [{field: "organizationId", value: organization["id"]}])
1280
+ return if count < limit
1281
+
1282
+ raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_MEMBERSHIP_LIMIT_REACHED"))
1283
+ end
1284
+
1285
+ def organization_membership_capacity_limit(ctx, config, user, organization)
1286
+ configured = config[:membership_limit] || 100
1287
+ value = configured.respond_to?(:call) ? configured.call(user, organization_wire(ctx, organization)) : configured
1288
+ numeric_member_limit(value)
1289
+ end
1290
+
1291
+ def numeric_member_limit(value)
1292
+ return value.to_i if value.is_a?(Numeric)
1293
+ return value.to_i if value.to_s.match?(/\A-?\d+\z/)
1294
+
1295
+ 100
1296
+ end
1297
+
1298
+ def member_users_by_id(ctx, members)
1299
+ user_ids = members.map { |member| member["userId"] }.compact.uniq
1300
+ return {} if user_ids.empty?
1301
+
1302
+ ctx.context.adapter.find_many(model: "user", where: [{field: "id", operator: "in", value: user_ids}]).each_with_object({}) do |user, result|
1303
+ result[user["id"]] = user
1304
+ end
1305
+ end
1306
+
1307
+ def ensure_team_member_capacity!(ctx, config, team_ids, adapter: ctx.context.adapter, lock: false)
994
1308
  max_members = config.dig(:teams, :maximum_members_per_team)
995
1309
  return unless max_members && team_ids.any?
996
1310
 
997
1311
  team_ids.each do |team_id|
998
- count = ctx.context.adapter.count(model: "teamMember", where: [{field: "teamId", value: team_id}])
1312
+ team = adapter.find_one(model: "team", where: [{field: "id", value: team_id}])
1313
+ lock_team_capacity!(adapter, team) if lock && team
1314
+ count = adapter.count(model: "teamMember", where: [{field: "teamId", value: team_id}])
999
1315
  if count >= max_members.to_i
1000
1316
  raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_MEMBER_LIMIT_REACHED"))
1001
1317
  end
1002
1318
  end
1003
1319
  end
1004
1320
 
1005
- def member_wire(ctx, member)
1321
+ def member_wire(ctx, member, users_by_id: nil)
1006
1322
  data = Schema.parse_output(ctx.context.options, "member", member)
1007
- user = ctx.context.internal_adapter.find_user_by_id(member["userId"])
1323
+ user = users_by_id ? users_by_id[member["userId"]] : ctx.context.internal_adapter.find_user_by_id(member["userId"])
1008
1324
  data["user"] = user.slice("id", "name", "email", "image") if user
1009
1325
  data
1010
1326
  end
@@ -1031,11 +1347,22 @@ module BetterAuth
1031
1347
  role.merge("permission" => parse_permission(role["permission"]))
1032
1348
  end
1033
1349
 
1034
- def ensure_not_last_owner!(ctx, member)
1350
+ def ensure_not_last_owner!(ctx, member, adapter: ctx.context.adapter)
1035
1351
  return unless member["role"].to_s.split(",").include?("owner")
1036
1352
 
1037
- owners = ctx.context.adapter.find_many(model: "member", where: [{field: "organizationId", value: member["organizationId"]}]).select { |entry| entry["role"].to_s.split(",").include?("owner") }
1038
- raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER")) if owners.length <= 1
1353
+ owner_count = 0
1354
+ organization_each_adapter_record(adapter, "member", where: [{field: "organizationId", value: member["organizationId"]}]) do |entry|
1355
+ owner_count += 1 if entry["role"].to_s.split(",").include?("owner")
1356
+ end
1357
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER")) if owner_count <= 1
1358
+ end
1359
+
1360
+ def clear_active_organization_session(ctx, session, organization_id)
1361
+ return unless session[:session]["activeOrganizationId"] == organization_id
1362
+
1363
+ update = {activeOrganizationId: nil, activeTeamId: nil}
1364
+ updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], update)
1365
+ Cookies.set_session_cookie(ctx, {session: updated_session || session[:session].merge("activeOrganizationId" => nil, "activeTeamId" => nil), user: session[:user]})
1039
1366
  end
1040
1367
 
1041
1368
  def create_default_team(ctx, config, organization, session)
@@ -1053,16 +1380,59 @@ module BetterAuth
1053
1380
  end
1054
1381
 
1055
1382
  def organization_created_count(ctx, user_id)
1056
- members = ctx.context.adapter.find_many(model: "member", where: [{field: "userId", value: user_id}])
1057
- members.count { |member| member["role"].to_s.split(",").include?("owner") }
1383
+ count = 0
1384
+ organization_each_adapter_record(ctx.context.adapter, "member", where: [{field: "userId", value: user_id}]) do |member|
1385
+ count += 1 if member["role"].to_s.split(",").include?("owner")
1386
+ end
1387
+ count
1388
+ end
1389
+
1390
+ def organization_each_adapter_record(adapter, model, where:, page_size: 100)
1391
+ offset = 0
1392
+ loop do
1393
+ records = adapter.find_many(model: model, where: where, limit: page_size, offset: offset)
1394
+ break if records.empty?
1395
+
1396
+ records.each { |record| yield record }
1397
+ break if records.length < page_size
1398
+
1399
+ offset += records.length
1400
+ end
1058
1401
  end
1059
1402
 
1060
1403
  def run_org_hook(config, key, data, ctx)
1061
- hooks = [config.dig(:organization_hooks, key), config.dig(:hooks, key)]
1062
- hooks.concat(ctx.context.options.plugins.filter_map { |plugin| plugin.dig(:options, :organization_hooks, key) || plugin.dig("options", "organizationHooks", key.to_s) }) if ctx&.context&.options
1404
+ hooks = org_hook_candidates(config, key)
1405
+ hooks.concat(ctx.context.options.plugins.flat_map { |plugin| org_hook_candidates(plugin_options(plugin), key) }) if ctx&.context&.options
1063
1406
  hooks.compact.uniq.filter_map { |hook| hook.call(data, ctx) if hook.respond_to?(:call) }.find { |response| response.is_a?(Hash) && normalize_hash(response).key?(:data) }
1064
1407
  end
1065
1408
 
1409
+ def org_hook_candidates(options, key)
1410
+ data = normalize_hash(options || {})
1411
+ camel_key = camelize_lower(key)
1412
+ candidates = [
1413
+ data.dig(:organization_hooks, key),
1414
+ data.dig(:organization_hooks, camel_key),
1415
+ data.dig(:hooks, key),
1416
+ data.dig(:hooks, camel_key)
1417
+ ]
1418
+ if options.respond_to?(:dig)
1419
+ candidates << options.dig("organizationHooks", camel_key)
1420
+ candidates << options.dig("organizationHooks", key.to_s)
1421
+ candidates << options.dig(:organizationHooks, camel_key)
1422
+ candidates << options.dig(:organizationHooks, key.to_s)
1423
+ end
1424
+ candidates
1425
+ end
1426
+
1427
+ def plugin_options(plugin)
1428
+ plugin.respond_to?(:dig) ? (plugin.dig(:options) || plugin.dig("options") || {}) : {}
1429
+ end
1430
+
1431
+ def camelize_lower(key)
1432
+ parts = key.to_s.split("_")
1433
+ ([parts.first] + parts.drop(1).map(&:capitalize)).join
1434
+ end
1435
+
1066
1436
  def merge_hook_data!(target, response)
1067
1437
  data = if response.is_a?(Hash)
1068
1438
  normalize_hash(response)[:data]
@@ -1108,6 +1478,80 @@ module BetterAuth
1108
1478
  Array(value).flat_map { |entry| entry.to_s.split(",") }.map(&:strip).reject(&:empty?)
1109
1479
  end
1110
1480
 
1481
+ def validate_invitation_team_ids!(ctx, config, organization_id, raw_value, team_ids)
1482
+ return if raw_value.nil?
1483
+ unless org_truthy?(config.dig(:teams, :enabled))
1484
+ raise APIError.new("BAD_REQUEST", message: "Teams are not enabled")
1485
+ end
1486
+ if Array(raw_value).any? { |team_id| team_id.to_s.include?(",") }
1487
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVALID_TEAM_ID"))
1488
+ end
1489
+ team_ids.each do |team_id|
1490
+ team = team_by_id(ctx, team_id)
1491
+ unless team && team["organizationId"] == organization_id
1492
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND"))
1493
+ end
1494
+ end
1495
+ end
1496
+
1497
+ def require_pending_invitation!(invitation, check_expiration: true)
1498
+ expired = invitation && invitation["expiresAt"] && organization_time(invitation["expiresAt"]) < Time.now
1499
+ valid = invitation && invitation["status"] == "pending"
1500
+ valid &&= !expired if check_expiration
1501
+ unless valid
1502
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND"))
1503
+ end
1504
+ invitation
1505
+ end
1506
+
1507
+ def require_invitation_recipient!(invitation, session)
1508
+ return if invitation["email"].to_s.downcase == session[:user]["email"].to_s.downcase
1509
+
1510
+ raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION"))
1511
+ end
1512
+
1513
+ def require_verified_invitation_recipient!(ctx, config, session, error_code)
1514
+ return unless require_verified_email_for_invitation_id_action?(ctx, config)
1515
+ return if session[:user]["emailVerified"]
1516
+
1517
+ raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch(error_code))
1518
+ end
1519
+
1520
+ def require_verified_email_for_invitation_id_action?(ctx, config)
1521
+ return org_truthy?(config[:require_email_verification_on_invitation]) if config.key?(:require_email_verification_on_invitation)
1522
+
1523
+ generator = ctx.context.options.advanced.dig(:database, :generate_id)
1524
+ !generator.nil? && generator != "uuid"
1525
+ end
1526
+
1527
+ def organization_time(value)
1528
+ value.is_a?(Time) ? value : Time.parse(value.to_s)
1529
+ end
1530
+
1531
+ def organization_transaction(ctx)
1532
+ adapter = ctx.context.adapter
1533
+ if adapter.method(:transaction).owner == BetterAuth::Adapters::Base
1534
+ raise BetterAuth::Error, "Organization mutations require an adapter with real transaction support"
1535
+ end
1536
+
1537
+ adapter.transaction { |transaction_adapter| yield(transaction_adapter || adapter) }
1538
+ end
1539
+
1540
+ def lock_organization_capacity!(adapter, organization)
1541
+ adapter.update(model: "organization", where: [{field: "id", value: organization["id"]}], update: {name: organization["name"]})
1542
+ end
1543
+
1544
+ def lock_team_capacity!(adapter, team)
1545
+ adapter.update(model: "team", where: [{field: "id", value: team["id"]}], update: {name: team["name"]})
1546
+ end
1547
+
1548
+ def validate_stored_invitation_teams!(adapter, organization_id, team_ids)
1549
+ team_ids.each do |team_id|
1550
+ team = adapter.find_one(model: "team", where: [{field: "id", value: team_id}, {field: "organizationId", value: organization_id}])
1551
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team
1552
+ end
1553
+ end
1554
+
1111
1555
  def additional_input(hash, *exclude)
1112
1556
  data = normalize_hash(hash)
1113
1557
  additional = normalize_hash(data.delete(:additional_fields))