* fix(family-sharing): prevent silent data loss when rehoming or removing users
Fixes#1689.
Two destructive paths could strand a pre-existing user's family and accounts:
1. Invitation#accept_for unconditionally overwrote user.family_id, orphaning
the prior family + its accounts with no user able to reach them.
2. Settings::ProfilesController#destroy then called @user.destroy when an admin
removed the rehomed member, destroying the only login path back to the
now-orphaned data.
Add hard-block guards on both paths. accept_for refuses when the invitee
already belongs to a family with accounts; ProfilesController#destroy refuses
when the member owns accounts in another family (legacy state from the old
flow). InvitationsController#create surfaces a specific, actionable flash so
the admin understands why the auto-accept was refused.
No automatic recovery of already-orphaned data — that needs a separate
one-shot script per dosubot's analysis on the issue.
* fix(family-sharing): scope invite orphan-guard to invitee-owned accounts (#1896 review)
Codex flagged (P1) and the maintainer review independently raised that
would_orphan_existing_family? keyed off user.family.accounts.exists? —
any account in the invitee's current family — which wrongly blocked a
non-owner member from leaving a multi-user household.
Rename to would_orphan_owned_accounts? and key off
user.owned_accounts.where.not(family_id: family_id), making the invite
guard symmetric with the destroy-path guard in
Settings::ProfilesController. A member who owns no accounts now orphans
nothing by moving and is free to accept the invitation; an owner is
still blocked.
Add a regression test for the non-owner case and update the existing
tests to give the invitee explicit account ownership.
* Remove extra comments per project conventions
---------
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
* Add invited users with delete button to admin users page
Shows pending invitations per family below active users in /admin/users/.
Each invitation row has a red Delete button aligned with the role column.
Alt/option-clicking any Delete button changes all invitation button labels
to "Delete All" and destroys all pending invitations for that family.
- Add admin routes: DELETE /admin/invitations/:id and DELETE /admin/families/:id/invitations
- Add Admin::InvitationsController with destroy and destroy_all actions
- Load pending invitations grouped by family in users controller index
- Render invitation rows in a dashed-border tbody below active user rows
- Add admin-invitation-delete Stimulus controller for alt-click behavior
- Add i18n strings for invitation UI and flash messages
https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm
* Fix destroy_all using params[:id] from member route
The member route /admin/families/:id/invitations sets params[:id],
not params[:family_id], so Family.find was always receiving nil.
https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm
* Fix translation key in destroy_all to match locale
t(".success_all") looked up a nonexistent key; the locale defines
admin.invitations.destroy_all.success, so t(".success") is correct.
https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm
* Scope bulk delete to pending invitations and allow re-inviting emails
- destroy_all now uses family.invitations.pending.destroy_all so accepted
and expired invitation history is preserved
- Replace blanket email uniqueness validation with a custom check scoped
to pending invitations only, so the same email can be invited again
after an invitation is deleted or expires
https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm
* Drop unconditional unique DB index on invitations(email, family_id)
The model-level uniqueness check was already scoped to pending
invitations, but the blanket unique index on (email, family_id)
still caused ActiveRecord::RecordNotUnique when re-inviting an
email that had any historical invitation record in the same family
(e.g. after an accepted invite or after an account deletion).
Replace it with no DB-level unique constraint — the
no_duplicate_pending_invitation_in_family model validation is the
sole enforcer and correctly scopes uniqueness to pending rows only.
https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm
* Replace blanket unique index with partial unique index on pending invitations
Instead of dropping the DB-level uniqueness constraint entirely, replace
the unconditional unique index on (email, family_id) with a partial unique
index scoped to WHERE accepted_at IS NULL. This enforces the invariant at
the DB layer (no two non-accepted invitations for the same email in a
family) while allowing re-invites once a prior invitation has been accepted.
https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm
* Fix migration version and make remove_index reversible
- Change Migration[8.0] to Migration[7.2] to match the rest of the codebase
- Pass column names to remove_index so Rails can reconstruct the old index on rollback
https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
* Enforce one pending invitation per email across all families
Users can only belong to one family, so allowing the same email to have
pending invitations from multiple families leads to ambiguous behavior.
Add a `no_other_pending_invitation` validation on create to prevent this.
Accepted and expired invitations from other families are not blocked.
Fixes#1172https://claude.ai/code/session_016fGqgha18jP48dhznm6k4m
* Normalize email before validation and use case-insensitive lookup
When ActiveRecord encryption is not configured, the email column stores
raw values preserving original casing. The prior validation used a direct
equality match which would miss case variants (e.g. Case@Test.com vs
case@test.com), leaving a gap in the cross-family uniqueness guarantee.
Fix by:
1. Adding a normalize_email callback that downcases/strips email before
validation, so all new records store lowercase consistently.
2. Using LOWER() in the SQL query for non-encrypted deployments to catch
any pre-existing mixed-case records.
https://claude.ai/code/session_016fGqgha18jP48dhznm6k4m
---------
Co-authored-by: Claude <noreply@anthropic.com>
* First cut of a simplified "intro" UI layout
* Linter
* Add guest role and intro-only access
* Fix guest role UI defaults (#940)
Use enum predicate to avoid missing role helper.
* Remove legacy user role mapping (#941)
Drop the unused user role references in role normalization
and SSO role mapping forms to avoid implying a role that
never existed.
Refs: #0
* Remove role normalization (#942)
Remove role normalization
Roles are now stored directly without legacy mappings.
* Revert role mapping logic
* Remove `normalize_role_settings`
* Remove unnecessary migration
* Make `member` the default
* Broken `.erb`
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
* Fix OIDC household invitation (issue #900)
- Auto-add existing user when inviting by email (no invite email sent)
- Accept page: choose 'Create account' or 'Sign in' (supports OIDC)
- Store invitation token in session on sign-in; accept after login (password,
OIDC, OIDC link, OIDC JIT, MFA)
- Invitation#accept_for!(user): add user to household and mark accepted
- Defensive guards: nil/blank user, token normalization, accept_for! return check
* Address PR review: rename accept_for! to accept_for, i18n OIDC notice, test fixes, stub Rails.application.config
* Fix flaky system test: assert only configure step, not flash message
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: mkdev11 <jaysmth689+github@users.noreply.github.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
* Initial sec
* Update PII fields
* FIX add tests
* FIX safely read plaintext data on rake backfill
* Update user.rb
* FIX tests
* encryption_ready? block
* Test conditional to encryption on
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>