diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb
new file mode 100644
index 0000000..e03fc19
--- /dev/null
+++ b/app/controllers/admin/applications_controller.rb
@@ -0,0 +1,83 @@
+module Admin
+ class ApplicationsController < BaseController
+ before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials]
+
+ def index
+ @applications = Application.order(created_at: :desc)
+ end
+
+ def show
+ @allowed_groups = @application.allowed_groups
+ end
+
+ def new
+ @application = Application.new
+ @available_groups = Group.order(:name)
+ end
+
+ def create
+ @application = Application.new(application_params)
+
+ if @application.save
+ # Handle group assignments
+ if params[:application][:group_ids].present?
+ group_ids = params[:application][:group_ids].reject(&:blank?)
+ @application.allowed_groups = Group.where(id: group_ids)
+ end
+
+ redirect_to admin_application_path(@application), notice: "Application created successfully."
+ else
+ @available_groups = Group.order(:name)
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def edit
+ @available_groups = Group.order(:name)
+ end
+
+ def update
+ if @application.update(application_params)
+ # Handle group assignments
+ if params[:application][:group_ids].present?
+ group_ids = params[:application][:group_ids].reject(&:blank?)
+ @application.allowed_groups = Group.where(id: group_ids)
+ else
+ @application.allowed_groups = []
+ end
+
+ redirect_to admin_application_path(@application), notice: "Application updated successfully."
+ else
+ @available_groups = Group.order(:name)
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ @application.destroy
+ redirect_to admin_applications_path, notice: "Application deleted successfully."
+ end
+
+ def regenerate_credentials
+ if @application.oidc?
+ @application.update!(
+ client_id: SecureRandom.urlsafe_base64(32),
+ client_secret: SecureRandom.urlsafe_base64(48)
+ )
+ redirect_to admin_application_path(@application), notice: "Credentials regenerated successfully. Make sure to update your application configuration."
+ else
+ redirect_to admin_application_path(@application), alert: "Only OIDC applications have credentials."
+ end
+ end
+
+ private
+
+ def set_application
+ @application = Application.find(params[:id])
+ end
+
+ def application_params
+ params.require(:application).permit(:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata)
+ end
+ end
+end
diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
new file mode 100644
index 0000000..ba7b475
--- /dev/null
+++ b/app/controllers/admin/base_controller.rb
@@ -0,0 +1,14 @@
+module Admin
+ class BaseController < ApplicationController
+ before_action :require_admin
+
+ private
+
+ def require_admin
+ user = Current.session&.user
+ unless user&.admin?
+ redirect_to root_path, alert: "You must be an administrator to access this page."
+ end
+ end
+ end
+end
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
new file mode 100644
index 0000000..c61192b
--- /dev/null
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -0,0 +1,12 @@
+module Admin
+ class DashboardController < BaseController
+ def index
+ @user_count = User.count
+ @active_user_count = User.active.count
+ @application_count = Application.count
+ @active_application_count = Application.active.count
+ @group_count = Group.count
+ @recent_users = User.order(created_at: :desc).limit(5)
+ end
+ end
+end
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
new file mode 100644
index 0000000..dc61a62
--- /dev/null
+++ b/app/controllers/admin/groups_controller.rb
@@ -0,0 +1,73 @@
+module Admin
+ class GroupsController < BaseController
+ before_action :set_group, only: [:show, :edit, :update, :destroy]
+
+ def index
+ @groups = Group.order(:name)
+ end
+
+ def show
+ @members = @group.users.order(:email_address)
+ @applications = @group.applications.order(:name)
+ @available_users = User.where.not(id: @members.pluck(:id)).order(:email_address)
+ end
+
+ def new
+ @group = Group.new
+ @available_users = User.order(:email_address)
+ end
+
+ def create
+ @group = Group.new(group_params)
+
+ if @group.save
+ # Handle user assignments
+ if params[:group][:user_ids].present?
+ user_ids = params[:group][:user_ids].reject(&:blank?)
+ @group.users = User.where(id: user_ids)
+ end
+
+ redirect_to admin_group_path(@group), notice: "Group created successfully."
+ else
+ @available_users = User.order(:email_address)
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def edit
+ @available_users = User.order(:email_address)
+ end
+
+ def update
+ if @group.update(group_params)
+ # Handle user assignments
+ if params[:group][:user_ids].present?
+ user_ids = params[:group][:user_ids].reject(&:blank?)
+ @group.users = User.where(id: user_ids)
+ else
+ @group.users = []
+ end
+
+ redirect_to admin_group_path(@group), notice: "Group updated successfully."
+ else
+ @available_users = User.order(:email_address)
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ @group.destroy
+ redirect_to admin_groups_path, notice: "Group deleted successfully."
+ end
+
+ private
+
+ def set_group
+ @group = Group.find(params[:id])
+ end
+
+ def group_params
+ params.require(:group).permit(:name, :description)
+ end
+ end
+end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
new file mode 100644
index 0000000..c8365a6
--- /dev/null
+++ b/app/controllers/admin/users_controller.rb
@@ -0,0 +1,70 @@
+module Admin
+ class UsersController < BaseController
+ before_action :set_user, only: [:show, :edit, :update, :destroy]
+
+ def index
+ @users = User.order(created_at: :desc)
+ end
+
+ def show
+ end
+
+ def new
+ @user = User.new
+ end
+
+ def create
+ @user = User.new(user_params)
+ @user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
+
+ if @user.save
+ redirect_to admin_users_path, notice: "User created successfully."
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ # Prevent changing params for the current user's email and admin status
+ # to avoid locking themselves out
+ update_params = user_params.dup
+
+ if @user == Current.session.user
+ update_params.delete(:admin)
+ end
+
+ # Only update password if provided
+ update_params.delete(:password) if update_params[:password].blank?
+
+ if @user.update(update_params)
+ redirect_to admin_users_path, notice: "User updated successfully."
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ # Prevent admin from deleting themselves
+ if @user == Current.session.user
+ redirect_to admin_users_path, alert: "You cannot delete your own account."
+ return
+ end
+
+ @user.destroy
+ redirect_to admin_users_path, notice: "User deleted successfully."
+ end
+
+ private
+
+ def set_user
+ @user = User.find(params[:id])
+ end
+
+ def user_params
+ params.require(:user).permit(:email_address, :password, :admin, :status)
+ end
+ end
+end
diff --git a/app/views/admin/applications/_form.html.erb b/app/views/admin/applications/_form.html.erb
new file mode 100644
index 0000000..8463c3e
--- /dev/null
+++ b/app/views/admin/applications/_form.html.erb
@@ -0,0 +1,99 @@
+<%= form_with(model: [:admin, application], class: "space-y-6") do |form| %>
+ <% if application.errors.any? %>
+
+
+
+
+ <%= pluralize(application.errors.count, "error") %> prohibited this application from being saved:
+
+
+
+ <% application.errors.full_messages.each do |message| %>
+ - <%= message %>
+ <% end %>
+
+
+
+
+
+ <% end %>
+
+
+ <%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
+ <%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "My Application" %>
+
+
+
+ <%= form.label :slug, class: "block text-sm font-medium text-gray-700" %>
+ <%= form.text_field :slug, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "my-app" %>
+
Lowercase letters, numbers, and hyphens only. Used in URLs and API calls.
+
+
+
+ <%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
+ <%= 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 sm:text-sm", placeholder: "Optional description of this application" %>
+
+
+
+ <%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %>
+ <%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["ForwardAuth (Trusted Headers)", "trusted_header"], ["SAML (Coming Soon)", "saml", { disabled: true }]], {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", disabled: application.persisted? %>
+ <% if application.persisted? %>
+
Application type cannot be changed after creation.
+ <% end %>
+
+
+
+
+
OIDC Configuration
+
+
+ <%= form.label :redirect_uris, "Redirect URIs", class: "block text-sm font-medium text-gray-700" %>
+ <%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
+
One URI per line. These are the allowed callback URLs for your application.
+
+
+
+
+ <%= form.label :group_ids, "Allowed Groups (Optional)", class: "block text-sm font-medium text-gray-700" %>
+
+ <% if @available_groups.any? %>
+ <% @available_groups.each do |group| %>
+
+ <%= check_box_tag "application[group_ids][]", group.id, application.allowed_groups.include?(group), class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
+ <%= label_tag "application_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900" %>
+ (<%= pluralize(group.users.count, "member") %>)
+
+ <% end %>
+ <% else %>
+
No groups available. Create groups first to restrict access.
+ <% end %>
+
+
If no groups are selected, all active users can access this application.
+
+
+
+ <%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
+ <%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
+
+
+
+ <%= form.submit application.persisted? ? "Update Application" : "Create Application", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
+ <%= link_to "Cancel", admin_applications_path, class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
+
+<% end %>
+
+
diff --git a/app/views/admin/applications/edit.html.erb b/app/views/admin/applications/edit.html.erb
new file mode 100644
index 0000000..2bf0e1b
--- /dev/null
+++ b/app/views/admin/applications/edit.html.erb
@@ -0,0 +1,5 @@
+
+
Edit Application
+
Editing: <%= @application.name %>
+ <%= render "form", application: @application %>
+
diff --git a/app/views/admin/applications/index.html.erb b/app/views/admin/applications/index.html.erb
new file mode 100644
index 0000000..de8db7d
--- /dev/null
+++ b/app/views/admin/applications/index.html.erb
@@ -0,0 +1,71 @@
+
+
+
Applications
+
Manage OIDC and ForwardAuth applications.
+
+
+ <%= link_to "New Application", new_admin_application_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
+
+
+
+
+
+
+
+
+
+ | Name |
+ Slug |
+ Type |
+ Status |
+ Groups |
+
+ Actions
+ |
+
+
+
+ <% @applications.each do |application| %>
+
+ |
+ <%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
+ |
+
+ <%= application.slug %>
+ |
+
+ <% case application.app_type %>
+ <% when "oidc" %>
+ OIDC
+ <% when "trusted_header" %>
+ ForwardAuth
+ <% when "saml" %>
+ SAML
+ <% end %>
+ |
+
+ <% if application.active? %>
+ Active
+ <% else %>
+ Inactive
+ <% end %>
+ |
+
+ <% if application.allowed_groups.empty? %>
+ All users
+ <% else %>
+ <%= application.allowed_groups.count %>
+ <% end %>
+ |
+
+ <%= link_to "View", admin_application_path(application), class: "text-blue-600 hover:text-blue-900 mr-4" %>
+ <%= link_to "Edit", edit_admin_application_path(application), class: "text-blue-600 hover:text-blue-900 mr-4" %>
+ <%= button_to "Delete", admin_application_path(application), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this application?" }, class: "text-red-600 hover:text-red-900" %>
+ |
+
+ <% end %>
+
+
+
+
+
diff --git a/app/views/admin/applications/new.html.erb b/app/views/admin/applications/new.html.erb
new file mode 100644
index 0000000..5b402bb
--- /dev/null
+++ b/app/views/admin/applications/new.html.erb
@@ -0,0 +1,4 @@
+
+
New Application
+ <%= render "form", application: @application %>
+
diff --git a/app/views/admin/applications/show.html.erb b/app/views/admin/applications/show.html.erb
new file mode 100644
index 0000000..fbd3334
--- /dev/null
+++ b/app/views/admin/applications/show.html.erb
@@ -0,0 +1,122 @@
+
+
+
+
<%= @application.name %>
+
<%= @application.description %>
+
+
+ <%= link_to "Edit", edit_admin_application_path(@application), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
+ <%= button_to "Delete", admin_application_path(@application), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500" %>
+
+
+
+
+
+
+
+
+
Basic Information
+
+
+
- Slug
+ <%= @application.slug %>
+
+
+
- Type
+ -
+ <% case @application.app_type %>
+ <% when "oidc" %>
+ OIDC
+ <% when "trusted_header" %>
+ ForwardAuth
+ <% when "saml" %>
+ SAML
+ <% end %>
+
+
+
+
- Status
+ -
+ <% if @application.active? %>
+ Active
+ <% else %>
+ Inactive
+ <% end %>
+
+
+
+
+
+
+
+ <% if @application.oidc? %>
+
+
+
+
OIDC Credentials
+ <%= button_to "Regenerate Credentials", regenerate_credentials_admin_application_path(@application), method: :post, data: { turbo_confirm: "This will invalidate the current credentials. Continue?" }, class: "text-sm text-red-600 hover:text-red-900" %>
+
+
+
+
- Client ID
+ -
+
<%= @application.client_id %>
+
+
+
+
- Client Secret
+ -
+
<%= @application.client_secret %>
+
+
+
+
- Redirect URIs
+ -
+ <% if @application.redirect_uris.present? %>
+ <% @application.parsed_redirect_uris.each do |uri| %>
+
<%= uri %>
+ <% end %>
+ <% else %>
+ No redirect URIs configured
+ <% end %>
+
+
+
+
+
+ <% end %>
+
+
+
+
+
Access Control
+
+
Allowed Groups
+
+ <% if @allowed_groups.empty? %>
+
+
+
+
+ No groups assigned - all active users can access this application.
+
+
+
+
+ <% else %>
+
+ <% @allowed_groups.each do |group| %>
+ -
+
+
<%= group.name %>
+
<%= pluralize(group.users.count, "member") %>
+
+
+ <% end %>
+
+ <% end %>
+
+
+
+
+
diff --git a/app/views/admin/dashboard/index.html.erb b/app/views/admin/dashboard/index.html.erb
new file mode 100644
index 0000000..d664852
--- /dev/null
+++ b/app/views/admin/dashboard/index.html.erb
@@ -0,0 +1,145 @@
+
+
Admin Dashboard
+
System overview and quick actions
+
+
+
+
+
+
+
+
+
+
+ -
+ Total Users
+
+ -
+
+ <%= @user_count %>
+
+
+ (<%= @active_user_count %> active)
+
+
+
+
+
+
+
+ <%= link_to "Manage users", admin_users_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
+
+
+
+
+
+
+
+
+
+
+ -
+ Applications
+
+ -
+
+ <%= @application_count %>
+
+
+ (<%= @active_application_count %> active)
+
+
+
+
+
+
+
+ <%= link_to "Manage applications", admin_applications_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
+
+
+
+
+
+
+
+
+
+
+ -
+ Groups
+
+ -
+ <%= @group_count %>
+
+
+
+
+
+
+ <%= link_to "Manage groups", admin_groups_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
+
+
+
+
+
+
+
Recent Users
+
+
+ <% @recent_users.each do |user| %>
+ -
+
+
+
<%= user.email_address %>
+
+ Created <%= time_ago_in_words(user.created_at) %> ago
+
+
+
+ <% if user.admin? %>
+ Admin
+ <% end %>
+ <% if user.totp_enabled? %>
+ 2FA
+ <% end %>
+ <%= user.status.titleize %>
+
+
+
+ <% end %>
+
+
+
+
+
+
+
Quick Actions
+
+ <%= link_to new_admin_user_path, class: "block p-6 bg-white rounded-lg border border-gray-200 shadow-sm hover:bg-gray-50 hover:shadow-md transition" do %>
+
Create User
+
Add a new user to the system
+ <% end %>
+
+ <%= link_to new_admin_application_path, class: "block p-6 bg-white rounded-lg border border-gray-200 shadow-sm hover:bg-gray-50 hover:shadow-md transition" do %>
+
Register Application
+
Add a new OIDC or ForwardAuth app
+ <% end %>
+
+ <%= link_to new_admin_group_path, class: "block p-6 bg-white rounded-lg border border-gray-200 shadow-sm hover:bg-gray-50 hover:shadow-md transition" do %>
+
Create Group
+
Organize users into a new group
+ <% end %>
+
+
diff --git a/app/views/admin/groups/_form.html.erb b/app/views/admin/groups/_form.html.erb
new file mode 100644
index 0000000..fff7fc0
--- /dev/null
+++ b/app/views/admin/groups/_form.html.erb
@@ -0,0 +1,56 @@
+<%= form_with(model: [:admin, group], class: "space-y-6") do |form| %>
+ <% if group.errors.any? %>
+
+
+
+
+ <%= pluralize(group.errors.count, "error") %> prohibited this group from being saved:
+
+
+
+ <% group.errors.full_messages.each do |message| %>
+ - <%= message %>
+ <% end %>
+
+
+
+
+
+ <% end %>
+
+
+ <%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
+ <%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "developers" %>
+
Group names are automatically normalized to lowercase.
+
+
+
+ <%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
+ <%= 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 sm:text-sm", placeholder: "Optional description of this group" %>
+
+
+
+ <%= form.label :user_ids, "Group Members", class: "block text-sm font-medium text-gray-700" %>
+
+ <% if @available_users.any? %>
+ <% @available_users.each do |user| %>
+
+ <%= check_box_tag "group[user_ids][]", user.id, group.users.include?(user), class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
+ <%= label_tag "group_user_ids_#{user.id}", user.email_address, class: "ml-2 text-sm text-gray-900" %>
+ <% if user.admin? %>
+ Admin
+ <% end %>
+
+ <% end %>
+ <% else %>
+
No users available.
+ <% end %>
+
+
Select which users should be members of this group.
+
+
+
+ <%= form.submit group.persisted? ? "Update Group" : "Create Group", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
+ <%= link_to "Cancel", admin_groups_path, class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
+
+<% end %>
diff --git a/app/views/admin/groups/edit.html.erb b/app/views/admin/groups/edit.html.erb
new file mode 100644
index 0000000..ba04d03
--- /dev/null
+++ b/app/views/admin/groups/edit.html.erb
@@ -0,0 +1,5 @@
+
+
Edit Group
+
Editing: <%= @group.name %>
+ <%= render "form", group: @group %>
+
diff --git a/app/views/admin/groups/index.html.erb b/app/views/admin/groups/index.html.erb
new file mode 100644
index 0000000..063886e
--- /dev/null
+++ b/app/views/admin/groups/index.html.erb
@@ -0,0 +1,52 @@
+
+
+
Groups
+
Organize users into groups for application access control.
+
+
+ <%= link_to "New Group", new_admin_group_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
+
+
+
+
+
+
+
+
+
+ | Name |
+ Description |
+ Members |
+ Applications |
+
+ Actions
+ |
+
+
+
+ <% @groups.each do |group| %>
+
+ |
+ <%= link_to group.name, admin_group_path(group), class: "text-blue-600 hover:text-blue-900" %>
+ |
+
+ <%= truncate(group.description, length: 80) || content_tag(:span, "No description", class: "text-gray-400") %>
+ |
+
+ <%= pluralize(group.users.count, "member") %>
+ |
+
+ <%= pluralize(group.applications.count, "app") %>
+ |
+
+ <%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 mr-4" %>
+ <%= link_to "Edit", edit_admin_group_path(group), class: "text-blue-600 hover:text-blue-900 mr-4" %>
+ <%= button_to "Delete", admin_group_path(group), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this group?" }, class: "text-red-600 hover:text-red-900" %>
+ |
+
+ <% end %>
+
+
+
+
+
diff --git a/app/views/admin/groups/new.html.erb b/app/views/admin/groups/new.html.erb
new file mode 100644
index 0000000..ebc8a7f
--- /dev/null
+++ b/app/views/admin/groups/new.html.erb
@@ -0,0 +1,4 @@
+
+
New Group
+ <%= render "form", group: @group %>
+
diff --git a/app/views/admin/groups/show.html.erb b/app/views/admin/groups/show.html.erb
new file mode 100644
index 0000000..4f34ad2
--- /dev/null
+++ b/app/views/admin/groups/show.html.erb
@@ -0,0 +1,88 @@
+
+
+
+
<%= @group.name %>
+ <% if @group.description.present? %>
+
<%= @group.description %>
+ <% end %>
+
+
+ <%= link_to "Edit", edit_admin_group_path(@group), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
+ <%= button_to "Delete", admin_group_path(@group), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500" %>
+
+
+
+
+
+
+
+
+
+ Members (<%= @members.count %>)
+
+ <% if @members.any? %>
+
+ <% @members.each do |user| %>
+ -
+
+
<%= user.email_address %>
+
+ <% if user.admin? %>
+ Admin
+ <% end %>
+ <% if user.totp_enabled? %>
+ 2FA
+ <% end %>
+ <%= user.status.titleize %>
+
+
+ <%= link_to "View", admin_user_path(user), class: "text-blue-600 hover:text-blue-900 text-sm" %>
+
+ <% end %>
+
+ <% else %>
+
+
No members in this group yet.
+
+ <% end %>
+
+
+
+
+
+
+
+ Assigned Applications (<%= @applications.count %>)
+
+ <% if @applications.any? %>
+
+ <% @applications.each do |app| %>
+ -
+
+
<%= app.name %>
+
+ <% case app.app_type %>
+ <% when "oidc" %>
+ OIDC
+ <% when "trusted_header" %>
+ ForwardAuth
+ <% end %>
+ <% if app.active? %>
+ Active
+ <% else %>
+ Inactive
+ <% end %>
+
+
+ <%= link_to "View", admin_application_path(app), class: "text-blue-600 hover:text-blue-900 text-sm" %>
+
+ <% end %>
+
+ <% else %>
+
+
This group is not assigned to any applications.
+
+ <% end %>
+
+
+
diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb
new file mode 100644
index 0000000..646f9b7
--- /dev/null
+++ b/app/views/admin/users/_form.html.erb
@@ -0,0 +1,53 @@
+<%= form_with(model: [:admin, user], class: "space-y-6") do |form| %>
+ <% if user.errors.any? %>
+
+
+
+
+ <%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:
+
+
+
+ <% user.errors.full_messages.each do |message| %>
+ - <%= message %>
+ <% end %>
+
+
+
+
+
+ <% end %>
+
+
+ <%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %>
+ <%= form.email_field :email_address, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %>
+
+
+
+ <%= form.label :password, class: "block text-sm font-medium text-gray-700" %>
+ <%= form.password_field :password, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: user.persisted? ? "Leave blank to keep current password" : "Enter password" %>
+ <% if user.persisted? %>
+
Leave blank to keep the current password
+ <% else %>
+
Leave blank to generate a random password
+ <% end %>
+
+
+
+ <%= form.label :status, class: "block text-sm font-medium text-gray-700" %>
+ <%= form.select :status, User.statuses.keys.map { |s| [s.titleize, s] }, {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
+
+
+
+ <%= form.check_box :admin, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500", disabled: (user == Current.session.user) %>
+ <%= form.label :admin, "Administrator", class: "ml-2 block text-sm text-gray-900" %>
+ <% if user == Current.session.user %>
+ (Cannot change your own admin status)
+ <% end %>
+
+
+
+ <%= form.submit user.persisted? ? "Update User" : "Create User", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
+ <%= link_to "Cancel", admin_users_path, class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
+
+<% end %>
diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb
new file mode 100644
index 0000000..1fe5b13
--- /dev/null
+++ b/app/views/admin/users/edit.html.erb
@@ -0,0 +1,5 @@
+
+
Edit User
+
Editing: <%= @user.email_address %>
+ <%= render "form", user: @user %>
+
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb
new file mode 100644
index 0000000..d8d257f
--- /dev/null
+++ b/app/views/admin/users/index.html.erb
@@ -0,0 +1,78 @@
+
+
+
Users
+
A list of all users in the system.
+
+
+ <%= link_to "New User", new_admin_user_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
+
+
+
+
+
+
+
+
+
+ | Email |
+ Status |
+ Role |
+ 2FA |
+ Groups |
+
+ Actions
+ |
+
+
+
+ <% @users.each do |user| %>
+
+ |
+ <%= user.email_address %>
+ |
+
+ <% if user.status.present? %>
+ <% case user.status.to_sym %>
+ <% when :active %>
+ Active
+ <% when :disabled %>
+ Disabled
+ <% when :pending_invitation %>
+ Pending
+ <% end %>
+ <% else %>
+ -
+ <% end %>
+ |
+
+ <% if user.admin? %>
+ Admin
+ <% else %>
+ User
+ <% end %>
+ |
+
+ <% if user.totp_enabled? %>
+
+ <% else %>
+
+ <% end %>
+ |
+
+ <%= user.groups.count %>
+ |
+
+ <%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900 mr-4" %>
+ <%= button_to "Delete", admin_user_path(user), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this user?" }, class: "text-red-600 hover:text-red-900" %>
+ |
+
+ <% end %>
+
+
+
+
+
diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb
new file mode 100644
index 0000000..7b19648
--- /dev/null
+++ b/app/views/admin/users/new.html.erb
@@ -0,0 +1,4 @@
+
+
New User
+ <%= render "form", user: @user %>
+
diff --git a/bin/generate_oidc_key b/bin/generate_oidc_key
new file mode 100755
index 0000000..6ef97a4
--- /dev/null
+++ b/bin/generate_oidc_key
@@ -0,0 +1,37 @@
+#!/bin/bash
+# Generate OIDC private key for Clinch
+# Usage: bin/generate_oidc_key
+
+set -e
+
+echo "Generating OIDC RSA private key..."
+echo
+
+# Generate the key
+KEY=$(openssl genrsa 2048 2>/dev/null)
+
+# Display the key
+echo "$KEY"
+echo
+echo "---"
+echo
+echo "✅ Key generated successfully!"
+echo
+echo "To use this key:"
+echo
+echo "1. Copy the entire key above (including BEGIN/END lines)"
+echo
+echo "2. Add to your .env file:"
+echo " OIDC_PRIVATE_KEY=\"-----BEGIN RSA PRIVATE KEY-----"
+echo " ...paste key here..."
+echo " -----END RSA PRIVATE KEY-----\""
+echo
+echo "3. Or save to file:"
+echo " bin/generate_oidc_key > oidc_private_key.pem"
+echo
+echo "⚠️ Important:"
+echo " - Generate this key ONCE and keep it forever"
+echo " - Backup the key securely"
+echo " - Don't commit .env to git (it's in .gitignore)"
+echo " - If you regenerate this key, all OIDC sessions become invalid"
+echo
diff --git a/config/routes.rb b/config/routes.rb
index 3c8a9f0..f1b9814 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -51,7 +51,11 @@ Rails.application.routes.draw do
namespace :admin do
root "dashboard#index"
resources :users
- resources :applications
+ resources :applications do
+ member do
+ post :regenerate_credentials
+ end
+ end
resources :groups
end
diff --git a/db/migrate/20251023053722_add_auth_fields_to_users.rb b/db/migrate/20251023053722_add_auth_fields_to_users.rb
index 52b256a..69252b1 100644
--- a/db/migrate/20251023053722_add_auth_fields_to_users.rb
+++ b/db/migrate/20251023053722_add_auth_fields_to_users.rb
@@ -4,7 +4,7 @@ class AddAuthFieldsToUsers < ActiveRecord::Migration[8.1]
add_column :users, :totp_secret, :string
add_column :users, :totp_required, :boolean, default: false, null: false
add_column :users, :backup_codes, :text
- add_column :users, :status, :string, default: "active", null: false
+ add_column :users, :status, :integer, default: 0, null: false
add_index :users, :status
end
diff --git a/db/migrate/20251023091355_change_user_status_to_integer.rb b/db/migrate/20251023091355_change_user_status_to_integer.rb
deleted file mode 100644
index c7595a1..0000000
--- a/db/migrate/20251023091355_change_user_status_to_integer.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-class ChangeUserStatusToInteger < ActiveRecord::Migration[8.1]
- def change
- change_column :users, :status, :integer
- end
-end