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" %> +
+
+ +
+
+
+ + + + + + + + + + + + + <% @applications.each do |application| %> + + + + + + + + + <% end %> + +
NameSlugTypeStatusGroups + Actions +
+ <%= 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" %> +
+
+
+
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

+
+ +
+
+ + +
+

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" %> +
+
+ +
+
+
+ + + + + + + + + + + + <% @groups.each do |group| %> + + + + + + + + <% end %> + +
NameDescriptionMembersApplications + Actions +
+ <%= 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" %> +
+
+
+
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" %> +
+
+ +
+
+
+ + + + + + + + + + + + + <% @users.each do |user| %> + + + + + + + + + <% end %> + +
EmailStatusRole2FAGroups + Actions +
+ <%= 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" %> +
+
+
+
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