From 56ec07c5d84a88196486d50e636b6e887530a5a6 Mon Sep 17 00:00:00 2001 From: Teal <git@teal.is> Date: Tue, 4 Mar 2025 18:24:41 +0100 Subject: [PATCH] Add Admin UI --- .../admin/conferences_controller.rb | 77 ++++++++ app/controllers/admin/roles_controller.rb | 38 ++++ app/helpers/admin/roles_helper.rb | 2 + app/views/admin/conferences/edit.html.erb | 182 ++++++++++++++++++ app/views/admin/conferences/index.html.erb | 42 ++++ app/views/admin/conferences/new.html.erb | 113 +++++++++++ app/views/admin/roles/edit.html.erb | 50 +++++ app/views/admin/roles/index.html.erb | 40 ++++ app/views/admin/roles/update.html.erb | 4 + config/routes.rb | 4 + lib/tasks/admin.rake | 89 +++++++++ lib/tasks/events_admin.rake | 89 +++++++++ .../admin/roles_controller_test.rb | 18 ++ 13 files changed, 748 insertions(+) create mode 100644 app/controllers/admin/conferences_controller.rb create mode 100644 app/controllers/admin/roles_controller.rb create mode 100644 app/helpers/admin/roles_helper.rb create mode 100644 app/views/admin/conferences/edit.html.erb create mode 100644 app/views/admin/conferences/index.html.erb create mode 100644 app/views/admin/conferences/new.html.erb create mode 100644 app/views/admin/roles/edit.html.erb create mode 100644 app/views/admin/roles/index.html.erb create mode 100644 app/views/admin/roles/update.html.erb create mode 100644 lib/tasks/admin.rake create mode 100644 lib/tasks/events_admin.rake create mode 100644 test/controllers/admin/roles_controller_test.rb diff --git a/app/controllers/admin/conferences_controller.rb b/app/controllers/admin/conferences_controller.rb new file mode 100644 index 0000000..56adf5b --- /dev/null +++ b/app/controllers/admin/conferences_controller.rb @@ -0,0 +1,77 @@ +module Admin + class ConferencesController < ApplicationController + before_action :authenticate_user! + before_action :authorize_permission + before_action :set_conference, only: [ :edit, :update, :destroy ] + + def index + @conferences = Conference.all + end + + def new + @conference = Conference.new + end + + def create + @conference = Conference.new(conference_params) + + if @conference.save + redirect_to admin_conferences_path, notice: "Conference was successfully created." + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @conference.update(conference_params) + redirect_to admin_conferences_path, notice: "Conference was successfully updated." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @conference.destroy + redirect_to admin_conferences_path, notice: "Conference was successfully deleted." + end + + private + + def authorize_permission + super("manage_conferences") + end + + def set_conference + @conference = Conference.find_by(slug: params[:slug]) + end + + def conference_params + all_params = params.require(:conference).permit(:name, :slug, :starts_at, :ends_at, :url, :time_zone, :import_job_class).to_h + + data_hash = @conference&.data&.dup || {} + + if params[:data].present? + params[:data].each do |key, value| + data_hash[key] = value.presence + end + end + + if params[:custom_field_keys].present? && params[:custom_field_values].present? + keys = params[:custom_field_keys] + values = params[:custom_field_values] + + keys.each_with_index do |key, index| + next if key.blank? + data_hash[key] = values[index].presence + end + end + + all_params[:data] = data_hash + + all_params + end + end +end diff --git a/app/controllers/admin/roles_controller.rb b/app/controllers/admin/roles_controller.rb new file mode 100644 index 0000000..358b384 --- /dev/null +++ b/app/controllers/admin/roles_controller.rb @@ -0,0 +1,38 @@ +module Admin + class RolesController < ApplicationController + before_action :authenticate_user! + before_action :authorize_role + before_action :set_role, only: [ :edit, :update ] + + def index + @roles = Role.all.includes(:permissions) + end + + def edit + @permissions = Permission.all + end + + def update + if @role.update(role_params) + redirect_to admin_roles_path, notice: "Role was successfully updated." + else + @permissions = Permission.all + render :edit, status: :unprocessable_entity + end + end + + private + + def authorize_role + super("events_admin") + end + + def set_role + @role = Role.find(params[:id]) + end + + def role_params + params.require(:role).permit(:name, :description, permission_ids: []) + end + end +end diff --git a/app/helpers/admin/roles_helper.rb b/app/helpers/admin/roles_helper.rb new file mode 100644 index 0000000..d3b0282 --- /dev/null +++ b/app/helpers/admin/roles_helper.rb @@ -0,0 +1,2 @@ +module Admin::RolesHelper +end diff --git a/app/views/admin/conferences/edit.html.erb b/app/views/admin/conferences/edit.html.erb new file mode 100644 index 0000000..3b7dd6b --- /dev/null +++ b/app/views/admin/conferences/edit.html.erb @@ -0,0 +1,182 @@ +<div class="container mx-auto px-4 py-8"> + <h1 class="text-2xl font-bold mb-6 dark:text-gray-200">Admin: Edit Conference: <%= @conference.name %></h1> + + <%= form_with(model: [:admin, @conference], url: admin_conference_path(slug: @conference.slug), method: :patch, class: "space-y-6", data: { controller: "conference-form" }) do |form| %> + <% if @conference.errors.any? %> + <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> + <h2 class="font-bold"><%= pluralize(@conference.errors.count, "error") %> prohibited this conference from being saved:</h2> + <ul class="list-disc list-inside mt-2"> + <% @conference.errors.full_messages.each do |message| %> + <li><%= message %></li> + <% end %> + </ul> + </div> + <% end %> + + <div class="space-y-4"> + <div> + <%= form.label :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> + <%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %> + </div> + + <div> + <%= form.label :slug, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> + <%= form.text_field :slug, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %> + <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Used in URLs. Should contain only lowercase letters, numbers, and hyphens.</p> + </div> + + <div> + <%= form.label :url, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> + <%= form.url_field :url, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %> + </div> + + <div> + <%= form.label :time_zone, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> + <%= form.time_zone_select :time_zone, nil, {}, { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" } %> + </div> + + <div class="grid grid-cols-1 gap-4 md:grid-cols-2"> + <div> + <%= form.label :starts_at, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> + <%= form.datetime_local_field :starts_at, value: @conference.starts_at&.strftime('%Y-%m-%dT%H:%M'), class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %> + </div> + + <div> + <%= form.label :ends_at, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> + <%= form.datetime_local_field :ends_at, value: @conference.ends_at&.strftime('%Y-%m-%dT%H:%M'), class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %> + </div> + </div> + + <div> + <%= form.label :import_job_class, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> + <%= form.select :import_job_class, + [["Select Import Job Class", ""]] + + ConferencesController.available_import_job_classes.map { |class_name, display_name| [display_name, class_name] }, + {}, + { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white", + data: { conference_form_target: "importJobClass", action: "change->conference-form#importJobClassChanged" } } %> + <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Select the import job class to see required data fields</p> + </div> + + <!-- Add hidden inputs to preserve all data values --> + <% if @conference.data.present? %> + <% @conference.data.each do |key, value| %> + <input type="hidden" name="data[<%= key %>]" value="<%= value %>" id="hidden_data_<%= key %>"> + <% end %> + <% end %> + + <fieldset class="mt-6 border border-gray-300 rounded-md p-4 dark:border-gray-600"> + <legend class="px-2 text-sm font-medium text-gray-700 dark:text-gray-300">Required Data Fields</legend> + + <div class="space-y-4" data-conference-form-target="requiredFields"> + <% if @conference.import_job_class.present? %> + <% begin %> + <% klass = @conference.import_job_class.constantize %> + <% @conference.required_data_fields.each do |field| %> + <% metadata = klass.respond_to?(:field_metadata) ? (klass.field_metadata[field] || {}) : {} %> + <div class="mb-4"> + <label for="data_<%= field %>" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> + <%= metadata[:title] || field.humanize %><%= metadata[:required] ? ' <span class="text-red-500">*</span>'.html_safe : '' %> + </label> + <% if metadata[:description].present? %> + <p class="mt-1 text-xs text-gray-500 dark:text-gray-400"><%= metadata[:description] %></p> + <% end %> + <input + type="text" + id="data_<%= field %>" + name="data[<%= field %>]" + value="<%= @conference.data&.dig(field) %>" + placeholder="<%= metadata[:placeholder] %>" + <%= 'required' if metadata[:required] %> + class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"> + </div> + <% end %> + <% rescue => e %> + <div class="text-red-500">Error loading field metadata: <%= e.message %></div> + <% end %> + <% else %> + <p class="text-sm text-gray-500 dark:text-gray-400">Select an import job class to see required fields</p> + <% end %> + </div> + </fieldset> + + <fieldset class="mt-6 border border-gray-300 rounded-md p-4 dark:border-gray-600"> + <legend class="px-2 text-sm font-medium text-gray-700 dark:text-gray-300">Custom Data Fields</legend> + + <div class="space-y-4" data-conference-form-target="customFields"> + <% if @conference.data.present? %> + <% @conference.data.except(*@conference.required_data_fields).each do |key, value| %> + <div class="flex items-center space-x-2 custom-field-row"> + <div class="flex-1"> + <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Key</label> + <input type="text" name="custom_field_keys[]" value="<%= key %>" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"> + </div> + <div class="flex-1"> + <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Value</label> + <input type="text" name="custom_field_values[]" value="<%= value %>" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"> + </div> + <div class="flex items-end"> + <button type="button" data-action="click->conference-form#removeCustomField" class="mt-1 p-2 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"> + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" /> + </svg> + </button> + </div> + </div> + <% end %> + <% end %> + </div> + + <template data-conference-form-target="customTemplate"> + <div class="flex items-center space-x-2 custom-field-row"> + <div class="flex-1"> + <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Key</label> + <input type="text" name="custom_field_keys[]" value="KEY_PLACEHOLDER" placeholder="Enter key" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"> + </div> + <div class="flex-1"> + <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Value</label> + <input type="text" name="custom_field_values[]" value="VALUE_PLACEHOLDER" placeholder="Enter value" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"> + </div> + <div class="flex items-end"> + <button type="button" data-action="click->conference-form#removeCustomField" class="mt-1 p-2 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"> + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" /> + </svg> + </button> + </div> + </div> + </template> + + <div class="mt-4"> + <button type="button" data-action="click->conference-form#addCustomField" class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:bg-indigo-900 dark:text-indigo-200 dark:hover:bg-indigo-800"> + <svg xmlns="http://www.w3.org/2000/svg" class="-ml-0.5 mr-2 h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" /> + </svg> + Add Custom Field + </button> + </div> + </fieldset> + </div> + + <div class="flex justify-between pt-6"> + <%= link_to "Cancel", admin_conferences_path, class: "px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600 dark:hover:bg-gray-600" %> + + <%= form.submit "Update Conference", class: "inline-flex justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-blue-700 dark:hover:bg-blue-800" %> + </div> + <% end %> + + <div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700"> + <h2 class="text-xl font-bold mb-4 dark:text-gray-200">Danger Zone</h2> + + <div class="bg-red-50 border border-red-300 rounded-md p-4 dark:bg-red-900/20 dark:border-red-800"> + <h3 class="text-lg font-medium text-red-800 dark:text-red-300">Delete This Conference</h3> + <p class="mt-1 text-sm text-red-700 dark:text-red-400">Once you delete a conference, there is no going back. This will delete all associated data including sessions, speakers, and stages.</p> + + <div class="mt-4"> + <%= button_to "Delete Conference", admin_conference_path(slug: @conference.slug), method: :delete, + data: { confirm: "Are you sure you want to delete this conference? This action cannot be undone." }, + class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 dark:bg-red-700 dark:hover:bg-red-800" %> + </div> + </div> + </div> +</div> diff --git a/app/views/admin/conferences/index.html.erb b/app/views/admin/conferences/index.html.erb new file mode 100644 index 0000000..6b0ead0 --- /dev/null +++ b/app/views/admin/conferences/index.html.erb @@ -0,0 +1,42 @@ +<div class="container mx-auto px-4 py-8"> + <div class="flex justify-between items-center mb-6"> + <h1 class="text-2xl font-bold dark:text-gray-200">Admin: Conferences</h1> + <%= link_to "New Conference", new_admin_conference_path, class: "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800" %> + </div> + + <div class="bg-white dark:bg-gray-800 shadow overflow-hidden rounded-lg"> + <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> + <thead class="bg-gray-50 dark:bg-gray-700"> + <tr> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Name</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Slug</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Dates</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Import Job</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Actions</th> + </tr> + </thead> + <tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700"> + <% @conferences.each do |conference| %> + <tr> + <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-200"> + <%= link_to conference.name, conference_path(slug: conference.slug), class: "text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" %> + </td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"><%= conference.slug %></td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"> + <%= conference.starts_at&.strftime('%Y-%m-%d') %> to <%= conference.ends_at&.strftime('%Y-%m-%d') %> + </td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"><%= conference.import_job_class %></td> + <td class="px-6 py-4 whitespace-nowrap text-sm font-medium"> + <div class="flex space-x-2"> + <%= link_to "Edit", edit_admin_conference_path(slug: conference.slug), class: "text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300" %> + <%= button_to "Delete", admin_conference_path(slug: conference.slug), method: :delete, + data: { confirm: "Are you sure you want to delete this conference? This action cannot be undone." }, + class: "text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 bg-transparent border-none cursor-pointer" %> + </div> + </td> + </tr> + <% end %> + </tbody> + </table> + </div> +</div> diff --git a/app/views/admin/conferences/new.html.erb b/app/views/admin/conferences/new.html.erb new file mode 100644 index 0000000..b1796ba --- /dev/null +++ b/app/views/admin/conferences/new.html.erb @@ -0,0 +1,113 @@ +<div class="container mx-auto px-4 py-8"> + <h1 class="text-2xl font-bold mb-6 dark:text-gray-200">Admin: New Conference</h1> + + <%= form_with(model: [:admin, @conference], class: "space-y-6", data: { controller: "conference-form" }) do |form| %> + <% if @conference.errors.any? %> + <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> + <h2 class="font-bold"><%= pluralize(@conference.errors.count, "error") %> prohibited this conference from being saved:</h2> + <ul class="list-disc list-inside mt-2"> + <% @conference.errors.full_messages.each do |message| %> + <li><%= message %></li> + <% end %> + </ul> + </div> + <% end %> + + <div class="space-y-4"> + <div> + <%= form.label :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> + <%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %> + </div> + + <div> + <%= form.label :slug, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> + <%= form.text_field :slug, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %> + <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Used in URLs. Should contain only lowercase letters, numbers, and hyphens.</p> + </div> + + <div> + <%= form.label :url, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> + <%= form.url_field :url, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %> + </div> + + <div> + <%= form.label :time_zone, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> + <%= form.time_zone_select :time_zone, nil, { include_blank: "Select Time Zone" }, { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" } %> + </div> + + <div class="grid grid-cols-1 gap-4 md:grid-cols-2"> + <div> + <%= form.label :starts_at, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> + <%= form.datetime_local_field :starts_at, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %> + </div> + + <div> + <%= form.label :ends_at, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> + <%= form.datetime_local_field :ends_at, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %> + </div> + </div> + + <div> + <%= form.label :import_job_class, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> + <%= form.select :import_job_class, + [["Select Import Job Class", ""]] + + ConferencesController.available_import_job_classes.map { |class_name, display_name| [display_name, class_name] }, + {}, + { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white", + data: { conference_form_target: "importJobClass", action: "change->conference-form#importJobClassChanged" } } %> + <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Select the import job class to see required data fields</p> + </div> + + <fieldset class="mt-6 border border-gray-300 rounded-md p-4 dark:border-gray-600"> + <legend class="px-2 text-sm font-medium text-gray-700 dark:text-gray-300">Required Data Fields</legend> + + <div class="space-y-4" data-conference-form-target="requiredFields"> + <p class="text-sm text-gray-500 dark:text-gray-400">Select an import job class to see required fields</p> + </div> + </fieldset> + + <fieldset class="mt-6 border border-gray-300 rounded-md p-4 dark:border-gray-600"> + <legend class="px-2 text-sm font-medium text-gray-700 dark:text-gray-300">Custom Data Fields</legend> + + <div class="space-y-4" data-conference-form-target="customFields"> + <!-- Custom fields will be added here --> + </div> + + <template data-conference-form-target="customTemplate"> + <div class="flex items-center space-x-2 custom-field-row"> + <div class="flex-1"> + <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Key</label> + <input type="text" name="custom_field_keys[]" value="KEY_PLACEHOLDER" placeholder="Enter key" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"> + </div> + <div class="flex-1"> + <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Value</label> + <input type="text" name="custom_field_values[]" value="VALUE_PLACEHOLDER" placeholder="Enter value" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"> + </div> + <div class="flex items-end"> + <button type="button" data-action="click->conference-form#removeCustomField" class="mt-1 p-2 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"> + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" /> + </svg> + </button> + </div> + </div> + </template> + + <div class="mt-4"> + <button type="button" data-action="click->conference-form#addCustomField" class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:bg-indigo-900 dark:text-indigo-200 dark:hover:bg-indigo-800"> + <svg xmlns="http://www.w3.org/2000/svg" class="-ml-0.5 mr-2 h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" /> + </svg> + Add Custom Field + </button> + </div> + </fieldset> + </div> + + <div class="flex justify-between pt-6"> + <%= link_to "Cancel", admin_conferences_path, class: "px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600 dark:hover:bg-gray-600" %> + + <%= form.submit "Create Conference", class: "inline-flex justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-blue-700 dark:hover:bg-blue-800" %> + </div> + <% end %> +</div> diff --git a/app/views/admin/roles/edit.html.erb b/app/views/admin/roles/edit.html.erb new file mode 100644 index 0000000..b07daa4 --- /dev/null +++ b/app/views/admin/roles/edit.html.erb @@ -0,0 +1,50 @@ +<div class="container mx-auto px-4 py-8"> + <h1 class="text-2xl font-bold mb-6 dark:text-gray-200">Admin: Edit Role: <%= @role.name %></h1> + + <%= form_with(model: [:admin, @role], class: "space-y-6") do |form| %> + <% if @role.errors.any? %> + <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> + <h2 class="font-bold"><%= pluralize(@role.errors.count, "error") %> prohibited this role from being saved:</h2> + <ul class="list-disc list-inside mt-2"> + <% @role.errors.full_messages.each do |message| %> + <li><%= message %></li> + <% end %> + </ul> + </div> + <% end %> + + <div class="space-y-4"> + <div> + <%= form.label :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> + <%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %> + </div> + + <div> + <%= form.label :description, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> + <%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %> + </div> + + <fieldset class="mt-6 border border-gray-300 rounded-md p-4 dark:border-gray-600"> + <legend class="px-2 text-sm font-medium text-gray-700 dark:text-gray-300">Permissions</legend> + + <div class="space-y-2 mt-2"> + <% @permissions.each do |permission| %> + <div class="flex items-center"> + <%= check_box_tag "role[permission_ids][]", permission.id, @role.permissions.include?(permission), id: "permission_#{permission.id}", class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded dark:bg-gray-700 dark:border-gray-600" %> + <%= label_tag "permission_#{permission.id}", permission.name, class: "ml-2 block text-sm text-gray-900 dark:text-gray-300" %> + <% if permission.description.present? %> + <span class="ml-2 text-xs text-gray-500 dark:text-gray-400"><%= permission.description %></span> + <% end %> + </div> + <% end %> + </div> + </fieldset> + </div> + + <div class="flex justify-between pt-6"> + <%= link_to "Cancel", admin_roles_path, class: "px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600 dark:hover:bg-gray-600" %> + + <%= form.submit "Update Role", class: "inline-flex justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-blue-700 dark:hover:bg-blue-800" %> + </div> + <% end %> +</div> diff --git a/app/views/admin/roles/index.html.erb b/app/views/admin/roles/index.html.erb new file mode 100644 index 0000000..ccb0665 --- /dev/null +++ b/app/views/admin/roles/index.html.erb @@ -0,0 +1,40 @@ +<div class="container mx-auto px-4 py-8"> + <h1 class="text-2xl font-bold mb-6 dark:text-gray-200">Admin: Roles and Permissions</h1> + + <div class="bg-white dark:bg-gray-800 shadow overflow-hidden rounded-lg"> + <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> + <thead class="bg-gray-50 dark:bg-gray-700"> + <tr> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Role</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Description</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Permissions</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Actions</th> + </tr> + </thead> + <tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700"> + <% @roles.each do |role| %> + <tr> + <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-200"> + <%= role.name %> + </td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"> + <%= role.description %> + </td> + <td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400"> + <div class="flex flex-wrap gap-1"> + <% role.permissions.each do |permission| %> + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"> + <%= permission.name %> + </span> + <% end %> + </div> + </td> + <td class="px-6 py-4 whitespace-nowrap text-sm font-medium"> + <%= link_to "Edit", edit_admin_role_path(role), class: "text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300" %> + </td> + </tr> + <% end %> + </tbody> + </table> + </div> +</div> diff --git a/app/views/admin/roles/update.html.erb b/app/views/admin/roles/update.html.erb new file mode 100644 index 0000000..b23fc5f --- /dev/null +++ b/app/views/admin/roles/update.html.erb @@ -0,0 +1,4 @@ +<div> + <h1 class="font-bold text-4xl">Admin::Roles#update</h1> + <p>Find me in app/views/admin/roles/update.html.erb</p> +</div> diff --git a/config/routes.rb b/config/routes.rb index d01a22b..e139691 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,8 @@ Rails.application.routes.draw do + namespace :admin do + resources :roles, only: [ :index, :edit, :update ] + resources :conferences, param: :slug + end devise_for :users mount Crono::Engine, at: "/crono" mount ActionCable.server => "/cable" diff --git a/lib/tasks/admin.rake b/lib/tasks/admin.rake new file mode 100644 index 0000000..290562f --- /dev/null +++ b/lib/tasks/admin.rake @@ -0,0 +1,89 @@ +namespace :admin do + desc "Assign admin role to a user by email" + task :make_admin, [ :email ] => :environment do |t, args| + email = args[:email] + + if email.blank? + puts "Error: Email is required." + puts "Usage: rake admin:make_admin[user@example.com]" + exit 1 + end + + user = User.find_by(email: email) + + if user.nil? + puts "Error: User with email '#{email}' not found." + exit 1 + end + + # Get the shift_coordinator role + admin_role = Role.find_by(name: "shift_coordinator") + + if admin_role.nil? + puts "Error: 'shift_coordinator' role does not exist." + exit 1 + end + + if user.has_role?("shift_coordinator") + puts "User '#{user.name}' already has the admin role." + else + user.roles << admin_role + puts "Successfully assigned admin role to '#{user.name}' (#{user.email})." + end + end + + desc "List all admin users" + task list: :environment do + admin_role = Role.find_by(name: "shift_coordinator") + + if admin_role.nil? + puts "Error: 'shift_coordinator' role does not exist." + exit 1 + end + + admins = admin_role.users + + if admins.empty? + puts "No users with admin rights found." + else + puts "Users with admin rights:" + puts "-----------------------" + admins.each do |admin| + puts "#{admin.name} (#{admin.email || 'No email'})" + end + end + end + + desc "Remove admin role from a user by email" + task :remove_admin, [ :email ] => :environment do |t, args| + email = args[:email] + + if email.blank? + puts "Error: Email is required." + puts "Usage: rake admin:remove_admin[user@example.com]" + exit 1 + end + + user = User.find_by(email: email) + + if user.nil? + puts "Error: User with email '#{email}' not found." + exit 1 + end + + # Get the shift_coordinator role + admin_role = Role.find_by(name: "shift_coordinator") + + if admin_role.nil? + puts "Error: 'shift_coordinator' role does not exist." + exit 1 + end + + if !user.has_role?("shift_coordinator") + puts "User '#{user.name}' does not have the admin role." + else + user.roles.delete(admin_role) + puts "Successfully removed admin role from '#{user.name}' (#{user.email})." + end + end +end diff --git a/lib/tasks/events_admin.rake b/lib/tasks/events_admin.rake new file mode 100644 index 0000000..2a2d727 --- /dev/null +++ b/lib/tasks/events_admin.rake @@ -0,0 +1,89 @@ +namespace :admin do + desc "Assign events_admin role to a user by email" + task :make_events_admin, [ :email ] => :environment do |t, args| + email = args[:email] + + if email.blank? + puts "Error: Email is required." + puts "Usage: rake admin:make_events_admin[user@example.com]" + exit 1 + end + + user = User.find_by(email: email) + + if user.nil? + puts "Error: User with email '#{email}' not found." + exit 1 + end + + # Get the events_admin role + admin_role = Role.find_by(name: "events_admin") + + if admin_role.nil? + puts "Error: 'events_admin' role does not exist." + exit 1 + end + + if user.has_role?("events_admin") + puts "User '#{user.name}' already has the events_admin role." + else + user.roles << admin_role + puts "Successfully assigned events_admin role to '#{user.name}' (#{user.email})." + end + end + + desc "List all events admin users" + task list_events_admins: :environment do + admin_role = Role.find_by(name: "events_admin") + + if admin_role.nil? + puts "Error: 'events_admin' role does not exist." + exit 1 + end + + admins = admin_role.users + + if admins.empty? + puts "No users with events_admin rights found." + else + puts "Users with events_admin rights:" + puts "------------------------------" + admins.each do |admin| + puts "#{admin.name} (#{admin.email || 'No email'})" + end + end + end + + desc "Remove events_admin role from a user by email" + task :remove_events_admin, [ :email ] => :environment do |t, args| + email = args[:email] + + if email.blank? + puts "Error: Email is required." + puts "Usage: rake admin:remove_events_admin[user@example.com]" + exit 1 + end + + user = User.find_by(email: email) + + if user.nil? + puts "Error: User with email '#{email}' not found." + exit 1 + end + + # Get the events_admin role + admin_role = Role.find_by(name: "events_admin") + + if admin_role.nil? + puts "Error: 'events_admin' role does not exist." + exit 1 + end + + if !user.has_role?("events_admin") + puts "User '#{user.name}' does not have the events_admin role." + else + user.roles.delete(admin_role) + puts "Successfully removed events_admin role from '#{user.name}' (#{user.email})." + end + end +end diff --git a/test/controllers/admin/roles_controller_test.rb b/test/controllers/admin/roles_controller_test.rb new file mode 100644 index 0000000..43add04 --- /dev/null +++ b/test/controllers/admin/roles_controller_test.rb @@ -0,0 +1,18 @@ +require "test_helper" + +class Admin::RolesControllerTest < ActionDispatch::IntegrationTest + test "should get index" do + get admin_roles_index_url + assert_response :success + end + + test "should get edit" do + get admin_roles_edit_url + assert_response :success + end + + test "should get update" do + get admin_roles_update_url + assert_response :success + end +end -- GitLab