rsb-admin 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +15 -0
  3. data/README.md +83 -0
  4. data/Rakefile +25 -0
  5. data/app/assets/javascripts/rsb/admin/themes/modern.js +37 -0
  6. data/app/assets/stylesheets/rsb/admin/themes/default.css +1358 -0
  7. data/app/assets/stylesheets/rsb/admin/themes/modern.css +1370 -0
  8. data/app/controllers/concerns/rsb/admin/authorization.rb +21 -0
  9. data/app/controllers/rsb/admin/admin_controller.rb +138 -0
  10. data/app/controllers/rsb/admin/admin_users_controller.rb +110 -0
  11. data/app/controllers/rsb/admin/dashboard_controller.rb +76 -0
  12. data/app/controllers/rsb/admin/profile_controller.rb +146 -0
  13. data/app/controllers/rsb/admin/profile_sessions_controller.rb +45 -0
  14. data/app/controllers/rsb/admin/resources_controller.rb +386 -0
  15. data/app/controllers/rsb/admin/roles_controller.rb +99 -0
  16. data/app/controllers/rsb/admin/sessions_controller.rb +139 -0
  17. data/app/controllers/rsb/admin/settings_controller.rb +203 -0
  18. data/app/controllers/rsb/admin/two_factor_controller.rb +105 -0
  19. data/app/helpers/rsb/admin/authorization_helper.rb +49 -0
  20. data/app/helpers/rsb/admin/branding_helper.rb +38 -0
  21. data/app/helpers/rsb/admin/formatting_helper.rb +205 -0
  22. data/app/helpers/rsb/admin/i18n_helper.rb +148 -0
  23. data/app/helpers/rsb/admin/icons_helper.rb +55 -0
  24. data/app/helpers/rsb/admin/table_helper.rb +132 -0
  25. data/app/helpers/rsb/admin/theme_helper.rb +84 -0
  26. data/app/helpers/rsb/admin/url_helper.rb +109 -0
  27. data/app/mailers/rsb/admin/admin_mailer.rb +37 -0
  28. data/app/models/rsb/admin/admin_session.rb +109 -0
  29. data/app/models/rsb/admin/admin_user.rb +153 -0
  30. data/app/models/rsb/admin/application_record.rb +10 -0
  31. data/app/models/rsb/admin/role.rb +63 -0
  32. data/app/views/layouts/rsb/admin/application.html.erb +45 -0
  33. data/app/views/rsb/admin/admin_mailer/email_verification.html.erb +11 -0
  34. data/app/views/rsb/admin/admin_mailer/email_verification.text.erb +11 -0
  35. data/app/views/rsb/admin/admin_users/_form.html.erb +52 -0
  36. data/app/views/rsb/admin/admin_users/edit.html.erb +10 -0
  37. data/app/views/rsb/admin/admin_users/index.html.erb +77 -0
  38. data/app/views/rsb/admin/admin_users/new.html.erb +10 -0
  39. data/app/views/rsb/admin/admin_users/show.html.erb +85 -0
  40. data/app/views/rsb/admin/dashboard/index.html.erb +36 -0
  41. data/app/views/rsb/admin/profile/edit.html.erb +67 -0
  42. data/app/views/rsb/admin/profile/show.html.erb +155 -0
  43. data/app/views/rsb/admin/resources/_filters.html.erb +58 -0
  44. data/app/views/rsb/admin/resources/_form.html.erb +20 -0
  45. data/app/views/rsb/admin/resources/_pagination.html.erb +33 -0
  46. data/app/views/rsb/admin/resources/_table.html.erb +70 -0
  47. data/app/views/rsb/admin/resources/edit.html.erb +7 -0
  48. data/app/views/rsb/admin/resources/index.html.erb +49 -0
  49. data/app/views/rsb/admin/resources/new.html.erb +7 -0
  50. data/app/views/rsb/admin/resources/page.html.erb +9 -0
  51. data/app/views/rsb/admin/resources/show.html.erb +55 -0
  52. data/app/views/rsb/admin/roles/_form.html.erb +197 -0
  53. data/app/views/rsb/admin/roles/edit.html.erb +7 -0
  54. data/app/views/rsb/admin/roles/index.html.erb +71 -0
  55. data/app/views/rsb/admin/roles/new.html.erb +7 -0
  56. data/app/views/rsb/admin/roles/show.html.erb +99 -0
  57. data/app/views/rsb/admin/sessions/new.html.erb +31 -0
  58. data/app/views/rsb/admin/sessions/two_factor.html.erb +39 -0
  59. data/app/views/rsb/admin/settings/_field.html.erb +115 -0
  60. data/app/views/rsb/admin/settings/index.html.erb +61 -0
  61. data/app/views/rsb/admin/shared/_badge.html.erb +1 -0
  62. data/app/views/rsb/admin/shared/_breadcrumbs.html.erb +12 -0
  63. data/app/views/rsb/admin/shared/_empty_state.html.erb +4 -0
  64. data/app/views/rsb/admin/shared/_flash.html.erb +22 -0
  65. data/app/views/rsb/admin/shared/_header.html.erb +50 -0
  66. data/app/views/rsb/admin/shared/_page_tabs.html.erb +21 -0
  67. data/app/views/rsb/admin/shared/_sidebar.html.erb +99 -0
  68. data/app/views/rsb/admin/shared/disabled.html.erb +38 -0
  69. data/app/views/rsb/admin/shared/fields/_checkbox.html.erb +6 -0
  70. data/app/views/rsb/admin/shared/fields/_datetime.html.erb +10 -0
  71. data/app/views/rsb/admin/shared/fields/_email.html.erb +10 -0
  72. data/app/views/rsb/admin/shared/fields/_hidden.html.erb +1 -0
  73. data/app/views/rsb/admin/shared/fields/_json.html.erb +11 -0
  74. data/app/views/rsb/admin/shared/fields/_number.html.erb +10 -0
  75. data/app/views/rsb/admin/shared/fields/_password.html.erb +10 -0
  76. data/app/views/rsb/admin/shared/fields/_select.html.erb +12 -0
  77. data/app/views/rsb/admin/shared/fields/_text.html.erb +10 -0
  78. data/app/views/rsb/admin/shared/fields/_textarea.html.erb +10 -0
  79. data/app/views/rsb/admin/shared/forbidden.html.erb +22 -0
  80. data/app/views/rsb/admin/themes/modern/views/shared/_header.html.erb +77 -0
  81. data/app/views/rsb/admin/themes/modern/views/shared/_sidebar.html.erb +135 -0
  82. data/app/views/rsb/admin/two_factor/backup_codes.html.erb +48 -0
  83. data/app/views/rsb/admin/two_factor/new.html.erb +53 -0
  84. data/config/locales/en.yml +140 -0
  85. data/config/locales/seo.en.yml +21 -0
  86. data/config/routes.rb +59 -0
  87. data/db/migrate/20260208000003_create_rsb_admin_tables.rb +43 -0
  88. data/db/migrate/20260214000001_add_otp_fields_to_rsb_admin_admin_users.rb +9 -0
  89. data/lib/generators/rsb/admin/install/install_generator.rb +45 -0
  90. data/lib/generators/rsb/admin/install/templates/rsb_admin_seeds.rb +24 -0
  91. data/lib/generators/rsb/admin/theme/templates/theme.css.tt +66 -0
  92. data/lib/generators/rsb/admin/theme/theme_generator.rb +218 -0
  93. data/lib/generators/rsb/admin/views/views_generator.rb +262 -0
  94. data/lib/rsb/admin/breadcrumb_item.rb +26 -0
  95. data/lib/rsb/admin/category_registration.rb +177 -0
  96. data/lib/rsb/admin/column_definition.rb +89 -0
  97. data/lib/rsb/admin/configuration.rb +69 -0
  98. data/lib/rsb/admin/engine.rb +34 -0
  99. data/lib/rsb/admin/filter_definition.rb +129 -0
  100. data/lib/rsb/admin/form_field_definition.rb +96 -0
  101. data/lib/rsb/admin/icons.rb +95 -0
  102. data/lib/rsb/admin/page_registration.rb +140 -0
  103. data/lib/rsb/admin/registry.rb +109 -0
  104. data/lib/rsb/admin/resource_dsl_context.rb +139 -0
  105. data/lib/rsb/admin/resource_registration.rb +287 -0
  106. data/lib/rsb/admin/settings_schema.rb +60 -0
  107. data/lib/rsb/admin/test_kit/helpers.rb +316 -0
  108. data/lib/rsb/admin/test_kit/resource_test_case.rb +193 -0
  109. data/lib/rsb/admin/test_kit.rb +11 -0
  110. data/lib/rsb/admin/theme_definition.rb +46 -0
  111. data/lib/rsb/admin/themes/modern.rb +44 -0
  112. data/lib/rsb/admin/version.rb +9 -0
  113. data/lib/rsb/admin.rb +177 -0
  114. data/lib/tasks/rsb/admin_tasks.rake +23 -0
  115. metadata +227 -0
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ module Authorization
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def authorize_admin_action!(resource: nil, action: nil)
11
+ resource ||= controller_name
12
+ action ||= action_name
13
+
14
+ return if current_admin_user.can?(resource, action)
15
+
16
+ render template: 'rsb/admin/shared/forbidden', status: :forbidden
17
+ nil
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ class AdminController < ActionController::Base
6
+ layout -> { RSB::Admin.configuration.layout }
7
+
8
+ include Authorization
9
+
10
+ helper RSB::Admin::IconsHelper
11
+ helper RSB::Admin::ThemeHelper
12
+ helper RSB::Admin::FormattingHelper
13
+ helper RSB::Admin::I18nHelper
14
+ helper RSB::Admin::TableHelper
15
+ helper RSB::Admin::AuthorizationHelper
16
+ helper RSB::Admin::BrandingHelper
17
+ helper RSB::Admin::UrlHelper
18
+ include RSB::Admin::UrlHelper
19
+ helper RSB::Settings::LocaleHelper
20
+ include RSB::Settings::LocaleHelper
21
+ helper RSB::Settings::SeoHelper
22
+ include RSB::Settings::SeoHelper
23
+
24
+ before_action :set_seo_context
25
+ before_action :check_admin_enabled
26
+ before_action :require_admin_authentication
27
+ before_action :enforce_two_factor_enrollment
28
+ before_action :build_breadcrumbs
29
+ before_action :track_session_activity
30
+
31
+ helper_method :current_admin_user, :admin_registry, :breadcrumbs, :current_admin_session
32
+
33
+ private
34
+
35
+ def set_seo_context
36
+ @rsb_seo_context = :admin
37
+ end
38
+
39
+ def check_admin_enabled
40
+ return if RSB::Admin.enabled?
41
+
42
+ render template: 'rsb/admin/shared/disabled', layout: false, status: :service_unavailable
43
+ end
44
+
45
+ def require_admin_authentication
46
+ return if current_admin_user
47
+
48
+ redirect_to rsb_admin.login_path, alert: 'Please sign in.'
49
+ end
50
+
51
+ def enforce_two_factor_enrollment
52
+ return unless current_admin_user
53
+ return unless ActiveModel::Type::Boolean.new.cast(RSB::Settings.get('admin.require_two_factor'))
54
+ return if current_admin_user.otp_enabled?
55
+
56
+ # Allow access to TwoFactorController and logout
57
+ return if is_a?(RSB::Admin::TwoFactorController)
58
+ return if controller_name == 'sessions' && action_name == 'destroy'
59
+
60
+ redirect_to rsb_admin.new_profile_two_factor_path,
61
+ alert: 'Two-factor authentication is required. Please set up 2FA to continue.'
62
+ end
63
+
64
+ def current_admin_user
65
+ return @current_admin_user if defined?(@current_admin_user)
66
+
67
+ token = session[:rsb_admin_session_token]
68
+ @current_admin_session = token ? AdminSession.find_by(session_token: token) : nil
69
+ @current_admin_user = @current_admin_session&.admin_user
70
+ end
71
+
72
+ # Returns the current admin session record for this request.
73
+ # Must call current_admin_user first to populate.
74
+ #
75
+ # @return [AdminSession, nil]
76
+ def current_admin_session
77
+ current_admin_user unless defined?(@current_admin_session)
78
+ @current_admin_session
79
+ end
80
+
81
+ # Touches the current session's last_active_at timestamp.
82
+ # Runs on every authenticated request. Uses update_column
83
+ # to avoid callbacks/timestamps overhead.
84
+ #
85
+ # @return [void]
86
+ def track_session_activity
87
+ current_admin_session&.touch_activity!
88
+ end
89
+
90
+ def admin_registry
91
+ RSB::Admin.registry
92
+ end
93
+
94
+ def breadcrumbs
95
+ @breadcrumbs || []
96
+ end
97
+
98
+ # Builds the initial breadcrumb trail with the app name as root.
99
+ # The root item links to the dashboard path as the home destination.
100
+ # Subclasses call super and then add their own items via add_breadcrumb.
101
+ #
102
+ # If breadcrumbs were passed via request.env (from Rack dispatch),
103
+ # those are used instead of building from scratch.
104
+ #
105
+ # @return [void]
106
+ def build_breadcrumbs
107
+ if request.env['rsb.admin.breadcrumbs']
108
+ @breadcrumbs = request.env['rsb.admin.breadcrumbs'].dup
109
+ return
110
+ end
111
+
112
+ @breadcrumbs = [
113
+ RSB::Admin::BreadcrumbItem.new(
114
+ label: RSB::Settings.get('admin.app_name').to_s.presence || RSB::Admin.configuration.app_name,
115
+ path: rsb_admin.dashboard_path
116
+ )
117
+ ]
118
+ end
119
+
120
+ # Appends a breadcrumb item to the current trail.
121
+ #
122
+ # @param label [String] the text to display
123
+ # @param path [String, nil] the URL path (nil for current/last item)
124
+ # @return [void]
125
+ def add_breadcrumb(label, path = nil)
126
+ @breadcrumbs << RSB::Admin::BreadcrumbItem.new(label: label, path: path)
127
+ end
128
+
129
+ # Replaces the entire breadcrumb trail.
130
+ #
131
+ # @param items [Array<RSB::Admin::BreadcrumbItem>] the new breadcrumb items
132
+ # @return [void]
133
+ def set_breadcrumbs(items)
134
+ @breadcrumbs = items
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Handles CRUD operations for admin user accounts.
6
+ # Provides listing, viewing, creating, editing, updating,
7
+ # and deleting admin users, with self-deletion protection.
8
+ class AdminUsersController < AdminController
9
+ before_action :authorize_admin_users
10
+ before_action :set_admin_user, only: %i[show edit update destroy]
11
+
12
+ # GET /admin/admin_users
13
+ # Lists all admin users ordered by email, eagerly loading roles.
14
+ def index
15
+ @rsb_page_title = I18n.t('rsb.admin.admin_users.index.page_title', default: 'Admin Users')
16
+ @admin_users = AdminUser.includes(:role).order(:email)
17
+ end
18
+
19
+ # GET /admin/admin_users/:id
20
+ # Displays details for a single admin user.
21
+ def show; end
22
+
23
+ # GET /admin/admin_users/new
24
+ # Renders the form for creating a new admin user.
25
+ def new
26
+ @admin_user = AdminUser.new
27
+ end
28
+
29
+ # POST /admin/admin_users
30
+ # Creates a new admin user with the given params.
31
+ # Redirects to show on success, re-renders form on failure.
32
+ def create
33
+ @admin_user = AdminUser.new(admin_user_params)
34
+ if @admin_user.save
35
+ redirect_to rsb_admin.admin_user_path(@admin_user), notice: 'Admin user created.'
36
+ else
37
+ render :new, status: :unprocessable_entity
38
+ end
39
+ end
40
+
41
+ # GET /admin/admin_users/:id/edit
42
+ # Renders the edit form for an existing admin user.
43
+ def edit; end
44
+
45
+ # PATCH /admin/admin_users/:id
46
+ # Updates an existing admin user.
47
+ # Blank password fields are stripped so the existing password is not cleared.
48
+ def update
49
+ update_params = admin_user_params
50
+ if update_params[:password].blank?
51
+ update_params.delete(:password)
52
+ update_params.delete(:password_confirmation)
53
+ end
54
+
55
+ if @admin_user.update(update_params)
56
+ redirect_to rsb_admin.admin_user_path(@admin_user), notice: 'Admin user updated.'
57
+ else
58
+ render :edit, status: :unprocessable_entity
59
+ end
60
+ end
61
+
62
+ # DELETE /admin/admin_users/:id
63
+ # Deletes an admin user. Self-deletion is prevented.
64
+ def destroy
65
+ if @admin_user == current_admin_user
66
+ redirect_to rsb_admin.admin_users_path, alert: 'You cannot delete your own account.'
67
+ return
68
+ end
69
+
70
+ @admin_user.destroy!
71
+ redirect_to rsb_admin.admin_users_path, notice: 'Admin user deleted.'
72
+ end
73
+
74
+ private
75
+
76
+ # Builds breadcrumbs for admin user pages.
77
+ # Dashboard > System > Admin Users > #ID (if applicable) > New/Edit
78
+ #
79
+ # @return [void]
80
+ def build_breadcrumbs
81
+ super
82
+ add_breadcrumb(I18n.t('rsb.admin.shared.system'))
83
+ add_breadcrumb(I18n.t('rsb.admin.admin_users.title'), rsb_admin.admin_users_path)
84
+ add_breadcrumb("##{params[:id]}", rsb_admin.admin_user_path(params[:id])) if params[:id].present?
85
+ add_breadcrumb(I18n.t('rsb.admin.shared.new', resource: 'Admin User')) if action_name.in?(%w[new create])
86
+ return unless action_name.in?(%w[edit update])
87
+
88
+ add_breadcrumb(I18n.t('rsb.admin.shared.edit'))
89
+ end
90
+
91
+ # Finds the admin user by ID from the URL params.
92
+ # @return [void]
93
+ def set_admin_user
94
+ @admin_user = AdminUser.find(params[:id])
95
+ end
96
+
97
+ # Permits the allowed admin user params from the request.
98
+ # @return [ActionController::Parameters]
99
+ def admin_user_params
100
+ params.require(:admin_user).permit(:email, :password, :password_confirmation, :role_id)
101
+ end
102
+
103
+ # Authorizes the current admin user for admin_users actions.
104
+ # @return [void]
105
+ def authorize_admin_users
106
+ authorize_admin_action!(resource: 'admin_users', action: action_name)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ class DashboardController < AdminController
6
+ before_action :authorize_dashboard, only: :index
7
+
8
+ def index
9
+ @rsb_page_title = I18n.t('rsb.admin.dashboard.page_title', default: 'Dashboard')
10
+ dashboard_page = RSB::Admin.registry.dashboard_page
11
+
12
+ return unless dashboard_page
13
+
14
+ dispatch_to_dashboard(dashboard_page, :index)
15
+ end
16
+
17
+ def dashboard_action
18
+ dashboard_page = RSB::Admin.registry.dashboard_page
19
+
20
+ unless dashboard_page
21
+ head :not_found
22
+ return
23
+ end
24
+
25
+ action_key = params[:action_key]
26
+ action = dashboard_page.find_action(action_key)
27
+
28
+ unless action
29
+ head :not_found
30
+ return
31
+ end
32
+
33
+ authorize_admin_action!(resource: 'dashboard', action: action_key)
34
+ return if performed?
35
+
36
+ request.env['rsb.admin.breadcrumbs'] = @breadcrumbs
37
+ dispatch_to_dashboard(dashboard_page, action_key.to_sym)
38
+ end
39
+
40
+ private
41
+
42
+ # Builds breadcrumbs for the dashboard page.
43
+ # Admin > Dashboard
44
+ #
45
+ # @return [void]
46
+ def build_breadcrumbs
47
+ super
48
+ add_breadcrumb(I18n.t('rsb.admin.dashboard.title'))
49
+ end
50
+
51
+ def authorize_dashboard
52
+ authorize_admin_action!(resource: 'dashboard', action: 'index')
53
+ end
54
+
55
+ # Dispatch to a custom dashboard controller via Rack interface.
56
+ #
57
+ # Uses the same Rack dispatch pattern as ResourcesController for pages.
58
+ # Passes breadcrumbs via request.env and copies the response (status,
59
+ # headers, body) back to the current controller.
60
+ #
61
+ # @param dashboard_page [PageRegistration] the dashboard page registration
62
+ # @param action [Symbol] the action to invoke on the custom controller
63
+ # @return [void]
64
+ def dispatch_to_dashboard(dashboard_page, action)
65
+ request.env['rsb.admin.breadcrumbs'] = @breadcrumbs
66
+ controller_name = dashboard_page.controller
67
+ controller_class_name = "#{controller_name}_controller".classify
68
+ controller_class = controller_class_name.constantize
69
+ status, headers, body = controller_class.action(action).call(request.env)
70
+ self.status = status
71
+ self.response_body = body
72
+ headers.each { |k, v| response.headers[k] = v }
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Handles the admin user profile page where authenticated admins
6
+ # can view and edit their own email and password.
7
+ #
8
+ # Unlike other admin controllers, ProfileController does NOT perform
9
+ # RBAC authorization — any authenticated admin can access their profile.
10
+ # This is intentional: even admins with no role can change their
11
+ # credentials and sign out.
12
+ class ProfileController < AdminController
13
+ skip_before_action :require_admin_authentication, only: [:verify_email]
14
+ skip_before_action :build_breadcrumbs, only: [:verify_email]
15
+ skip_before_action :track_session_activity, only: [:verify_email]
16
+ # GET /admin/profile
17
+ # Displays the current admin user's profile information.
18
+ #
19
+ # @return [void]
20
+ def show
21
+ @rsb_page_title = I18n.t('rsb.admin.profile.show.page_title', default: 'Profile')
22
+ @admin_user = current_admin_user
23
+ end
24
+
25
+ # GET /admin/profile/edit
26
+ # Renders the profile edit form for email and password changes.
27
+ #
28
+ # @return [void]
29
+ def edit
30
+ @rsb_page_title = I18n.t('rsb.admin.profile.edit.page_title', default: 'Edit Profile')
31
+ @admin_user = current_admin_user
32
+ end
33
+
34
+ # PATCH /admin/profile
35
+ # Updates the current admin user's email and/or password.
36
+ # Requires current password confirmation for all changes.
37
+ #
38
+ # Email changes: stores new email as pending_email and sends verification.
39
+ # Password changes: updates immediately and destroys other sessions.
40
+ # If email unchanged: only password is updated (if provided).
41
+ #
42
+ # @return [void]
43
+ def update
44
+ @admin_user = current_admin_user
45
+
46
+ unless @admin_user.authenticate(params[:current_password].to_s)
47
+ flash.now[:alert] = I18n.t('rsb.admin.profile.password_incorrect')
48
+ render :edit, status: :unprocessable_entity
49
+ return
50
+ end
51
+
52
+ new_email = params[:admin_user][:email]&.strip&.downcase
53
+ email_changed = new_email.present? && new_email != @admin_user.email
54
+
55
+ # Handle password update
56
+ password_params = {}
57
+ if params[:admin_user][:password].present?
58
+ password_params[:password] = params[:admin_user][:password]
59
+ password_params[:password_confirmation] = params[:admin_user][:password_confirmation]
60
+ end
61
+
62
+ if password_params.any?
63
+ unless @admin_user.update(password_params)
64
+ render :edit, status: :unprocessable_entity
65
+ return
66
+ end
67
+ # Revoke all other sessions on password change (rule #15)
68
+ @admin_user.admin_sessions.where.not(session_token: session[:rsb_admin_session_token]).destroy_all
69
+ end
70
+
71
+ # Handle email change — initiate verification
72
+ if email_changed
73
+ begin
74
+ @admin_user.generate_email_verification!(new_email)
75
+ AdminMailer.email_verification(@admin_user).deliver_later
76
+ redirect_to rsb_admin.profile_path, notice: I18n.t('rsb.admin.profile.verification_sent')
77
+ rescue ActiveRecord::RecordInvalid
78
+ render :edit, status: :unprocessable_entity
79
+ end
80
+ else
81
+ redirect_to rsb_admin.profile_path, notice: I18n.t('rsb.admin.profile.updated')
82
+ end
83
+ end
84
+
85
+ # GET /admin/profile/verify_email?token=...
86
+ # Confirms a pending email change if the token is valid and not expired.
87
+ # Does not require authentication — the token itself is the proof.
88
+ #
89
+ # @return [void]
90
+ def verify_email
91
+ admin = AdminUser.find_by(email_verification_token: params[:token])
92
+
93
+ if admin.nil?
94
+ redirect_to rsb_admin.login_path, alert: I18n.t('rsb.admin.profile.verification_invalid')
95
+ return
96
+ end
97
+
98
+ if admin.email_verification_expired?
99
+ redirect_to rsb_admin.profile_path, alert: I18n.t('rsb.admin.profile.verification_expired')
100
+ return
101
+ end
102
+
103
+ admin.verify_email!
104
+ redirect_to rsb_admin.profile_path, notice: I18n.t('rsb.admin.profile.email_verified')
105
+ end
106
+
107
+ # POST /admin/profile/resend_verification
108
+ # Regenerates verification token and resends the verification email.
109
+ #
110
+ # @return [void]
111
+ def resend_verification
112
+ @admin_user = current_admin_user
113
+
114
+ if @admin_user.email_verification_pending?
115
+ @admin_user.generate_email_verification!(@admin_user.pending_email)
116
+ AdminMailer.email_verification(@admin_user).deliver_later
117
+ redirect_to rsb_admin.profile_path, notice: I18n.t('rsb.admin.profile.verification_resent')
118
+ else
119
+ redirect_to rsb_admin.profile_path
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ # Builds breadcrumbs for profile pages.
126
+ # Admin > Profile (show) or Admin > Profile > Edit (edit/update)
127
+ #
128
+ # @return [void]
129
+ def build_breadcrumbs
130
+ super
131
+ add_breadcrumb(I18n.t('rsb.admin.profile.title'), rsb_admin.profile_path)
132
+ return unless action_name.in?(%w[edit update])
133
+
134
+ add_breadcrumb(I18n.t('rsb.admin.shared.edit'))
135
+ end
136
+
137
+ # Permits the allowed profile params from the request.
138
+ # Note: role_id is NOT permitted — admins cannot change their own role.
139
+ #
140
+ # @return [ActionController::Parameters]
141
+ def profile_params
142
+ params.require(:admin_user).permit(:email, :password, :password_confirmation)
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Handles session revocation from the admin profile page.
6
+ #
7
+ # Like ProfileController, this does NOT perform RBAC authorization —
8
+ # any authenticated admin can manage their own sessions.
9
+ class ProfileSessionsController < AdminController
10
+ # DELETE /admin/profile/sessions/:id
11
+ # Revokes a single session belonging to the current admin user.
12
+ # Cannot revoke the current session (safety check).
13
+ #
14
+ # @return [void]
15
+ def destroy
16
+ admin_session = current_admin_user.admin_sessions.find_by(id: params[:id])
17
+
18
+ if admin_session.nil?
19
+ redirect_to rsb_admin.profile_path, alert: I18n.t('rsb.admin.profile.session_not_found')
20
+ return
21
+ end
22
+
23
+ if admin_session.current?(session[:rsb_admin_session_token])
24
+ redirect_to rsb_admin.profile_path, alert: I18n.t('rsb.admin.profile.cannot_revoke_current')
25
+ return
26
+ end
27
+
28
+ admin_session.destroy
29
+ redirect_to rsb_admin.profile_path, notice: I18n.t('rsb.admin.profile.session_revoked')
30
+ end
31
+
32
+ # DELETE /admin/profile/sessions
33
+ # Revokes all sessions except the current one.
34
+ #
35
+ # @return [void]
36
+ def destroy_all
37
+ current_token = session[:rsb_admin_session_token]
38
+ count = current_admin_user.admin_sessions.where.not(session_token: current_token).count
39
+ current_admin_user.admin_sessions.where.not(session_token: current_token).destroy_all
40
+
41
+ redirect_to rsb_admin.profile_path, notice: I18n.t('rsb.admin.profile.all_sessions_revoked', count: count)
42
+ end
43
+ end
44
+ end
45
+ end