Skip to content

Feature: SOFIA-local accounts with login (with optional 2fa) - #925

Merged
lodewiges merged 26 commits into
stagingfrom
feature/login
Dec 3, 2025
Merged

Feature: SOFIA-local accounts with login (with optional 2fa)#925
lodewiges merged 26 commits into
stagingfrom
feature/login

Conversation

@Ellen-Wittingen

@Ellen-Wittingen Ellen-Wittingen commented May 8, 2024

Copy link
Copy Markdown
Contributor

Fixes #923.

Streepsysteem account aanmaken

Bij gebruikers kan iemand met de rol treasurer streepsysteem gebruikers inzien en aanmaken:
image
Nieuwe gebruiker aanmaken scherm (precies hetzelfde scherm als voor aanmaken van handmatige gebruikers):
image

Wanneer de treasurer een streepsysteem gebruiker aanmaakt, wordt het ingevulde emailadres gemaild met de volgende mail:
image

Als de persoon dan op de activatielink klikt (/sofia_accounts/activate_account?activation_token=activation_token&user_id=user_id), ziet hij dit (zonder de icoontjes van mijn KeypassXC):
image

Als hij dat invult en drukt op activeren dan wordt een SofiaAccount object aangemaakt, waar de username, password_digest en aanmaak-datum worden opgeslagen. Hij wordt dan ook meteen aan de user gelinkt waar die bij hoort. Als de user wordt verwijderd, wordt ook de SofiaAccount verwijdert.

Na activatie ziet de persoon dit:
image

Instellingen van login

Met de instellingen knop kan de persoon zijn user- en inlog-instellingen wijzingen, zoals de gebruikersnaam, email, wachtwoord, en 2FA instellen (zelfde 2FA menu als op AMBER)
image

Login pagina

Op de login pagina kan je nu kiezen om in te loggen met een streepsysteem account:
image

Dan krijg je deze inlog pagina:
image

Als je 2FA hebt aangezet, krijg je daarna nog een pagina waar je de token moet invullen.

Wachtwoord vergeten

Als je drukt op "wachtwoord vergeten?" krijg je de volgende pagina:
image

Als je je gegevens invult krijg je een email op het emailadres gelinkt aan de user:
image

Als je op de link klikt kom je op het volgende scherm, waar je een nieuw wachtwoord instellen:
image

Als je dat niet binnen 1 dag doet, verloopt de activatiecode. Na het verlopen, moet ze zelf weer op "wachtwoord vergeten?" klikken op de inlog pagina

Resetten van activatie token

Voor het activeren van een nieuw account heeft een user 5 dagen, daarna is de link verlopen. Maar in de activatiemail staat een 2e link, en als je daar op klikt dan wordt er een nieuwe activatielink gemaakt die vanaf dan 1 dag geldig is, en die wordt naar hetzelfde emailadres gemailt:
image

Deactiveren user

Wanneer een treasurer een user deactiveerd kan die user niet meer inloggen met zijn gelinkte streepsysteem account. Het streepsysteem account blijft wel bestaan, want als de user weer wordt geactiveerd dan moet die wel weer kunnen inloggen.

Technische details

  • Nieuw model SofiaAccount waar logingegevens in worden opgeslagen. Deze is altijd gelinkt aan een User en wordt verwijdert wanneer de user wordt verwijdert.
  • In callback_controller staat de logica van het inloggen met een streepsysteem account.
  • OmniAuth module omniauth-identity gebruikt om dit zo te maken.
  • Afgetest met RSpec tests.

Summary by CodeRabbit

  • New Features

    • Sofia account sign-up/login (username/password), account activation, time-limited reset, identity-based login, admin user management, and OTP (2FA) flows with QR setup.
  • UI / Style

    • New login/activation/reset pages, modals, input/QR styling, flash improvements, and conditional users table/sections.
  • Localization & Emails

    • Dutch translations and new Dutch email templates for activation, new activation link, and password reset.
  • Tests

    • Extensive specs for Sofia account flows, OTP, mailers, controllers.
  • Chores

    • Added gems, routes, migration, policies and mailer support.

✏️ Tip: You can customize this high-level summary in your review settings.

@Ellen-Wittingen Ellen-Wittingen self-assigned this May 8, 2024
@codecov

codecov Bot commented Dec 31, 2024

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.92683% with 49 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.28%. Comparing base (bfa23f0) to head (fb174f4).
⚠️ Report is 3 commits behind head on staging.

Files with missing lines Patch % Lines
app/controllers/sofia_accounts_controller.rb 82.99% 25 Missing ⚠️
app/mailers/user_mailer.rb 28.57% 15 Missing ⚠️
app/controllers/callbacks_controller.rb 80.00% 6 Missing ⚠️
app/controllers/users_controller.rb 90.00% 3 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           staging     #925      +/-   ##
===========================================
+ Coverage    75.27%   77.28%   +2.00%     
===========================================
  Files           50       54       +4     
  Lines         1076     1347     +271     
===========================================
+ Hits           810     1041     +231     
- Misses         266      306      +40     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Ellen-Wittingen

Ellen-Wittingen commented Dec 31, 2024

Copy link
Copy Markdown
Contributor Author

Kan de emails voor account activatie en wachtwoord resetten die daadwerkelijk worden verstuurd niet inzien, want sidekiq weigert lokaal te draaien door een deprecation, zie hieronder. Kan wel de globale preview zien met de url /rails/mailers/user_mailer

2024-12-31T11:26:18.130Z pid=775 tid=5cn WARN: DEPRECATION WARNING: Using legacy connection handling is deprecated. Please set
`legacy_connection_handling` to `false` in your application.

The new connection handling does not support `connection_handlers`
getter and setter.

Read more about how to migrate at: https://guides.rubyonrails.org/active_record_multiple_databases.html#migrate-to-the-new-connection-handling

@lodewiges

lodewiges commented Jan 1, 2025

Copy link
Copy Markdown
Contributor

Kan de emails voor account activatie en wachtwoord resetten die daadwerkelijk worden verstuurd niet inzien, want sidekiq weigert lokaal te draaien door een deprecation, zie hieronder. Kan wel de globale preview zien met de url /rails/mailers/user_mailer

2024-12-31T11:26:18.130Z pid=775 tid=5cn WARN: DEPRECATION WARNING: Using legacy connection handling is deprecated. Please set
`legacy_connection_handling` to `false` in your application.

The new connection handling does not support `connection_handlers`
getter and setter.

Read more about how to migrate at: https://guides.rubyonrails.org/active_record_multiple_databases.html#migrate-to-the-new-connection-handling

A I see, there are a couple more deprecation issues I will have a look if i can fix a couple in a PR. Are you going to look into fixing this issue or should I look into it,

Update, I have gotten the error go away using config.load_defaults 7.0 in aplication.rb
i am not sure what all the effects are of this change but sofia still runs normaly

@Ellen-Wittingen

Copy link
Copy Markdown
Contributor Author

TODO: merge conflicts met staging oplossen en RuboCop linting errors oplossen

@Ellen-Wittingen
Ellen-Wittingen marked this pull request as ready for review January 4, 2025 16:58
@lodewiges

Copy link
Copy Markdown
Contributor

How is this coming along?

@Ellen-Wittingen

Copy link
Copy Markdown
Contributor Author

Apologies for the delay, I got busy again and kept putting it off. It is completely done now :)

@lodewiges
lodewiges self-requested a review February 24, 2025 10:12
@lodewiges lodewiges added the critical Dit moet gefixt vóór de volgende borrel label Aug 7, 2025
@coderabbitai

coderabbitai Bot commented Nov 26, 2025

Copy link
Copy Markdown

Walkthrough

Adds self-contained Sofia (streepsysteem) authentication: OmniAuth identity strategy, new SofiaAccount model with OTP and URL helpers, account activation/reset and OTP enable/disable flows, Devise integration and initializer, mailer/templates, views/modals and JS, DB migration/schema changes, policies, many specs, and three new gems.

Changes

