diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-31 15:55:43 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-31 15:55:43 +0200 |
| commit | 464dd4266bdc433805010b5dca428f4cb75c2a81 (patch) | |
| tree | 47fdf4f065960d49da015eafdf56cb817dcf9ea4 | |
| parent | 5f17f421b176d48ef556fb379f59bbb7d284b48e (diff) | |
Group user accounts by role
Replaces the two-way admin/user split with four groups ordered by
capability: administration, Redaktion, editors, alumni. alumni takes
precedence over capability in role_group, so a retired admin appears at the
bottom rather than the top.
Forms now offer the three roles as checkboxes rather than a single admin
checkbox, with a trailing hidden blank so an empty set can be posted, and
user_params permits roles only for admins. Three create buttons prefill the
common combinations.
| -rw-r--r-- | app/controllers/users_controller.rb | 27 | ||||
| -rw-r--r-- | app/helpers/users_helper.rb | 9 | ||||
| -rw-r--r-- | app/models/user.rb | 51 | ||||
| -rw-r--r-- | app/views/users/_user.html.erb | 13 | ||||
| -rw-r--r-- | app/views/users/edit.html.erb | 14 | ||||
| -rw-r--r-- | app/views/users/index.html.erb | 55 | ||||
| -rw-r--r-- | app/views/users/new.html.erb | 14 | ||||
| -rw-r--r-- | app/views/users/show.html.erb | 6 | ||||
| -rw-r--r-- | config/locales/de.yml | 29 | ||||
| -rw-r--r-- | config/locales/en.yml | 29 | ||||
| -rw-r--r-- | db/migrate/20260731124136_add_roles_to_users.rb | 13 | ||||
| -rw-r--r-- | lib/authenticated_system.rb | 7 | ||||
| -rw-r--r-- | lib/tasks/development_init.rake | 2 | ||||
| -rw-r--r-- | public/stylesheets/admin.css | 19 | ||||
| -rw-r--r-- | test/controllers/users_controller_test.rb | 27 | ||||
| -rw-r--r-- | test/fixtures/users.yml | 3 |
16 files changed, 243 insertions, 75 deletions
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index cb71db23..95dff220 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb | |||
| @@ -9,14 +9,20 @@ class UsersController < ApplicationController | |||
| 9 | 9 | ||
| 10 | layout 'admin' | 10 | layout 'admin' |
| 11 | 11 | ||
| 12 | ROLE_PRESETS = { | ||
| 13 | "editor" => [], | ||
| 14 | "redaktion" => ["redaktion"], | ||
| 15 | "admin" => ["admin", "redaktion"] | ||
| 16 | }.freeze | ||
| 17 | |||
| 18 | GROUP_ORDER = [:admin, :redaktion, :editor, :alumni].freeze | ||
| 19 | |||
| 12 | def index | 20 | def index |
| 13 | @users = User.order("login ASC").all.group_by do |user| | 21 | @users = User.order("login ASC").all.group_by(&:role_group) |
| 14 | user.admin? ? :admin : :user | ||
| 15 | end | ||
| 16 | end | 22 | end |
| 17 | 23 | ||
| 18 | def new | 24 | def new |
| 19 | @user = User.new(admin: params[:admin].present?) | 25 | @user = User.new(:roles => ROLE_PRESETS.fetch(params[:preset], [])) |
| 20 | end | 26 | end |
| 21 | 27 | ||
| 22 | def create | 28 | def create |
| @@ -35,8 +41,7 @@ class UsersController < ApplicationController | |||
| 35 | 41 | ||
| 36 | def update | 42 | def update |
| 37 | permitted = user_params | 43 | permitted = user_params |
| 38 | permitted.delete(:admin) unless current_user.is_admin? | 44 | |
| 39 | |||
| 40 | if @user.update(permitted) | 45 | if @user.update(permitted) |
| 41 | flash[:notice] = t("flash.users.updated", :login => @user.login) | 46 | flash[:notice] = t("flash.users.updated", :login => @user.login) |
| 42 | redirect_to user_path(@user) | 47 | redirect_to user_path(@user) |
| @@ -63,9 +68,13 @@ class UsersController < ApplicationController | |||
| 63 | private | 68 | private |
| 64 | 69 | ||
| 65 | def user_params | 70 | def user_params |
| 66 | allowed = [:login, :email, :password, :password_confirmation] | 71 | permitted = params.fetch(:user, {}) |
| 67 | allowed << :admin if current_user.admin? | 72 | .permit(:login, :email, :password, :password_confirmation, |
| 68 | params.fetch(:user, {}).permit(allowed) | 73 | :roles => []) |
| 74 | # Checkbox arrays post a leading blank from the hidden field. | ||
| 75 | permitted[:roles] = Array(permitted[:roles]).reject(&:blank?) if permitted.key?(:roles) | ||
| 76 | permitted.delete(:roles) unless current_user.is_admin? | ||
| 77 | permitted | ||
| 69 | end | 78 | end |
| 70 | 79 | ||
| 71 | def find_user | 80 | def find_user |
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb deleted file mode 100644 index ff031387..00000000 --- a/app/helpers/users_helper.rb +++ /dev/null | |||
| @@ -1,9 +0,0 @@ | |||
| 1 | module UsersHelper | ||
| 2 | def user_list_by_admin_status | ||
| 3 | if current_user && current_user.admin | ||
| 4 | render :partial => 'admin_user_item', :collection => @users | ||
| 5 | else | ||
| 6 | render :partial => 'user_item', :collection => @users | ||
| 7 | end | ||
| 8 | end | ||
| 9 | end | ||
diff --git a/app/models/user.rb b/app/models/user.rb index e1eff059..2e9da86c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb | |||
| @@ -9,6 +9,8 @@ class User < ApplicationRecord | |||
| 9 | include Authentication | 9 | include Authentication |
| 10 | include Authentication::ByPassword | 10 | include Authentication::ByPassword |
| 11 | 11 | ||
| 12 | ROLES = %w[redaktion admin alumni].freeze | ||
| 13 | |||
| 12 | # Validations | 14 | # Validations |
| 13 | validates_presence_of :login | 15 | validates_presence_of :login |
| 14 | validates_length_of :login, :within => 1..40 | 16 | validates_length_of :login, :within => 1..40 |
| @@ -22,6 +24,8 @@ class User < ApplicationRecord | |||
| 22 | validates_format_of :email, :with => Authentication.email_regex, | 24 | validates_format_of :email, :with => Authentication.email_regex, |
| 23 | :message => Authentication.bad_email_message | 25 | :message => Authentication.bad_email_message |
| 24 | 26 | ||
| 27 | validate :roles_are_known | ||
| 28 | |||
| 25 | # Authenticates a user by their login name and unencrypted password. Returns the user or nil. | 29 | # Authenticates a user by their login name and unencrypted password. Returns the user or nil. |
| 26 | def self.authenticate(login, password) | 30 | def self.authenticate(login, password) |
| 27 | return if login.blank? || password.blank? | 31 | return if login.blank? || password.blank? |
| @@ -60,7 +64,45 @@ class User < ApplicationRecord | |||
| 60 | end | 64 | end |
| 61 | 65 | ||
| 62 | def is_admin? | 66 | def is_admin? |
| 63 | !!admin | 67 | roles.include?("admin") |
| 68 | end | ||
| 69 | |||
| 70 | # Compatibility shims for the users form, which posts user[admin] as a | ||
| 71 | # checkbox. Goes away when that form learns about roles. | ||
| 72 | def admin | ||
| 73 | is_admin? | ||
| 74 | end | ||
| 75 | |||
| 76 | def admin? | ||
| 77 | is_admin? | ||
| 78 | end | ||
| 79 | |||
| 80 | def admin=(value) | ||
| 81 | if ActiveModel::Type::Boolean.new.cast(value) | ||
| 82 | self.roles = (roles | ["admin"]) | ||
| 83 | else | ||
| 84 | self.roles = (roles - ["admin"]) | ||
| 85 | end | ||
| 86 | end | ||
| 87 | |||
| 88 | def redaktion? | ||
| 89 | roles.include?("redaktion") | ||
| 90 | end | ||
| 91 | |||
| 92 | def alumni? | ||
| 93 | roles.include?("alumni") | ||
| 94 | end | ||
| 95 | |||
| 96 | def role_group | ||
| 97 | return :alumni if alumni? | ||
| 98 | return :admin if is_admin? | ||
| 99 | return :redaktion if redaktion? | ||
| 100 | :editor | ||
| 101 | end | ||
| 102 | |||
| 103 | # Human-readable role names, for the list and the forms. | ||
| 104 | def role_labels | ||
| 105 | roles.map { |r| I18n.t("users.roles.#{r}", :default => r) } | ||
| 64 | end | 106 | end |
| 65 | 107 | ||
| 66 | # otp_secret present == enrolled. otp_pending_secret holds the secret | 108 | # otp_secret present == enrolled. otp_pending_secret holds the secret |
| @@ -133,4 +175,11 @@ class User < ApplicationRecord | |||
| 133 | end | 175 | end |
| 134 | true | 176 | true |
| 135 | end | 177 | end |
| 178 | |||
| 179 | private | ||
| 180 | |||
| 181 | def roles_are_known | ||
| 182 | unknown = roles.to_a - ROLES | ||
| 183 | errors.add(:roles, :unknown, :list => unknown.join(", ")) if unknown.any? | ||
| 184 | end | ||
| 136 | end | 185 | end |
diff --git a/app/views/users/_user.html.erb b/app/views/users/_user.html.erb index 9c6466a2..04884be8 100644 --- a/app/views/users/_user.html.erb +++ b/app/views/users/_user.html.erb | |||
| @@ -1,11 +1,16 @@ | |||
| 1 | <% users.each do |user| %> | 1 | <% users.each do |user| %> |
| 2 | <tr> | 2 | <tr> |
| 3 | <td class="user_login"><%= user.login %></td> | 3 | <td class="user_login"><%= user.login %></td> |
| 4 | <td><%= link_to "show", user_path(user) %></td> | 4 | <td class="user_roles"> |
| 5 | <% if current_user.admin? || current_user == user %> | 5 | <% if user.roles.any? %> |
| 6 | <td> | 6 | <%= user.role_labels.join(", ") %> |
| 7 | <%= link_to "edit", edit_user_path(user) %> | 7 | <% else %> |
| 8 | <span class="field_hint"><%= t(".no_roles") %></span> | ||
| 9 | <% end %> | ||
| 8 | </td> | 10 | </td> |
| 11 | <td><%= link_to t("admin.common.show"), user_path(user) %></td> | ||
| 12 | <% if current_user.admin? || current_user == user %> | ||
| 13 | <td><%= link_to t("admin.common.edit"), edit_user_path(user) %></td> | ||
| 9 | <td> | 14 | <td> |
| 10 | <%= button_to user_path(user), method: :delete, | 15 | <%= button_to user_path(user), method: :delete, |
| 11 | form: { data: { confirm: t(".confirm_destroy", :login => user.login) }, class: 'button_to destructive' } do %> | 16 | form: { data: { confirm: t(".confirm_destroy", :login => user.login) }, class: 'button_to destructive' } do %> |
diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index 9df93815..ad2e0891 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb | |||
| @@ -21,8 +21,18 @@ | |||
| 21 | <div class="layout_row_content"><%= f.password_field :password_confirmation, :autocomplete => "new-password" %></div> | 21 | <div class="layout_row_content"><%= f.password_field :password_confirmation, :autocomplete => "new-password" %></div> |
| 22 | 22 | ||
| 23 | <% if current_user.admin? %> | 23 | <% if current_user.admin? %> |
| 24 | <div class="layout_row_label"><%= t("users.labels.admin") %></div> | 24 | <div class="layout_row_label"><%= t("users.labels.roles") %></div> |
| 25 | <div class="layout_row_content"><%= f.check_box :admin %></div> | 25 | <div class="layout_row_content"> |
| 26 | <% User::ROLES.each do |role| %> | ||
| 27 | <label class="role_choice"> | ||
| 28 | <%= check_box_tag "user[roles][]", role, @user.roles.include?(role), | ||
| 29 | :id => "user_roles_#{role}" %> | ||
| 30 | <%= t("users.roles.#{role}") %> | ||
| 31 | <span class="field_hint"><%= t("users.role_hints.#{role}") %></span> | ||
| 32 | </label> | ||
| 33 | <% end %> | ||
| 34 | <%= hidden_field_tag "user[roles][]", "" %> | ||
| 35 | </div> | ||
| 26 | <% end %> | 36 | <% end %> |
| 27 | 37 | ||
| 28 | <div class="layout_row_label"></div> | 38 | <div class="layout_row_label"></div> |
diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 0a003cb1..854811a2 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb | |||
| @@ -1,27 +1,32 @@ | |||
| 1 | <h1><%= t(".admins") %></h1> | 1 | <h1><%= t(".title") %></h1> |
| 2 | <%= link_to new_user_path(admin: true), class: 'action_button' do %> | 2 | |
| 3 | <%= icon("plus", library: "tabler", "aria-hidden": true) %> <%= t(".create_admin") %> | 3 | <p class="button_row"> |
| 4 | <% end %> | 4 | <% UsersController::ROLE_PRESETS.each_key do |preset| %> |
| 5 | <table class="user_table"> | 5 | <%= link_to new_user_path(:preset => preset), class: 'action_button' do %> |
| 6 | <tr class="header"> | 6 | <%= icon("plus", library: "tabler", "aria-hidden": true) %> |
| 7 | <th><%= t("users.labels.login") %></th> | 7 | <%= t(".create_#{preset}") %> |
| 8 | <th></th> | 8 | <% end %> |
| 9 | <th></th> | 9 | <% end %> |
| 10 | <th></th> | 10 | </p> |
| 11 | </tr> | ||
| 12 | <%= render :partial => "user", :locals => {:users => @users[:admin] ||= []} %> | ||
| 13 | </table> | ||
| 14 | 11 | ||
| 15 | <h1><%= t(".users") %></h1> | 12 | <% UsersController::GROUP_ORDER.each do |group| %> |
| 16 | <%= link_to new_user_path, class: 'action_button' do %> | 13 | <% members = @users[group] || [] %> |
| 17 | <%= icon("plus", library: "tabler", "aria-hidden": true) %> <%= t(".create_user") %> | 14 | <h2 class="user_group_heading <%= "user_group_alumni" if group == :alumni %>"> |
| 15 | <%= t(".group_#{group}") %> | ||
| 16 | <span class="dashboard_widget_meta"><%= members.size %></span> | ||
| 17 | </h2> | ||
| 18 | <% if members.any? %> | ||
| 19 | <table class="user_table <%= "user_table_alumni" if group == :alumni %>"> | ||
| 20 | <tr class="header"> | ||
| 21 | <th><%= t("users.labels.login") %></th> | ||
| 22 | <th><%= t("users.labels.roles") %></th> | ||
| 23 | <th></th> | ||
| 24 | <th></th> | ||
| 25 | <th></th> | ||
| 26 | </tr> | ||
| 27 | <%= render :partial => "user", :locals => { :users => members } %> | ||
| 28 | </table> | ||
| 29 | <% else %> | ||
| 30 | <p class="field_hint"><%= t(".group_empty") %></p> | ||
| 31 | <% end %> | ||
| 18 | <% end %> | 32 | <% end %> |
| 19 | <table class="user_table"> | ||
| 20 | <tr class="header"> | ||
| 21 | <th><%= t("users.labels.login") %></th> | ||
| 22 | <th></th> | ||
| 23 | <th></th> | ||
| 24 | <th></th> | ||
| 25 | </tr> | ||
| 26 | <%= render :partial => "user", :locals => {:users => @users[:user] ||= []} %> | ||
| 27 | </table> | ||
diff --git a/app/views/users/new.html.erb b/app/views/users/new.html.erb index 776e6e96..8d99fd92 100644 --- a/app/views/users/new.html.erb +++ b/app/views/users/new.html.erb | |||
| @@ -21,8 +21,18 @@ | |||
| 21 | <div class="layout_row_label"><%= t("users.labels.confirm") %></div> | 21 | <div class="layout_row_label"><%= t("users.labels.confirm") %></div> |
| 22 | <div class="layout_row_content"><%= f.password_field :password_confirmation %></div> | 22 | <div class="layout_row_content"><%= f.password_field :password_confirmation %></div> |
| 23 | 23 | ||
| 24 | <div class="layout_row_label"><%= t("users.labels.admin") %></div> | 24 | <div class="layout_row_label"><%= t("users.labels.roles") %></div> |
| 25 | <div class="layout_row_content"><%= f.check_box :admin %></div> | 25 | <div class="layout_row_content"> |
| 26 | <% User::ROLES.each do |role| %> | ||
| 27 | <label class="role_choice"> | ||
| 28 | <%= check_box_tag "user[roles][]", role, @user.roles.include?(role), | ||
| 29 | :id => "user_roles_#{role}" %> | ||
| 30 | <%= t("users.roles.#{role}") %> | ||
| 31 | <span class="field_hint"><%= t("users.role_hints.#{role}") %></span> | ||
| 32 | </label> | ||
| 33 | <% end %> | ||
| 34 | <%= hidden_field_tag "user[roles][]", "" %> | ||
| 35 | </div> | ||
| 26 | 36 | ||
| 27 | <div class="layout_row_label"></div> | 37 | <div class="layout_row_label"></div> |
| 28 | <div class="layout_row_content"><%= f.submit t("admin.common.create") %></div> | 38 | <div class="layout_row_content"><%= f.submit t("admin.common.create") %></div> |
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 44976800..a320e53e 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb | |||
| @@ -19,7 +19,9 @@ | |||
| 19 | <div class="layout_row_label"><%= t("users.labels.email") %></div> | 19 | <div class="layout_row_label"><%= t("users.labels.email") %></div> |
| 20 | <div class="layout_row_content"><%= @user.email %></div> | 20 | <div class="layout_row_content"><%= @user.email %></div> |
| 21 | 21 | ||
| 22 | <div class="layout_row_label"><%= t("users.labels.admin") %></div> | 22 | <div class="layout_row_label"><%= t("users.labels.roles") %></div> |
| 23 | <div class="layout_row_content"><%= @user.admin ? t("admin.common.yes") : t("admin.common.no") %></div> | 23 | <div class="layout_row_content"> |
| 24 | <%= @user.roles.any? ? @user.role_labels.join(", ") : t("users.no_roles") %> | ||
| 25 | </div> | ||
| 24 | </div> | 26 | </div> |
| 25 | </div> | 27 | </div> |
diff --git a/config/locales/de.yml b/config/locales/de.yml index cf93e05b..e15a08d2 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml | |||
| @@ -145,6 +145,10 @@ de: | |||
| 145 | restore_target_invalid: "Das Wiederherstellungsziel muss ein lebender Node sein" | 145 | restore_target_invalid: "Das Wiederherstellungsziel muss ein lebender Node sein" |
| 146 | destroy_outside_trash: "Nodes können nur aus dem Papierkorb gelöscht werden" | 146 | destroy_outside_trash: "Nodes können nur aus dem Papierkorb gelöscht werden" |
| 147 | attach_in_trash: "An einen Node im Papierkorb können keine Assets angehängt werden" | 147 | attach_in_trash: "An einen Node im Papierkorb können keine Assets angehängt werden" |
| 148 | user: | ||
| 149 | attributes: | ||
| 150 | roles: | ||
| 151 | unknown: "enthält unbekannte Rollen: %{list}" | ||
| 148 | related_asset: | 152 | related_asset: |
| 149 | attributes: | 153 | attributes: |
| 150 | headline: | 154 | headline: |
| @@ -276,11 +280,28 @@ de: | |||
| 276 | admin: "Admin?" | 280 | admin: "Admin?" |
| 277 | user: | 281 | user: |
| 278 | confirm_destroy: "Benutzer %{login} wirklich löschen?" | 282 | confirm_destroy: "Benutzer %{login} wirklich löschen?" |
| 283 | no_roles: "—" | ||
| 279 | index: | 284 | index: |
| 280 | admins: "Admins" | 285 | title: "Benutzerkonten" |
| 281 | create_admin: "Admin-Benutzer anlegen" | 286 | create_editor: "Editor-Konto anlegen" |
| 282 | users: "Benutzer" | 287 | create_redaktion: "Redaktions-Konto anlegen" |
| 283 | create_user: "Benutzer anlegen" | 288 | create_admin: "Admin-Konto anlegen" |
| 289 | group_admin: "Administration" | ||
| 290 | group_redaktion: "Redaktion" | ||
| 291 | group_editor: "Editors" | ||
| 292 | group_alumni: "Ehemalige" | ||
| 293 | group_empty: "— keine —" | ||
| 294 | labels: | ||
| 295 | roles: "Rollen" | ||
| 296 | roles: | ||
| 297 | admin: "Administration" | ||
| 298 | redaktion: "Redaktion" | ||
| 299 | alumni: "Ehemalig" | ||
| 300 | role_hints: | ||
| 301 | admin: "Benutzerverwaltung und Navigation, jeweils nach erneuter Bestätigung." | ||
| 302 | redaktion: "Darf in den geschützten Bereichen veröffentlichen." | ||
| 303 | alumni: "Kein Login mehr möglich; Zuschreibungen bleiben erhalten." | ||
| 304 | no_roles: "keine besonderen Rollen" | ||
| 284 | new: | 305 | new: |
| 285 | title: "Neuen Benutzer anlegen" | 306 | title: "Neuen Benutzer anlegen" |
| 286 | 307 | ||
diff --git a/config/locales/en.yml b/config/locales/en.yml index c3f515b5..f8b94a00 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml | |||
| @@ -92,6 +92,10 @@ en: | |||
| 92 | restore_target_invalid: "Restore target must be a living node" | 92 | restore_target_invalid: "Restore target must be a living node" |
| 93 | destroy_outside_trash: "Nodes are only destroyed from the Trash" | 93 | destroy_outside_trash: "Nodes are only destroyed from the Trash" |
| 94 | attach_in_trash: "Cannot attach assets to a node in the Trash" | 94 | attach_in_trash: "Cannot attach assets to a node in the Trash" |
| 95 | user: | ||
| 96 | attributes: | ||
| 97 | roles: | ||
| 98 | unknown: "contains unknown roles: %{list}" | ||
| 95 | related_asset: | 99 | related_asset: |
| 96 | attributes: | 100 | attributes: |
| 97 | headline: | 101 | headline: |
| @@ -224,11 +228,28 @@ en: | |||
| 224 | admin: "admin?" | 228 | admin: "admin?" |
| 225 | user: | 229 | user: |
| 226 | confirm_destroy: "Do you really want to destroy user %{login}?" | 230 | confirm_destroy: "Do you really want to destroy user %{login}?" |
| 231 | no_roles: "—" | ||
| 227 | index: | 232 | index: |
| 228 | admins: "Admins" | 233 | title: "User accounts" |
| 229 | create_admin: "Create admin user" | 234 | create_editor: "Create editor account" |
| 230 | users: "Users" | 235 | create_redaktion: "Create Redaktion account" |
| 231 | create_user: "Create user" | 236 | create_admin: "Create admin account" |
| 237 | group_admin: "Administration" | ||
| 238 | group_redaktion: "Redaktion" | ||
| 239 | group_editor: "Editors" | ||
| 240 | group_alumni: "Alumni" | ||
| 241 | group_empty: "— none —" | ||
| 242 | labels: | ||
| 243 | roles: "Roles" | ||
| 244 | roles: | ||
| 245 | admin: "Administration" | ||
| 246 | redaktion: "Redaktion" | ||
| 247 | alumni: "Alumnus" | ||
| 248 | role_hints: | ||
| 249 | admin: "User management and navigation, each behind a fresh confirmation." | ||
| 250 | redaktion: "May publish in the protected sections." | ||
| 251 | alumni: "Can no longer log in; attributions are preserved." | ||
| 252 | no_roles: "no special roles" | ||
| 232 | new: | 253 | new: |
| 233 | title: "Create new user" | 254 | title: "Create new user" |
| 234 | 255 | ||
diff --git a/db/migrate/20260731124136_add_roles_to_users.rb b/db/migrate/20260731124136_add_roles_to_users.rb new file mode 100644 index 00000000..ea003e60 --- /dev/null +++ b/db/migrate/20260731124136_add_roles_to_users.rb | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | class AddRolesToUsers < ActiveRecord::Migration[8.1] | ||
| 2 | def up | ||
| 3 | add_column :users, :roles, :string, :array => true, :default => [], :null => false | ||
| 4 | execute "UPDATE users SET roles = ARRAY['admin','redaktion'] WHERE admin = true" | ||
| 5 | remove_column :users, :admin | ||
| 6 | end | ||
| 7 | |||
| 8 | def down | ||
| 9 | add_column :users, :admin, :boolean | ||
| 10 | execute "UPDATE users SET admin = true WHERE 'admin' = ANY(roles)" | ||
| 11 | remove_column :users, :roles | ||
| 12 | end | ||
| 13 | end | ||
diff --git a/lib/authenticated_system.rb b/lib/authenticated_system.rb index 2ec15a77..4e70c28d 100644 --- a/lib/authenticated_system.rb +++ b/lib/authenticated_system.rb | |||
| @@ -102,7 +102,12 @@ module AuthenticatedSystem | |||
| 102 | def login_from_session | 102 | def login_from_session |
| 103 | return unless session[:user_id] | 103 | return unless session[:user_id] |
| 104 | if session[:logged_in_at].to_i > SESSION_MAX_AGE.ago.to_i | 104 | if session[:logged_in_at].to_i > SESSION_MAX_AGE.ago.to_i |
| 105 | self.current_user = User.find_by(:id => session[:user_id]) | 105 | user = User.find_by(:id => session[:user_id]) |
| 106 | if user.nil? || user.alumni? | ||
| 107 | session[:user_id] = nil | ||
| 108 | else | ||
| 109 | self.current_user = user | ||
| 110 | end | ||
| 106 | else | 111 | else |
| 107 | session[:user_id] = nil | 112 | session[:user_id] = nil |
| 108 | end | 113 | end |
diff --git a/lib/tasks/development_init.rake b/lib/tasks/development_init.rake index 94f323c3..91797d9b 100644 --- a/lib/tasks/development_init.rake +++ b/lib/tasks/development_init.rake | |||
| @@ -19,7 +19,7 @@ namespace :cccms do | |||
| 19 | :email => 'admin@cccms.de', | 19 | :email => 'admin@cccms.de', |
| 20 | :password => 'foobar', | 20 | :password => 'foobar', |
| 21 | :password_confirmation => 'foobar', | 21 | :password_confirmation => 'foobar', |
| 22 | :admin => true | 22 | :roles => ["admin", "redaktion"] |
| 23 | ) | 23 | ) |
| 24 | end | 24 | end |
| 25 | 25 | ||
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index c0496c6e..9991fad0 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css | |||
| @@ -796,6 +796,25 @@ table.revisions_table tr:hover { | |||
| 796 | margin: 0; | 796 | margin: 0; |
| 797 | } | 797 | } |
| 798 | 798 | ||
| 799 | .user_group_heading { | ||
| 800 | margin-top: 1.5rem; | ||
| 801 | } | ||
| 802 | |||
| 803 | /* Retired accounts: present for attribution, not for action. */ | ||
| 804 | .user_group_alumni, | ||
| 805 | .user_table_alumni { | ||
| 806 | color: var(--text-muted); | ||
| 807 | } | ||
| 808 | |||
| 809 | .role_choice { | ||
| 810 | display: block; | ||
| 811 | margin-bottom: 0.35rem; | ||
| 812 | } | ||
| 813 | |||
| 814 | .role_choice .field_hint { | ||
| 815 | margin-left: 0.5rem; | ||
| 816 | } | ||
| 817 | |||
| 799 | /* ============================================================ | 818 | /* ============================================================ |
| 800 | Translation compare view (page_translations#show) | 819 | Translation compare view (page_translations#show) |
| 801 | ============================================================ */ | 820 | ============================================================ */ |
diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 67f7c053..1c5d16fc 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb | |||
| @@ -9,12 +9,12 @@ class UsersControllerTest < ActionController::TestCase | |||
| 9 | assert_select "a", { :count => 0, :text => "Destroy" } | 9 | assert_select "a", { :count => 0, :text => "Destroy" } |
| 10 | end | 10 | end |
| 11 | 11 | ||
| 12 | test "get index as admin user renders admin partial" do | 12 | test "get index as admin shows every group with per-row actions" do |
| 13 | login_as :aaron | 13 | login_as :aaron |
| 14 | get :index | 14 | get :index |
| 15 | assert_response :success | 15 | assert_response :success |
| 16 | assert_select "button[type=submit]", I18n.t("admin.common.destroy") | 16 | assert_select "button[type=submit]", I18n.t("admin.common.destroy") |
| 17 | assert_select "a", "show" | 17 | assert_select "a", I18n.t("admin.common.show") |
| 18 | end | 18 | end |
| 19 | 19 | ||
| 20 | test "get new when logged in as admin" do | 20 | test "get new when logged in as admin" do |
| @@ -60,7 +60,7 @@ class UsersControllerTest < ActionController::TestCase | |||
| 60 | :email => "foo@bar.com", | 60 | :email => "foo@bar.com", |
| 61 | :password => "xxxzzz", | 61 | :password => "xxxzzz", |
| 62 | :password_confirmation => "xxxzzz", | 62 | :password_confirmation => "xxxzzz", |
| 63 | :admin => true | 63 | :roles => ["admin", "redaktion"] |
| 64 | } | 64 | } |
| 65 | } | 65 | } |
| 66 | end | 66 | end |
| @@ -174,19 +174,17 @@ class UsersControllerTest < ActionController::TestCase | |||
| 174 | test "admin user can promote regular users to admins" do | 174 | test "admin user can promote regular users to admins" do |
| 175 | login_as :aaron | 175 | login_as :aaron |
| 176 | user = users(:quentin) | 176 | user = users(:quentin) |
| 177 | put :update, params: { :id => user.id, :user => {:admin => true} } | 177 | put :update, params: { :id => user.id, :user => {:roles => ["admin", "redaktion"]} } |
| 178 | 178 | ||
| 179 | user.reload | 179 | assert_equal true, user.reload.is_admin? |
| 180 | assert_equal true, user.is_admin? | ||
| 181 | end | 180 | end |
| 182 | 181 | ||
| 183 | test "regular users cannot promote themselves to admins" do | 182 | test "regular users cannot promote themselves to admins" do |
| 184 | login_as :quentin | 183 | login_as :quentin |
| 185 | user = users(:quentin) | 184 | user = users(:quentin) |
| 186 | put :update, params: { :id => user.id, :user => {:admin => true} } | 185 | put :update, params: { :id => user.id, :user => {:roles => ["admin", "redaktion"]} } |
| 187 | 186 | ||
| 188 | user.reload | 187 | assert_equal false, user.reload.is_admin? |
| 189 | assert_equal false, user.is_admin? | ||
| 190 | end | 188 | end |
| 191 | 189 | ||
| 192 | test "reset_otp is admin-only and witnessed" do | 190 | test "reset_otp is admin-only and witnessed" do |
| @@ -202,4 +200,15 @@ class UsersControllerTest < ActionController::TestCase | |||
| 202 | assert_not user.reload.otp_enrolled? | 200 | assert_not user.reload.otp_enrolled? |
| 203 | assert_equal "otp_reset", NodeAction.last.action | 201 | assert_equal "otp_reset", NodeAction.last.action |
| 204 | end | 202 | end |
| 203 | |||
| 204 | test "index groups a retired admin under alumni, not administration" do | ||
| 205 | login_as :aaron | ||
| 206 | user = users(:quentin) | ||
| 207 | user.update_column(:roles, ["admin", "alumni"]) | ||
| 208 | |||
| 209 | get :index | ||
| 210 | |||
| 211 | assert_response :success | ||
| 212 | assert_select "h2", :text => /#{I18n.t("users.index.group_alumni")}/ | ||
| 213 | end | ||
| 205 | end | 214 | end |
diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 7276bcb4..f8d32d3c 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml | |||
| @@ -13,5 +13,4 @@ aaron: | |||
| 13 | salt: 5be6f9cdd04fd7ab3c91cd32a5334ba2339b8005 | 13 | salt: 5be6f9cdd04fd7ab3c91cd32a5334ba2339b8005 |
| 14 | crypted_password: 740a48caf7dd5ff11318d812d57c0a0928cfbc12 # 'monkey' | 14 | crypted_password: 740a48caf7dd5ff11318d812d57c0a0928cfbc12 # 'monkey' |
| 15 | created_at: 2024-01-02 00:00:00 | 15 | created_at: 2024-01-02 00:00:00 |
| 16 | admin: true | 16 | roles: ["admin", "redaktion"] |
| 17 | |||
