Compare commits
16 Commits
d480d7dd0a
...
2025.01
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
831bd083c2 | ||
|
|
1212e0f22e | ||
|
|
a21b21ace2 | ||
|
|
ad70841689 | ||
|
|
9be6ef09ff | ||
|
|
21bdc21486 | ||
|
|
fc9afcd1b7 | ||
|
|
ee4af20000 | ||
|
|
7200a6735f | ||
|
|
e3e2a565e7 | ||
|
|
19cc425f94 | ||
|
|
96a9ce2258 | ||
|
|
ec2eb27da1 | ||
|
|
8cbf0731e0 | ||
|
|
7f075391c1 | ||
|
|
91573ee2b9 |
10
.env.example
10
.env.example
@@ -19,6 +19,16 @@ SMTP_ENABLE_STARTTLS=true
|
|||||||
CLINCH_HOST=http://localhost:9000
|
CLINCH_HOST=http://localhost:9000
|
||||||
CLINCH_FROM_EMAIL=noreply@example.com
|
CLINCH_FROM_EMAIL=noreply@example.com
|
||||||
|
|
||||||
|
# OIDC Configuration
|
||||||
|
# RSA private key for signing ID tokens (JWT)
|
||||||
|
# Generate with: openssl genrsa 2048
|
||||||
|
# Important: Generate once and keep the same key across deployments
|
||||||
|
# If you change this key, all existing OIDC sessions will be invalidated
|
||||||
|
# OIDC_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
# MIIEpAIBAAKCAQEAyZ0qaICMiLVWSFs+ef9Xok3fzy0p6k/7D5TQzmxf...
|
||||||
|
# ...your key content here...
|
||||||
|
# -----END RSA PRIVATE KEY-----"
|
||||||
|
|
||||||
# Optional: Force SSL in production
|
# Optional: Force SSL in production
|
||||||
# FORCE_SSL=true
|
# FORCE_SSL=true
|
||||||
|
|
||||||
|
|||||||
3
Gemfile
3
Gemfile
@@ -28,6 +28,9 @@ gem "rotp", "~> 6.3"
|
|||||||
# QR code generation for TOTP setup
|
# QR code generation for TOTP setup
|
||||||
gem "rqrcode", "~> 2.0"
|
gem "rqrcode", "~> 2.0"
|
||||||
|
|
||||||
|
# JWT for OIDC ID tokens
|
||||||
|
gem "jwt", "~> 2.9"
|
||||||
|
|
||||||
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ GEM
|
|||||||
actionview (>= 7.0.0)
|
actionview (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
json (2.15.1)
|
json (2.15.1)
|
||||||
|
jwt (2.10.2)
|
||||||
|
base64
|
||||||
kamal (2.8.1)
|
kamal (2.8.1)
|
||||||
activesupport (>= 7.0)
|
activesupport (>= 7.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
@@ -412,6 +414,7 @@ DEPENDENCIES
|
|||||||
image_processing (~> 1.2)
|
image_processing (~> 1.2)
|
||||||
importmap-rails
|
importmap-rails
|
||||||
jbuilder
|
jbuilder
|
||||||
|
jwt (~> 2.9)
|
||||||
kamal
|
kamal
|
||||||
propshaft
|
propshaft
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -8,7 +8,13 @@ Clinch gives you one place to manage users and lets any web app authenticate aga
|
|||||||
|
|
||||||
Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management.
|
Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management.
|
||||||
|
|
||||||
**Clinch is a lightweight alternative to Authelia and Authentik**, designed for simplicity and ease of deployment.
|
Clinch sits in a sweet spot between two excellent open-source identity solutions:
|
||||||
|
|
||||||
|
**[Authelia](https://www.authelia.com)** is a fantastic choice for those who prefer external user management through LDAP and enjoy comprehensive YAML-based configuration. It's lightweight, secure, and works beautifully with reverse proxies.
|
||||||
|
|
||||||
|
**[Authentik](https://goauthentik.io)** is an enterprise-grade powerhouse offering extensive protocol support (OAuth2, SAML, LDAP, RADIUS), advanced policy engines, and distributed "outpost" architecture for complex deployments.
|
||||||
|
|
||||||
|
**Clinch** offers a middle ground with built-in user management, a modern web interface, and focused SSO capabilities (OIDC + ForwardAuth). It's perfect for users who want self-hosted simplicity without external dependencies or enterprise complexity.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -46,6 +52,8 @@ Works with reverse proxies (Caddy, Traefik, Nginx):
|
|||||||
|
|
||||||
Apps that speak OIDC use the OIDC flow; apps that only need "who is it?" headers use ForwardAuth.
|
Apps that speak OIDC use the OIDC flow; apps that only need "who is it?" headers use ForwardAuth.
|
||||||
|
|
||||||
|
**Note:** ForwardAuth requires applications to run on the same domain as Clinch (e.g., `app.yourdomain.com` with Clinch at `auth.yourdomain.com`) for secure session cookie sharing. Take a look at Authentik if you need multi domain support.
|
||||||
|
|
||||||
### SMTP Integration
|
### SMTP Integration
|
||||||
Send emails for:
|
Send emails for:
|
||||||
- Invitation links (one-time token, 7-day expiry)
|
- Invitation links (one-time token, 7-day expiry)
|
||||||
|
|||||||
83
app/controllers/admin/applications_controller.rb
Normal file
83
app/controllers/admin/applications_controller.rb
Normal file
@@ -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
|
||||||
14
app/controllers/admin/base_controller.rb
Normal file
14
app/controllers/admin/base_controller.rb
Normal file
@@ -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
|
||||||
12
app/controllers/admin/dashboard_controller.rb
Normal file
12
app/controllers/admin/dashboard_controller.rb
Normal file
@@ -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
|
||||||
71
app/controllers/admin/forward_auth_rules_controller.rb
Normal file
71
app/controllers/admin/forward_auth_rules_controller.rb
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
module Admin
|
||||||
|
class ForwardAuthRulesController < BaseController
|
||||||
|
before_action :set_forward_auth_rule, only: [:show, :edit, :update, :destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@forward_auth_rules = ForwardAuthRule.ordered
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
@allowed_groups = @forward_auth_rule.allowed_groups
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@forward_auth_rule = ForwardAuthRule.new
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@forward_auth_rule = ForwardAuthRule.new(forward_auth_rule_params)
|
||||||
|
|
||||||
|
if @forward_auth_rule.save
|
||||||
|
# Handle group assignments
|
||||||
|
if params[:forward_auth_rule][:group_ids].present?
|
||||||
|
group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?)
|
||||||
|
@forward_auth_rule.allowed_groups = Group.where(id: group_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_to admin_forward_auth_rule_path(@forward_auth_rule), notice: "Forward auth rule 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 @forward_auth_rule.update(forward_auth_rule_params)
|
||||||
|
# Handle group assignments
|
||||||
|
if params[:forward_auth_rule][:group_ids].present?
|
||||||
|
group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?)
|
||||||
|
@forward_auth_rule.allowed_groups = Group.where(id: group_ids)
|
||||||
|
else
|
||||||
|
@forward_auth_rule.allowed_groups = []
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_to admin_forward_auth_rule_path(@forward_auth_rule), notice: "Forward auth rule updated successfully."
|
||||||
|
else
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@forward_auth_rule.destroy
|
||||||
|
redirect_to admin_forward_auth_rules_path, notice: "Forward auth rule deleted successfully."
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_forward_auth_rule
|
||||||
|
@forward_auth_rule = ForwardAuthRule.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def forward_auth_rule_params
|
||||||
|
params.require(:forward_auth_rule).permit(:domain_pattern, :active)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
73
app/controllers/admin/groups_controller.rb
Normal file
73
app/controllers/admin/groups_controller.rb
Normal file
@@ -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
|
||||||
70
app/controllers/admin/users_controller.rb
Normal file
70
app/controllers/admin/users_controller.rb
Normal file
@@ -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
|
||||||
151
app/controllers/api/forward_auth_controller.rb
Normal file
151
app/controllers/api/forward_auth_controller.rb
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
module Api
|
||||||
|
class ForwardAuthController < ApplicationController
|
||||||
|
# ForwardAuth endpoints need session storage for return URL
|
||||||
|
allow_unauthenticated_access
|
||||||
|
skip_before_action :verify_authenticity_token
|
||||||
|
|
||||||
|
# GET /api/verify
|
||||||
|
# This endpoint is called by reverse proxies (Traefik, Caddy, nginx)
|
||||||
|
# to verify if a user is authenticated and authorized to access a domain
|
||||||
|
def verify
|
||||||
|
# Note: app_slug parameter is no longer used - we match domains directly with ForwardAuthRule
|
||||||
|
|
||||||
|
# Get the session from cookie
|
||||||
|
session_id = extract_session_id
|
||||||
|
unless session_id
|
||||||
|
# No session cookie - user is not authenticated
|
||||||
|
return render_unauthorized("No session cookie")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find the session
|
||||||
|
session = Session.find_by(id: session_id)
|
||||||
|
unless session
|
||||||
|
# Invalid session
|
||||||
|
return render_unauthorized("Invalid session")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if session is expired
|
||||||
|
if session.expired?
|
||||||
|
session.destroy
|
||||||
|
return render_unauthorized("Session expired")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update last activity
|
||||||
|
session.update_column(:last_activity_at, Time.current)
|
||||||
|
|
||||||
|
# Get the user
|
||||||
|
user = session.user
|
||||||
|
unless user.active?
|
||||||
|
return render_unauthorized("User account is not active")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check for forward auth rule authorization
|
||||||
|
# Get the forwarded host for domain matching
|
||||||
|
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
||||||
|
|
||||||
|
if forwarded_host.present?
|
||||||
|
# Find matching forward auth rule for this domain
|
||||||
|
rule = ForwardAuthRule.active.find { |r| r.matches_domain?(forwarded_host) }
|
||||||
|
|
||||||
|
unless rule
|
||||||
|
Rails.logger.warn "ForwardAuth: No rule found for domain: #{forwarded_host}"
|
||||||
|
return render_forbidden("No authentication rule configured for this domain")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if user is allowed by this rule
|
||||||
|
unless rule.user_allowed?(user)
|
||||||
|
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by rule #{rule.domain_pattern}"
|
||||||
|
return render_forbidden("You do not have permission to access this domain")
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{forwarded_host} by rule #{rule.domain_pattern} (policy: #{rule.policy_for_user(user)})"
|
||||||
|
else
|
||||||
|
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
|
||||||
|
end
|
||||||
|
|
||||||
|
# User is authenticated and authorized
|
||||||
|
# Return 200 with user information headers
|
||||||
|
response.headers["Remote-User"] = user.email_address
|
||||||
|
response.headers["Remote-Email"] = user.email_address
|
||||||
|
response.headers["Remote-Name"] = user.email_address
|
||||||
|
|
||||||
|
# Add groups if user has any
|
||||||
|
if user.groups.any?
|
||||||
|
response.headers["Remote-Groups"] = user.groups.pluck(:name).join(",")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add admin flag
|
||||||
|
response.headers["Remote-Admin"] = user.admin? ? "true" : "false"
|
||||||
|
|
||||||
|
# Return 200 OK with no body
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def extract_session_id
|
||||||
|
# Extract session ID from cookie
|
||||||
|
# Rails uses signed cookies by default
|
||||||
|
cookies.signed[:session_id]
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_app_from_headers
|
||||||
|
# This method is deprecated since we now use ForwardAuthRule domain matching
|
||||||
|
# Keeping it for backward compatibility but it's no longer used
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_unauthorized(reason = nil)
|
||||||
|
Rails.logger.info "ForwardAuth: Unauthorized - #{reason}"
|
||||||
|
|
||||||
|
# Set header to help with debugging
|
||||||
|
response.headers["X-Auth-Reason"] = reason if reason
|
||||||
|
|
||||||
|
# Get the redirect URL from query params or construct default
|
||||||
|
base_url = params[:rd] || "https://clinch.aapamilne.com"
|
||||||
|
|
||||||
|
# Set the original URL that user was trying to access
|
||||||
|
# This will be used after authentication
|
||||||
|
original_host = request.headers["X-Forwarded-Host"]
|
||||||
|
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
|
||||||
|
|
||||||
|
# Debug logging to see what headers we're getting
|
||||||
|
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers['Host']}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers['X-Forwarded-Uri']}, X-Forwarded-Path=#{request.headers['X-Forwarded-Path']}"
|
||||||
|
|
||||||
|
original_url = if original_host
|
||||||
|
# Use the forwarded host and URI
|
||||||
|
"https://#{original_host}#{original_uri}"
|
||||||
|
else
|
||||||
|
# Fallback: just redirect to the root of the original host
|
||||||
|
"https://#{request.headers['Host']}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Debug: log what we're redirecting to after login
|
||||||
|
Rails.logger.info "ForwardAuth: Will redirect to after login: #{original_url}"
|
||||||
|
|
||||||
|
session[:return_to_after_authenticating] = original_url
|
||||||
|
|
||||||
|
# Build login URL with redirect parameters like Authelia
|
||||||
|
login_params = {
|
||||||
|
rd: original_url,
|
||||||
|
rm: request.method
|
||||||
|
}
|
||||||
|
login_url = "#{base_url}/signin?#{login_params.to_query}"
|
||||||
|
|
||||||
|
# Return 302 Found directly to login page (matching Authelia)
|
||||||
|
# This is the same as Authelia's StatusFound response
|
||||||
|
Rails.logger.info "Setting 302 redirect to: #{login_url}"
|
||||||
|
redirect_to login_url, allow_other_host: true, status: :found
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_forbidden(reason = nil)
|
||||||
|
Rails.logger.info "ForwardAuth: Forbidden - #{reason}"
|
||||||
|
|
||||||
|
# Set header to help with debugging
|
||||||
|
response.headers["X-Auth-Reason"] = reason if reason
|
||||||
|
|
||||||
|
# Return 403 Forbidden
|
||||||
|
head :forbidden
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -41,7 +41,21 @@ module Authentication
|
|||||||
def start_new_session_for(user)
|
def start_new_session_for(user)
|
||||||
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
||||||
Current.session = session
|
Current.session = session
|
||||||
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
|
|
||||||
|
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
||||||
|
domain = extract_root_domain(request.host)
|
||||||
|
|
||||||
|
cookie_options = {
|
||||||
|
value: session.id,
|
||||||
|
httponly: true,
|
||||||
|
same_site: :lax,
|
||||||
|
secure: Rails.env.production?
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set domain for cross-subdomain authentication if we can extract it
|
||||||
|
cookie_options[:domain] = domain if domain.present?
|
||||||
|
|
||||||
|
cookies.signed.permanent[:session_id] = cookie_options
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -49,4 +63,37 @@ module Authentication
|
|||||||
Current.session.destroy
|
Current.session.destroy
|
||||||
cookies.delete(:session_id)
|
cookies.delete(:session_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Extract root domain for cross-subdomain cookies
|
||||||
|
# Examples:
|
||||||
|
# - clinch.aapamilne.com -> .aapamilne.com
|
||||||
|
# - app.example.co.uk -> .example.co.uk
|
||||||
|
# - localhost -> nil (no domain setting for local development)
|
||||||
|
def extract_root_domain(host)
|
||||||
|
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
||||||
|
|
||||||
|
# Split hostname into parts
|
||||||
|
parts = host.split('.')
|
||||||
|
|
||||||
|
# For normal domains like example.com, we need at least 2 parts
|
||||||
|
# For complex domains like co.uk, we need at least 3 parts
|
||||||
|
return nil if parts.length < 2
|
||||||
|
|
||||||
|
# Extract root domain with leading dot for cross-subdomain cookies
|
||||||
|
if parts.length >= 3
|
||||||
|
# Check if it's a known complex TLD
|
||||||
|
complex_tlds = %w[co.uk com.au co.nz co.za co.jp]
|
||||||
|
second_level = "#{parts[-2]}.#{parts[-1]}"
|
||||||
|
|
||||||
|
if complex_tlds.include?(second_level)
|
||||||
|
# For complex TLDs, include more parts: app.example.co.uk -> .example.co.uk
|
||||||
|
root_parts = parts[-3..-1]
|
||||||
|
return ".#{root_parts.join('.')}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# For regular domains: app.example.com -> .example.com
|
||||||
|
root_parts = parts[-2..-1]
|
||||||
|
".#{root_parts.join('.')}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
class OidcController < ApplicationController
|
class OidcController < ApplicationController
|
||||||
# Discovery and JWKS endpoints are public
|
# Discovery and JWKS endpoints are public
|
||||||
allow_unauthenticated_access only: [:discovery, :jwks, :token]
|
allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo]
|
||||||
skip_before_action :verify_authenticity_token, only: [:token]
|
skip_before_action :verify_authenticity_token, only: [:token]
|
||||||
|
|
||||||
# GET /.well-known/openid-configuration
|
# GET /.well-known/openid-configuration
|
||||||
|
|||||||
@@ -16,8 +16,13 @@ class SessionsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Store the redirect URL from forward auth if present
|
||||||
|
if params[:rd].present?
|
||||||
|
session[:return_to_after_authenticating] = params[:rd]
|
||||||
|
end
|
||||||
|
|
||||||
# Check if user is active
|
# Check if user is active
|
||||||
unless user.status == "active"
|
unless user.active?
|
||||||
redirect_to signin_path, alert: "Your account is not active. Please contact an administrator."
|
redirect_to signin_path, alert: "Your account is not active. Please contact an administrator."
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -26,13 +31,17 @@ class SessionsController < ApplicationController
|
|||||||
if user.totp_enabled?
|
if user.totp_enabled?
|
||||||
# Store user ID in session temporarily for TOTP verification
|
# Store user ID in session temporarily for TOTP verification
|
||||||
session[:pending_totp_user_id] = user.id
|
session[:pending_totp_user_id] = user.id
|
||||||
redirect_to totp_verification_path
|
# Preserve the redirect URL through TOTP verification
|
||||||
|
if params[:rd].present?
|
||||||
|
session[:totp_redirect_url] = params[:rd]
|
||||||
|
end
|
||||||
|
redirect_to totp_verification_path(rd: params[:rd])
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Sign in successful
|
# Sign in successful
|
||||||
start_new_session_for user
|
start_new_session_for user
|
||||||
redirect_to after_authentication_url, notice: "Signed in successfully."
|
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify_totp
|
def verify_totp
|
||||||
@@ -57,16 +66,24 @@ class SessionsController < ApplicationController
|
|||||||
# Try TOTP verification first
|
# Try TOTP verification first
|
||||||
if user.verify_totp(code)
|
if user.verify_totp(code)
|
||||||
session.delete(:pending_totp_user_id)
|
session.delete(:pending_totp_user_id)
|
||||||
|
# Restore redirect URL if it was preserved
|
||||||
|
if session[:totp_redirect_url].present?
|
||||||
|
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||||
|
end
|
||||||
start_new_session_for user
|
start_new_session_for user
|
||||||
redirect_to after_authentication_url, notice: "Signed in successfully."
|
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Try backup code verification
|
# Try backup code verification
|
||||||
if user.verify_backup_code(code)
|
if user.verify_backup_code(code)
|
||||||
session.delete(:pending_totp_user_id)
|
session.delete(:pending_totp_user_id)
|
||||||
|
# Restore redirect URL if it was preserved
|
||||||
|
if session[:totp_redirect_url].present?
|
||||||
|
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||||
|
end
|
||||||
start_new_session_for user
|
start_new_session_for user
|
||||||
redirect_to after_authentication_url, notice: "Signed in successfully using backup code."
|
redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class Application < ApplicationRecord
|
|||||||
validates :slug, presence: true, uniqueness: { case_sensitive: false },
|
validates :slug, presence: true, uniqueness: { case_sensitive: false },
|
||||||
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
|
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
|
||||||
validates :app_type, presence: true,
|
validates :app_type, presence: true,
|
||||||
inclusion: { in: %w[oidc trusted_header saml] }
|
inclusion: { in: %w[oidc saml] }
|
||||||
validates :client_id, uniqueness: { allow_nil: true }
|
validates :client_id, uniqueness: { allow_nil: true }
|
||||||
|
|
||||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||||
@@ -18,7 +18,6 @@ class Application < ApplicationRecord
|
|||||||
# Scopes
|
# Scopes
|
||||||
scope :active, -> { where(active: true) }
|
scope :active, -> { where(active: true) }
|
||||||
scope :oidc, -> { where(app_type: "oidc") }
|
scope :oidc, -> { where(app_type: "oidc") }
|
||||||
scope :trusted_header, -> { where(app_type: "trusted_header") }
|
|
||||||
scope :saml, -> { where(app_type: "saml") }
|
scope :saml, -> { where(app_type: "saml") }
|
||||||
|
|
||||||
# Type checks
|
# Type checks
|
||||||
@@ -26,10 +25,6 @@ class Application < ApplicationRecord
|
|||||||
app_type == "oidc"
|
app_type == "oidc"
|
||||||
end
|
end
|
||||||
|
|
||||||
def trusted_header?
|
|
||||||
app_type == "trusted_header"
|
|
||||||
end
|
|
||||||
|
|
||||||
def saml?
|
def saml?
|
||||||
app_type == "saml"
|
app_type == "saml"
|
||||||
end
|
end
|
||||||
|
|||||||
53
app/models/forward_auth_rule.rb
Normal file
53
app/models/forward_auth_rule.rb
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
class ForwardAuthRule < ApplicationRecord
|
||||||
|
has_many :forward_auth_rule_groups, dependent: :destroy
|
||||||
|
has_many :allowed_groups, through: :forward_auth_rule_groups, source: :group
|
||||||
|
|
||||||
|
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }
|
||||||
|
validates :active, inclusion: { in: [true, false] }
|
||||||
|
|
||||||
|
normalizes :domain_pattern, with: ->(pattern) { pattern.strip.downcase }
|
||||||
|
|
||||||
|
# Scopes
|
||||||
|
scope :active, -> { where(active: true) }
|
||||||
|
scope :ordered, -> { order(domain_pattern: :asc) }
|
||||||
|
|
||||||
|
# Check if a domain matches this rule
|
||||||
|
def matches_domain?(domain)
|
||||||
|
return false if domain.blank?
|
||||||
|
|
||||||
|
pattern = domain_pattern.gsub('.', '\.')
|
||||||
|
pattern = pattern.gsub('*', '[^.]*')
|
||||||
|
|
||||||
|
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
||||||
|
regex.match?(domain.downcase)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Access control for forward auth
|
||||||
|
def user_allowed?(user)
|
||||||
|
return false unless active?
|
||||||
|
return false unless user.active?
|
||||||
|
|
||||||
|
# If no groups are specified, allow all active users (bypass)
|
||||||
|
return true if allowed_groups.empty?
|
||||||
|
|
||||||
|
# Otherwise, user must be in at least one of the allowed groups
|
||||||
|
(user.groups & allowed_groups).any?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Policy determination based on user status and rule configuration
|
||||||
|
def policy_for_user(user)
|
||||||
|
return 'deny' unless active?
|
||||||
|
return 'deny' unless user.active?
|
||||||
|
|
||||||
|
# If no groups specified, bypass authentication
|
||||||
|
return 'bypass' if allowed_groups.empty?
|
||||||
|
|
||||||
|
# If user is in allowed groups, determine auth level
|
||||||
|
if user_allowed?(user)
|
||||||
|
# Require 2FA if user has TOTP configured, otherwise one factor
|
||||||
|
user.totp_enabled? ? 'two_factor' : 'one_factor'
|
||||||
|
else
|
||||||
|
'deny'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
6
app/models/forward_auth_rule_group.rb
Normal file
6
app/models/forward_auth_rule_group.rb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class ForwardAuthRuleGroup < ApplicationRecord
|
||||||
|
belongs_to :forward_auth_rule
|
||||||
|
belongs_to :group
|
||||||
|
|
||||||
|
validates :forward_auth_rule_id, uniqueness: { scope: :group_id }
|
||||||
|
end
|
||||||
@@ -15,7 +15,7 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
expires_at <= Time.current
|
expires_at <= Time.current
|
||||||
end
|
end
|
||||||
|
|
||||||
def valid?
|
def usable?
|
||||||
!used? && !expired?
|
!used? && !expired?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ class User < ApplicationRecord
|
|||||||
validates :email_address, presence: true, uniqueness: { case_sensitive: false },
|
validates :email_address, presence: true, uniqueness: { case_sensitive: false },
|
||||||
format: { with: URI::MailTo::EMAIL_REGEXP }
|
format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||||
validates :password, length: { minimum: 8 }, allow_nil: true
|
validates :password, length: { minimum: 8 }, allow_nil: true
|
||||||
validates :status, presence: true,
|
|
||||||
inclusion: { in: %w[active disabled pending_invitation] }
|
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
|
||||||
|
enum :status, { active: 0, disabled: 1, pending_invitation: 2 }
|
||||||
|
|
||||||
# Scopes
|
# Scopes
|
||||||
scope :active, -> { where(status: "active") }
|
|
||||||
scope :admins, -> { where(admin: true) }
|
scope :admins, -> { where(admin: true) }
|
||||||
|
|
||||||
# TOTP methods
|
# TOTP methods
|
||||||
|
|||||||
@@ -63,15 +63,17 @@ class OidcJwtService
|
|||||||
# Get or generate RSA private key
|
# Get or generate RSA private key
|
||||||
def private_key
|
def private_key
|
||||||
@private_key ||= begin
|
@private_key ||= begin
|
||||||
# Try to load from Rails credentials first
|
# Try ENV variable first (best for Docker/Kamal)
|
||||||
key_pem = Rails.application.credentials.oidc_private_key
|
if ENV["OIDC_PRIVATE_KEY"].present?
|
||||||
|
OpenSSL::PKey::RSA.new(ENV["OIDC_PRIVATE_KEY"])
|
||||||
if key_pem.present?
|
# Then try Rails credentials
|
||||||
OpenSSL::PKey::RSA.new(key_pem)
|
elsif Rails.application.credentials.oidc_private_key.present?
|
||||||
|
OpenSSL::PKey::RSA.new(Rails.application.credentials.oidc_private_key)
|
||||||
else
|
else
|
||||||
# Generate a new key for development
|
# Generate a new key for development
|
||||||
# In production, you should generate this once and store in credentials
|
# In production, you MUST set OIDC_PRIVATE_KEY env var or add to credentials
|
||||||
Rails.logger.warn "OIDC: No private key found in credentials, generating new key (development only)"
|
Rails.logger.warn "OIDC: No private key found in ENV or credentials, generating new key (development only)"
|
||||||
|
Rails.logger.warn "OIDC: Set OIDC_PRIVATE_KEY environment variable in production!"
|
||||||
OpenSSL::PKey::RSA.new(2048)
|
OpenSSL::PKey::RSA.new(2048)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
99
app/views/admin/applications/_form.html.erb
Normal file
99
app/views/admin/applications/_form.html.erb
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<%= form_with(model: [:admin, application], class: "space-y-6") do |form| %>
|
||||||
|
<% if application.errors.any? %>
|
||||||
|
<div class="rounded-md bg-red-50 p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-red-800">
|
||||||
|
<%= pluralize(application.errors.count, "error") %> prohibited this application from being saved:
|
||||||
|
</h3>
|
||||||
|
<div class="mt-2 text-sm text-red-700">
|
||||||
|
<ul class="list-disc pl-5 space-y-1">
|
||||||
|
<% application.errors.full_messages.each do |message| %>
|
||||||
|
<li><%= message %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= 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" %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Lowercase letters, numbers, and hyphens only. Used in URLs and API calls.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["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? %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Application type cannot be changed after creation.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OIDC-specific fields -->
|
||||||
|
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6" style="<%= 'display: none;' unless application.oidc? || !application.persisted? %>">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= 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" %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :group_ids, "Allowed Groups (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto border border-gray-200 rounded-md p-3">
|
||||||
|
<% if @available_groups.any? %>
|
||||||
|
<% @available_groups.each do |group| %>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= 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" %>
|
||||||
|
<span class="ml-2 text-xs text-gray-500">(<%= pluralize(group.users.count, "member") %>)</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-sm text-gray-500">No groups available. Create groups first to restrict access.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">If no groups are selected, all active users can access this application.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Show/hide OIDC fields based on app type selection
|
||||||
|
const appTypeSelect = document.querySelector('#application_app_type');
|
||||||
|
const oidcFields = document.querySelector('#oidc-fields');
|
||||||
|
|
||||||
|
if (appTypeSelect && oidcFields) {
|
||||||
|
appTypeSelect.addEventListener('change', function() {
|
||||||
|
if (this.value === 'oidc') {
|
||||||
|
oidcFields.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
oidcFields.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
5
app/views/admin/applications/edit.html.erb
Normal file
5
app/views/admin/applications/edit.html.erb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<div class="max-w-3xl">
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit Application</h1>
|
||||||
|
<p class="text-sm text-gray-600 mb-6">Editing: <%= @application.name %></p>
|
||||||
|
<%= render "form", application: @application %>
|
||||||
|
</div>
|
||||||
69
app/views/admin/applications/index.html.erb
Normal file
69
app/views/admin/applications/index.html.erb
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<div class="sm:flex sm:items-center">
|
||||||
|
<div class="sm:flex-auto">
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900">Applications</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-700">Manage OIDC applications.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flow-root">
|
||||||
|
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||||
|
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||||
|
<table class="min-w-full divide-y divide-gray-300">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Name</th>
|
||||||
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Slug</th>
|
||||||
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th>
|
||||||
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
|
||||||
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Groups</th>
|
||||||
|
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
|
||||||
|
<span class="sr-only">Actions</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
<% @applications.each do |application| %>
|
||||||
|
<tr>
|
||||||
|
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
|
||||||
|
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
|
<code class="text-xs bg-gray-100 px-2 py-1 rounded"><%= application.slug %></code>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
|
<% case application.app_type %>
|
||||||
|
<% when "oidc" %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
|
||||||
|
<% when "saml" %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
|
<% if application.active? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Active</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Inactive</span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
|
<% if application.allowed_groups.empty? %>
|
||||||
|
<span class="text-gray-400">All users</span>
|
||||||
|
<% else %>
|
||||||
|
<%= application.allowed_groups.count %>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||||
|
<%= 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" %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
4
app/views/admin/applications/new.html.erb
Normal file
4
app/views/admin/applications/new.html.erb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="max-w-3xl">
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900 mb-6">New Application</h1>
|
||||||
|
<%= render "form", application: @application %>
|
||||||
|
</div>
|
||||||
120
app/views/admin/applications/show.html.erb
Normal file
120
app/views/admin/applications/show.html.erb
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<div class="mb-6">
|
||||||
|
<div class="sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500"><%= @application.description %></p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 sm:mt-0 flex gap-3">
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Basic Information -->
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Basic Information</h3>
|
||||||
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Slug</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><code class="bg-gray-100 px-2 py-1 rounded"><%= @application.slug %></code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Type</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<% case @application.app_type %>
|
||||||
|
<% when "oidc" %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
|
||||||
|
<% when "saml" %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Status</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<% if @application.active? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Active</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Inactive</span>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OIDC Configuration (only for OIDC apps) -->
|
||||||
|
<% if @application.oidc? %>
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-base font-semibold leading-6 text-gray-900">OIDC Credentials</h3>
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
||||||
|
<dl class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Client ID</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.client_id %></code>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.client_secret %></code>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Redirect URIs</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<% if @application.redirect_uris.present? %>
|
||||||
|
<% @application.parsed_redirect_uris.each do |uri| %>
|
||||||
|
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all mb-2"><%= uri %></code>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-gray-400">No redirect URIs configured</span>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Group Access Control -->
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Access Control</h3>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 mb-2">Allowed Groups</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<% if @allowed_groups.empty? %>
|
||||||
|
<div class="rounded-md bg-blue-50 p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-blue-700">
|
||||||
|
No groups assigned - all active users can access this application.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<ul class="divide-y divide-gray-200 border border-gray-200 rounded-md">
|
||||||
|
<% @allowed_groups.each do |group| %>
|
||||||
|
<li class="px-4 py-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900"><%= group.name %></p>
|
||||||
|
<p class="text-xs text-gray-500"><%= pluralize(group.users.count, "member") %></p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
145
app/views/admin/dashboard/index.html.erb
Normal file
145
app/views/admin/dashboard/index.html.erb
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Admin Dashboard</h1>
|
||||||
|
<p class="mt-2 text-gray-600">System overview and quick actions</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<!-- Users Card -->
|
||||||
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">
|
||||||
|
Total Users
|
||||||
|
</dt>
|
||||||
|
<dd class="flex items-baseline">
|
||||||
|
<div class="text-2xl font-semibold text-gray-900">
|
||||||
|
<%= @user_count %>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm text-gray-600">
|
||||||
|
(<%= @active_user_count %> active)
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 px-5 py-3">
|
||||||
|
<%= link_to "Manage users", admin_users_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Applications Card -->
|
||||||
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h4a1 1 0 010 2H6v10a1 1 0 001 1h10a1 1 0 001-1v-3a1 1 0 112 0v3a3 3 0 01-3 3H7a3 3 0 01-3-3V6a1 1 0 011-1zm9 1a1 1 0 10-2 0v3a1 1 0 102 0V6zm-4 8a1 1 0 100 2h.01a1 1 0 100-2H9zm4 0a1 1 0 100 2h.01a1 1 0 100-2H13z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">
|
||||||
|
Applications
|
||||||
|
</dt>
|
||||||
|
<dd class="flex items-baseline">
|
||||||
|
<div class="text-2xl font-semibold text-gray-900">
|
||||||
|
<%= @application_count %>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm text-gray-600">
|
||||||
|
(<%= @active_application_count %> active)
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 px-5 py-3">
|
||||||
|
<%= link_to "Manage applications", admin_applications_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Groups Card -->
|
||||||
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">
|
||||||
|
Groups
|
||||||
|
</dt>
|
||||||
|
<dd class="text-2xl font-semibold text-gray-900">
|
||||||
|
<%= @group_count %>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 px-5 py-3">
|
||||||
|
<%= link_to "Manage groups", admin_groups_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Users -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Recent Users</h2>
|
||||||
|
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||||
|
<ul class="divide-y divide-gray-200">
|
||||||
|
<% @recent_users.each do |user| %>
|
||||||
|
<li class="px-6 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900"><%= user.email_address %></p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Created <%= time_ago_in_words(user.created_at) %> ago
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<% if user.admin? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Admin</span>
|
||||||
|
<% end %>
|
||||||
|
<% if user.totp_enabled? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">2FA</span>
|
||||||
|
<% end %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700"><%= user.status.titleize %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Quick Actions</h2>
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<%= 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 %>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">Create User</h3>
|
||||||
|
<p class="text-sm text-gray-600">Add a new user to the system</p>
|
||||||
|
<% 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 %>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">Register Application</h3>
|
||||||
|
<p class="text-sm text-gray-600">Add a new OIDC or ForwardAuth app</p>
|
||||||
|
<% 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 %>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">Create Group</h3>
|
||||||
|
<p class="text-sm text-gray-600">Organize users into a new group</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
57
app/views/admin/forward_auth_rules/edit.html.erb
Normal file
57
app/views/admin/forward_auth_rules/edit.html.erb
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<% content_for :title, "Edit Forward Auth Rule" %>
|
||||||
|
|
||||||
|
<div class="md:flex md:items-center md:justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||||
|
Edit Forward Auth Rule
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<%= form_with(model: [:admin, @forward_auth_rule], local: true, class: "space-y-6") do |form| %>
|
||||||
|
<%= render "shared/form_errors", form: form %>
|
||||||
|
|
||||||
|
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2">
|
||||||
|
<div class="px-4 py-6 sm:p-8">
|
||||||
|
<div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<%= form.label :domain_pattern, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||||
|
<div class="mt-2">
|
||||||
|
<%= form.text_field :domain_pattern, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6", placeholder: "*.example.com" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||||
|
Use patterns like "*.example.com" or "api.example.com". Wildcards (*) are supported.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<%= form.label :active, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||||
|
<div class="mt-2">
|
||||||
|
<%= form.select :active, options_for_select([["Active", true], ["Inactive", false]], @forward_auth_rule.active), { prompt: "Select status" }, { class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:max-w-xs sm:text-sm sm:leading-6" } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-full">
|
||||||
|
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
|
||||||
|
Groups
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 space-y-2">
|
||||||
|
<%= form.collection_select :group_ids, @available_groups, :id, :name,
|
||||||
|
{ selected: @forward_auth_rule.allowed_groups.map(&:id), prompt: "Select groups (leave empty for bypass)" },
|
||||||
|
{ multiple: true, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" } %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||||
|
Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex items-center justify-end gap-x-6">
|
||||||
|
<%= link_to "Cancel", admin_forward_auth_rule_path(@forward_auth_rule), class: "text-sm font-semibold leading-6 text-gray-900 hover:text-gray-700" %>
|
||||||
|
<%= form.submit "Update Rule", 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" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
89
app/views/admin/forward_auth_rules/index.html.erb
Normal file
89
app/views/admin/forward_auth_rules/index.html.erb
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<% content_for :title, "Forward Auth Rules" %>
|
||||||
|
|
||||||
|
<div class="sm:flex sm:items-center">
|
||||||
|
<div class="sm:flex-auto">
|
||||||
|
<h1 class="text-base font-semibold leading-6 text-gray-900">Forward Auth Rules</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-700">A list of all forward authentication rules for domain-based access control.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||||
|
<%= link_to "Add rule", new_admin_forward_auth_rule_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" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flow-root">
|
||||||
|
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||||
|
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||||
|
<% if @forward_auth_rules.any? %>
|
||||||
|
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||||
|
<table class="min-w-full divide-y divide-gray-300">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Domain Pattern</th>
|
||||||
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Groups</th>
|
||||||
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
|
||||||
|
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||||
|
<span class="sr-only">Actions</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
|
<% @forward_auth_rules.each do |rule| %>
|
||||||
|
<tr>
|
||||||
|
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
|
||||||
|
<%= rule.domain_pattern %>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-4 text-sm text-gray-500">
|
||||||
|
<% if rule.allowed_groups.any? %>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<% rule.allowed_groups.each do |group| %>
|
||||||
|
<span class="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">
|
||||||
|
<%= group.name %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
||||||
|
Bypass (All Users)
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-4 text-sm text-gray-500">
|
||||||
|
<% if rule.active? %>
|
||||||
|
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||||
|
<%= link_to "Edit", edit_admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
||||||
|
<%= link_to "Delete", admin_forward_auth_rule_path(rule),
|
||||||
|
data: {
|
||||||
|
turbo_method: :delete,
|
||||||
|
turbo_confirm: "Are you sure you want to delete this forward auth rule?"
|
||||||
|
},
|
||||||
|
class: "text-red-600 hover:text-red-900" %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-semibold text-gray-900">No forward auth rules</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Get started by creating a new forward authentication rule.</p>
|
||||||
|
<div class="mt-6">
|
||||||
|
<%= link_to "Add rule", new_admin_forward_auth_rule_path, class: "inline-flex items-center 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" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
57
app/views/admin/forward_auth_rules/new.html.erb
Normal file
57
app/views/admin/forward_auth_rules/new.html.erb
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<% content_for :title, "New Forward Auth Rule" %>
|
||||||
|
|
||||||
|
<div class="md:flex md:items-center md:justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||||
|
New Forward Auth Rule
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<%= form_with(model: [:admin, @forward_auth_rule], local: true, class: "space-y-6") do |form| %>
|
||||||
|
<%= render "shared/form_errors", form: form %>
|
||||||
|
|
||||||
|
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2">
|
||||||
|
<div class="px-4 py-6 sm:p-8">
|
||||||
|
<div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<%= form.label :domain_pattern, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||||
|
<div class="mt-2">
|
||||||
|
<%= form.text_field :domain_pattern, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6", placeholder: "*.example.com" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||||
|
Use patterns like "*.example.com" or "api.example.com". Wildcards (*) are supported.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<%= form.label :active, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||||
|
<div class="mt-2">
|
||||||
|
<%= form.select :active, options_for_select([["Active", true], ["Inactive", false]], @forward_auth_rule.active), { prompt: "Select status" }, { class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:max-w-xs sm:text-sm sm:leading-6" } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-full">
|
||||||
|
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
|
||||||
|
Groups
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 space-y-2">
|
||||||
|
<%= form.collection_select :group_ids, @available_groups, :id, :name,
|
||||||
|
{ prompt: "Select groups (leave empty for bypass)" },
|
||||||
|
{ multiple: true, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" } %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||||
|
Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex items-center justify-end gap-x-6">
|
||||||
|
<%= link_to "Cancel", admin_forward_auth_rules_path, class: "text-sm font-semibold leading-6 text-gray-900 hover:text-gray-700" %>
|
||||||
|
<%= form.submit "Create Rule", 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" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
111
app/views/admin/forward_auth_rules/show.html.erb
Normal file
111
app/views/admin/forward_auth_rules/show.html.erb
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<% content_for :title, "Forward Auth Rule: #{@forward_auth_rule.domain_pattern}" %>
|
||||||
|
|
||||||
|
<div class="md:flex md:items-center md:justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||||
|
<%= @forward_auth_rule.domain_pattern %>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex md:ml-4 md:mt-0">
|
||||||
|
<%= link_to "Edit", edit_admin_forward_auth_rule_path(@forward_auth_rule), class: "inline-flex items-center 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" %>
|
||||||
|
<%= link_to "Delete", admin_forward_auth_rule_path(@forward_auth_rule),
|
||||||
|
data: {
|
||||||
|
turbo_method: :delete,
|
||||||
|
turbo_confirm: "Are you sure you want to delete this forward auth rule?"
|
||||||
|
},
|
||||||
|
class: "ml-3 inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:px-6">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900">Rule Details</h3>
|
||||||
|
<p class="mt-1 max-w-2xl text-sm text-gray-500">Forward authentication rule configuration.</p>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200">
|
||||||
|
<dl>
|
||||||
|
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Domain Pattern</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||||
|
<code class="bg-gray-100 px-2 py-1 rounded text-sm"><%= @forward_auth_rule.domain_pattern %></code>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Status</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||||
|
<% if @forward_auth_rule.active? %>
|
||||||
|
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Access Policy</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||||
|
<% if @allowed_groups.any? %>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm">Only users in these groups are allowed access:</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% @allowed_groups.each do |group| %>
|
||||||
|
<span class="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">
|
||||||
|
<%= group.name %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
||||||
|
Bypass - All authenticated users allowed
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Created</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||||
|
<%= @forward_auth_rule.created_at.strftime("%B %d, %Y at %I:%M %p") %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Last Updated</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||||
|
<%= @forward_auth_rule.updated_at.strftime("%B %d, %Y at %I:%M %p") %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<div class="bg-blue-50 border-l-4 border-blue-400 p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-blue-800">How this rule works</h3>
|
||||||
|
<div class="mt-2 text-sm text-blue-700">
|
||||||
|
<ul class="list-disc list-inside space-y-1">
|
||||||
|
<li>This rule matches domains that fit the pattern: <code class="bg-blue-100 px-1 rounded"><%= @forward_auth_rule.domain_pattern %></code></li>
|
||||||
|
<% if @allowed_groups.any? %>
|
||||||
|
<li>Only users belonging to the specified groups will be granted access</li>
|
||||||
|
<li>Users will be required to authenticate with password (and 2FA if enabled)</li>
|
||||||
|
<% else %>
|
||||||
|
<li>All authenticated users will be granted access (bypass mode)</li>
|
||||||
|
<% end %>
|
||||||
|
<li>Inactive rules are ignored during authentication</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
56
app/views/admin/groups/_form.html.erb
Normal file
56
app/views/admin/groups/_form.html.erb
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<%= form_with(model: [:admin, group], class: "space-y-6") do |form| %>
|
||||||
|
<% if group.errors.any? %>
|
||||||
|
<div class="rounded-md bg-red-50 p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-red-800">
|
||||||
|
<%= pluralize(group.errors.count, "error") %> prohibited this group from being saved:
|
||||||
|
</h3>
|
||||||
|
<div class="mt-2 text-sm text-red-700">
|
||||||
|
<ul class="list-disc pl-5 space-y-1">
|
||||||
|
<% group.errors.full_messages.each do |message| %>
|
||||||
|
<li><%= message %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= 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" %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Group names are automatically normalized to lowercase.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :user_ids, "Group Members", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 rounded-md p-3">
|
||||||
|
<% if @available_users.any? %>
|
||||||
|
<% @available_users.each do |user| %>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= 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? %>
|
||||||
|
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">Admin</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-sm text-gray-500">No users available.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
5
app/views/admin/groups/edit.html.erb
Normal file
5
app/views/admin/groups/edit.html.erb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<div class="max-w-2xl">
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit Group</h1>
|
||||||
|
<p class="text-sm text-gray-600 mb-6">Editing: <%= @group.name %></p>
|
||||||
|
<%= render "form", group: @group %>
|
||||||
|
</div>
|
||||||
52
app/views/admin/groups/index.html.erb
Normal file
52
app/views/admin/groups/index.html.erb
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<div class="sm:flex sm:items-center">
|
||||||
|
<div class="sm:flex-auto">
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900">Groups</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-700">Organize users into groups for application access control.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flow-root">
|
||||||
|
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||||
|
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||||
|
<table class="min-w-full divide-y divide-gray-300">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Name</th>
|
||||||
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Description</th>
|
||||||
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Members</th>
|
||||||
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Applications</th>
|
||||||
|
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
|
||||||
|
<span class="sr-only">Actions</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
<% @groups.each do |group| %>
|
||||||
|
<tr>
|
||||||
|
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
|
||||||
|
<%= link_to group.name, admin_group_path(group), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-4 text-sm text-gray-500">
|
||||||
|
<%= truncate(group.description, length: 80) || content_tag(:span, "No description", class: "text-gray-400") %>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
|
<%= pluralize(group.users.count, "member") %>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
|
<%= pluralize(group.applications.count, "app") %>
|
||||||
|
</td>
|
||||||
|
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||||
|
<%= 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" %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
4
app/views/admin/groups/new.html.erb
Normal file
4
app/views/admin/groups/new.html.erb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="max-w-2xl">
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900 mb-6">New Group</h1>
|
||||||
|
<%= render "form", group: @group %>
|
||||||
|
</div>
|
||||||
88
app/views/admin/groups/show.html.erb
Normal file
88
app/views/admin/groups/show.html.erb
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<div class="mb-6">
|
||||||
|
<div class="sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900"><%= @group.name %></h1>
|
||||||
|
<% if @group.description.present? %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500"><%= @group.description %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 sm:mt-0 flex gap-3">
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Members -->
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">
|
||||||
|
Members (<%= @members.count %>)
|
||||||
|
</h3>
|
||||||
|
<% if @members.any? %>
|
||||||
|
<ul class="divide-y divide-gray-200 border border-gray-200 rounded-md">
|
||||||
|
<% @members.each do |user| %>
|
||||||
|
<li class="px-4 py-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900"><%= user.email_address %></p>
|
||||||
|
<div class="flex gap-2 mt-1">
|
||||||
|
<% if user.admin? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">Admin</span>
|
||||||
|
<% end %>
|
||||||
|
<% if user.totp_enabled? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">2FA</span>
|
||||||
|
<% end %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700"><%= user.status.titleize %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%= link_to "View", admin_user_path(user), class: "text-blue-600 hover:text-blue-900 text-sm" %>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% else %>
|
||||||
|
<div class="rounded-md bg-gray-50 p-4">
|
||||||
|
<p class="text-sm text-gray-500">No members in this group yet.</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Applications -->
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">
|
||||||
|
Assigned Applications (<%= @applications.count %>)
|
||||||
|
</h3>
|
||||||
|
<% if @applications.any? %>
|
||||||
|
<ul class="divide-y divide-gray-200 border border-gray-200 rounded-md">
|
||||||
|
<% @applications.each do |app| %>
|
||||||
|
<li class="px-4 py-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900"><%= app.name %></p>
|
||||||
|
<div class="flex gap-2 mt-1">
|
||||||
|
<% case app.app_type %>
|
||||||
|
<% when "oidc" %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700">OIDC</span>
|
||||||
|
<% when "trusted_header" %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-medium text-indigo-700">ForwardAuth</span>
|
||||||
|
<% end %>
|
||||||
|
<% if app.active? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">Active</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700">Inactive</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%= link_to "View", admin_application_path(app), class: "text-blue-600 hover:text-blue-900 text-sm" %>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% else %>
|
||||||
|
<div class="rounded-md bg-gray-50 p-4">
|
||||||
|
<p class="text-sm text-gray-500">This group is not assigned to any applications.</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
53
app/views/admin/users/_form.html.erb
Normal file
53
app/views/admin/users/_form.html.erb
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<%= form_with(model: [:admin, user], class: "space-y-6") do |form| %>
|
||||||
|
<% if user.errors.any? %>
|
||||||
|
<div class="rounded-md bg-red-50 p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-red-800">
|
||||||
|
<%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:
|
||||||
|
</h3>
|
||||||
|
<div class="mt-2 text-sm text-red-700">
|
||||||
|
<ul class="list-disc pl-5 space-y-1">
|
||||||
|
<% user.errors.full_messages.each do |message| %>
|
||||||
|
<li><%= message %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= 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? %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Leave blank to keep the current password</p>
|
||||||
|
<% else %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Leave blank to generate a random password</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= 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 %>
|
||||||
|
<span class="ml-2 text-xs text-gray-500">(Cannot change your own admin status)</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
5
app/views/admin/users/edit.html.erb
Normal file
5
app/views/admin/users/edit.html.erb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<div class="max-w-2xl">
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit User</h1>
|
||||||
|
<p class="text-sm text-gray-600 mb-6">Editing: <%= @user.email_address %></p>
|
||||||
|
<%= render "form", user: @user %>
|
||||||
|
</div>
|
||||||
78
app/views/admin/users/index.html.erb
Normal file
78
app/views/admin/users/index.html.erb
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<div class="sm:flex sm:items-center">
|
||||||
|
<div class="sm:flex-auto">
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900">Users</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-700">A list of all users in the system.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flow-root">
|
||||||
|
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||||
|
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||||
|
<table class="min-w-full divide-y divide-gray-300">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Email</th>
|
||||||
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
|
||||||
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Role</th>
|
||||||
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">2FA</th>
|
||||||
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Groups</th>
|
||||||
|
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
|
||||||
|
<span class="sr-only">Actions</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
<% @users.each do |user| %>
|
||||||
|
<tr>
|
||||||
|
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
|
||||||
|
<%= user.email_address %>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
|
<% if user.status.present? %>
|
||||||
|
<% case user.status.to_sym %>
|
||||||
|
<% when :active %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Active</span>
|
||||||
|
<% when :disabled %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-700">Disabled</span>
|
||||||
|
<% when :pending_invitation %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-700">Pending</span>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
|
<% if user.admin? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Admin</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-gray-500">User</span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
|
<% if user.totp_enabled? %>
|
||||||
|
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<% else %>
|
||||||
|
<svg class="h-5 w-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
|
<%= user.groups.count %>
|
||||||
|
</td>
|
||||||
|
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||||
|
<%= 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" %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
4
app/views/admin/users/new.html.erb
Normal file
4
app/views/admin/users/new.html.erb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="max-w-2xl">
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900 mb-6">New User</h1>
|
||||||
|
<%= render "form", user: @user %>
|
||||||
|
</div>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_with url: signin_path, class: "contents" do |form| %>
|
<%= form_with url: signin_path, class: "contents" do |form| %>
|
||||||
|
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %>
|
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %>
|
||||||
<%= form.email_field :email_address,
|
<%= form.email_field :email_address,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_with url: totp_verification_path, method: :post, class: "space-y-6" do |form| %>
|
<%= form_with url: totp_verification_path, method: :post, class: "space-y-6" do |form| %>
|
||||||
|
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
|
||||||
<div>
|
<div>
|
||||||
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700" %>
|
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700" %>
|
||||||
<%= text_field_tag :code,
|
<%= text_field_tag :code,
|
||||||
|
|||||||
23
app/views/shared/_form_errors.html.erb
Normal file
23
app/views/shared/_form_errors.html.erb
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<% if form.object.errors.any? %>
|
||||||
|
<div class="rounded-md bg-red-50 p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-red-800">
|
||||||
|
There were <%= pluralize(form.object.errors.count, "error") %> with your submission:
|
||||||
|
</h3>
|
||||||
|
<div class="mt-2 text-sm text-red-700">
|
||||||
|
<ul class="list-disc space-y-1 pl-5">
|
||||||
|
<% form.object.errors.full_messages.each do |message| %>
|
||||||
|
<li><%= message %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
@@ -57,6 +57,16 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<!-- Admin: Forward Auth Rules -->
|
||||||
|
<li>
|
||||||
|
<%= link_to admin_forward_auth_rules_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/forward_auth_rules') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %>
|
||||||
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
Forward Auth Rules
|
||||||
|
<% end %>
|
||||||
|
</li>
|
||||||
|
|
||||||
<!-- Admin: Groups -->
|
<!-- Admin: Groups -->
|
||||||
<li>
|
<li>
|
||||||
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %>
|
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %>
|
||||||
@@ -160,6 +170,14 @@
|
|||||||
Groups
|
Groups
|
||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<%= link_to admin_forward_auth_rules_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
||||||
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
Forward Auth Rules
|
||||||
|
<% end %>
|
||||||
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
||||||
|
|||||||
37
bin/generate_oidc_key
Executable file
37
bin/generate_oidc_key
Executable file
@@ -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
|
||||||
@@ -1 +0,0 @@
|
|||||||
DxrlTZBeCpuC6si/9Fw1CJc6YR2p7LCIRxlJgTpjvgL+SEH1caIYRHhu5V2JgeqwdXnmoDnKX8cKbzQQSlTyQ7TPKA6qjPKoeHLvDYGCxHNr5mOboa9jPLdsrGrKg5+GA3X/5piNQQfFzHiiSgzkD3pxPRpX1V0MjA5r0wAFuqzSmkOHq5zV46PFX1WDdfUEZocPDrdOmEt2xxmuq+k+csh5SLi73MjHOTz5KWMVUuwISAUTMqSwzoPRpb1H3Dh8+YXhtbJDifIfPu8vfH4qK6C+6+Bi9cH2YwhTblAggqB3lQ1Tkpo4b631MdT7eRT5qwJ+Rr4EfrFHVfcT46QT774Rc8pd2ifLdJqEOSnskWN1BJza1mxrLcb8kI0cV6zCTZI0ChzPI9gR2YZ9CNJCbHZtR+s9LsmnCx2iy3vPVYsLeCU3Qg0+kILVuA/EY+m/bUhPiD5POj73e8ygBnUgU37HLosllBLs5tHPSeF5BYXq9IxLQe+pMaYY--wcVJty2PK9h/klT6--pZOot1WksDZmdWdBHi7s3w==
|
|
||||||
@@ -18,6 +18,19 @@ Rails.application.routes.draw do
|
|||||||
get "/totp-verification", to: "sessions#verify_totp", as: :totp_verification
|
get "/totp-verification", to: "sessions#verify_totp", as: :totp_verification
|
||||||
post "/totp-verification", to: "sessions#verify_totp"
|
post "/totp-verification", to: "sessions#verify_totp"
|
||||||
|
|
||||||
|
# OIDC (OpenID Connect) routes
|
||||||
|
get "/.well-known/openid-configuration", to: "oidc#discovery"
|
||||||
|
get "/.well-known/jwks.json", to: "oidc#jwks"
|
||||||
|
get "/oauth/authorize", to: "oidc#authorize"
|
||||||
|
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
|
||||||
|
post "/oauth/token", to: "oidc#token"
|
||||||
|
get "/oauth/userinfo", to: "oidc#userinfo"
|
||||||
|
|
||||||
|
# ForwardAuth / Trusted Header SSO
|
||||||
|
namespace :api do
|
||||||
|
get "/verify", to: "forward_auth#verify"
|
||||||
|
end
|
||||||
|
|
||||||
# Authenticated routes
|
# Authenticated routes
|
||||||
root "dashboard#index"
|
root "dashboard#index"
|
||||||
resource :profile, only: [:show, :update]
|
resource :profile, only: [:show, :update]
|
||||||
@@ -38,8 +51,13 @@ Rails.application.routes.draw do
|
|||||||
namespace :admin do
|
namespace :admin do
|
||||||
root "dashboard#index"
|
root "dashboard#index"
|
||||||
resources :users
|
resources :users
|
||||||
resources :applications
|
resources :applications do
|
||||||
|
member do
|
||||||
|
post :regenerate_credentials
|
||||||
|
end
|
||||||
|
end
|
||||||
resources :groups
|
resources :groups
|
||||||
|
resources :forward_auth_rules
|
||||||
end
|
end
|
||||||
|
|
||||||
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
|
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ class AddAuthFieldsToUsers < ActiveRecord::Migration[8.1]
|
|||||||
add_column :users, :totp_secret, :string
|
add_column :users, :totp_secret, :string
|
||||||
add_column :users, :totp_required, :boolean, default: false, null: false
|
add_column :users, :totp_required, :boolean, default: false, null: false
|
||||||
add_column :users, :backup_codes, :text
|
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
|
add_index :users, :status
|
||||||
end
|
end
|
||||||
|
|||||||
11
db/migrate/20251023210508_create_forward_auth_rules.rb
Normal file
11
db/migrate/20251023210508_create_forward_auth_rules.rb
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
class CreateForwardAuthRules < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :forward_auth_rules do |t|
|
||||||
|
t.string :domain_pattern
|
||||||
|
t.integer :policy
|
||||||
|
t.boolean :active
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
10
db/migrate/20251023234744_create_forward_auth_rule_groups.rb
Normal file
10
db/migrate/20251023234744_create_forward_auth_rule_groups.rb
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
class CreateForwardAuthRuleGroups < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :forward_auth_rule_groups do |t|
|
||||||
|
t.references :forward_auth_rule, null: false, foreign_key: true
|
||||||
|
t.references :group, null: false, foreign_key: true
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
23
db/schema.rb
generated
23
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2025_10_23_054039) do
|
ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do
|
||||||
create_table "application_groups", force: :cascade do |t|
|
create_table "application_groups", force: :cascade do |t|
|
||||||
t.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -37,6 +37,23 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_054039) do
|
|||||||
t.index ["slug"], name: "index_applications_on_slug", unique: true
|
t.index ["slug"], name: "index_applications_on_slug", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "forward_auth_rule_groups", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.integer "forward_auth_rule_id", null: false
|
||||||
|
t.integer "group_id", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["forward_auth_rule_id"], name: "index_forward_auth_rule_groups_on_forward_auth_rule_id"
|
||||||
|
t.index ["group_id"], name: "index_forward_auth_rule_groups_on_group_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "forward_auth_rules", force: :cascade do |t|
|
||||||
|
t.boolean "active"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.string "domain_pattern"
|
||||||
|
t.integer "policy"
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
end
|
||||||
|
|
||||||
create_table "groups", force: :cascade do |t|
|
create_table "groups", force: :cascade do |t|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.text "description"
|
t.text "description"
|
||||||
@@ -108,7 +125,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_054039) do
|
|||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.string "email_address", null: false
|
t.string "email_address", null: false
|
||||||
t.string "password_digest", null: false
|
t.string "password_digest", null: false
|
||||||
t.string "status", default: "active", null: false
|
t.integer "status", default: 0, null: false
|
||||||
t.boolean "totp_required", default: false, null: false
|
t.boolean "totp_required", default: false, null: false
|
||||||
t.string "totp_secret"
|
t.string "totp_secret"
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
@@ -118,6 +135,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_054039) do
|
|||||||
|
|
||||||
add_foreign_key "application_groups", "applications"
|
add_foreign_key "application_groups", "applications"
|
||||||
add_foreign_key "application_groups", "groups"
|
add_foreign_key "application_groups", "groups"
|
||||||
|
add_foreign_key "forward_auth_rule_groups", "forward_auth_rules"
|
||||||
|
add_foreign_key "forward_auth_rule_groups", "groups"
|
||||||
add_foreign_key "oidc_access_tokens", "applications"
|
add_foreign_key "oidc_access_tokens", "applications"
|
||||||
add_foreign_key "oidc_access_tokens", "users"
|
add_foreign_key "oidc_access_tokens", "users"
|
||||||
add_foreign_key "oidc_authorization_codes", "applications"
|
add_foreign_key "oidc_authorization_codes", "applications"
|
||||||
|
|||||||
11
test/fixtures/forward_auth_rules.yml
vendored
Normal file
11
test/fixtures/forward_auth_rules.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
|
one:
|
||||||
|
domain_pattern: MyString
|
||||||
|
policy: 1
|
||||||
|
active: false
|
||||||
|
|
||||||
|
two:
|
||||||
|
domain_pattern: MyString
|
||||||
|
policy: 1
|
||||||
|
active: false
|
||||||
127
test/models/forward_auth_rule_test.rb
Normal file
127
test/models/forward_auth_rule_test.rb
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ForwardAuthRuleTest < ActiveSupport::TestCase
|
||||||
|
def setup
|
||||||
|
@rule = ForwardAuthRule.new(
|
||||||
|
domain_pattern: "*.example.com",
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should be valid with valid attributes" do
|
||||||
|
assert @rule.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should require domain_pattern" do
|
||||||
|
@rule.domain_pattern = ""
|
||||||
|
assert_not @rule.valid?
|
||||||
|
assert_includes @rule.errors[:domain_pattern], "can't be blank"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should require active to be boolean" do
|
||||||
|
@rule.active = nil
|
||||||
|
assert_not @rule.valid?
|
||||||
|
assert_includes @rule.errors[:active], "is not included in the list"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should normalize domain_pattern to lowercase" do
|
||||||
|
@rule.domain_pattern = "*.EXAMPLE.COM"
|
||||||
|
@rule.save!
|
||||||
|
assert_equal "*.example.com", @rule.reload.domain_pattern
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should enforce unique domain_pattern" do
|
||||||
|
@rule.save!
|
||||||
|
duplicate = ForwardAuthRule.new(
|
||||||
|
domain_pattern: "*.example.com",
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
assert_not duplicate.valid?
|
||||||
|
assert_includes duplicate.errors[:domain_pattern], "has already been taken"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should match domain patterns correctly" do
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
assert @rule.matches_domain?("app.example.com")
|
||||||
|
assert @rule.matches_domain?("api.example.com")
|
||||||
|
assert @rule.matches_domain?("sub.app.example.com")
|
||||||
|
assert_not @rule.matches_domain?("example.org")
|
||||||
|
assert_not @rule.matches_domain?("otherexample.com")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should handle exact domain matches" do
|
||||||
|
@rule.domain_pattern = "api.example.com"
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
assert @rule.matches_domain?("api.example.com")
|
||||||
|
assert_not @rule.matches_domain?("app.example.com")
|
||||||
|
assert_not @rule.matches_domain?("sub.api.example.com")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "policy_for_user should return bypass when no groups assigned" do
|
||||||
|
user = users(:one)
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
assert_equal "bypass", @rule.policy_for_user(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "policy_for_user should return deny for inactive rule" do
|
||||||
|
user = users(:one)
|
||||||
|
@rule.active = false
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
assert_equal "deny", @rule.policy_for_user(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "policy_for_user should return deny for inactive user" do
|
||||||
|
user = users(:one)
|
||||||
|
user.update!(active: false)
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
assert_equal "deny", @rule.policy_for_user(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "policy_for_user should return correct policy based on user groups and TOTP" do
|
||||||
|
group = groups(:one)
|
||||||
|
user_with_totp = users(:two)
|
||||||
|
user_without_totp = users(:one)
|
||||||
|
|
||||||
|
user_with_totp.totp_secret = "test_secret"
|
||||||
|
user_with_totp.save!
|
||||||
|
|
||||||
|
@rule.allowed_groups << group
|
||||||
|
user_with_totp.groups << group
|
||||||
|
user_without_totp.groups << group
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
assert_equal "two_factor", @rule.policy_for_user(user_with_totp)
|
||||||
|
assert_equal "one_factor", @rule.policy_for_user(user_without_totp)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "user_allowed? should return true when no groups assigned" do
|
||||||
|
user = users(:one)
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
assert @rule.user_allowed?(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "user_allowed? should return true when user in allowed groups" do
|
||||||
|
group = groups(:one)
|
||||||
|
user = users(:one)
|
||||||
|
user.groups << group
|
||||||
|
@rule.allowed_groups << group
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
assert @rule.user_allowed?(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "user_allowed? should return false when user not in allowed groups" do
|
||||||
|
group = groups(:one)
|
||||||
|
user = users(:one)
|
||||||
|
@rule.allowed_groups << group
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
assert_not @rule.user_allowed?(user)
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user