Cohort / File(s) Summary
Gems & Dependencies
Gemfile
Added gems: active_model_otp '~> 2.3', '>= 2.3.1', omniauth-identity '~> 3.1', '>= 3.1.2', rqrcode '~> 2.2'.
Database & Migration
db/migrate/20251113094147_create_sofia_accounts.rb, db/schema.rb, db/seeds.rb
New sofia_accounts table (username, password_digest, user_id, otp_secret_key, otp_enabled); added activation_token and activation_token_valid_till to users; roles.group_uid made nullable; added role seeds without group_uid.
Models
app/models/sofia_account.rb, app/models/user.rb, app/models/role.rb
New SofiaAccount model with OmniAuth::Identity integration, has_one_time_password, validations, auth_key and URL helpers; User gains has_one :sofia_account, nested attributes, new scopes (sofia_account, not_activated), activation token lifecycle and from_omniauth_inspect; Role validation relaxed (removed group_uid presence).
Controllers (Auth & Accounts)
app/controllers/callbacks_controller.rb, app/controllers/sofia_accounts_controller.rb, app/controllers/application_controller.rb
CallbacksController: identity/OTP/failure flows returning JSON states (logged_in/otp_prompt/password_prompt) and safer persisted checks; SofiaAccountsController: omniauth redirects, create, activation, forgot/reset password, enable/disable OTP, update_password, helpers and localized error handling; ApplicationController: CSRF prepend and normalize_error_messages.
Policies
app/policies/sofia_account_policy.rb, app/policies/user_policy.rb
New SofiaAccountPolicy with update? and delegated actions (update_with_sofia_account?, update_password?, enable_otp?, disable_otp?); UserPolicy adds update_with_sofia_account?.
Views & Frontend
app/views/sofia_accounts/*, app/views/users/*, app/views/partials/*, app/views/user_mailer/*, app/assets/stylesheets/application.scss, app/javascript/*, app/javascript/components/user/UsersTable.vue
New Sofia account views (activate, login w/ OTP, forgot/reset, new activation link), user modals for create/edit Sofia accounts (including QR), flash sanitization changes, updated login prompt (Dutch), CSS tweaks, Vue/JS changes to include sofia_account/not_activated/deactivated datasets, UsersTable empty-state handling.
Mailer & Previews
app/mailers/user_mailer.rb, spec/mailers/previews/user_mailer_preview.rb, app/views/user_mailer/*
New UserMailer with account_creation_email, new_activation_link_email, forgot_password_email and Dutch templates; mailer preview added.
Devise & Form Config
config/initializers/devise.rb, config/initializers/simple_form_bootstrap.rb, config/locales/nl.yml
Registered OmniAuth :identity strategy bound to SofiaAccount with on_login/on_registration lambdas; added SimpleForm wrapper :with_hint_link; added Dutch i18n for SofiaAccount attributes.
Routing
config/routes.rb
Added resources :sofia_accounts routes (login, create, activate_account, new_activation_link, forgot_password, reset_password, update_password, enable_otp, disable_otp); added patch :update_with_sofia_account on users member.
Users Controller & UI hooks
app/controllers/users_controller.rb, app/views/users/*, app/javascript/users.js
New update_with_sofia_account action; users index/show updated to surface sofia_account_users, not_activated_users, deactivated_users; modals and JS wired to new datasets.
Tests & Test Support
spec/controllers/sofia_accounts_controller/*, spec/controllers/callbacks_controller/sofia_account_spec.rb, spec/controllers/users_controller/*, spec/models/sofia_account_spec.rb, spec/models/user_spec.rb, spec/factories/sofia_account.rb, spec/factories/user.rb, spec/support/devise_helper.rb, spec/rails_helper.rb
Extensive specs added for Sofia flows (activation/create/forgot/reset/enable_disable OTP), callback OTP flows, users controller changes, model specs, factories and Devise test helper; DeviseHelper included for request specs.

Sequence Diagram(s)

sequenceDiagram
    participant Browser as User (Browser)
    participant LoginJS as Login View (JS)
    participant Callbacks as CallbacksController
    participant Sofia as SofiaAccount model
    participant User as User model
    participant Auth as Devise/Auth

    Browser->>LoginJS: submit username/password
    LoginJS->>Callbacks: POST /users/auth/identity/callback (auth params)
    Callbacks->>Sofia: from_omniauth_inspect(auth)
    Sofia->>User: find associated user
    alt user found and allowed
        Callbacks->>Sofia: check otp_enabled
        alt otp_enabled
            Callbacks-->>LoginJS: JSON { state: "otp_prompt" }
            LoginJS->>Callbacks: POST verification_code
            Callbacks->>Sofia: verify otp
            alt otp valid
                Callbacks->>Auth: sign_in(user)
                Callbacks-->>LoginJS: JSON { state: "logged_in", redirect_url }
            else otp invalid
                Callbacks-->>LoginJS: JSON { state: "otp_prompt", error_message }
            end
        else otp not enabled
            Callbacks->>Auth: sign_in(user)
            Callbacks-->>LoginJS: JSON { state: "logged_in", redirect_url }
        end
    else missing/deactivated
        Callbacks-->>LoginJS: JSON { state: "password_prompt", error_message }
    end
Loading
sequenceDiagram
    participant Browser as User (Browser)
    participant Activation as Activation Page
    participant SofiaCtrl as SofiaAccountsController
    participant User as User model
    participant Sofia as SofiaAccount model
    participant Auth as Devise/Auth

    Browser->>Activation: visit /sofia_accounts/activate_account?user_id&token
    Activation->>SofiaCtrl: GET activate_account
    SofiaCtrl->>User: load user, validate token
    alt token valid
        SofiaCtrl-->>Activation: render activation form (may request email)
        Browser->>SofiaCtrl: POST /sofia_accounts (username/password/[email])
        SofiaCtrl->>Sofia: build & validate SofiaAccount
        alt valid
            Sofia->>User: associate and save
            SofiaCtrl->>User: clear activation fields
            SofiaCtrl->>Auth: sign_in(user)
            SofiaCtrl-->>Browser: redirect to user page
        else validation fails
            SofiaCtrl-->>Activation: render form with errors
        end
    else token invalid/expired
        SofiaCtrl-->>Activation: show error and offer new activation link
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60–75 minutes

  • Areas requiring careful review:
    • CallbacksController identity/OTP branching, JSON states, and i18n error messages
    • SofiaAccountsController: token validation, create/reset/update and OTP enable/disable edge cases
    • User model callbacks (activation token lifecycle, after_create mail enqueue, archive/destroy interactions)
    • SofiaAccount validations, auth_key mapping, password/OTP handling and URL helpers
    • Devise initializer OmniAuth :identity configure/on_login/on_registration lambdas and route wiring
    • New and updated specs for completeness, fixtures/factory changes and test helper behavior
    • DB migration/schema changes and seeds impact on validations

Suggested reviewers

  • ToMaarton

Poem

🐇 I nibbled code and found a key,

usernames, tokens, OTP;
QR dances in a midnight loop,
users wake and take the stoop—
a tiny rabbit hops: "Sofia can log in, whee!"

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.96% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main feature addition: local SOFIA accounts with login and optional 2FA support.
Description check ✅ Passed The description includes comprehensive details, screenshots, linked issue reference, and technical context; however, the database migration and testing checklist items are unchecked.
Linked Issues check ✅ Passed The PR fully implements all objectives from issue #923: self-contained login, treasurer account creation, activation emails, password reset, 2FA support, and proper account lifecycle management.
Out of Scope Changes check ✅ Passed All changes are scoped to implementing SOFIA local accounts: new SofiaAccount model, controllers, views, mailers, authentication flows, and related updates to existing models (User, Role validation relaxation). No unrelated changes detected.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/login

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0fed95e and fb174f4.

📒 Files selected for processing (1)
  • spec/controllers/users_controller/update_with_sofia_account_spec.rb (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • spec/controllers/users_controller/update_with_sofia_account_spec.rb
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Build

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
config/initializers/devise.rb (1)

1-29: Fix locate_conditions lambda and remove unnecessary manual require in Devise initializer

The review comment identifies two real and actionable issues:

  1. locate_conditions lambda references undefined model variable (critical)

    The lambda { model.auth_key => req.params['auth_key'] } will raise NoMethodError when OmniAuth Identity strategy invokes it, because model is not defined in the closure. This code path is not exercised by the current tests (they POST directly to the callback endpoint), so the bug would only manifest during actual user login via the identity strategy.

    The fix requires referencing the SofiaAccount constant directly:

    locate_conditions: ->(req) { { SofiaAccount.auth_key => req.params['auth_key'] } }

    This evaluates to { :username => req.params['auth_key'] }, which omniauth-identity uses to locate the account.

  2. Manual require of SofiaAccount bypasses Rails autoloading

    Rails 7.2.3 uses Zeitwerk autoloading, so the explicit require './app/models/sofia_account' is unnecessary and should be removed. SofiaAccount will be autoloaded when first referenced in the Devise configuration.

app/models/user.rb (1)

108-115: sofia_account is an association, not an attribute.

Line 110 adds 'sofia_account' to the whitelist of attributes to preserve during archiving, but sofia_account is an association (defined on line 28), not a column in the attributes hash. This inclusion has no effect.

-    self[attribute] = nil unless %w[deleted_at updated_at created_at provider sofia_account id uid].include? attribute
+    self[attribute] = nil unless %w[deleted_at updated_at created_at provider id uid].include? attribute
🟡 Minor comments (8)
spec/controllers/sofia_accounts_controller/forgot_pasword_spec.rb-1-1 (1)

1-1: Fix typo in filename.

The file is named forgot_pasword_spec.rb but should be forgot_password_spec.rb (missing 's' in password).

Rename the file to match the controller action it tests:

spec/controllers/sofia_accounts_controller/forgot_password_spec.rb
spec/controllers/sofia_accounts_controller/enable_otp_spec.rb-86-86 (1)

86-86: Fix typo in describe block.

The describe block has a typo: "verication_code" should be "verification_code".

Apply this diff to fix the typo:

-    describe 'with wrong verication_code' do
+    describe 'with wrong verification_code' do
app/views/users/_edit_sofia_account_modal.html.erb-7-7 (1)

7-7: Invalid self-closing button tag.

Self-closing tags (/>) are not valid for <button> elements in HTML5. This may cause rendering issues in some browsers.

-        <button aria-label="close" class="btn-close" data-bs-dismiss="modal" type="button"/>
+        <button aria-label="close" class="btn-close" data-bs-dismiss="modal" type="button"></button>
spec/mailers/previews/user_mailer_preview.rb-8-12 (1)

8-12: SofiaAccount instance is created but not associated with the user.

The SofiaAccount.new(...) on line 10 creates an instance but doesn't assign it to a variable or wire it to user.sofia_account. If forgot_password_email accesses user.sofia_account, this preview will fail or show incorrect data.

  def forgot_password_mail
    user = User.new(id: 999_999, provider: 'sofia_account', activation_token: 'mockup_activation_token')
-   SofiaAccount.new(id: 888_888, user:, username: 'mockup_username')
+   user.build_sofia_account(id: 888_888, username: 'mockup_username')
    UserMailer.forgot_password_email(user)
  end

Or if the email doesn't require the association:

-   SofiaAccount.new(id: 888_888, user:, username: 'mockup_username')
app/controllers/users_controller.rb-184-186 (1)

184-186: Based on my verification, I can now provide a rewritten review comment with proper context:

Ensure provider parameter is intentionally permitted and consider whether it should be restricted to specific values.

The permitted_attributes now allows setting provider during user creation. While the form for sofia_account users (in _new_sofia_account_user_modal.html.erb) explicitly sets it via hidden field, the backend now permits ANY provider value. A treasurer could craft a request to set provider='amber_oauth2' without the user going through OAuth authentication, potentially bypassing the intended authentication flow. Consider:

  • Adding a model-level validation to restrict provider to allowed values ('sofia_account', 'amber_oauth2', nil)
  • Or limiting the controller to only permit provider='sofia_account' when creating users through the API
spec/controllers/callbacks_controller/sofia_account_spec.rb-12-18 (1)

12-18: sofia_account.password will be nil after persistence due to bcrypt's has_secure_password clearing the virtual attribute.

OmniAuth::Identity uses bcrypt with has_secure_password, which stores the hashed password in password_digest and clears the virtual password attribute after the record is saved for security reasons. The test accessing sofia_account.password on line 15 will receive nil, which will likely cause the login tests to fail.

Store the password before creating the account or pass it directly as a string constant:

let(:password) { 'password1234' }
let(:sofia_account) do
  create(:sofia_account, password: password, password_confirmation: password)
end
let(:request_params) do
  {
    auth_key: sofia_account.username,
    password: password,  # Use the stored password string
    verification_code: sofia_account.otp_code
  }
end
spec/controllers/sofia_accounts_controller/create_spec.rb-62-65 (1)

62-65: Duplicate request call in test.

The request is already called in the before block (line 50), so calling it again in the it block (line 63) sends a second HTTP request. This is likely unintentional.

     it 'redirects after create' do
-      request
       expect(response).to be_redirect
     end
app/controllers/callbacks_controller.rb-56-65 (1)

56-65: Potential information leakage in failure message.

Line 62 appends the raw omniauth.error.type to the error message for non-invalid_credentials errors. This could expose internal error details to users. Consider using a generic fallback message instead.

       error_message << if request.env['omniauth.error.type'].to_s == 'invalid_credentials'
                          ' De ingevulde gegevens zijn incorrect.'
                        else
-                         " #{request.env['omniauth.error.type']}"
+                         ' Er is een onverwachte fout opgetreden.'
                        end
🧹 Nitpick comments (28)
config/initializers/simple_form_bootstrap.rb (1)

361-375: Review :with_hint_link wrapper – spacing and anchor semantics

Nice reuse of the existing vertical pattern. Two small points to consider:

  • For consistent spacing with the other wrappers, you may want to add a container class (e.g. class: 'mb-3') on the wrapper itself; right now this one is the odd one out.
  • Using tag: :a for the hint wrapper assumes callers will provide a proper href via hint_html:; if instead they pass an HTML link in the hint (e.g. hint: link_to(...)), you’ll end up with nested <a> tags, and if they forget href you get a non-interactive anchor from an a11y standpoint. Might be worth:
    • Standardizing on plain text hints + hint_html: { href: ... } for this wrapper, or
    • Keeping tag as a non-interactive element (e.g. :span) and letting the hint content itself be a link.

Example for the spacing tweak:

-  config.wrappers :with_hint_link do |b|
+  config.wrappers :with_hint_link, class: 'mb-3' do |b|
app/controllers/application_controller.rb (1)

40-42: Make normalize_error_messages more defensive and i18n‑friendly (optional)

Lowercasing full messages and assuming an array is fine for your current callers, but it’s easy for this to be reused in other contexts where:

  • full_messages is nil or contains non‑string values, or
  • full lowercasing hurts readability (proper nouns, acronyms, multi‑language messages).

A small defensive tweak keeps behavior the same while being safer to reuse:

-  def normalize_error_messages(full_messages)
-    full_messages.map(&:downcase).join(', ')
-  end
+  def normalize_error_messages(full_messages)
+    Array(full_messages)
+      .map { |m| m.to_s.downcase }
+      .join(', ')
+  end

If you later hit i18n issues, consider only lowercasing the first character or leaving casing to the translation layer.

app/javascript/users.js (1)

22-35: Guard against missing/invalid dataset JSON when initializing Vue

The added datasets (sofiaAccountUsers, notActivatedUsers, deactivatedUsers) are parsed unconditionally:

const sofia_account_users   = JSON.parse(element.dataset.sofiaAccountUsers);
const not_activated_users   = JSON.parse(element.dataset.notActivatedUsers);
const deactivated_users     = JSON.parse(element.dataset.deactivatedUsers);

This is fine as long as the view always sets these data-* attributes to valid JSON. If any of them are omitted or rendered as an empty string, this will throw and prevent the users table from mounting.

Consider defensively defaulting to an empty array on missing values:

- const sofia_account_users = JSON.parse(element.dataset.sofiaAccountUsers);
+ const sofia_account_users = JSON.parse(element.dataset.sofiaAccountUsers || '[]');
...
- const not_activated_users = JSON.parse(element.dataset.notActivatedUsers);
- const deactivated_users = JSON.parse(element.dataset.deactivatedUsers);
+ const not_activated_users = JSON.parse(element.dataset.notActivatedUsers || '[]');
+ const deactivated_users = JSON.parse(element.dataset.deactivatedUsers || '[]');

or ensure the corresponding view always renders these attributes as valid JSON (e.g. '[]') even when empty.

db/seeds.rb (1)

43-49: Duplicated seeded Role records per role_type; verify this is intentional

Seeding both group-specific and “global” roles:

Role.create(role_type: :treasurer, group_uid: 4)
...
Role.create(role_type: :treasurer)

means there are now two Role rows for each role_type. That’s fine if callers always scope by group_uid (or explicitly by “global” vs “per-group” roles), but it can become ambiguous for code like:

Role.find_by(role_type: :treasurer)

which will now hit whichever record the DB returns first.

If the intent is to have distinct global vs group-scoped roles, consider:

  • Auditing lookups to ensure they always filter by group_uid where appropriate, or
  • Introducing explicit semantics (e.g. a scope or flag) so “global” treasurer is uniquely identifiable.

Otherwise, it might be safer to seed only one record per role_type or to enforce uniqueness at the model/db level.

app/policies/user_policy.rb (1)

26-28: LGTM with a minor clarity suggestion.

The authorization logic correctly ensures only the authenticated user can update their own Sofia account. For explicit clarity, consider using record.id:

  def update_with_sofia_account?
-   record == user && User.active.sofia_account.exists?(id: record)
+   record == user && User.active.sofia_account.exists?(id: record.id)
  end

This is functionally equivalent but makes the intent clearer.

app/views/sofia_accounts/forgot_password_view.html.erb (1)

12-12: Remove redundant method: :post on submit button.

The form already declares method: :post on line 8, making the method: :post on the submit button redundant.

Apply this diff:

-        <%= f.button :submit, 'Link aanvragen', method: :post, class: 'btn btn-primary mt-1' %>
+        <%= f.button :submit, 'Link aanvragen', class: 'btn btn-primary mt-1' %>
app/views/sofia_accounts/reset_password_view.html.erb (1)

12-12: Remove redundant method: :patch on submit button.

The form already declares method: :patch on line 8, making the method: :patch on the submit button redundant.

Apply this diff:

-        <%= f.button :submit, 'Opslaan', method: :patch, class: 'btn btn-primary' %>
+        <%= f.button :submit, 'Opslaan', class: 'btn btn-primary' %>
spec/controllers/sofia_accounts_controller/disable_otp_spec.rb (1)

19-22: Consider verifying the redirect location.

The test confirms a 302 status but doesn't verify where the user is redirected after successfully disabling OTP. Consider adding a redirect expectation for completeness.

 it 'updates sofia_account' do
   expect(request.status).to eq 302
+  expect(response).to redirect_to(user_path(sofia_account.user))
   expect(sofia_account.otp_enabled).to be false
 end
spec/support/mailer_matcher.rb (1)

3-8: Matcher doesn't verify the delivery method is called.

Using allow on line 7 means the test won't fail if deliver_later (or the specified mailer_when) is never invoked. Consider using expect to assert the delivery actually happens.

 RSpec::Matchers.define :send_email do |mailer_action, mailer_when, mailer_args|
   match do |mailer_class|
     message_delivery = instance_double(ActionMailer::MessageDelivery)
     expect(mailer_class).to receive(mailer_action).with(mailer_args).and_return(message_delivery) # rubocop:disable RSpec/StubbedMock, RSpec/MessageSpies
-    allow(message_delivery).to receive(mailer_when)
+    expect(message_delivery).to receive(mailer_when) # rubocop:disable RSpec/MessageSpies
   end
 end
spec/controllers/users_controller/update_with_sofia_account_spec.rb (2)

7-7: Unused variable sofia_account_attributes.

This let is defined but never referenced in the test. Consider removing it or using it in the request params if that was the intent.

-    let(:sofia_account_attributes) { { username: 'AAAA' } }

5-6: Confusing test setup with duplicate sofia_account creation.

The user is created with the :sofia_account trait (line 5), but then a separate sofia_account is created with user: association (line 6). If User has_one :sofia_account, this creates ambiguity about which record is the "real" association. Consider using only one approach.

-    let(:user) { create(:user, :sofia_account, name: 'Old name') }
-    let(:sofia_account) { create(:sofia_account, user:, username: 'Old username') }
+    let(:user) { create(:user, name: 'Old name') }
+    let(:sofia_account) { create(:sofia_account, user:, username: 'Old username') }
spec/controllers/sofia_accounts_controller/new_activation_link_spec.rb (2)

47-51: Consider using user.reload instead of user.dup for database state verification.

The assertion expect(user.dup.attributes).to eq old_user.attributes compares in-memory state. If the intent is to verify the database record wasn't modified, use user.reload before comparison. The same pattern appears in other contexts (lines 65, 82, 99, 116).

       it 'shows error message and does not send email' do
-        expect(user.dup.attributes).to eq old_user.attributes
+        user.reload
+        expect(user.attributes).to eq old_user.attributes
         expect(assigns(:message)).to match(/uw account heeft geen emailadres/)
         expect(enqueued_jobs.size).to eq(0)
       end

92-92: Consider using a more robust non-existent user ID.

User.count + 1 assumes sequential IDs without gaps. A safer approach would be using a clearly invalid ID.

-        request_params[:user_id] = User.count + 1
+        request_params[:user_id] = -1
spec/controllers/sofia_accounts_controller/activate_account_spec.rb (1)

1-61: Consider adding more comprehensive test coverage for the activation flow.

The current tests only verify that the activation form renders successfully. Consider adding tests for:

  • Successful account activation with valid token
  • Rejected activation with expired token
  • Rejected activation with invalid token
  • Rejected activation with mismatched user_id and token

These tests would improve confidence in the security and correctness of the activation flow.

app/mailers/user_mailer.rb (1)

20-27: Consider defensive coding for missing SofiaAccount association.

The method assumes user.sofia_account exists. If this mailer is called with a user lacking a SofiaAccount, it will raise a NoMethodError on Line 22-23.

Consider adding a guard clause:

 def forgot_password_email(user)
   @user = user
+  raise ArgumentError, "User must have a SofiaAccount" unless user.sofia_account
   @username = user.sofia_account.username
   @reset_password_url = user.sofia_account.reset_password_url(@user.activation_token)
   @forgot_password_url = SofiaAccount.forgot_password_url
   @call_to_action = { text: 'Wachtwoord herstellen!', url: @reset_password_url }
   mail to: user.email, subject: 'Wachtwoordherstel voor het streepsysteem van uw vereniging'
 end

Alternatively, if the caller always ensures the association exists, this is fine as-is.

app/views/sofia_accounts/login.html.erb (2)

27-27: Consider using standard event handling instead of javascript: protocol.

Line 27 uses formaction='javascript:authenticate_login();' which is non-standard and can cause issues with Content Security Policy (CSP).

Consider using a standard event handler instead:

-        <button class="btn btn-primary" type="submit" formaction='javascript:authenticate_login();' id="login_submit_button">Inloggen</button>
+        <button class="btn btn-primary" type="button" onclick="authenticate_login();" id="login_submit_button">Inloggen</button>

Or better, use addEventListener in the script section:

-        <button class="btn btn-primary" type="submit" formaction='javascript:authenticate_login();' id="login_submit_button">Inloggen</button>
+        <button class="btn btn-primary" type="button" id="login_submit_button">Inloggen</button>

And in the script:

document.querySelector('#login_submit_button').addEventListener('click', authenticate_login);

The same applies to Line 42 with the OTP button.


81-84: Use textContent instead of innerHTML for consistency and security.

Line 83 uses innerHTML to set an error message. While the current content is static, using textContent is safer and more consistent with Line 78.

Apply this diff:

-    document.querySelector("#authenticate_flash_message").innerHTML = "Inloggen mislukt door een error. Herlaad de pagina en probeer het nog een keer. <br/><i>Werkt het na een paar keer proberen nog steeds niet? Neem dan contact op met de ICT-commissie.</i>";
+    document.querySelector("#authenticate_flash_message").textContent = "Inloggen mislukt door een error. Herlaad de pagina en probeer het nog een keer. Werkt het na een paar keer proberen nog steeds niet? Neem dan contact op met de ICT-commissie.";

This removes the HTML formatting but improves security by eliminating any potential for injection.

app/policies/sofia_account_policy.rb (1)

2-4: Consider simplifying the authorization check.

The User.exists?(id: record.user) check on Line 3 appears redundant. If record.user is a loaded association and equals user, the User record necessarily exists in the database.

Unless there's a specific concern about race conditions or stale data, consider simplifying to:

 def update?
-  record.user == user && User.exists?(id: record.user)
+  record.user == user && user.present?
 end

Or simply:

 def update?
-  record.user == user && User.exists?(id: record.user)
+  record.user == user
 end

If the User.exists? check is intentionally defensive against specific edge cases, consider adding a comment explaining why.

app/views/users/_edit_sofia_account_modal.html.erb (1)

90-114: Add rel="noopener noreferrer" to external links.

External links with target='_blank' should include rel="noopener noreferrer" to prevent potential reverse tabnabbing attacks. While modern browsers mitigate this by default, it's still a recommended best practice.

                      <a
                        href='https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2'
                        target='_blank'
+                       rel='noopener noreferrer'
                      >

Apply similarly to the Apple and Microsoft authenticator links.

app/views/users/show.html.erb (1)

124-134: Remove or document commented-out code.

This =begin/=end block comments out iDEAL payment and related content. If this feature is intentionally disabled, consider removing the code entirely or adding a TODO/comment explaining why it's preserved and when it should be restored.

spec/models/user_spec.rb (1)

492-495: Fragile date comparison using .day.

Comparing activation_token_valid_till.day to 5.days.from_now.day can fail around month boundaries (e.g., if today is the 28th and 5 days later is the 2nd of next month). Use be_within for more robust time comparison:

       it do
         expect(user.activation_token_valid_till).not_to be_nil
-        expect(user.activation_token_valid_till.day).to eq 5.days.from_now.day
+        expect(user.activation_token_valid_till).to be_within(1.minute).of(5.days.from_now)
       end
app/views/users/index.html.erb (2)

58-58: Minor: Missing space in ERB tag.

For consistency with the rest of the file and ERB conventions, add a space after <%:

-    <%end %>
+    <% end %>

65-65: Minor: Missing space in ERB tag.

Same issue as line 58:

-    <%end %>
+    <% end %>
app/controllers/callbacks_controller.rb (1)

14-20: Redundant SofiaAccount lookup.

User.from_omniauth_inspect(auth) already fetches the SofiaAccount via SofiaAccount.find(auth.uid) (see app/models/user.rb, lines 133-135). The subsequent SofiaAccount.find_by(user_id: user.id) on line 20 is a redundant database query.

Consider refactoring from_omniauth_inspect to return both the user and sofia_account, or cache the sofia_account on the user object to avoid the extra query:

 def identity
-  user = User.from_omniauth_inspect(request.env['omniauth.auth'])
+  auth = request.env['omniauth.auth']
+  sofia_account = SofiaAccount.find_by(id: auth.uid)
+  user = sofia_account&.user

   if user.persisted?
     if user.deactivated
       render(json: { state: 'password_prompt', error_message: 'Uw account is gedeactiveerd, dus inloggen is niet mogelijk.' })
     else
-      check_identity_with_user(user, SofiaAccount.find_by(user_id: user.id))
+      check_identity_with_user(user, sofia_account)
     end
spec/controllers/sofia_accounts_controller/create_spec.rb (1)

303-319: Fragile test setup for non-existent user scenario.

Using User.count as a non-existent user_id is fragile—if the count happens to equal an existing user's id (e.g., after deletions), the test could fail unexpectedly or pass for the wrong reason.

     before do
-      request_params[:user_id] = User.count
+      request_params[:user_id] = User.maximum(:id).to_i + 1
       old_user
       request
       user.reload
     end
app/models/user.rb (2)

40-41: Clarify purpose of calling age in after_save.

Line 41 calls age but discards the return value. If this is intended for caching or side effects, add a comment. If it's dead code, remove it.


20-23: Complex active scope - consider readability.

The scope logic is correct but the raw SQL with subquery could be simplified using Rails query methods for better maintainability.

scope :active, lambda {
  sofia_user_ids = SofiaAccount.select(:user_id)
  where(deactivated: false)
    .where("provider IS NULL OR provider != 'sofia_account' OR id IN (?)", sofia_user_ids)
}

Note: The static analysis warning about "hardcoded passphrase" is a false positive—'sofia_account' is simply a provider identifier string, not a credential.

app/controllers/sofia_accounts_controller.rb (1)

108-115: Consider checking update return value and adding error handling.

While failure here leaves the account in a more secure state (OTP remains enabled), the user should be informed if the operation fails.

   def disable_otp
     @sofia_account = SofiaAccount.find(params[:id])
     authorize @sofia_account

-    @sofia_account.update(otp_enabled: false)
-
-    redirect_to user_path(@sofia_account.user_id)
+    if @sofia_account.update(otp_enabled: false)
+      redirect_to user_path(@sofia_account.user_id), flash: { success: 'Two-factor-authenticatie uitgezet.' }
+    else
+      redirect_to user_path(@sofia_account.user_id), flash: { error: 'Two-factor-authenticatie uitzetten mislukt.' }
+    end
   end
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bfa23f0 and 8285eb0.

⛔ Files ignored due to path filters (1)
  • Gemfile.lock is excluded by !**/*.lock
