diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index fdbc6e281..d460b1ac5 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -6,7 +6,35 @@ module Admin def index authorize User - @users = policy_scope(User).order(:email) + scope = policy_scope(User) + .left_joins(family: :subscription) + .includes(family: :subscription) + + scope = scope.where(role: params[:role]) if params[:role].present? + scope = apply_trial_filter(scope) if params[:trial_status].present? + + @users = scope.order( + Arel.sql( + "CASE " \ + "WHEN subscriptions.status = 'trialing' THEN 0 " \ + "WHEN subscriptions.id IS NULL THEN 1 " \ + "ELSE 2 END, " \ + "subscriptions.trial_ends_at ASC NULLS LAST, users.email ASC" + ) + ) + + family_ids = @users.map(&:family_id).uniq + @accounts_count_by_family = Account.where(family_id: family_ids).group(:family_id).count + @entries_count_by_family = Entry.joins(:account).where(accounts: { family_id: family_ids }).group("accounts.family_id").count + + user_ids = @users.map(&:id).uniq + @last_login_by_user = Session.where(user_id: user_ids).group(:user_id).maximum(:created_at) + @sessions_count_by_user = Session.where(user_id: user_ids).group(:user_id).count + + @trials_expiring_in_7_days = Subscription + .where(status: :trialing) + .where(trial_ends_at: Time.current..7.days.from_now) + .count end def update @@ -34,5 +62,17 @@ module Admin def user_params params.require(:user).permit(:role) end + + def apply_trial_filter(scope) + case params[:trial_status] + when "expiring_soon" + scope.where(subscriptions: { status: :trialing }) + .where(subscriptions: { trial_ends_at: Time.current..7.days.from_now }) + when "trialing" + scope.where(subscriptions: { status: :trialing }) + else + scope + end + end end end diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index 002e1a06c..abc09a652 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -1,53 +1,120 @@ <%= content_for :page_title, t(".title") %> -
-

<%= t(".description") %>

+
+
+

<%= t(".description") %>

+
- <%= settings_section title: t(".section_title") do %> -
- <% @users.each do |user| %> -
-
-
- <%= user.initials %> -
-
-

<%= user.display_name %>

-

<%= user.email %>

-
-
-
- <% if user.id == Current.user.id %> - <%= t(".you") %> - - <%= t(".roles.#{user.role}", default: user.role.humanize) %> - - <% else %> - <%= form_with model: [:admin, user], method: :patch, class: "flex items-center gap-2" do |form| %> - <%= form.select :role, - options_for_select([ - [t(".roles.guest"), "guest"], - [t(".roles.member", default: "Member"), "member"], - [t(".roles.admin"), "admin"], - [t(".roles.super_admin"), "super_admin"] - ], user.role), - {}, - class: "text-sm rounded-lg border-primary bg-container text-primary px-2 py-1", - onchange: "this.form.requestSubmit()" %> - <% end %> + +
+ <%= form_with url: admin_users_path, method: :get, class: "flex gap-4 items-end flex-wrap" do |f| %> +
+ <%= f.label :role, t(".filters.role"), class: "block text-sm font-medium text-primary mb-1" %> + <%= f.select :role, + options_for_select( + [[t(".filters.role_all"), ""], [t(".roles.guest"), "guest"], [t(".roles.member", default: "Member"), "member"], [t(".roles.admin"), "admin"], [t(".roles.super_admin"), "super_admin"]], + params[:role] + ), + {}, + class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %> +
+
+ <%= f.label :trial_status, t(".filters.trial_status"), class: "block text-sm font-medium text-primary mb-1" %> + <%= f.select :trial_status, + options_for_select( + [[t(".filters.trial_all"), ""], [t(".filters.trial_expiring_soon"), "expiring_soon"], [t(".filters.trial_trialing"), "trialing"]], + params[:trial_status] + ), + {}, + class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %> +
+ <%= render DS::Button.new(variant: :primary, size: :md, type: "submit", text: t(".filters.submit"), class: "md:w-auto w-full justify-center") %> + <% end %> +
+ + +
+
+
+ <%= icon "calendar-clock", class: "w-5 h-5 text-secondary" %> +

<%= t(".summary.trials_expiring_7_days") %>

+
+

<%= @trials_expiring_in_7_days %>

+
+
+ + +
+

<%= t(".section_title") %>

+
+ <% if @users.any? %> + + + + + + + + + + + + <% @users.each do |user| %> + + + + + + + <% end %> - + +
<%= t(".table.user") %><%= t(".table.trial_ends_at") %><%= t(".table.family_accounts") %><%= t(".table.family_transactions") %><%= t(".table.role") %>
+
+
+ <%= user.initials %> +
+
+

<%= user.display_name %>

+

<%= user.email %>

+

+ <%= t(".table.last_login") %>: <%= @last_login_by_user[user.id]&.to_fs(:long) || t(".table.never") %> + <%= t(".table.session_count") %>: <%= number_with_delimiter(@sessions_count_by_user[user.id] || 0) %> +

