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