📒 Files selected for processing (57)
  • Gemfile (3 hunks)
  • app/assets/stylesheets/application.scss (1 hunks)
  • app/controllers/application_controller.rb (2 hunks)
  • app/controllers/callbacks_controller.rb (1 hunks)
  • app/controllers/sofia_accounts_controller.rb (1 hunks)
  • app/controllers/users_controller.rb (3 hunks)
  • app/javascript/components/user/UsersTable.vue (3 hunks)
  • app/javascript/users.js (2 hunks)
  • app/mailers/user_mailer.rb (1 hunks)
  • app/models/role.rb (1 hunks)
  • app/models/sofia_account.rb (1 hunks)
  • app/models/user.rb (5 hunks)
  • app/policies/sofia_account_policy.rb (1 hunks)
  • app/policies/user_policy.rb (1 hunks)
  • app/views/partials/_flash.html.erb (1 hunks)
  • app/views/partials/_login_prompt.html.erb (1 hunks)
  • app/views/sofia_accounts/activate_account.html.erb (1 hunks)
  • app/views/sofia_accounts/forgot_password_view.html.erb (1 hunks)
  • app/views/sofia_accounts/login.html.erb (1 hunks)
  • app/views/sofia_accounts/new_activation_link.html.erb (1 hunks)
  • app/views/sofia_accounts/reset_password_view.html.erb (1 hunks)
  • app/views/user_mailer/account_creation_email.html.erb (1 hunks)
  • app/views/user_mailer/forgot_password_email.html.erb (1 hunks)
  • app/views/user_mailer/new_activation_link_email.html.erb (1 hunks)
  • app/views/users/_edit_sofia_account_modal.html.erb (1 hunks)
  • app/views/users/_new_sofia_account_user_modal.html.erb (1 hunks)
  • app/views/users/index.html.erb (2 hunks)
  • app/views/users/show.html.erb (3 hunks)
  • config/initializers/devise.rb (2 hunks)
  • config/initializers/simple_form_bootstrap.rb (1 hunks)
  • config/locales/nl.yml (1 hunks)
  • config/routes.rb (2 hunks)
  • db/migrate/20240413094147_create_sofia_accounts.rb (1 hunks)
  • db/schema.rb (7 hunks)
  • db/seeds.rb (1 hunks)
  • spec/controllers/callbacks_controller/sofia_account_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/activate_account_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/create_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/disable_otp_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/enable_otp_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/forgot_pasword_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/new_activation_link_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/reset_password_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/update_password_spec.rb (1 hunks)
  • spec/controllers/users_controller/index_spec.rb (1 hunks)
  • spec/controllers/users_controller/show_spec.rb (5 hunks)
  • spec/controllers/users_controller/update_spec.rb (1 hunks)
  • spec/controllers/users_controller/update_with_sofia_account_spec.rb (1 hunks)
  • spec/factories/sofia_account.rb (1 hunks)
  • spec/factories/user.rb (1 hunks)
  • spec/mailers/previews/user_mailer_preview.rb (1 hunks)
  • spec/models/role_spec.rb (0 hunks)
  • spec/models/sofia_account_spec.rb (1 hunks)
  • spec/models/user_spec.rb (7 hunks)
  • spec/rails_helper.rb (1 hunks)
  • spec/support/devise_helper.rb (1 hunks)
  • spec/support/mailer_matcher.rb (1 hunks)
💤 Files with no reviewable changes (1)
  • spec/models/role_spec.rb
🧰 Additional context used
🧬 Code graph analysis (13)
app/policies/user_policy.rb (1)
app/javascript/user.js (1)
  • user (12-12)
spec/controllers/users_controller/show_spec.rb (1)
app/controllers/sofia_accounts_controller.rb (1)
  • create (10-28)
spec/controllers/sofia_accounts_controller/new_activation_link_spec.rb (1)
app/controllers/sofia_accounts_controller.rb (1)
  • create (10-28)
spec/controllers/callbacks_controller/sofia_account_spec.rb (1)
spec/support/devise_helper.rb (1)
  • signed_in? (2-4)
spec/models/user_spec.rb (3)
app/controllers/sofia_accounts_controller.rb (1)
  • create (10-28)
app/models/user.rb (1)
  • archive! (108-115)
db/migrate/20240413094147_create_sofia_accounts.rb (2)
  • change (1-22)
  • change (2-21)
app/controllers/callbacks_controller.rb (1)
app/models/user.rb (1)
  • from_omniauth_inspect (133-136)
spec/models/sofia_account_spec.rb (1)
app/models/sofia_account.rb (4)
  • activate_account_url (13-17)
  • new_activation_link_url (19-23)
  • forgot_password_url (25-28)
  • reset_password_url (30-34)
app/controllers/users_controller.rb (3)
app/policies/user_policy.rb (1)
  • show? (14-16)
app/models/user.rb (2)
  • calculate_credits (148-153)
  • treasurer? (86-88)
config/application.rb (1)
  • config (10-93)
app/policies/sofia_account_policy.rb (1)
app/javascript/user.js (1)
  • user (12-12)
spec/controllers/sofia_accounts_controller/create_spec.rb (5)
webpack.config.js (1)
  • require (5-5)