+
+
+
+ <%= user.family.subscription&.trial_ends_at&.to_fs(:long) || t(".not_available") %> + + <%= number_with_delimiter(@accounts_count_by_family[user.family_id] || 0) %> + + <%= number_with_delimiter(@entries_count_by_family[user.family_id] || 0) %> + + <% if user.id == Current.user.id %> + <%= t(".you") %> + <% else %> + <%= form_with model: [:admin, user], method: :patch, class: "flex items-center justify-end gap-2" do |form| %> + <%= form.select :role, + options_for_select([ + [t(".roles.guest"), "guest"], + [t(".roles.member", default: "Member"), "member"], + [t(".roles.admin"), "admin"], + [t(".roles.super_admin"), "super_admin"] + ], user.role), + {}, + class: "text-sm rounded-lg border border-primary bg-container text-primary px-2 py-1", + onchange: "this.form.requestSubmit()" %> + <% end %> + <% end %> +
+ <% else %> +
+ <%= icon "users", class: "w-12 h-12 mx-auto text-secondary mb-3" %> +

<%= t(".no_users") %>

<% end %>
- - <% if @users.empty? %> -
- <%= icon "users", class: "w-12 h-12 mx-auto text-secondary mb-3" %> -

<%= t(".no_users") %>

-
- <% end %> - <% end %> +
<%= settings_section title: t(".role_descriptions_title"), collapsible: true, open: false do %>
diff --git a/config/locales/views/admin/users/ca.yml b/config/locales/views/admin/users/ca.yml index 25c66fc83..e0fa5149a 100644 --- a/config/locales/views/admin/users/ca.yml +++ b/config/locales/views/admin/users/ca.yml @@ -21,6 +21,8 @@ ca: section_title: Usuaris title: Gestió d'usuaris you: "(Tu)" + trial_ends_at: Fi de prova + not_available: n/a update: failure: No s'ha pogut actualitzar el rol d'usuari. success: El rol d'usuari s'ha actualitzat correctament. diff --git a/config/locales/views/admin/users/en.yml b/config/locales/views/admin/users/en.yml index 13af72c16..7eb7102a7 100644 --- a/config/locales/views/admin/users/en.yml +++ b/config/locales/views/admin/users/en.yml @@ -7,7 +7,28 @@ en: description: "Manage user roles for your instance. Super admins can access SSO provider settings and user management." section_title: "Users" you: "(You)" + trial_ends_at: "Trial ends" + not_available: "n/a" no_users: "No users found." + filters: + role: "Role" + role_all: "All roles" + trial_status: "Trial status" + trial_all: "All" + trial_expiring_soon: "Expiring in 7 days" + trial_trialing: "On trial" + submit: "Filter" + summary: + trials_expiring_7_days: "Trials expiring in next 7 days" + table: + user: "User" + trial_ends_at: "Trial ends" + family_accounts: "Family accounts" + family_transactions: "Family transactions" + last_login: "Last login" + session_count: "Session count" + never: "Never" + role: "Role" role_descriptions_title: "Role Descriptions" roles: guest: "Guest" diff --git a/config/locales/views/admin/users/fr.yml b/config/locales/views/admin/users/fr.yml index 94044c3de..eaee30343 100644 --- a/config/locales/views/admin/users/fr.yml +++ b/config/locales/views/admin/users/fr.yml @@ -7,6 +7,8 @@ fr: description: "Gérez les rôles des utilisateurs pour votre instance. Les super administrateurs peuvent accéder aux paramètres des fournisseurs SSO et à la gestion des utilisateurs." section_title: "Utilisateurs" you: "(Vous)" + trial_ends_at: "Fin de l'essai" + not_available: "n/a" no_users: "Aucun utilisateur trouvé." role_descriptions_title: "Description des rôles" roles: diff --git a/config/locales/views/admin/users/nl.yml b/config/locales/views/admin/users/nl.yml index e543aa364..dde18bf14 100644 --- a/config/locales/views/admin/users/nl.yml +++ b/config/locales/views/admin/users/nl.yml @@ -7,6 +7,8 @@ nl: description: "Beheer gebruikersrollen voor uw instantie. Superbeheerders hebben toegang tot SSO-providerinstellingen en gebruikersbeheer." section_title: "Gebruikers" you: "(U)" + trial_ends_at: "Proefperiode eindigt" + not_available: "n/a" no_users: "Geen gebruikers gevonden." role_descriptions_title: "Rolbeschrijvingen" roles: diff --git a/test/controllers/admin/users_controller_test.rb b/test/controllers/admin/users_controller_test.rb new file mode 100644 index 000000000..8cbca0bbc --- /dev/null +++ b/test/controllers/admin/users_controller_test.rb @@ -0,0 +1,28 @@ +require "test_helper" + +class Admin::UsersControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in users(:sure_support_staff) + end + + test "index sorts users by subscription trial end date with nils last" do + get admin_users_url + + assert_response :success + + body = response.body + trial_user_index = body.index("user1@example.com") + no_trial_user_index = body.index("bob@bobdylan.com") + + assert_not_nil trial_user_index + assert_not_nil no_trial_user_index + assert_operator trial_user_index, :<, no_trial_user_index + end + + test "index shows n/a when trial end date is unavailable" do + get admin_users_url + + assert_response :success + assert_match(/n\/a/, response.body, "Page should show n/a for users without trial end date") + end +end