app/controllers/users_controller.rb (2)
  • create (76-87)
  • update (89-100)
app/controllers/sofia_accounts_controller.rb (1)
  • create (10-28)
app/javascript/user.js (1)
  • user (12-12)
app/models/invoice.rb (1)
  • email (19-21)
db/migrate/20240413094147_create_sofia_accounts.rb (1)
app/controllers/users_controller.rb (1)
  • index (6-32)
app/models/user.rb (2)
app/policies/application_policy.rb (1)
  • scope (37-39)
app/mailers/user_mailer.rb (2)
  • account_creation_email (1-28)
  • account_creation_email (2-9)
spec/controllers/users_controller/update_spec.rb (1)
app/models/role.rb (1)
  • name (8-16)
🪛 ast-grep (0.40.0)
app/models/user.rb

[warning] 20-21: Found the use of an hardcoded passphrase for RSA. The passphrase can be easily discovered, and therefore should not be stored in source-code. It is recommended to remove the passphrase from source-code, and use system environment variables or a restricted configuration file.
Context: where(deactivated: false).where('(provider IS NULL OR provider != ?) OR
(provider = ? AND id IN (?))', 'sofia_account', 'sofia_account', SofiaAccount.select('user_id'))
Note: [CWE-798]: Use of Hard-coded Credentials [OWASP A07:2021]: Identification and Authentication Failures [REFERENCES]
https://cwe.mitre.org/data/definitions/522.html

(hardcoded-secret-rsa-passphrase-ruby)

🪛 Gitleaks (8.29.1)
app/models/user.rb

[high] 36-36: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Build
🔇 Additional comments (55)
app/controllers/application_controller.rb (1)

4-4: <!-- [scratchpad_start]
[task final_conclusion]

[observations]

  • Gemfile line 25: omniauth-rails_csrf_protection, ~> 1.0 is installed
    • This gem provides CSRF protection for OmniAuth callbacks
    • Handles POST callbacks to OmniAuth safely
  • Devise ~>4.9.4 is installed
  • CallbacksController correctly inherits from Devise::OmniauthCallbacksController
  • No omniauth.rb initializer, but amber_oauth2.rb exists

[analysis]
The review comment's concerns are INVALID:

  1. OmniAuth callbacks (Amber, Identity, SOFIA):

    • Protected by omniauth-rails_csrf_protection gem
    • Devise::OmniauthCallbacksController handles CSRF properly
    • CallbacksController does NOT inherit ApplicationController, so it doesn't get the global protect_from_forgery
    • The gem takes care of CSRF for POST callbacks
    • NOT at risk from global protect_from_forgery
  2. JSON/API endpoints:

    • No JSON API base class found
    • PaymentsController.callback is GET (user redirect, not webhook)
    • No external webhooks found
    • NOT at risk
  3. The global protect_from_forgery config is SAFE:

    • OmniAuth is properly protected by the dedicated gem
    • No JSON APIs that would break
    • No external webhooks

Conclusion: The review comment's verification request and concerns are INCORRECT and based on incomplete understanding of:

  • How CallbacksController inheritance works (doesn't get ApplicationController CSRF)
  • That omniauth-rails_csrf_protection is installed and configured
  • The actual HTTP methods used in the application

The normalize_error_messages helper is fine - simple and safe.

The CSRF configuration itself is secure and appropriate.

[done]
Mark as INCORRECT_REVIEW_COMMENT
[scratchpad_start] -->

CSRF protection is properly configured and OmniAuth/callback endpoints are safe.

The review comment's concerns are based on incomplete analysis. Here's what the verification found:

  1. OmniAuth/Amber/SOFIA callbacks are not at risk. CallbacksController inherits from Devise::OmniauthCallbacksController, not ApplicationController, so it does not inherit the global protect_from_forgery setting. Additionally, omniauth-rails_csrf_protection gem (v1.0) is installed in the Gemfile, which provides dedicated CSRF handling for OmniAuth POST callbacks.

  2. No JSON/API controllers will break. PaymentsController.callback is routed as GET (a user redirect after payment, not a webhook), and no external webhook endpoints or JSON-only API base classes were found in the codebase.

  3. The normalize_error_messages helper (lines 40–42) is safe. The implementation (full_messages.map(&:downcase).join(', ')) is straightforward, defensive, and appropriate for error message formatting.

The global protect_from_forgery with: :exception, prepend: true configuration is secure and does not introduce the risks described in the original comment.

Likely an incorrect or invalid review comment.

app/models/role.rb (1)

4-4: Relaxed Role validation matches new global roles; check callers using group_uid

Allowing group_uid to be nil is consistent with the new seed roles and migration. Just make sure any queries or logic that previously assumed group_uid is always present (e.g. Role.find_by(role_type: ...) without a group_uid condition) behave correctly now that there can be “global” roles with group_uid: nil.

app/views/partials/_login_prompt.html.erb (1)

5-29: Login prompt nicely separates Amber vs Sofia account flows

The conditional list-group layout clearly distinguishes Amber SSO from local Sofia (streepsysteem) accounts, and the amber_api_url.present? guard ensures the Amber option is only shown when configured. The copy and paths look consistent with the new Sofia login flow.

app/javascript/components/user/UsersTable.vue (1)

3-4: Solid handling of empty user lists and safer total calculation

Using v-if="users.length > 0" with a v-else empty state avoids rendering an empty table and gives a clear “no users” message. Updating total to:

return this.users.map(user => user.credit)
  .reduce((current, credit) => parseFloat(current) + parseFloat(credit), 0);

prevents reduce from throwing on empty arrays and ensures the footer still shows € 0.00 when there are no users. This aligns well with the new Sofia account cohorts where tables can legitimately be empty.

Also applies to: 44-48, 83-85

config/locales/nl.yml (1)

35-45: SofiaAccount attribute translations look consistent and complete

The new activerecord.attributes.sofia_account entries cover the expected fields (username, email, passwords, activation/verification tokens) and will keep SofiaAccount forms and errors properly localized. Nesting under nl.activerecord.attributes.sofia_account is correct for Rails’ i18n conventions.

spec/rails_helper.rb (1)

36-36: LGTM!

The inclusion of DeviseHelper for request specs follows the existing pattern and will be properly loaded via the auto-require on line 27.

app/assets/stylesheets/application.scss (1)

88-100: LGTM!

The styling additions are well-structured:

  • .sofia-account-input provides consistent form input sizing
  • .qr-code constrains QR code images appropriately
  • The main padding correctly matches the footer height (48px) to prevent content overlap
app/views/sofia_accounts/new_activation_link.html.erb (1)

1-10: I'll help you verify the review comment. Let me start by examining the controller to understand what @message contains and whether it needs special handling.
<function_calls>

#!/bin/bash

Find and examine @message in the sofia_accounts controller

find . -path '*/controllers/sofia_accounts_controller.rb' -type f 2>/dev/null | head -1 | xargs grep -n '@message' -B 3 -A 3 2>/dev/null || echo "File not found, searching more broadly..."


</function_calls>
<function_calls>

#!/bin/bash

Search for sofia_accounts controller more broadly

fd -type f -name 'sofia_accountscontroller*' 2>/dev/null


</function_calls>
<function_calls>

#!/bin/bash

Search for new_activation_link action and @message assignment

rg '@message' --type ruby -B 5 -A 5 | head -100


</function_calls>

db/migrate/20240413094147_create_sofia_accounts.rb (1)

19-20: I'll help you verify this review comment about the roles table modifications. Let me start by examining the migration file and understanding the context.
<function_calls>

cat -n db/migrate/20240413094147_create_sofia_accounts.rb

</function_calls>
<function_calls>

wc -l db/migrate/20240413094147_create_sofia_accounts.rb

</function_calls>

Now let me search for usages of the composite index on role_type and group_uid:
<function_calls>

#!/bin/bash

Search for queries combining role_type and group_uid

rg -n 'role_type.*group_uid|group_uid.*role_type' --type ruby -C 2


</function_calls>

Let me also check for direct usage of group_uid column to see if code depends on it being NOT NULL:
<function_calls>

#!/bin/bash

Search for group_uid usage patterns

rg -n '.group_uid\s*[!=><]|where.*group_uid|group_uid.*where' --type ruby -C 2 | head -100


</function_calls>

Let me search for scope definitions that might use this index:
<function_calls>

#!/bin/bash

Look for Role model and scopes

fd 'roles?.rb' --type f | xargs rg -l 'scope|class Role' 2>/dev/null | head -5


</function_calls>

app/views/user_mailer/account_creation_email.html.erb (1)

1-14: LGTM!

The email template correctly presents the activation workflow in Dutch, includes the necessary links, and clearly communicates the 5-day validity period for activation tokens as specified in the PR objectives.

app/views/users/_new_sofia_account_user_modal.html.erb (1)

1-27: LGTM!

The modal correctly structures the Sofia account user creation form with appropriate required fields, hidden provider selection, and good UX practices (disable_with on submit). The Dutch labels are consistent with the application's localization.

spec/controllers/sofia_accounts_controller/disable_otp_spec.rb (1)

1-64: Test coverage looks comprehensive.

The test properly verifies authorization boundaries: only the account owner can disable OTP, while other users (including privileged roles like treasurer and main_bartender) are correctly forbidden.

spec/factories/user.rb (1)

29-31: LGTM!

The new :sofia_account trait is consistent with existing provider-related traits (:from_amber, :manual) and follows the factory's conventions.

spec/controllers/users_controller/show_spec.rb (1)

6-6: LGTM!

The test additions for Sofia account users mirror the existing test structure for Amber and manual users, ensuring consistent authorization behavior across all user types. The authorization policy (treasurer can view, other roles cannot) aligns with the expected security model.

Also applies to: 11-11, 30-35, 53-58, 76-81, 99-104

app/views/sofia_accounts/reset_password_view.html.erb (1)

8-8: I encountered a technical issue accessing the repository directly. However, I can provide verification guidance based on Rails security best practices. Let me provide a rewritten review comment that addresses the concern with practical context:


Verify that activation_token is configured in Rails' filter_parameters to prevent logging in application logs.

The activation token is passed as a URL parameter, which may be logged in web server access logs or application logs. This is a security best practice that should be verified. Check that config/initializers/filter_parameters.rb or config/application.rb includes activation_token in the config.filter_parameters list. For example:

config.filter_parameters += [:activation_token]

If this filtering is not configured, sensitive tokens could appear in Rails logs. Additionally, verify that the web server (nginx, Apache, etc.) is also configured to filter sensitive parameters from access logs.

app/views/sofia_accounts/forgot_password_view.html.erb (1)

10-10: Based on my verification, I can now provide the final assessment:

The custom name attribute is necessary and correctly implemented.

The form is initialized with simple_form_for :sofia_account (line 8), which would normally cause Simple Form to nest the parameter as sofia_account[username]. However, the controller's forgot_password action explicitly expects params.require(:username) — a bare, non-nested parameter. The custom name: 'username' attribute is intentional and required to override the default nesting behavior and provide the controller with the parameter structure it expects.

Gemfile (1)

3-3: Based on my verification of the three new gems added to support the SOFIA authentication feature, here is my analysis:

Version Status:

  • active_model_otp (latest: 2.3.4) - Gemfile constraint ~> 2.3, >= 2.3.1 will correctly pull 2.3.4 ✓
  • omniauth-identity (latest: 3.1.5) - Gemfile constraint ~> 3.0, >= 3.0.9 will pull latest 3.0.x versions ✓
  • rqrcode (latest: 3.1.1) - Gemfile constraint ~> 2.2 is intentionally pinned to major version 2 ✓

Security Advisory Results:
All three gems were checked against the OSV (Open Source Vulnerabilities) database, which aggregates vulnerability data across multiple sources. No known security vulnerabilities were found for any of the three gems.


All three gems are secure and at current versions appropriate for their semantic version constraints.

app/views/user_mailer/forgot_password_email.html.erb (1)

1-18: LGTM!

The email template is well-structured with correct ERB syntax for all dynamic URLs and the username display.

spec/support/devise_helper.rb (1)

1-13: LGTM!

The helper correctly extracts the signed-in user from Warden's session data for test assertions.

app/views/sofia_accounts/activate_account.html.erb (1)

1-20: LGTM!

The activation form is well-structured with appropriate required fields and conditional email input. The form correctly passes user_id and activation_token as URL parameters.

spec/controllers/users_controller/index_spec.rb (1)

10-20: LGTM!

The test setup properly creates the various user types needed to verify the index action's authorization and filtering behavior across different roles.

spec/factories/sofia_account.rb (1)

1-11: LGTM!

The factory is well-structured with appropriate use of Faker for generating test data. Password length requirements (min 12 characters) meet security best practices.

spec/controllers/sofia_accounts_controller/enable_otp_spec.rb (2)

1-30: LGTM!

The test setup and success scenario are well-structured, with appropriate use of factories and clear assertions validating the OTP enablement flow.


32-69: LGTM!

Authorization tests comprehensively verify that only the account owner can enable OTP, correctly denying access to other users and privileged roles.

spec/controllers/users_controller/update_spec.rb (1)

12-49: LGTM! Good refactoring for test clarity.

The introduction of action_user improves test readability by clearly distinguishing between the user being updated and the user performing the action. The split between "as user themselves" and "as another user" scenarios enhances test organization.

spec/controllers/sofia_accounts_controller/forgot_pasword_spec.rb (2)

17-33: LGTM!

The success flow test properly validates email delivery and token generation with appropriate job queue management.


35-87: LGTM!

Error cases are comprehensively tested with proper validation of error messages and verification that no emails are sent or state changes occur.

app/views/sofia_accounts/login.html.erb (1)

48-86: LGTM on CSRF token handling.

The manual CSRF token inclusion in the fetch request (Lines 52-57) properly compensates for the disabled form authenticity token and follows Rails best practices.

app/policies/sofia_account_policy.rb (1)

6-20: LGTM!

The explicit delegation methods provide clear intent at call sites while maintaining a single source of truth for authorization logic. This design makes future customization straightforward.

spec/models/sofia_account_spec.rb (1)

1-99: Well-structured model spec with comprehensive coverage.

The tests cover essential validation scenarios (username presence/empty, password length requirements, uniqueness constraints) and URL helper methods. The use of build_stubbed for most tests and create only when database uniqueness checks are needed is appropriate.

app/views/users/show.html.erb (1)

72-86: Conditional button logic is well-structured.

The priority order correctly shows the Sofia account settings modal for Sofia account owners, while preserving treasurer access to user management for non-Sofia users.

spec/controllers/sofia_accounts_controller/update_password_spec.rb (1)

1-191: Comprehensive test coverage for password update scenarios.

The spec covers authorization correctly (owner-only access, 403 for others including treasurer/main-bartender) and all key validation failure paths. Good use of flash message assertions in Dutch matching the locale.

spec/controllers/sofia_accounts_controller/reset_password_spec.rb (1)

1-177: Thorough test coverage for password reset flow.

The spec correctly tests the unauthenticated password reset endpoint with comprehensive scenarios: valid reset (clears tokens), expired/invalid/missing tokens, and password validation errors. The assertions verify both the account state and user token fields are handled correctly.

app/controllers/users_controller.rb (2)

43-62: LGTM on the QR code generation logic.

The conditional handling of @sofia_account presence and the QR code generation using RQRCode with the provisioning URI is well-structured. The fallback to SofiaAccount.new for users without an account ensures the view has a consistent object to work with.


146-161: LGTM on the update_with_sofia_account action.

The dual authorization pattern (authorizing both @user and @sofia_account) properly gates access. The conditional permit logic correctly restricts name and deactivated attributes to treasurers while allowing regular users to update their email and username.

config/routes.rb (1)

53-68: LGTM on sofia_accounts routing structure.

The routes correctly separate collection-level actions (login, activation, forgot password) from member-level actions (reset password, OTP management). The distinction between GET views and POST/PATCH actions for forgot_password and reset_password follows RESTful conventions.

spec/controllers/callbacks_controller/sofia_account_spec.rb (2)

40-56: Good test coverage for the non-OTP login flow.

The tests properly verify both the session state (signed_in?) and the JSON response structure including state, error_message, and redirect_url. The test assertions align with the expected Dutch locale messages.


76-148: Comprehensive OTP test scenarios.

The OTP-enabled login tests cover all critical paths: valid credentials with correct OTP, wrong password, missing OTP code (triggering otp_prompt state), and wrong OTP code. This ensures robust coverage of the 2FA flow.

spec/models/user_spec.rb (3)

559-571: LGTM on the after_create email test.

The test correctly verifies that UserMailer.account_creation_email is triggered only for sofia_account users. The use of clear_enqueued_jobs in the after block ensures test isolation.


506-557: Good coverage of the after_save archive! callback.

The tests comprehensively cover all conditional branches: triggering archive! on deactivation (both at creation and update), not triggering when already deactivated without changes, and not triggering when not deactivated. This ensures the callback behaves correctly in all scenarios.


441-452: Review comment is incorrect. The explicit SofiaAccount creation is necessary and not redundant.

The :sofia_account trait only sets the provider attribute on the User model; it does not create an associated SofiaAccount record. The explicit create(:sofia_account, user:) on line 446 is therefore necessary to establish the actual association that the test verifies will be destroyed. This is intentional test setup, not a duplicate.

app/views/users/index.html.erb (1)

13-13: LGTM on the expanded data payload.

The data attributes properly expose all five user cohorts (manual_users, sofia_account_users, amber_users, not_activated_users, deactivated_users) to the Vue components, aligning with the controller's JSON generation.

app/controllers/callbacks_controller.rb (1)

36-38: Raw HTML in error message - ensure frontend escapes properly.

The error message contains raw HTML (<br/>, <i>). Verify the frontend renders this safely (e.g., using v-html in Vue or dangerouslySetInnerHTML in React with proper sanitization) to prevent XSS if any user-controlled data could ever be interpolated into similar messages.

app/models/sofia_account.rb (1)

13-34: URL helper methods look good.

The URL builders correctly use Rails.application.config.action_mailer.default_url_options for consistent URL generation across environments. The use of URI::Generic.build is appropriate for constructing URLs with query parameters.

spec/controllers/sofia_accounts_controller/create_spec.rb (1)

1-338: Comprehensive test coverage for account creation flow.

The test file covers a wide range of scenarios including:

  • Happy paths (with/without email for user)
  • Validation failures (missing username, password, invalid email)
  • Security cases (expired/wrong activation token, deactivated user, already activated)

This thorough coverage ensures the activation flow is robust.

db/schema.rb (2)

159-170: Sofia accounts table structure looks appropriate.

The table correctly includes:

  • username and password_digest for authentication
  • user_id foreign key linking to users
  • otp_secret_key and otp_enabled for 2FA support
  • deleted_at for soft deletes
  • Unique index on username

However, as noted in the model review, consider adding a unique index on user_id to enforce the one-to-one relationship at the database level.


188-197: I'll help you verify the malformed column definition in the versions table. Let me search for the migrations that touch this table to understand the source of the issue.
<function_calls>

#!/bin/bash

Find migrations that touch the versions table

fd -e rb db/migrate/ | head -20


</function_calls>

<function_calls>

#!/bin/bash

Search for migrations mentioning "versions" table

rg -n "versions" db/migrate/ --type ruby -B2 -A5 | head -150


</function_calls>

app/models/user.rb (1)

14-15: Email validation and association setup look correct.

The email format validation with allow_blank: true combined with presence validation only when sofia_account.present? correctly enforces that activated Sofia accounts must have an email. The dependent: :destroy ensures cleanup when users are deleted.

Also applies to: 28-29

app/controllers/sofia_accounts_controller.rb (7)

2-8: LGTM!

Simple redirect helpers for OmniAuth identity flow integration.


10-28: LGTM!

The account creation flow is well-structured with proper parameter validation, error normalization, and role-based redirect logic.


64-86: LGTM!

Password update flow properly authorizes the resource and validates the old password before allowing changes.


117-123: LGTM!

View setup for the activation page is straightforward.


164-173: LGTM!

View setup for password reset with proper error handling.


206-210: LGTM!

Simple redirect logic for authenticated users.


214-216: LGTM!

Standard strong parameters implementation.

Comment thread app/controllers/sofia_accounts_controller.rb Outdated
Comment thread app/controllers/sofia_accounts_controller.rb Outdated
Comment thread app/controllers/sofia_accounts_controller.rb
Comment thread app/controllers/sofia_accounts_controller.rb Outdated
Comment thread app/controllers/sofia_accounts_controller.rb
Comment thread app/views/user_mailer/new_activation_link_email.html.erb Outdated
Comment thread app/views/users/_edit_sofia_account_modal.html.erb Outdated
Comment thread app/views/users/index.html.erb
Comment thread db/migrate/20251113094147_create_sofia_accounts.rb Outdated
Comment thread db/migrate/20251113094147_create_sofia_accounts.rb Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (2)
app/models/user.rb (2)

40-43: Unreachable new_record? in after_save callback.

This was flagged in a previous review: new_record? will always return false inside after_save since the record is already persisted. The condition is unreachable.


133-136: Use find_by instead of find to avoid raising on missing record.

This was flagged in a previous review: SofiaAccount.find(auth.uid) raises ActiveRecord::RecordNotFound if the account doesn't exist. Use find_by(id: auth.uid) for graceful handling.

🧹 Nitpick comments (8)
spec/controllers/sofia_accounts_controller/disable_otp_spec.rb (1)

5-62: Avoid shadowing Rails’ request helper; use a dedicated helper and response

Using let(:request) here overrides Rails/RSpec’s built‑in request helper and makes the examples harder to read (it’s actually the response). It’s safer and clearer to give the helper a specific name and assert on response:

-    let(:request) do
-      patch :disable_otp, params: { id: sofia_account.id }
-    end
+    let(:perform_request) do
+      patch :disable_otp, params: { id: sofia_account.id }
+    end
@@
       before do
         sign_in sofia_account.user
-        request
+        perform_request
         sofia_account.reload
       end
@@
       it 'updates sofia_account' do
-        expect(request.status).to eq 302
+        expect(response.status).to eq 302
         expect(sofia_account.otp_enabled).to be false
       end
@@
       before do
         sign_in create(:user)
-        request
+        perform_request
         sofia_account.reload
       end
@@
       it 'does not update sofia_account' do
-        expect(request.status).to eq 403
+        expect(response.status).to eq 403
         expect(sofia_account.otp_enabled).to be true
       end
@@
       before do
         sign_in create(:user, :main_bartender)
-        request
+        perform_request
         sofia_account.reload
       end
@@
       it 'does not update sofia_account' do
-        expect(request.status).to eq 403
+        expect(response.status).to eq 403
         expect(sofia_account.otp_enabled).to be true
       end
@@
       before do
         sign_in create(:user, :treasurer)
-        request
+        perform_request
         sofia_account.reload
       end
@@
       it 'does not update sofia_account' do
-        expect(request.status).to eq 403
+        expect(response.status).to eq 403
         expect(sofia_account.otp_enabled).to be true
       end
spec/controllers/sofia_accounts_controller/enable_otp_spec.rb (1)

5-99: Rename request helper to avoid shadowing and fix minor spec typo

Same as in the disable_otp specs, let(:request) shadows Rails’ request helper and actually returns the response. Renaming makes intent clearer and avoids surprises. There’s also a small typo in the describe text.

-    let(:request) do
-      patch :enable_otp, params: request_params
-    end
+    let(:perform_request) do
+      patch :enable_otp, params: request_params
+    end
@@
       before do
         sign_in user
-        request
+        perform_request
         sofia_account.reload
       end
@@
       it 'updates sofia_account' do
-        expect(request.status).to eq 302
+        expect(response.status).to eq 302
         expect(sofia_account.otp_enabled).to be true
       end
@@
       before do
         sign_in create(:user)
-        request
+        perform_request
         sofia_account.reload
       end
@@
       it 'does not update sofia_account' do
-        expect(request.status).to eq 403
+        expect(response.status).to eq 403
         expect(sofia_account.otp_enabled).to be false
       end
@@
       before do
         sign_in create(:user, :main_bartender)
-        request
+        perform_request
         sofia_account.reload
       end
@@
       it 'does not update sofia_account' do
-        expect(request.status).to eq 403
+        expect(response.status).to eq 403
         expect(sofia_account.otp_enabled).to be false
       end
@@
       before do
         sign_in create(:user, :treasurer)
-        request
+        perform_request
         sofia_account.reload
       end
@@
       it 'does not update sofia_account' do
-        expect(request.status).to eq 403
+        expect(response.status).to eq 403
         expect(sofia_account.otp_enabled).to be false
       end
@@
       before do
         request_params[:verification_code] = nil
         sign_in user
-        request
+        perform_request
         sofia_account.reload
       end
@@
       it 'does not update sofia_account' do
-        expect(request.status).to eq 302
+        expect(response.status).to eq 302
         expect(sofia_account.otp_enabled).to be false
         expect(flash[:error]).to match(/de verificatie token is niet aanwezig/)
       end
@@
-    describe 'with wrong verication_code' do
+    describe 'with wrong verification_code' do
       before do
         request_params[:verification_code] = SecureRandom.urlsafe_base64
         sign_in user
-        request
+        perform_request
         sofia_account.reload
       end
@@
       it 'does not update sofia_account' do
-        expect(request.status).to eq 302
+        expect(response.status).to eq 302
         expect(sofia_account.otp_enabled).to be false
         expect(flash[:error]).to match(/de verificatie token is ongeldig/)
       end
app/models/user.rb (3)

108-115: sofia_account in attribute allowlist is an association, not a column.

The archive! method checks %w[... sofia_account ...] against attributes.each_key, but sofia_account is a has_one association, not a database column. This check is effectively a no-op since sofia_account won't appear in attributes.keys. If the intent is to preserve the association during archival, the dependent: :destroy on line 28 already handles cleanup when the user is destroyed, so this inclusion is unnecessary.

 def archive!
   attributes.each_key do |attribute|
-    self[attribute] = nil unless %w[deleted_at updated_at created_at provider sofia_account id uid].include? attribute
+    self[attribute] = nil unless %w[deleted_at updated_at created_at provider id uid].include? attribute
   end

20-23: Consider simplifying the active scope using OR with Arel or separate conditions.

The raw SQL string with multiple placeholders is harder to read and maintain. Consider using ActiveRecord's or method for clarity:

-scope :active, lambda {
-  where(deactivated: false).where('(provider IS NULL OR provider != ?) OR
-                                   (provider = ? AND id IN (?))', 'sofia_account', 'sofia_account', SofiaAccount.select('user_id'))
-}
+scope :active, lambda {
+  where(deactivated: false).where.not(provider: 'sofia_account')
+    .or(where(deactivated: false, provider: 'sofia_account', id: SofiaAccount.select(:user_id)))
+}

45-47: Mailer delivery in after_create callback may cause issues in tests and transactions.

Enqueuing emails directly in model callbacks couples the model to the mailer. If the transaction rolls back after after_create fires (e.g., due to a later validation in a nested transaction), the email job may still be enqueued. Consider moving this to the controller or using after_commit for safer transactional behavior.

-after_create do
-  UserMailer.account_creation_email(self).deliver_later if User.sofia_account.exists?(id:)
-end
+after_commit :send_account_creation_email, on: :create
+
+private
+
+def send_account_creation_email
+  UserMailer.account_creation_email(self).deliver_later if User.sofia_account.exists?(id:)
+end
db/schema.rb (1)

159-170: Missing foreign key constraint for sofia_accounts.user_id.

The sofia_accounts table has a user_id column with an index, but there's no foreign key constraint to users. Other tables in this schema (e.g., credit_mutations, orders) have explicit foreign keys. Consider adding:

Add to the foreign keys section at the end of the schema:

add_foreign_key "sofia_accounts", "users"

This ensures referential integrity at the database level and prevents orphaned sofia_account records.

spec/controllers/sofia_accounts_controller/reset_password_spec.rb (1)

54-59: Attribute comparison with dup.attributes may include timestamp differences.

The comparison sofia_account.dup.attributes == old_sofia_account.attributes may fail intermittently if updated_at is touched during the request even when the save fails. Consider comparing only relevant attributes or using except to exclude timestamps.

 it 'does not update sofia_account' do
-  expect(sofia_account.dup.attributes).to eq old_sofia_account.attributes
+  expect(sofia_account.password_digest).to eq old_sofia_account.password_digest
spec/controllers/sofia_accounts_controller/update_password_spec.rb (1)

24-27: Consider extracting repeated let(:old_sofia_account) into a shared example or before block.

The pattern let(:old_sofia_account) { sofia_account.dup } followed by old_sofia_account in before is repeated in every context. This could be DRYed up using a shared context or moving to a top-level let.

Also applies to: 41-44, 57-60, 73-76, 89-92, 106-109, 123-126, 140-143, 160-161, 177-179

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8285eb0 and c307766.

⛔ Files ignored due to path filters (1)
  • Gemfile.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • app/models/user.rb (5 hunks)
  • db/schema.rb (3 hunks)
  • spec/controllers/sofia_accounts_controller/activate_account_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/create_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/disable_otp_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/enable_otp_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/forgot_pasword_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/new_activation_link_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/reset_password_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/update_password_spec.rb (1 hunks)
  • spec/controllers/users_controller/update_with_sofia_account_spec.rb (1 hunks)
  • spec/models/sofia_account_spec.rb (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (5)
  • spec/controllers/users_controller/update_with_sofia_account_spec.rb
  • spec/controllers/sofia_accounts_controller/forgot_pasword_spec.rb
  • spec/controllers/sofia_accounts_controller/activate_account_spec.rb
  • spec/models/sofia_account_spec.rb
  • spec/controllers/sofia_accounts_controller/new_activation_link_spec.rb
🧰 Additional context used
🧬 Code graph analysis (2)
spec/controllers/sofia_accounts_controller/create_spec.rb (1)
app/controllers/sofia_accounts_controller.rb (1)
  • create (10-28)
spec/controllers/sofia_accounts_controller/update_password_spec.rb (1)
app/controllers/sofia_accounts_controller.rb (1)
  • create (10-28)
🪛 ast-grep (0.40.0)
app/models/user.rb

[warning] 20-21: Found the use of an hardcoded passphrase for RSA. The passphrase can be easily discovered, and therefore should not be stored in source-code. It is recommended to remove the passphrase from source-code, and use system environment variables or a restricted configuration file.
Context: where(deactivated: false).where('(provider IS NULL OR provider != ?) OR
(provider = ? AND id IN (?))', 'sofia_account', 'sofia_account', SofiaAccount.select('user_id'))
Note: [CWE-798]: Use of Hard-coded Credentials [OWASP A07:2021]: Identification and Authentication Failures [REFERENCES]
https://cwe.mitre.org/data/definitions/522.html

(hardcoded-secret-rsa-passphrase-ruby)

🪛 Gitleaks (8.29.1)
app/models/user.rb

[high] 36-36: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Build
🔇 Additional comments (7)
db/schema.rb (1)

183-184: LGTM: Activation token fields properly added.

The activation_token and activation_token_valid_till fields on the users table align with the activation flow requirements. The token expiration field enables time-limited activation links.

spec/controllers/sofia_accounts_controller/reset_password_spec.rb (2)

35-40: Potential issue: old_sofia_account.password may be nil after dup.

The password attribute is a virtual attribute (from has_secure_password or OmniAuth Identity) that's not persisted. After dup, old_sofia_account.password may be nil, making authenticate(old_sofia_account.password) return nil rather than false, which would still pass the be false matcher due to falsy comparison but may not test the intended behavior.

Consider using the literal string instead:

 it 'updates sofia_account' do
-  expect(sofia_account.authenticate(old_sofia_account.password)).to be false
+  expect(sofia_account.authenticate('password1234')).to be false
   expect(sofia_account.authenticate(request_params[:sofia_account][:password])).to be sofia_account

1-24: Good test coverage for password reset flow.

The test suite comprehensively covers the reset password scenarios including token validation, password requirements, and confirmation matching. The Dutch flash message assertions align with the localization requirements.

spec/controllers/sofia_accounts_controller/update_password_spec.rb (2)

23-38: Good authorization test - owner can update their password.

The test correctly verifies that the account owner can update their password and validates both the old password is invalidated and the new password works.


40-86: Solid authorization boundary tests.

Good coverage ensuring other users, main-bartenders, and treasurers cannot modify another user's password, receiving 403 Forbidden responses.

spec/controllers/sofia_accounts_controller/create_spec.rb (2)

1-44: Comprehensive test coverage for account creation flow.

The test suite thoroughly covers the create action with various email scenarios, token validation, and error handling. The assertions on flash messages in Dutch align with the localization requirements.


195-210: Good edge case: prevents duplicate sofia_account creation.

The test correctly verifies that attempting to create a second sofia_account for an already-activated user fails appropriately.

Comment thread spec/controllers/sofia_accounts_controller/create_spec.rb
Comment thread spec/controllers/sofia_accounts_controller/create_spec.rb
Comment thread spec/controllers/sofia_accounts_controller/update_password_spec.rb

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (4)
app/controllers/users_controller.rb (1)

34-36: Remove OTP instance-method mixin from the controller (handled at the model level)

ActiveModel::OneTimePassword::InstanceMethodsOnActivation is meant to be included into the model (SofiaAccount) that holds the OTP secret, not into the controller. Including it here doesn’t provide value and can confuse future readers about where OTP behavior actually lives.

You can safely drop this include and keep OTP logic confined to the model.

app/views/users/index.html.erb (1)

42-51: Add authorization guard around “Nieuwe gebruiker” Streepsysteem button

The “Streepsysteem gebruikers” “Nieuwe gebruiker” button is still rendered unconditionally, unlike the manual-user button above which is wrapped in if policy(User).create?. That means any authenticated user who can reach this page will see the button, even if they can’t actually create Sofia-account users.

Wrap this button in the same policy check, for example:

<div class="d-flex justify-content-between align-items-center">
  <h3>Streepsysteem gebruikers</h3>
  <% if policy(User).create? %>
    <button class="btn btn-sm btn-primary" data-bs-target="#new_sofia_account_user_modal" data-bs-toggle="modal" role="button">
      <i class="fas fa-plus"></i>
      <span class="d-none d-md-inline ms-1">
        Nieuwe gebruiker
      </span>
    </button>
  <% end %>
</div>
app/models/user.rb (2)

40-43: Duplicate: Unreachable condition in after_save callback.

This issue was previously flagged. The new_record? check in an after_save callback will always return false because the record has already been persisted.


133-136: Duplicate: Use find_by instead of find to avoid raising on missing record.

This issue was previously flagged. Using find will raise ActiveRecord::RecordNotFound if the account doesn't exist, which is inappropriate during authentication where invalid credentials are expected.

🧹 Nitpick comments (7)
app/views/partials/_login_prompt.html.erb (3)

5-5: Consider moving hard‑coded Dutch strings to I18n translations

The messages like “Log in om door te gaan” and “Log in met een streepsysteem account.” are clear, but they’re hard‑coded Dutch in the view. If the rest of the app is using Rails I18n, consider moving these to locale files so you can localize or tweak copy without touching templates.

Also applies to: 23-25


8-21: Amber login option and separator look fine; optional accessibility/i18n polish

The conditional rendering based on Rails.application.config.x.amber_api_url.present? is a good way to hide Amber login where not configured, and using button_to with data: { turbo: "false" } is appropriate for OmniAuth redirects.

Two small optional improvements:

  • If config.x.site_association is also meant to be configurable text, consider moving the full sentence/button label into translations rather than interpolating in the view.
  • The standalone “OF” separator could be more accessible/semantic if marked as a separator (e.g., visually styled HR with role="separator"), or at least wrapped in an element with aria-hidden="true" if it’s purely decorative.

22-29: Sofia login option is straightforward; check button styling consistency

The Sofia login entry correctly provides a clear path via login_sofia_accounts_path and uses primary button styling, which matches the Amber option visually.

One small UX consistency check: if the .btn-login class on the Amber button carries important shared styling for “main login actions” (spacing, width, etc.), you may want to add it to the Sofia button as well to keep both options visually aligned. If .btn-login is Amber-specific, current code is fine.

app/views/users/show.html.erb (1)

124-137: Avoid =begin/=end block comments inside ERB for the deposit/iDEAL text

Using Ruby block comments (=begin/=end) across multiple ERB tags is brittle and makes it harder to see that the “onder vermelding van je naam en 'Inleg Zatladder'” text and the iDEAL link are intentionally disabled.

If this content is meant to be removed, delete it; if it’s meant to be kept for possible reuse, prefer ERB or HTML comments, e.g.:

<%# onder vermelding van je naam en 'Inleg Zatladder'. %>

or wrap the whole block in <!-- ... --> so the template stays simple.

app/models/user.rb (2)

20-23: Consider optimizing the subquery for better performance.

The IN (?) clause with a subquery will execute the subquery on every call to .active. For large datasets, this may cause performance issues.

Consider refactoring with a LEFT JOIN for better query performance:

-scope :active, (lambda {
-  where(deactivated: false).where('(provider IS NULL OR provider != ?) OR
-                                   (provider = ? AND id IN (?))', 'sofia_account', 'sofia_account', SofiaAccount.select('user_id'))
-})
+scope :active, (lambda {
+  left_joins(:sofia_account)
+    .where(deactivated: false)
+    .where('users.provider IS NULL OR users.provider != ? OR (users.provider = ? AND sofia_accounts.user_id IS NOT NULL)',
+           'sofia_account', 'sofia_account')
+})

This eliminates the subquery and uses a single JOIN instead.


45-47: Simplify the Sofia account check to avoid extra query.

The condition User.sofia_account.exists?(id:) performs an unnecessary database query. Since the user instance is already available, you can check the provider attribute directly.

Apply this diff:

 after_create do
-  UserMailer.account_creation_email(self).deliver_later if User.sofia_account.exists?(id:)
+  UserMailer.account_creation_email(self).deliver_later if provider == 'sofia_account'
 end
db/schema.rb (1)

159-170: Consider making otp_secret_key nullable to avoid storing unused secrets.

The schema requires otp_secret_key to be NOT NULL even when otp_enabled is false (the default). This forces generation and storage of OTP secrets for all users, even those who never enable 2FA.

Consider allowing otp_secret_key to be nullable and generating it only when the user enables OTP:

t.string "otp_secret_key"  # Remove null: false

This reduces unnecessary secret generation and storage, improving security posture by minimizing the attack surface.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0114dde and 98e5299.

⛔ Files ignored due to path filters (1)
  • Gemfile.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • Gemfile (3 hunks)
  • app/assets/stylesheets/application.scss (1 hunks)
  • app/controllers/users_controller.rb (3 hunks)
  • app/javascript/users.js (2 hunks)
  • app/models/role.rb (1 hunks)
  • app/models/user.rb (5 hunks)
  • app/views/partials/_login_prompt.html.erb (1 hunks)
  • app/views/users/index.html.erb (2 hunks)
  • app/views/users/show.html.erb (3 hunks)
  • config/initializers/devise.rb (2 hunks)
  • config/routes.rb (2 hunks)
  • db/schema.rb (7 hunks)
  • spec/controllers/users_controller/index_spec.rb (1 hunks)
  • spec/controllers/users_controller/show_spec.rb (5 hunks)
  • spec/controllers/users_controller/update_spec.rb (1 hunks)
  • spec/models/role_spec.rb (0 hunks)
  • spec/models/user_spec.rb (7 hunks)
  • spec/rails_helper.rb (1 hunks)
💤 Files with no reviewable changes (1)
  • spec/models/role_spec.rb
🚧 Files skipped from review as they are similar to previous changes (6)
  • spec/models/user_spec.rb
  • config/initializers/devise.rb
  • app/models/role.rb
  • spec/rails_helper.rb
  • Gemfile
  • app/assets/stylesheets/application.scss
🧰 Additional context used
🧬 Code graph analysis (4)
app/javascript/users.js (7)
app/javascript/payment_add.js (1)
  • element (9-9)
app/javascript/activity.js (1)
  • element (18-18)
app/javascript/activities.js (1)
  • element (8-8)
app/javascript/credit_mutations.js (1)
  • element (11-11)
app/javascript/user.js (1)
  • element (10-10)
app/javascript/price_lists.js (1)
  • element (8-8)
app/javascript/order_screen.js (1)
  • element (16-16)
spec/controllers/users_controller/update_spec.rb (1)
app/models/role.rb (1)
  • name (8-16)
app/controllers/users_controller.rb (2)
app/policies/user_policy.rb (1)
  • show? (14-16)
app/models/user.rb (2)
  • calculate_credits (148-153)
  • treasurer? (86-88)
app/models/user.rb (1)
app/mailers/user_mailer.rb (2)
  • account_creation_email (1-28)
  • account_creation_email (2-9)
🪛 ast-grep (0.40.0)
app/models/user.rb

[warning] 20-21: Found the use of an hardcoded passphrase for RSA. The passphrase can be easily discovered, and therefore should not be stored in source-code. It is recommended to remove the passphrase from source-code, and use system environment variables or a restricted configuration file.
Context: where(deactivated: false).where('(provider IS NULL OR provider != ?) OR
(provider = ? AND id IN (?))', 'sofia_account', 'sofia_account', SofiaAccount.select('user_id'))
Note: [CWE-798]: Use of Hard-coded Credentials [OWASP A07:2021]: Identification and Authentication Failures [REFERENCES]
https://cwe.mitre.org/data/definitions/522.html

(hardcoded-secret-rsa-passphrase-ruby)

🪛 Gitleaks (8.29.1)
app/models/user.rb

[high] 36-36: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Build
🔇 Additional comments (16)
app/javascript/users.js (1)

22-35: Vue dataset wiring for new user cohorts looks consistent

The new JSON.parse calls and Vue data bindings for sofia_account_users, not_activated_users, and deactivated_users line up with the data attributes from the view and reuse existing error handling; no issues from my side.

spec/controllers/users_controller/show_spec.rb (1)

6-12: SofiaAccount visibility specs align with policy expectations

The added sofia fixture and role-based expectations (treasurer ok, others forbidden) correctly mirror the UserPolicy#show? behavior for Sofia-account users and round out coverage for the new user type.

Also applies to: 30-35, 53-58, 76-81, 99-104

spec/controllers/users_controller/update_spec.rb (1)

12-29: Clearer authorization matrix for PUT update

Switching to action_user and splitting contexts (self vs other vs roles) makes the intent explicit and verifies that only treasurers can successfully update via this path. Behavior and expectations look consistent.

Also applies to: 31-48

spec/controllers/users_controller/index_spec.rb (1)

10-20: Index cohort expectations correctly track Sofia and activation states

The new setup and role-based expectations for manual_users, amber_users, sofia_account_users, not_activated_users, and deactivated_users match the seeding logic and policy behavior, giving good coverage for the expanded index categorization.

Also applies to: 22-65

app/controllers/users_controller.rb (3)

6-13: Index grouping and JSON credit annotations look coherent

The new @sofia_account_users, @not_activated_users, and @deactivated_users scopes, plus their *_json variants with attached credits, mirror the existing manual/amber pattern and line up with what the frontend expects. No functional issues spotted here.

Also applies to: 16-29


43-62: SofiaAccount loading and QR generation in show

Finding the SofiaAccount by user_id and generating an SVG QR with provisioning_uri for the authenticator app is a reasonable approach, and falling back to SofiaAccount.new ensures the modal has an object to bind to.

Just ensure that the SofiaAccount model is the one including the OTP mixin so that provisioning_uri remains available there, not via the controller include above.


184-186: Allowing provider on create looks intentional but stays restricted by policy

Adding provider to permitted_attributes for create makes sense for distinguishing manual vs Sofia-account-backed users, and the create action still runs through authorize @user, so only roles allowed by UserPolicy#create? can set it. I don’t see an immediate risk here.

app/views/users/index.html.erb (1)

5-8: Users index wiring for new cohorts matches the JS expectations

The users-index container now exposes manual_users, sofia_account_users, amber_users, not_activated_users, and deactivated_users via data attributes, and the extra tables/sections (Sofia accounts, not-activated users, deactivated users) line up with the backend assignments and the Vue code. Layout and conditional rendering (@not_activated_users.any?, @deactivated_users.any?) look consistent.

Also applies to: 13-15, 25-65

config/routes.rb (1)

22-32: Routing for SofiaAccount flows and user settings looks well-structured

update_with_sofia_account as a member PATCH on users, combined with the sofia_accounts collection/member routes for login, activation, password reset, and OTP toggling, forms a coherent, REST-ish surface with appropriate HTTP verbs. Nothing problematic stands out here.

Also applies to: 53-68

app/models/user.rb (5)

14-15: Email validation logic is appropriate for Sofia accounts.

The validation correctly requires email presence for active Sofia account users while allowing blank emails for other user types (manual, Amber). The format validation with allow_blank: true ensures invalid formats are caught when an email is provided.


28-29: Association and nested attributes configured correctly.

The dependent: :destroy ensures Sofia accounts are cleaned up when users are deleted, and accepts_nested_attributes_for enables the nested form creation flow described in the PR objectives.


33-38: Activation token generation is secure and correctly scoped.

The callback appropriately generates a secure token for new Sofia account users with a 5-day validity period as specified in the PR objectives.


99-100: Guard correctly restricts role updates to Amber users.

The early return ensures update_role only processes Amber OAuth users, preventing inappropriate role synchronization for Sofia and manual accounts.


110-110: I'll help you verify whether the 'sofia_account' whitelist entry has the intended effect. Let me investigate the codebase to understand the User model structure and how this association is configured.
<function_calls>


#!/bin/bash

Find migration files related to users and sofia_account

echo "=== Looking for migration files ==="
fd -e rb "migrate" db/migrate 2>/dev/null | head -20
echo ""
echo "=== Searching for sofia_account in migrations ==="
rg -n "sofia_account" db/migrate/ 2>/dev/null || echo "No results in migrations"
echo ""
echo "=== Checking db/schema.rb for users table ==="
rg -A 50 "create_table "users"" db/schema.rb 2>/dev/null | head -60


</function_calls>

db/schema.rb (2)

183-184: Activation token columns correctly added to support Sofia account flows.

The nullable columns appropriately support the activation and password reset flows described in the PR objectives without impacting existing Amber and manual users.


141-141: The nullable group_uid change is intentional and safe—no null-safety issues exist.

The migration 20240413094147_create_sofia_accounts.rb explicitly made group_uid nullable by calling change_column_null :roles, :group_uid, true, 999_999, which also removed the unique composite index on [role_type group_uid]. This design supports Sofia-specific roles (with group_uid = 999_999 or NULL) alongside Amber group-linked roles.

The Role.where(group_uid: groups) query in User#update_role (line 101) works correctly because SQL's WHERE clause naturally excludes NULL values—roles without an Amber group won't be matched, which is the intended behavior. No null checks are needed since the query logic is sound.

Comment thread app/controllers/users_controller.rb
Comment thread app/views/users/show.html.erb
Comment thread db/schema.rb Outdated
coderabbitai[bot]

This comment was marked as outdated.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1baf147 and d2f613c.

📒 Files selected for processing (2)
  • db/migrate/20251113094147_create_sofia_accounts.rb (1 hunks)
  • db/schema.rb (8 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • db/migrate/20251113094147_create_sofia_accounts.rb
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Build
🔇 Additional comments (2)
db/schema.rb (2)

13-13: LGTM: Schema version updated.

The schema version bump reflects the new migrations for Sofia local accounts.


183-184: No index needed on activation_token.

The activation_token column is not used for database lookups. In the activation and password reset flows, users are retrieved by id (from URL parameters), and the token is validated in-memory by comparing the user's stored token against the one from the request. Since no query ever searches by activation_token, an index would not improve performance and is unnecessary for this implementation.

Comment thread db/schema.rb
Comment thread db/schema.rb Outdated
Comment thread db/schema.rb Outdated
coderabbitai[bot]

This comment was marked as outdated.

coderabbitai[bot]

This comment was marked as outdated.

@lodewiges

Copy link
Copy Markdown
Contributor

still need to review manually and introduce coderabbit nitpick comments (28)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
db/migrate/20251113094147_create_sofia_accounts.rb (1)

6-6: Missing unique index on user_id for 1:1 relationship.

The model has validates :user, uniqueness: true, but there's no database-level unique index on user_id. Application-level validations can have race conditions. Add a unique index for data integrity:

      t.index :username, unique: true
+     t.index :user_id, unique: true
    end
🧹 Nitpick comments (9)
app/views/partials/_flash.html.erb (1)

6-15: Good fix for the XSS vulnerability.

This properly addresses the previous concern by escaping regular flash values and sanitizing _html-suffixed keys before rendering. The opt-in pattern is a safe approach.

Minor note: sanitize() already returns an ActiveSupport::SafeBuffer, so the .html_safe call on line 12 is redundant.

-                <% if html_key %>
-                  <%= trusted_html.html_safe %>
+                <% if html_key %>
+                  <%= trusted_html %>
spec/controllers/users_controller/update_with_sofia_account_spec.rb (1)

8-21: Test setup may not be testing the intended behavior.

The request let (lines 8-11) captures sofia_account.attributes with username: 'Old username'. Then in the before block (line 17), you modify user.sofia_account.username = 'New username', but this doesn't affect the already-defined request params.

The test expects 'New username' (line 27), but the request sends 'Old username' in the params. Either the test is passing for the wrong reason, or there's a different mechanism at play.

Consider restructuring to explicitly send the new username:

 let(:request) do
   patch :update_with_sofia_account,
-        params: { id: user.id, user: user.attributes.merge({ sofia_account_attributes: sofia_account.attributes }) }
+        params: { id: user.id, user: { sofia_account_attributes: { username: 'New username' } } }
 end

 before do
   user
   sofia_account
   sign_in action_user
-  user.sofia_account.username = 'New username'
-  user.name = 'New name'
   request
   user.reload
 end
app/views/users/index.html.erb (1)

60-67: Minor ERB formatting inconsistency.

Lines 60 and 67 use <%end %> instead of the conventional <% end %> with a space.

-    <%end %>
+    <% end %>
app/controllers/callbacks_controller.rb (1)

34-39: HTML in JSON error message - verify frontend escaping.

The error message contains raw HTML (<br/>, <i> tags). Ensure the frontend properly escapes or safely renders this content to prevent XSS if the message is ever user-influenced in the future.

Also, the multi-line string may introduce unwanted whitespace. Consider using a single line or heredoc:

-    render(json: { state: 'password_prompt', error_message: 'Inloggen mislukt door een error. Herlaad de pagina en probeer het nog
-                                                             een keer. <br/><i>Werkt het na een paar keer proberen nog steeds niet?
-                                                             Neem dan contact op met de ICT-commissie.</i>' })
+    render(json: { state: 'password_prompt', error_message: 'Inloggen mislukt door een error. Herlaad de pagina en probeer het nog een keer. <br/><i>Werkt het na een paar keer proberen nog steeds niet? Neem dan contact op met de ICT-commissie.</i>' })
app/views/users/show.html.erb (1)

126-136: Commented-out code should be removed or documented.

The =begin/=end block disables payment instruction text and iDEAL functionality. If this is intentional, consider removing the code entirely or adding a comment explaining why it's disabled. Commented-out code in views creates maintenance burden.

-                  <%
-=begin%>
- <%= 'onder vermelding van je naam en \'Inleg Zatladder\'.' %>
-
-                  <% if Rails.application.config.x.mollie_api_key.present? %>
-                    <%= link_to add_payments_path do %>
-                      <%= 'Klik hier om je saldo over te maken via iDEAL ' %>
-                    <% end %>
-                  <% end %> 
-<%
-=end%>

Do you want me to open an issue to track this cleanup or investigate why this section was disabled?

app/views/users/_edit_sofia_account_modal.html.erb (1)

90-114: Add rel="noopener noreferrer" to external links for security.

Links with target='_blank' should include rel="noopener noreferrer" to prevent the opened page from accessing window.opener, which could be exploited for phishing or tabnabbing attacks.

                     <a
                       href='https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2'
                       target='_blank'
+                      rel='noopener noreferrer'
                     >
                       Google Authenticator
                     </a>

Apply the same pattern to the Apple and Microsoft authenticator links on lines 100 and 109.

app/controllers/users_controller.rb (1)

58-60: Consider setting user association on new SofiaAccount.

When no SofiaAccount exists, a new empty one is created without the user association. If the policy or view attempts to access @sofia_account.user, it may return nil. Consider:

     else
-      @sofia_account = SofiaAccount.new
+      @sofia_account = @user.build_sofia_account
     end

This ensures the association is set even for unsaved records, which may help policy checks and view logic that depend on the relationship.

spec/controllers/callbacks_controller/sofia_account_spec.rb (1)

76-93: Consider clarifying OTP setup order in tests.

The otp_enabled flag is set in the before block after sofia_account is created with password but the verification_code in request_params references sofia_account.otp_code from the base let block. This works because otp_code is generated from otp_secret, but the test setup order could be confusing to future readers.

For clarity, consider moving otp_enabled: true into the factory or using a separate let for OTP-enabled accounts:

context 'with sofia_account with otp' do
  let(:sofia_account) do
    create(:sofia_account, password: 'password1234', password_confirmation: 'password1234', otp_enabled: true)
  end
  # ...
end
app/models/user.rb (1)

99-100: Consider optimizing the guard with an attribute check.

The User.in_amber.exists?(id) guard triggers a separate database query. Since you already have access to the provider attribute on the current instance, you can optimize this by checking the attribute directly.

Apply this diff:

 def update_role(groups)
-  return unless User.in_amber.exists?(id)
+  return unless provider == 'amber_oauth2'
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ab3f1ca and 2842e98.

📒 Files selected for processing (23)
  • app/controllers/callbacks_controller.rb (1 hunks)
  • app/controllers/sofia_accounts_controller.rb (1 hunks)
  • app/controllers/users_controller.rb (3 hunks)
  • app/mailers/user_mailer.rb (1 hunks)
  • app/models/user.rb (5 hunks)
  • app/policies/user_policy.rb (1 hunks)
  • app/views/partials/_flash.html.erb (1 hunks)
  • app/views/sofia_accounts/login.html.erb (1 hunks)
  • app/views/user_mailer/new_activation_link_email.html.erb (1 hunks)
  • app/views/users/_edit_sofia_account_modal.html.erb (1 hunks)
  • app/views/users/index.html.erb (2 hunks)
  • app/views/users/show.html.erb (3 hunks)
  • config/initializers/devise.rb (2 hunks)
  • db/migrate/20251113094147_create_sofia_accounts.rb (1 hunks)
  • spec/controllers/callbacks_controller/sofia_account_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/create_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/disable_otp_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/enable_otp_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/forgot_password_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/new_activation_link_spec.rb (1 hunks)
  • spec/controllers/sofia_accounts_controller/update_password_spec.rb (1 hunks)
  • spec/controllers/users_controller/update_with_sofia_account_spec.rb (1 hunks)
  • spec/mailers/previews/user_mailer_preview.rb (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (9)
  • spec/controllers/sofia_accounts_controller/enable_otp_spec.rb
  • spec/controllers/sofia_accounts_controller/disable_otp_spec.rb
  • spec/controllers/sofia_accounts_controller/new_activation_link_spec.rb
  • app/policies/user_policy.rb
  • app/controllers/sofia_accounts_controller.rb
  • config/initializers/devise.rb
  • app/mailers/user_mailer.rb
  • app/views/sofia_accounts/login.html.erb
  • app/views/user_mailer/new_activation_link_email.html.erb
🧰 Additional context used
🧬 Code graph analysis (7)
app/controllers/callbacks_controller.rb (1)
app/models/user.rb (1)
  • from_omniauth_inspect (133-138)
app/models/user.rb (1)
app/mailers/user_mailer.rb (2)
  • account_creation_email (1-29)
  • account_creation_email (2-4)
app/controllers/users_controller.rb (2)
app/policies/user_policy.rb (1)
  • show? (14-16)
app/policies/application_policy.rb (1)
  • show? (13-15)
spec/controllers/users_controller/update_with_sofia_account_spec.rb (1)
app/controllers/sofia_accounts_controller.rb (1)
  • create (12-30)
spec/controllers/sofia_accounts_controller/create_spec.rb (1)
app/controllers/sofia_accounts_controller.rb (1)
  • create (12-30)
spec/controllers/callbacks_controller/sofia_account_spec.rb (2)
app/controllers/sofia_accounts_controller.rb (1)
  • create (12-30)
spec/support/devise_helper.rb (2)
  • signed_in_user (6-12)
  • signed_in? (2-4)
spec/mailers/previews/user_mailer_preview.rb (2)
app/javascript/user.js (1)
  • user (12-12)
app/mailers/user_mailer.rb (4)
  • account_creation_email (1-29)
  • account_creation_email (2-4)
  • forgot_password_email (10-17)
  • new_activation_link_email (6-8)
🪛 ast-grep (0.40.0)
app/models/user.rb

[warning] 20-21: Found the use of an hardcoded passphrase for RSA. The passphrase can be easily discovered, and therefore should not be stored in source-code. It is recommended to remove the passphrase from source-code, and use system environment variables or a restricted configuration file.
Context: where(deactivated: false).where('(provider IS NULL OR provider != ?) OR
(provider = ? AND id IN (?))', 'sofia_account', 'sofia_account', SofiaAccount.select('user_id'))
Note: [CWE-798]: Use of Hard-coded Credentials [OWASP A07:2021]: Identification and Authentication Failures [REFERENCES]
https://cwe.mitre.org/data/definitions/522.html

(hardcoded-secret-rsa-passphrase-ruby)

🪛 Gitleaks (8.29.1)
app/models/user.rb

[high] 36-36: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Build
🔇 Additional comments (20)
app/views/partials/_flash.html.erb (1)

2-2: LGTM on the styling adjustment.

The added mt-3 provides consistent spacing with the updated layouts.

spec/controllers/users_controller/update_with_sofia_account_spec.rb (3)

31-54: Authorization tests look comprehensive.

Good coverage of unauthorized access scenarios for regular users, main bartenders, renting managers, and treasurers. This aligns with the policy that only the account owner can update their Sofia account settings.


56-75: Good edge case coverage for users without Sofia accounts.

This test correctly verifies that attempting to update a non-existent Sofia account results in a redirect with an appropriate alert and doesn't inadvertently create one.


6-7: No issues identified. The :sofia_account trait only sets the provider attribute without creating a SofiaAccount object. Line 7 correctly creates a single SofiaAccount for the user, so the uniqueness validation constraint is satisfied.

app/views/users/index.html.erb (2)

42-53: Authorization check is now in place.

The policy(User).create? check (line 44) properly guards the "Nieuwe gebruiker" button for Streepsysteem users, addressing the previous review concern.


13-13: Data payload structure looks good.

The extended data attributes properly segment users by type (manual, sofia_account, amber, not_activated, deactivated) for the Vue components.

app/controllers/callbacks_controller.rb (1)

42-66: OTP and failure handling look correct.

The OTP flow properly handles the three states: prompting when code is missing, validating when present, and returning appropriate errors. The failure handler correctly differentiates between credential errors and unexpected failures.

app/views/users/show.html.erb (1)

72-88: Good fix: Settings button is now additive, not mutually exclusive.

The Sofia account "Instellingen" button (lines 72-77) and treasurer controls (lines 79-88) now use separate if blocks, allowing both to appear when applicable. This addresses the previous review concern.

spec/controllers/sofia_accounts_controller/forgot_password_spec.rb (2)

5-6: Same potential duplicate SofiaAccount issue as in other specs.

Similar to update_with_sofia_account_spec.rb, this creates a user with :sofia_account trait and then a separate sofia_account. If the trait creates an account, this will violate the uniqueness constraint.

-    let(:user) { create(:user, :sofia_account) }
+    let(:user) { create(:user) }
     let(:sofia_account) { create(:sofia_account, user:) }

17-86: Good security-conscious test coverage.

The tests correctly verify that:

  • Non-existent usernames receive the same generic success message as existing ones (line 83), preventing username enumeration attacks
  • Missing username gets an error (appropriate since it's a required field)
  • Users without email get a generic message without sending (graceful handling)
spec/controllers/sofia_accounts_controller/update_password_spec.rb (2)

5-18: Password handling correctly uses literal values.

Good fix from the previous review: the test now uses the literal 'password1234' string (lines 6 and 13) instead of relying on the virtual password attribute which doesn't persist.


23-189: Comprehensive test coverage for password update scenarios.

The tests cover:

  • Successful owner update with proper authentication
  • Authorization (403) for non-owners including other users, bartenders, and treasurers
  • Validation errors: missing/wrong old password, missing/invalid/mismatched new password

This provides good confidence in the update_password action's behavior.

app/views/users/_edit_sofia_account_modal.html.erb (1)

74-74: Previous issue resolved: disable_otp_sofia_account_path now includes the required id parameter.

The routing error issue flagged in a previous review has been addressed. The path helper now correctly receives @sofia_account.id.

spec/mailers/previews/user_mailer_preview.rb (1)

1-18: LGTM!

The mailer preview correctly builds mock User and SofiaAccount objects for previewing activation and password-reset emails without requiring database records. The approach using User.new with build_sofia_account is appropriate for preview purposes.

app/controllers/users_controller.rb (1)

144-164: Previous issue resolved: Nil guard for SofiaAccount is now in place.

The action now properly checks for a nil SofiaAccount and redirects with an appropriate flash message before attempting authorization. This addresses the previously flagged vulnerability where calling authorize @sofia_account with a nil record would cause errors.

spec/controllers/callbacks_controller/sofia_account_spec.rb (1)

6-21: Good test coverage for identity authentication flow.

The test suite comprehensively covers the key login scenarios: non-existent accounts, valid/invalid passwords, and OTP-enabled accounts with correct/incorrect/missing codes. The use of helper methods like signed_in? and signed_in_user makes the assertions readable.

spec/controllers/sofia_accounts_controller/create_spec.rb (2)

302-318: Previous issue resolved: Non-existent user ID now uses reliable approach.

The test now correctly uses User.maximum(:id).to_i + 1 to generate a guaranteed non-existent user ID, addressing the fragility concern from the previous review.


1-22: Well-structured test suite with comprehensive coverage.

The test file provides thorough coverage of the SofiaAccount creation flow, including:

  • Valid creation paths (with/without email)
  • Token validation (missing, expired, wrong tokens)
  • User state validation (deactivated, already activated, non-existent)
  • Input validation (missing username, password, password confirmation)

The setup with let blocks is clear and the test isolation is good.

app/models/user.rb (2)

40-43: Previous issue resolved: callback now correctly detects deactivation transitions.

The use of saved_change_to_deactivated?(from: false, to: true) correctly identifies when a user transitions to deactivated status in the after_save callback. This resolves the earlier concern about the unreachable new_record? condition.


133-138: Previous issue resolved: safe lookup with graceful nil handling.

The use of SofiaAccount.find_by(id: auth.uid) with explicit nil checking now provides graceful error handling when the account doesn't exist, rather than raising ActiveRecord::RecordNotFound. This resolves the earlier concern and aligns with the caller's expectations.

Comment thread app/controllers/callbacks_controller.rb
Comment thread app/models/user.rb Outdated
Comment thread db/migrate/20251113094147_create_sofia_accounts.rb

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
app/models/user.rb (1)

20-23: Consider refactoring the active scope for clarity.

The SQL condition is complex and difficult to read. Consider breaking it into smaller scopes or using Ruby logic to combine simpler scopes for better maintainability.

For example:

scope :active, lambda {
  where(deactivated: false).where.not(
    provider: 'sofia_account',
    id: where(provider: 'sofia_account').where.not(id: SofiaAccount.select('user_id'))
  )
}

Or define helper scopes:

scope :activated_sofia_accounts, -> { sofia_account.where(id: SofiaAccount.select('user_id')) }
scope :active, -> { where(deactivated: false).where.not(id: not_activated.select(:id)) }
app/controllers/callbacks_controller.rb (2)

34-39: Avoid embedding HTML in controller error messages.

The multi-line HTML error message is difficult to maintain and poses XSS risks if variables are ever interpolated. Consider moving this to I18n locales or a view helper.

Apply this diff:

     else
       # sofia_account does not exist, should not be possible
-      render(json: { state: 'password_prompt', error_message: 'Inloggen mislukt door een error. Herlaad de pagina en probeer het nog
-                                                               een keer. <br/><i>Werkt het na een paar keer proberen nog steeds niet?
-                                                               Neem dan contact op met de ICT-commissie.</i>' })
+      render(json: { state: 'password_prompt', error_message: I18n.t('errors.sofia_account.missing_account_html') })
     end

Then add to config/locales/nl.yml:

nl:
  errors:
    sofia_account:
      missing_account_html: "Inloggen mislukt door een error. Herlaad de pagina en probeer het nog een keer. <br/><i>Werkt het na een paar keer proberen nog steeds niet? Neem dan contact op met de ICT-commissie.</i>"

56-66: Consider using string interpolation instead of mutation.

The string mutation with << on line 59 works but is less clear than building the string immutably.

Apply this diff:

   def failure
-    error_message = 'Inloggen mislukt.'
-    if request.env['omniauth.error.strategy'].instance_of? OmniAuth::Strategies::Identity
-      error_message << if request.env['omniauth.error.type'].to_s == 'invalid_credentials'
-                         ' De ingevulde gegevens zijn incorrect.'
-                       else
-                         ' Er is een onverwachte fout opgetreden.'
-                       end
-    end
+    error_message = if request.env['omniauth.error.strategy'].instance_of?(OmniAuth::Strategies::Identity)
+                      if request.env['omniauth.error.type'].to_s == 'invalid_credentials'
+                        'Inloggen mislukt. De ingevulde gegevens zijn incorrect.'
+                      else
+                        'Inloggen mislukt. Er is een onverwachte fout opgetreden.'
+                      end
+                    else
+                      'Inloggen mislukt.'
+                    end
     render(json: { state: 'password_prompt', error_message: })
   end
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2842e98 and 01733f5.

📒 Files selected for processing (11)
  • app/controllers/callbacks_controller.rb (1 hunks)
  • app/controllers/sofia_accounts_controller.rb (1 hunks)
  • app/javascript/users.js (2 hunks)
  • app/mailers/user_mailer.rb (1 hunks)
  • app/models/user.rb (4 hunks)
  • app/views/partials/_flash.html.erb (1 hunks)
  • app/views/users/index.html.erb (2 hunks)
  • app/views/users/show.html.erb (3 hunks)
  • spec/controllers/sofia_accounts_controller/disable_otp_spec.rb (1 hunks)
  • spec/controllers/users_controller/update_with_sofia_account_spec.rb (1 hunks)
  • spec/models/user_spec.rb (7 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
  • app/views/partials/_flash.html.erb
  • app/javascript/users.js
  • spec/controllers/users_controller/update_with_sofia_account_spec.rb
  • app/mailers/user_mailer.rb
🧰 Additional context used
🧬 Code graph analysis (3)
app/controllers/callbacks_controller.rb (1)
app/models/user.rb (1)
  • from_omniauth_inspect (133-138)
app/models/user.rb (1)
app/mailers/user_mailer.rb (2)
  • account_creation_email (1-30)
  • account_creation_email (2-4)
spec/models/user_spec.rb (2)
app/controllers/sofia_accounts_controller.rb (1)
  • create (12-30)
app/models/user.rb (1)
  • archive! (108-115)
🪛 ast-grep (0.40.0)
app/models/user.rb

[warning] 20-21: Found the use of an hardcoded passphrase for RSA. The passphrase can be easily discovered, and therefore should not be stored in source-code. It is recommended to remove the passphrase from source-code, and use system environment variables or a restricted configuration file.
Context: where(deactivated: false).where('(provider IS NULL OR provider != ?) OR
(provider = ? AND id IN (?))', 'sofia_account', 'sofia_account', SofiaAccount.select('user_id'))
Note: [CWE-798]: Use of Hard-coded Credentials [OWASP A07:2021]: Identification and Authentication Failures [REFERENCES]
https://cwe.mitre.org/data/definitions/522.html

(hardcoded-secret-rsa-passphrase-ruby)

🪛 Gitleaks (8.29.1)
app/models/user.rb

[high] 36-36: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Build

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
spec/controllers/users_controller/update_with_sofia_account_spec.rb (2)

23-53: Clarify request naming and consider asserting redirect target in the 302 case.

The role-based authorization expectations here look good, but two small readability points:

  • Using let(:request) shadows Rails’ request helper and makes the examples read a bit oddly (request.status). Renaming to something like let(:perform_request) and asserting on response.status after calling it in a before would be clearer.
  • For the “as user themselves” case, you currently only assert 302. Adding a redirect_to expectation would document where a successful update is supposed to send the user.

These are purely cosmetic and can be deferred, but they’ll make the spec easier to read and maintain.


56-75: Good coverage of “no Sofia account yet” behavior; could tighten the “no create” assertion.

This context nicely checks both the redirect + alert and that user.sofia_account stays nil. If you want a slightly stronger guarantee that nothing is created at all, you could also wrap the PATCH in an expectation like:

expect do
  patch :update_with_sofia_account,
        params: { id: user.id, user: { sofia_account_attributes: { username: 'New username' } } }
end.not_to change(SofiaAccount, :count)

Not required, but it hardens the spec against accidental creation of detached SofiaAccount records.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 01733f5 and 0fed95e.

📒 Files selected for processing (2)
  • app/mailers/user_mailer.rb (1 hunks)
  • spec/controllers/users_controller/update_with_sofia_account_spec.rb (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/mailers/user_mailer.rb
🧰 Additional context used
🧬 Code graph analysis (1)
spec/controllers/users_controller/update_with_sofia_account_spec.rb (2)
app/controllers/sofia_accounts_controller.rb (1)
  • create (12-30)
app/controllers/users_controller.rb (1)
  • create (74-85)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Build

Comment thread spec/controllers/users_controller/update_with_sofia_account_spec.rb
@lodewiges
lodewiges enabled auto-merge December 3, 2025 12:48
Comment thread db/seeds.rb
Role.create(role_type: :treasurer, group_uid: 4)
Role.create(role_type: :renting_manager, group_uid: 5)
Role.create(role_type: :main_bartender, group_uid: 6)
Role.create(role_type: :treasurer)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would I be a good idea to change the seeding so we assign the roles to certain users. This would also make it easier to switch between user during testing

@lodewiges
lodewiges added this pull request to the merge queue Dec 3, 2025
Merged via the queue into staging with commit 514b044 Dec 3, 2025
6 checks passed
@lodewiges
lodewiges deleted the feature/login branch December 3, 2025 12:57
@lodewiges lodewiges removed the critical Dit moet gefixt vóór de volgende borrel label Feb 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Self-contained login option

2 participants