Compare commits
106 Commits
c1c6e0112e
...
v0.16.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
782e197d91 | ||
|
|
020759bfb3 | ||
|
|
85f50bfc96 | ||
|
|
b55139eb1c | ||
|
|
8f578ed3f4 | ||
|
|
aa5736ddab | ||
|
|
49068aa344 | ||
|
|
07ea031b61 | ||
|
|
209c5496d8 | ||
|
|
d49e7ce4f5 | ||
|
|
44892e3301 | ||
|
|
24266872f9 | ||
|
|
cd862c7cd7 | ||
|
|
89bd5f1432 | ||
|
|
57d7d1f691 | ||
|
|
406a79d9eb | ||
|
|
f38ac2ecc8 | ||
|
|
84ed462f40 | ||
|
|
96a657e349 | ||
|
|
8a095e4939 | ||
|
|
703d24e4e4 | ||
|
|
2843790cef | ||
|
|
0e9ec71013 | ||
|
|
fe68f6e81e | ||
|
|
c5ab7dc2a5 | ||
|
|
bfad9c4e9d | ||
|
|
5b41db2c6a | ||
|
|
03dfdbd83a | ||
|
|
6b58b685c4 | ||
|
|
a399907dfd | ||
|
|
bbfb564e1c | ||
|
|
9663110938 | ||
|
|
0bca1d2bac | ||
|
|
bdb10d86fb | ||
|
|
37e6e2cc19 | ||
|
|
9648b64043 | ||
|
|
a5eba9a5cd | ||
|
|
afa90303c8 | ||
|
|
df5dbfc46c | ||
|
|
2768104c1e | ||
|
|
2e427a0520 | ||
|
|
556656d090 | ||
|
|
cc93f72f0a | ||
|
|
09e9b32e46 | ||
|
|
7d352654fd | ||
|
|
e39721c7e6 | ||
|
|
5178cf3d81 | ||
|
|
2d5650e620 | ||
|
|
7f0d3d3900 | ||
|
|
b876e02c3a | ||
|
|
93d8381214 | ||
|
|
2068675173 | ||
|
|
b7fa49953c | ||
|
|
b7dd3c02e7 | ||
|
|
17a464fd15 | ||
|
|
9197524c88 | ||
|
|
2235924f37 | ||
|
|
c7d9df48b5 | ||
|
|
3d98261a51 | ||
|
|
43958f50ce | ||
|
|
d8d8000b92 | ||
|
|
6844c5fab3 | ||
|
|
5505f99287 | ||
|
|
1b691ad341 | ||
|
|
f65df76d99 | ||
|
|
c5898bd9a4 | ||
|
|
9dbde8ea31 | ||
|
|
191a7b5fb3 | ||
|
|
7a9348c1f1 | ||
|
|
225d8ae5ca | ||
|
|
65c19fa732 | ||
|
|
fd8785a43d | ||
|
|
444ae6291c | ||
|
|
233fb723d5 | ||
|
|
cc6d4fcc65 | ||
|
|
5268f10eb3 | ||
|
|
5c5662eaab | ||
|
|
27d77ebf47 | ||
|
|
ba08158c85 | ||
|
|
a6480b0860 | ||
|
|
75cc223329 | ||
|
|
46ae65f4d2 | ||
|
|
95d0d844e9 | ||
|
|
524a7719c3 | ||
|
|
8110d547dd | ||
|
|
25e1043312 | ||
|
|
074a734c0c | ||
|
|
4a48012a82 | ||
|
|
e631f606e7 | ||
|
|
f4a697ae9b | ||
|
|
16e34ffaf0 | ||
|
|
0bb84f08d6 | ||
|
|
182682024d | ||
|
|
b517ebe809 | ||
|
|
dd8bd15a76 | ||
|
|
f67a73821c | ||
|
|
b09ddf6db5 | ||
|
|
abbb11a41d | ||
|
|
b2030df8c2 | ||
|
|
07cddf5823 | ||
|
|
46aa983189 | ||
|
|
d0d79ee1da | ||
|
|
2f6a2c7406 | ||
|
|
5137a25626 | ||
|
|
fed7c3cedb | ||
|
|
e288fcad7c |
56
.github/workflows/build.yml
vendored
Normal file
56
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Build and publish image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
tags: [ 'v*' ]
|
||||
|
||||
# Only one build per ref at a time; cancel superseded main builds.
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # Required to push to GHCR
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract image metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=edge,branch=main
|
||||
type=sha,prefix=sha-,format=short,enable={{is_default_branch}}
|
||||
type=semver,pattern=v{{version}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
flavor: |
|
||||
latest=auto
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@@ -41,6 +41,34 @@ jobs:
|
||||
- name: Scan for security vulnerabilities in JavaScript dependencies
|
||||
run: bin/importmap audit
|
||||
|
||||
scan_container:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write # Required for uploading SARIF results
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t clinch:${{ github.sha }} .
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: clinch:${{ github.sha }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-results.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
scanners: 'vuln' # Only scan vulnerabilities, not secrets (avoids false positives in vendored gems)
|
||||
|
||||
- name: Upload Trivy results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.4.6
|
||||
4.0.5
|
||||
|
||||
48
.trivyignore
Normal file
48
.trivyignore
Normal file
@@ -0,0 +1,48 @@
|
||||
# Trivy ignore file
|
||||
# This file tells Trivy to skip specific vulnerabilities or files
|
||||
# See: https://aquasecurity.github.io/trivy/latest/docs/configuration/filtering/
|
||||
|
||||
# =============================================================================
|
||||
# False Positives - Test Fixtures
|
||||
# =============================================================================
|
||||
|
||||
# Capybara test fixture - not a real private key
|
||||
# Ignore secrets in test fixtures
|
||||
# Format: secret:<rule-id>:<exact-file-path>
|
||||
secret:private-key:/usr/local/bundle/ruby/3.4.0/gems/capybara-3.40.0/spec/fixtures/key.pem
|
||||
|
||||
# =============================================================================
|
||||
# Unfixable CVEs - No Patches Available (Status: affected/fix_deferred)
|
||||
# =============================================================================
|
||||
|
||||
# GnuPG vulnerabilities - not used by Clinch at runtime
|
||||
# Low risk: dirmngr/gpg tools not invoked during normal operation
|
||||
CVE-2025-68973
|
||||
|
||||
# Image processing library vulnerabilities
|
||||
# Low risk for Clinch: Only admins upload images (app icons), not untrusted users
|
||||
# Waiting on Debian security team to release patches
|
||||
|
||||
# ImageMagick - Integer overflow (32-bit only)
|
||||
CVE-2025-66628
|
||||
|
||||
# glib - Integer overflow in URI escaping
|
||||
CVE-2025-13601
|
||||
|
||||
# HDF5 - Critical vulnerabilities in scientific data format library
|
||||
CVE-2025-2153
|
||||
CVE-2025-2308
|
||||
CVE-2025-2309
|
||||
CVE-2025-2310
|
||||
|
||||
# libmatio - MATLAB file format library
|
||||
CVE-2025-2338
|
||||
|
||||
# OpenEXR - Image format vulnerabilities
|
||||
CVE-2025-12495
|
||||
CVE-2025-12839
|
||||
CVE-2025-12840
|
||||
CVE-2025-64181
|
||||
|
||||
# libvips - Image processing library
|
||||
CVE-2025-59933
|
||||
@@ -8,7 +8,7 @@
|
||||
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
|
||||
|
||||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
|
||||
ARG RUBY_VERSION=3.4.6
|
||||
ARG RUBY_VERSION=4.0.5
|
||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
|
||||
@@ -16,8 +16,9 @@ LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
|
||||
# Rails app lives here
|
||||
WORKDIR /rails
|
||||
|
||||
# Install base packages
|
||||
# Install base packages and upgrade to latest security patches
|
||||
RUN apt-get update -qq && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
|
||||
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
|
||||
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
||||
|
||||
5
Gemfile
5
Gemfile
@@ -1,7 +1,7 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
|
||||
gem "rails", "~> 8.1.1"
|
||||
gem "rails", "~> 8.1.3"
|
||||
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
|
||||
gem "propshaft"
|
||||
# Use sqlite3 as the database for Active Record
|
||||
@@ -90,4 +90,7 @@ group :test do
|
||||
|
||||
# Code coverage analysis
|
||||
gem "simplecov", require: false
|
||||
|
||||
# Pin minitest to < 6.0 until Rails 8.1 supports the new API
|
||||
gem "minitest", "< 6.0"
|
||||
end
|
||||
|
||||
323
Gemfile.lock
323
Gemfile.lock
@@ -1,31 +1,31 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
action_text-trix (2.1.15)
|
||||
action_text-trix (2.1.19)
|
||||
railties
|
||||
actioncable (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
actioncable (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activestorage (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
actionmailbox (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
actionview (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
actionmailer (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.1.1)
|
||||
actionview (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
actionpack (8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
@@ -33,36 +33,36 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.1.1)
|
||||
actiontext (8.1.3)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activestorage (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
actionpack (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
actionview (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
activejob (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
activerecord (8.1.1)
|
||||
activemodel (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
activemodel (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activerecord (8.1.3)
|
||||
activemodel (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
activestorage (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.1.1)
|
||||
activesupport (8.1.3)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
@@ -75,19 +75,19 @@ GEM
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
addressable (2.8.8)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
android_key_attestation (0.3.0)
|
||||
ast (2.4.3)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.20)
|
||||
bcrypt_pbkdf (1.1.1)
|
||||
bigdecimal (3.3.1)
|
||||
bcrypt (3.1.22)
|
||||
bcrypt_pbkdf (1.1.2)
|
||||
bigdecimal (4.1.2)
|
||||
bindata (2.5.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.19.0)
|
||||
bootsnap (1.24.6)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.1.1)
|
||||
brakeman (8.0.5)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
bundler-audit (0.9.3)
|
||||
@@ -102,61 +102,62 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
cbor (0.5.10.1)
|
||||
cbor (0.5.10.3)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.5)
|
||||
concurrent-ruby (1.3.7)
|
||||
connection_pool (3.0.2)
|
||||
cose (1.3.1)
|
||||
cbor (~> 0.5.9)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
crass (1.0.6)
|
||||
date (3.5.0)
|
||||
debug (1.11.0)
|
||||
date (3.5.1)
|
||||
debug (1.11.1)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
docile (1.4.1)
|
||||
dotenv (3.1.8)
|
||||
dotenv (3.2.0)
|
||||
drb (2.2.3)
|
||||
ed25519 (1.4.0)
|
||||
erb (6.0.0)
|
||||
erb (6.0.4)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-aarch64-linux-musl)
|
||||
ffi (1.17.2-arm-linux-gnu)
|
||||
ffi (1.17.2-arm-linux-musl)
|
||||
ffi (1.17.2-arm64-darwin)
|
||||
ffi (1.17.2-x86_64-linux-gnu)
|
||||
ffi (1.17.2-x86_64-linux-musl)
|
||||
fugit (1.12.1)
|
||||
ffi (1.17.4-aarch64-linux-gnu)
|
||||
ffi (1.17.4-aarch64-linux-musl)
|
||||
ffi (1.17.4-arm-linux-gnu)
|
||||
ffi (1.17.4-arm-linux-musl)
|
||||
ffi (1.17.4-arm64-darwin)
|
||||
ffi (1.17.4-x86_64-linux-gnu)
|
||||
ffi (1.17.4-x86_64-linux-musl)
|
||||
fugit (1.12.2)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
i18n (1.14.7)
|
||||
i18n (1.15.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.14.0)
|
||||
mini_magick (>= 4.9.5, < 6)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
importmap-rails (2.2.2)
|
||||
importmap-rails (2.2.3)
|
||||
actionpack (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
io-console (0.8.1)
|
||||
irb (1.15.3)
|
||||
io-console (0.8.2)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jbuilder (2.14.1)
|
||||
jbuilder (2.15.1)
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
json (2.16.0)
|
||||
jwt (3.1.2)
|
||||
json (2.19.9)
|
||||
jwt (3.2.0)
|
||||
base64
|
||||
kamal (2.9.0)
|
||||
kamal (2.12.0)
|
||||
activesupport (>= 7.0)
|
||||
base64 (~> 0.2)
|
||||
bcrypt_pbkdf (~> 1.0)
|
||||
@@ -176,7 +177,7 @@ GEM
|
||||
launchy (>= 2.2, < 4)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.24.1)
|
||||
loofah (2.25.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.9.0)
|
||||
@@ -185,14 +186,14 @@ GEM
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.1.0)
|
||||
marcel (1.2.1)
|
||||
matrix (0.4.3)
|
||||
mini_magick (5.3.1)
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.26.2)
|
||||
msgpack (1.8.0)
|
||||
net-imap (0.5.12)
|
||||
minitest (5.27.0)
|
||||
msgpack (1.8.3)
|
||||
net-imap (0.6.4.1)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -205,78 +206,78 @@ GEM
|
||||
net-ssh (>= 5.0.0, < 8.0.0)
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
net-ssh (7.3.0)
|
||||
net-ssh (7.3.2)
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.18.10-aarch64-linux-gnu)
|
||||
nokogiri (1.19.4-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-aarch64-linux-musl)
|
||||
nokogiri (1.19.4-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-arm-linux-gnu)
|
||||
nokogiri (1.19.4-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-arm-linux-musl)
|
||||
nokogiri (1.19.4-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-arm64-darwin)
|
||||
nokogiri (1.19.4-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-x86_64-linux-gnu)
|
||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-x86_64-linux-musl)
|
||||
nokogiri (1.19.4-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
openssl (3.3.2)
|
||||
openssl (4.0.2)
|
||||
openssl-signature_algorithm (1.3.0)
|
||||
openssl (> 2.0)
|
||||
ostruct (0.6.3)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.10.0)
|
||||
parallel (2.1.0)
|
||||
parser (3.3.11.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.6.0)
|
||||
propshaft (1.3.1)
|
||||
prism (1.9.0)
|
||||
propshaft (1.3.2)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
psych (5.2.6)
|
||||
psych (5.4.0)
|
||||
date
|
||||
stringio
|
||||
public_suffix (7.0.0)
|
||||
puma (7.1.0)
|
||||
public_suffix (7.0.5)
|
||||
puma (8.0.2)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.4)
|
||||
rack-session (2.1.1)
|
||||
rack (3.2.6)
|
||||
rack-session (2.1.2)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
rails (8.1.1)
|
||||
actioncable (= 8.1.1)
|
||||
actionmailbox (= 8.1.1)
|
||||
actionmailer (= 8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
actiontext (= 8.1.1)
|
||||
actionview (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activemodel (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activestorage (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
rails (8.1.3)
|
||||
actioncable (= 8.1.3)
|
||||
actionmailbox (= 8.1.3)
|
||||
actionmailer (= 8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
actiontext (= 8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activemodel (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.1.1)
|
||||
railties (= 8.1.3)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
rails-html-sanitizer (1.7.0)
|
||||
loofah (~> 2.25)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
railties (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
railties (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -284,65 +285,66 @@ GEM
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
rdoc (6.16.1)
|
||||
rake (13.4.2)
|
||||
rdoc (7.2.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
regexp_parser (2.11.3)
|
||||
regexp_parser (2.12.0)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.4.4)
|
||||
rotp (6.3.0)
|
||||
rqrcode (3.1.1)
|
||||
rqrcode (3.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.0.1)
|
||||
rubocop (1.81.7)
|
||||
rqrcode_core (2.1.0)
|
||||
rubocop (1.87.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (~> 1.10)
|
||||
parallel (>= 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-ast (>= 1.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.48.0)
|
||||
rubocop-ast (1.49.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
prism (~> 1.7)
|
||||
rubocop-performance (1.26.1)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.5)
|
||||
ruby-vips (2.3.0)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyzip (3.2.2)
|
||||
rubyzip (3.4.0)
|
||||
safety_net_attestation (0.5.0)
|
||||
jwt (>= 2.0, < 4.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.38.0)
|
||||
selenium-webdriver (4.45.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 4.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (6.2.0)
|
||||
sentry-rails (6.6.2)
|
||||
railties (>= 5.2.0)
|
||||
sentry-ruby (~> 6.2.0)
|
||||
sentry-ruby (6.2.0)
|
||||
sentry-ruby (~> 6.6.2)
|
||||
sentry-ruby (6.6.2)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
logger
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
solid_cable (3.0.12)
|
||||
solid_cable (4.0.0)
|
||||
actioncable (>= 7.2)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
@@ -351,31 +353,31 @@ GEM
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_queue (1.2.4)
|
||||
solid_queue (1.4.0)
|
||||
activejob (>= 7.1)
|
||||
activerecord (>= 7.1)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
fugit (~> 1.11)
|
||||
railties (>= 7.1)
|
||||
thor (>= 1.3.1)
|
||||
sqlite3 (2.8.1-aarch64-linux-gnu)
|
||||
sqlite3 (2.8.1-aarch64-linux-musl)
|
||||
sqlite3 (2.8.1-arm-linux-gnu)
|
||||
sqlite3 (2.8.1-arm-linux-musl)
|
||||
sqlite3 (2.8.1-arm64-darwin)
|
||||
sqlite3 (2.8.1-x86_64-linux-gnu)
|
||||
sqlite3 (2.8.1-x86_64-linux-musl)
|
||||
sshkit (1.24.0)
|
||||
sqlite3 (2.9.5-aarch64-linux-gnu)
|
||||
sqlite3 (2.9.5-aarch64-linux-musl)
|
||||
sqlite3 (2.9.5-arm-linux-gnu)
|
||||
sqlite3 (2.9.5-arm-linux-musl)
|
||||
sqlite3 (2.9.5-arm64-darwin)
|
||||
sqlite3 (2.9.5-x86_64-linux-gnu)
|
||||
sqlite3 (2.9.5-x86_64-linux-musl)
|
||||
sshkit (1.25.0)
|
||||
base64
|
||||
logger
|
||||
net-scp (>= 1.1.2)
|
||||
net-sftp (>= 2.1.2)
|
||||
net-ssh (>= 2.8.0)
|
||||
ostruct
|
||||
standard (1.52.0)
|
||||
standard (1.55.0)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.0)
|
||||
rubocop (~> 1.81.7)
|
||||
rubocop (~> 1.87.0)
|
||||
standard-custom (~> 1.0.0)
|
||||
standard-performance (~> 1.8)
|
||||
standard-custom (1.0.2)
|
||||
@@ -386,28 +388,28 @@ GEM
|
||||
rubocop-performance (~> 1.26.0)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.8)
|
||||
tailwindcss-rails (4.4.0)
|
||||
stringio (3.2.0)
|
||||
tailwindcss-rails (4.6.0)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-ruby (~> 4.0)
|
||||
tailwindcss-ruby (4.1.16)
|
||||
tailwindcss-ruby (4.1.16-aarch64-linux-gnu)
|
||||
tailwindcss-ruby (4.1.16-aarch64-linux-musl)
|
||||
tailwindcss-ruby (4.1.16-arm64-darwin)
|
||||
tailwindcss-ruby (4.1.16-x86_64-linux-gnu)
|
||||
tailwindcss-ruby (4.1.16-x86_64-linux-musl)
|
||||
thor (1.4.0)
|
||||
thruster (0.1.16)
|
||||
thruster (0.1.16-aarch64-linux)
|
||||
thruster (0.1.16-arm64-darwin)
|
||||
thruster (0.1.16-x86_64-linux)
|
||||
timeout (0.4.4)
|
||||
tailwindcss-ruby (4.3.1)
|
||||
tailwindcss-ruby (4.3.1-aarch64-linux-gnu)
|
||||
tailwindcss-ruby (4.3.1-aarch64-linux-musl)
|
||||
tailwindcss-ruby (4.3.1-arm64-darwin)
|
||||
tailwindcss-ruby (4.3.1-x86_64-linux-gnu)
|
||||
tailwindcss-ruby (4.3.1-x86_64-linux-musl)
|
||||
thor (1.5.0)
|
||||
thruster (0.1.21)
|
||||
thruster (0.1.21-aarch64-linux)
|
||||
thruster (0.1.21-arm64-darwin)
|
||||
thruster (0.1.21-x86_64-linux)
|
||||
timeout (0.6.1)
|
||||
tpm-key_attestation (0.14.1)
|
||||
bindata (~> 2.4)
|
||||
openssl (> 2.0)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.20)
|
||||
turbo-rails (2.0.23)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
@@ -417,11 +419,10 @@ GEM
|
||||
unicode-emoji (4.2.0)
|
||||
uri (1.1.1)
|
||||
useragent (0.16.11)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
web-console (4.3.0)
|
||||
actionview (>= 8.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
railties (>= 8.0.0)
|
||||
webauthn (3.4.3)
|
||||
android_key_attestation (~> 0.3.0)
|
||||
bindata (~> 2.4)
|
||||
@@ -431,13 +432,13 @@ GEM
|
||||
safety_net_attestation (~> 0.5.0)
|
||||
tpm-key_attestation (~> 0.14.0)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.8.0)
|
||||
websocket-driver (0.8.1)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.7.3)
|
||||
zeitwerk (2.8.2)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
@@ -446,6 +447,7 @@ PLATFORMS
|
||||
arm-linux-gnu
|
||||
arm-linux-musl
|
||||
arm64-darwin-24
|
||||
arm64-darwin-25
|
||||
x86_64-linux
|
||||
x86_64-linux-gnu
|
||||
x86_64-linux-musl
|
||||
@@ -463,10 +465,11 @@ DEPENDENCIES
|
||||
jwt (~> 3.1)
|
||||
kamal
|
||||
letter_opener
|
||||
minitest (< 6.0)
|
||||
propshaft
|
||||
public_suffix (~> 7.0)
|
||||
puma (>= 5.0)
|
||||
rails (~> 8.1.1)
|
||||
rails (~> 8.1.3)
|
||||
rotp (~> 6.3)
|
||||
rqrcode (~> 3.1)
|
||||
selenium-webdriver
|
||||
@@ -487,4 +490,4 @@ DEPENDENCIES
|
||||
webauthn (~> 3.0)
|
||||
|
||||
BUNDLED WITH
|
||||
2.7.2
|
||||
4.0.6
|
||||
|
||||
223
README.md
223
README.md
@@ -1,8 +1,10 @@
|
||||
# Clinch
|
||||
|
||||
## Position and Control for your Authentication
|
||||
> [!NOTE]
|
||||
> This software is experimental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
|
||||
|
||||
We do these things not because they're easy, but because we thought they'd be easy.
|
||||
|
||||
**A lightweight, self-hosted identity & SSO / IpD portal**
|
||||
|
||||
Clinch gives you one place to manage users and lets any web app authenticate against it without managing its own users.
|
||||
@@ -13,14 +15,20 @@ Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea, Grafana, P
|
||||
|
||||
Clinch runs as a single Docker container, using SQLite as the database, the job queue (Solid Queue) and the shared cache (Solid Cache). The webserver, Puma, runs the job queue in-process, avoiding the need for another container.
|
||||
|
||||
Clinch sits in a sweet spot between two excellent open-source identity solutions:
|
||||
Clinch sits in a sweet spot among several 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.
|
||||
|
||||
**[VoidAuth](https://voidauth.app/)** is an open-source SSO provider with a similar feature set to Clinch — OIDC, ForwardAuth, passkeys, user management, and easy Docker deployment. If you're evaluating self-hosted auth solutions, it's well worth a look.
|
||||
|
||||
**[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.
|
||||
|
||||
- **[Passes the OpenID Connect Conformance Tests](https://www.certification.openid.net/plan-detail.html?plan=FbQNTJuYVzrzs&public=true)** — verified against the official OIDC test suite
|
||||
- **450+ tests, 1800+ assertions** — comprehensive test coverage across integration, model, controller, and security tests
|
||||
- **Single Docker container** — SQLite, job queue, and cache all in one process
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
@@ -73,6 +81,7 @@ Apps that speak OIDC use the OIDC flow.
|
||||
Apps that only need "who is it?", or you want available from the internet behind authentication (MeTube, Jellyfin) use ForwardAuth.
|
||||
|
||||
#### OpenID Connect (OIDC)
|
||||
|
||||
Standard OAuth2/OIDC provider with endpoints:
|
||||
- `/.well-known/openid-configuration` - Discovery endpoint
|
||||
- `/authorize` - Authorization endpoint with PKCE support
|
||||
@@ -126,6 +135,32 @@ Works with reverse proxies (Caddy, Traefik, Nginx):
|
||||
|
||||
**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.
|
||||
|
||||
#### API Keys (Bearer Tokens)
|
||||
|
||||
For server-to-server access to ForwardAuth-protected services (e.g., a video player accessing WebDAV, rclone syncing files), Clinch supports API keys that work as bearer tokens — no browser or cookies needed.
|
||||
|
||||
- **Token format:** `clk_<base64>` prefix for easy identification
|
||||
- **Storage:** HMAC-SHA256 hashed (plaintext shown once at creation, never stored)
|
||||
- **Scope:** Each key is tied to one ForwardAuth application and one user
|
||||
- **Expiration:** Optional — set a date or leave blank for no expiry
|
||||
- **Auth flow:** `Authorization: Bearer clk_...` header checked before cookie auth
|
||||
- **Failure response:** 401 JSON `{"error": "..."}` (no redirect)
|
||||
|
||||
**Creating an API key:**
|
||||
1. Go to **Dashboard → Manage API Keys** (or `/api_keys`)
|
||||
2. Click **New API Key**, select a ForwardAuth application, and give it a name
|
||||
3. Copy the `clk_...` token — it's shown only once
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
curl -H "Authorization: Bearer clk_..." \
|
||||
-H "X-Forwarded-Host: webdav.example.com" \
|
||||
https://auth.example.com/api/verify
|
||||
# Returns 200 with X-Remote-User headers on success
|
||||
```
|
||||
|
||||
API keys respect the same access controls as browser sessions — the user must have access to the application, the application must be active, and the user's account must be active.
|
||||
|
||||
### SMTP Integration
|
||||
Send emails for:
|
||||
- Invitation links (one-time token, 7-day expiry)
|
||||
@@ -282,7 +317,7 @@ This is transparent to end users and requires no configuration.
|
||||
## Setup & Installation
|
||||
|
||||
### Requirements
|
||||
- Ruby 3.3+
|
||||
- Ruby 4.0+
|
||||
- SQLite 3.8+
|
||||
- SMTP server (for sending emails)
|
||||
|
||||
@@ -306,21 +341,112 @@ bin/dev
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Docker
|
||||
### Docker Compose (Recommended)
|
||||
|
||||
Create a `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
clinch:
|
||||
image: ghcr.io/dkam/clinch:latest
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000" # Bind to localhost only (reverse proxy on same host)
|
||||
# Use "3000:3000" if reverse proxy is in Docker network or different host
|
||||
environment:
|
||||
# Rails Configuration
|
||||
RAILS_ENV: production
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
||||
|
||||
# Application Configuration
|
||||
CLINCH_HOST: ${CLINCH_HOST}
|
||||
CLINCH_FROM_EMAIL: ${CLINCH_FROM_EMAIL:-noreply@example.com}
|
||||
|
||||
# SMTP Configuration
|
||||
SMTP_ADDRESS: ${SMTP_ADDRESS}
|
||||
SMTP_PORT: ${SMTP_PORT}
|
||||
SMTP_DOMAIN: ${SMTP_DOMAIN}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||
SMTP_AUTHENTICATION: ${SMTP_AUTHENTICATION:-plain}
|
||||
SMTP_ENABLE_STARTTLS: ${SMTP_ENABLE_STARTTLS:-true}
|
||||
|
||||
# OIDC Configuration (optional - generates temporary key if not provided)
|
||||
OIDC_PRIVATE_KEY: ${OIDC_PRIVATE_KEY}
|
||||
|
||||
# Optional Configuration
|
||||
FORCE_SSL: ${FORCE_SSL:-false}
|
||||
volumes:
|
||||
- ./storage:/rails/storage
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Create a `.env` file in the same directory:
|
||||
|
||||
**Generate required secrets first:**
|
||||
|
||||
```bash
|
||||
# Build image
|
||||
docker build -t clinch .
|
||||
# Generate SECRET_KEY_BASE (required)
|
||||
openssl rand -hex 64
|
||||
|
||||
# Run container
|
||||
docker run -p 3000:3000 \
|
||||
-v clinch-storage:/rails/storage \
|
||||
-e SECRET_KEY_BASE=your-secret-key \
|
||||
-e SMTP_ADDRESS=smtp.example.com \
|
||||
-e SMTP_PORT=587 \
|
||||
-e SMTP_USERNAME=your-username \
|
||||
-e SMTP_PASSWORD=your-password \
|
||||
clinch
|
||||
# Generate OIDC private key (optional - auto-generated if not provided)
|
||||
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
|
||||
cat private_key.pem # Copy the output into OIDC_PRIVATE_KEY below
|
||||
```
|
||||
|
||||
**Then create `.env`:**
|
||||
|
||||
```bash
|
||||
# Rails Secret (REQUIRED)
|
||||
SECRET_KEY_BASE=paste-output-from-openssl-rand-hex-64-here
|
||||
|
||||
# Application URLs (REQUIRED)
|
||||
CLINCH_HOST=https://auth.yourdomain.com
|
||||
CLINCH_FROM_EMAIL=noreply@yourdomain.com
|
||||
|
||||
# SMTP Settings (REQUIRED for invitations and password resets)
|
||||
SMTP_ADDRESS=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_DOMAIN=yourdomain.com
|
||||
SMTP_USERNAME=your-smtp-username
|
||||
SMTP_PASSWORD=your-smtp-password
|
||||
|
||||
# OIDC Private Key (OPTIONAL - generates temporary key if not provided)
|
||||
# For production, generate a persistent key and paste the ENTIRE contents here
|
||||
OIDC_PRIVATE_KEY=
|
||||
|
||||
# Optional: Force SSL redirects (only if NOT behind a reverse proxy handling SSL)
|
||||
FORCE_SSL=false
|
||||
```
|
||||
|
||||
Start Clinch:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**First Run:**
|
||||
1. Visit `http://localhost:3000` (or your configured domain)
|
||||
2. Complete the first-run wizard to create your admin account
|
||||
3. Configure applications and invite users
|
||||
|
||||
**Upgrading:**
|
||||
|
||||
```bash
|
||||
# Pull latest image
|
||||
docker compose pull
|
||||
|
||||
# Restart with new image (migrations run automatically)
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Logs:**
|
||||
|
||||
```bash
|
||||
# View logs
|
||||
docker compose logs -f clinch
|
||||
|
||||
# View last 100 lines
|
||||
docker compose logs --tail=100 clinch
|
||||
```
|
||||
|
||||
### Backup & Restore
|
||||
@@ -336,9 +462,6 @@ Use SQLite's `VACUUM INTO` command for safe, atomic backups of a running databas
|
||||
```bash
|
||||
# Local development
|
||||
sqlite3 storage/production.sqlite3 "VACUUM INTO 'backup.sqlite3';"
|
||||
|
||||
# Docker
|
||||
docker exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup.sqlite3';"
|
||||
```
|
||||
|
||||
This creates an optimized copy of the database that's safe to make even while Clinch is running.
|
||||
@@ -354,9 +477,9 @@ sqlite3 storage/production.sqlite3 "VACUUM INTO 'backup-$(date +%Y%m%d).sqlite3'
|
||||
# 2. Backup uploaded files (ActiveStorage files are immutable)
|
||||
tar -czf uploads-backup-$(date +%Y%m%d).tar.gz storage/uploads/
|
||||
|
||||
# Docker equivalent
|
||||
docker exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup-$(date +%Y%m%d).sqlite3';"
|
||||
docker exec clinch tar -czf /rails/storage/uploads-backup-$(date +%Y%m%d).tar.gz /rails/storage/uploads/
|
||||
# Docker Compose equivalent
|
||||
docker compose exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup-$(date +%Y%m%d).sqlite3';"
|
||||
docker compose exec clinch tar -czf /rails/storage/uploads-backup-$(date +%Y%m%d).tar.gz /rails/storage/uploads/
|
||||
```
|
||||
|
||||
**Restore:**
|
||||
@@ -383,13 +506,13 @@ sqlite3 /host/path/production.sqlite3 "VACUUM INTO '/host/path/backup-$(date +%Y
|
||||
rsync -av /host/path/backup-*.sqlite3 /host/path/uploads/ remote:/backups/clinch/
|
||||
```
|
||||
|
||||
b) **Docker volumes** (e.g., `-v clinch_storage:/rails/storage`):
|
||||
b) **Docker volumes** (e.g., using named volumes in compose):
|
||||
```bash
|
||||
# Database backup (safe while running)
|
||||
docker exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup.sqlite3';"
|
||||
docker compose exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup.sqlite3';"
|
||||
|
||||
# Copy out of container
|
||||
docker cp clinch:/rails/storage/backup.sqlite3 ./backup-$(date +%Y%m%d).sqlite3
|
||||
docker compose cp clinch:/rails/storage/backup.sqlite3 ./backup-$(date +%Y%m%d).sqlite3
|
||||
```
|
||||
|
||||
**Option 2: While Stopped (Offline Backup)**
|
||||
@@ -414,35 +537,7 @@ docker compose up -d
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create a `.env` file (see `.env.example`):
|
||||
|
||||
```bash
|
||||
# Rails
|
||||
SECRET_KEY_BASE=generate-with-bin-rails-secret
|
||||
RAILS_ENV=production
|
||||
|
||||
# Database
|
||||
# SQLite database stored in storage/ directory (Docker volume mount point)
|
||||
|
||||
# SMTP (for sending emails)
|
||||
SMTP_ADDRESS=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_DOMAIN=example.com
|
||||
SMTP_USERNAME=your-username
|
||||
SMTP_PASSWORD=your-password
|
||||
SMTP_AUTHENTICATION=plain
|
||||
SMTP_ENABLE_STARTTLS=true
|
||||
|
||||
# Application
|
||||
CLINCH_HOST=https://auth.example.com
|
||||
CLINCH_FROM_EMAIL=noreply@example.com
|
||||
|
||||
# OIDC (optional - generates temporary key in development)
|
||||
# Generate with: openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
|
||||
OIDC_PRIVATE_KEY=<contents-of-private-key.pem>
|
||||
```
|
||||
All configuration is handled via environment variables (see the `.env` file in the Docker Compose section above).
|
||||
|
||||
### First Run
|
||||
1. Visit Clinch at `http://localhost:3000` (or your configured domain)
|
||||
@@ -636,7 +731,7 @@ user.revoke_all_consents!
|
||||
|
||||
### Running Tests
|
||||
|
||||
Clinch has comprehensive test coverage with 341 tests covering integration, models, controllers, services, and system tests.
|
||||
Clinch has comprehensive test coverage with 450 tests covering integration, models, controllers, services, and system tests.
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
@@ -667,18 +762,36 @@ bin/bundler-audit check --update # Dependency vulnerability scan
|
||||
bin/importmap audit # JavaScript dependency scan
|
||||
```
|
||||
|
||||
**Container Image Scanning:**
|
||||
|
||||
```bash
|
||||
# Install Trivy
|
||||
brew install trivy # macOS
|
||||
# or use Docker: alias trivy='docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy'
|
||||
|
||||
# Build and scan image (CRITICAL and HIGH severity only, like CI)
|
||||
docker build -t clinch:local .
|
||||
trivy image --severity CRITICAL,HIGH --scanners vuln clinch:local
|
||||
|
||||
# Scan only for fixable vulnerabilities
|
||||
trivy image --severity CRITICAL,HIGH --scanners vuln --ignore-unfixed clinch:local
|
||||
```
|
||||
|
||||
**CI/CD Integration:**
|
||||
All security scans run automatically on every pull request and push to main via GitHub Actions.
|
||||
|
||||
**Security Tools:**
|
||||
- **Brakeman** - Static analysis for Rails security vulnerabilities
|
||||
- **bundler-audit** - Checks gems for known CVEs
|
||||
- **Trivy** - Container image vulnerability scanning (OS/system packages)
|
||||
- **Dependabot** - Automated dependency updates
|
||||
- **GitHub Secret Scanning** - Detects leaked credentials with push protection
|
||||
- **SimpleCov** - Code coverage tracking
|
||||
- **RuboCop** - Code style and quality enforcement
|
||||
|
||||
**Current Status:**
|
||||
- ✅ All security scans passing
|
||||
- ✅ 341 tests, 1349 assertions, 0 failures
|
||||
- ✅ 450 tests, 1818 assertions, 0 failures
|
||||
- ✅ No known dependency vulnerabilities
|
||||
- ✅ Phases 1-4 security hardening complete (18+ vulnerabilities fixed)
|
||||
- 🟡 3 outstanding security issues (all MEDIUM/LOW priority)
|
||||
|
||||
110
SECURITY_REVIEW_TODO.md
Normal file
110
SECURITY_REVIEW_TODO.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Security Review — Tracking
|
||||
|
||||
Status of findings from the multi-surface security review (OIDC/OAuth2, ForwardAuth,
|
||||
WebAuthn/TOTP, sessions, admin/config). Work landed on branch
|
||||
`security/forward-auth-and-consent-csrf`.
|
||||
|
||||
## ✅ Done (branch `security/forward-auth-and-consent-csrf`)
|
||||
|
||||
All HIGH findings are closed. Each fix has tests; suite is green.
|
||||
|
||||
| Commit | Fix | Sev |
|
||||
|--------|-----|-----|
|
||||
| `703d24e` | ForwardAuth fail-open when no host header; consent endpoint CSRF | HIGH ×2 |
|
||||
| `8a095e4` | Bearer API-key skipped group check at use-time | HIGH |
|
||||
| `96a657e` | Open redirect via unvalidated `X-Forwarded-Host` in login redirect | HIGH |
|
||||
| `84ed462` | `CLINCH_HOST` made mandatory in deployed envs; dropped request-host fallback | MEDIUM |
|
||||
| `f38ac2e` | TOTP code replay within drift window (+ latent plaintext backup-code bug) | HIGH |
|
||||
| `406a79d` | SSRF via `backchannel_logout_uri` (metadata/loopback/RFC1918) | HIGH |
|
||||
| `57d7d1f` | Host-auth regex unanchored (`evil-example.com` matched) | HIGH |
|
||||
| `89bd5f1` | Disabled user could complete 2FA mid-flow / keep session; enforce active status | HIGH |
|
||||
| `cd862c7` | TOTP/backup/OAuth/PKCE `code` params not filtered from logs | MEDIUM |
|
||||
| `2426687` | `revoke_family!` didn't revoke access tokens on refresh-token reuse | HIGH |
|
||||
| `44892e3` | WebAuthn clone detection logged but didn't block; false-positive on synced passkeys | HIGH |
|
||||
| `d49e7ce` | CSP `unsafe-inline` removed (script-src + style-src → nonces) | HIGH |
|
||||
|
||||
**Verified false positive (no change):** PKCE *is* required by default —
|
||||
`require_pkce` column defaults to `true` (`db/schema.rb`), token endpoint enforces
|
||||
it, admin UI exposes the opt-out. Operational check: confirm no legacy confidential
|
||||
apps sit on `require_pkce = false`.
|
||||
|
||||
**Follow-up before relying on CSP change:** do one manual browser pass (DevTools
|
||||
console) on `/signin`, OAuth consent, a Turbo navigation, dark-mode toggle, and a
|
||||
WebAuthn sign-in — expect zero CSP violations. Dev is report-only so violations
|
||||
surface as warnings without breaking. Fallback if style-src surprises: keep
|
||||
`style-src 'unsafe-inline'`, ship script-src only.
|
||||
|
||||
## ☐ Remaining — MEDIUM
|
||||
|
||||
- [ ] **`id_token_hint` ignored at OIDC logout** — any client can redirect logout to
|
||||
any other registered client's post-logout URI. Validate the hint's `aud` and
|
||||
scope the redirect to that app. `app/controllers/oidc_controller.rb` (logout).
|
||||
- [ ] **`offline_access` doesn't gate refresh-token issuance** — refresh tokens are
|
||||
minted unconditionally; gate on the granted scope.
|
||||
`app/controllers/oidc_controller.rb` (authorization_code grant, ~line 564).
|
||||
- [ ] **CSP-report endpoint hardening** — unauthenticated, no rate limit / body-size
|
||||
cap, logs raw CRLF (log injection). Sanitize values, cap size, rate-limit.
|
||||
`app/controllers/api/csp_controller.rb`.
|
||||
- [ ] **Port not stripped from `X-Forwarded-Host`** in main verify + bearer paths →
|
||||
403 outages on non-standard ports (also a correctness bug). Reuse the
|
||||
port-stripping done in `check_forward_auth_token`.
|
||||
`app/controllers/api/forward_auth_controller.rb`.
|
||||
- [ ] **WebAuthn `acr:"2"` without enforced user verification** — `user_verification:
|
||||
"preferred"` lets a PIN-less key authenticate yet reports verified 2FA. Use
|
||||
`"required"`, or downgrade `acr` to `"1"` when the UV flag is absent.
|
||||
`app/controllers/sessions_controller.rb` (webauthn_challenge/verify),
|
||||
`app/controllers/webauthn_controller.rb`.
|
||||
- [ ] **`RESERVED_CLAIMS` incomplete** — missing `at_hash`/`auth_time`/`acr`; and
|
||||
`ApplicationUserClaims` has no reserved-name validation (User/Group do). Could
|
||||
let a custom claim overwrite a security claim. `app/services/oidc_jwt_service.rb`,
|
||||
`app/models/application_user_claim.rb`.
|
||||
- [ ] **`reset_session` not called on login** — defensive best practice for an IdP;
|
||||
clears pre-auth session state. `app/controllers/concerns/authentication.rb`
|
||||
(`start_new_session_for`).
|
||||
- [x] **Hardcoded private IP `192.168.2.246`** in `config/environments/production.rb`
|
||||
— removed; it was redundant with the `192.168.0.0/16` regex already in the
|
||||
`CLINCH_ALLOW_INTERNAL_IPS` block.
|
||||
- [ ] **CSP `form-action` widened by unvalidated `redirect_uri`** before auth — only
|
||||
add to `form-action` if the client_id+redirect_uri is a registered pair.
|
||||
`app/controllers/concerns/authentication.rb` (`allow_oauth_redirect_in_csp`).
|
||||
- [ ] **SVG `style` attribute permits `url()`/`expression()`** — mitigated today by
|
||||
`Content-Disposition: attachment`, but fragile. Sanitize CSS values or drop
|
||||
`style` from the allowlist. `app/models/svg_scrubber.rb`.
|
||||
- [ ] **WebAuthn error messages leak internals** — return generic errors to client,
|
||||
log detail server-side. `app/controllers/sessions_controller.rb`,
|
||||
`app/controllers/webauthn_controller.rb`.
|
||||
- [ ] **Account enumeration via webauthn challenge** — distinguishes "user not found"
|
||||
vs "no passkey". Return a uniform message. `app/controllers/sessions_controller.rb`
|
||||
(`webauthn_challenge`).
|
||||
- [ ] **`token_family_id` only 31 bits** (`SecureRandom.random_number(2**31)`) —
|
||||
birthday collision ~46k; use a UUID/string. `app/models/oidc_refresh_token.rb`.
|
||||
- [ ] **Session cookie uses sequential integer DB id** — HMAC-signed so not forgeable,
|
||||
but consider a random `token` column (Rails 8 generator default).
|
||||
`app/models/session.rb`, `app/controllers/concerns/authentication.rb`.
|
||||
- [ ] **Login rate-limit is IP-only** — no account lockout (distributed brute force /
|
||||
credential stuffing). Add failed-count + `locked_until` on users.
|
||||
- [ ] **Backup-code rate limit not reset on success** and is cache-based (resets on
|
||||
cache flush). Reset on success; consider DB-backed counter. `app/models/user.rb`.
|
||||
|
||||
## ☐ Remaining — LOW / INFO
|
||||
|
||||
- [ ] Public clients can't revoke their own tokens (revoke endpoint requires secret).
|
||||
- [ ] Basic-auth client creds not URL-decoded per RFC 6749 §2.3.1.
|
||||
- [ ] `token_hmac` columns nullable at DB level despite model `presence: true`.
|
||||
- [ ] Group names allow commas → injection into `X-Remote-Groups` (false memberships
|
||||
downstream). Add a format validator. `app/models/group.rb`.
|
||||
- [ ] `fa_token` leaks in redirect URL / Referer / history (60s TTL, host-bound).
|
||||
- [ ] Admin `domain_pattern` allows ReDoS — add a format validator.
|
||||
`app/models/application.rb`.
|
||||
- [ ] Forced-TOTP-setup login path can redirect-loop (`totp_required` + no TOTP).
|
||||
- [ ] `complete_setup` creates an unprompted session for any authenticated user.
|
||||
- [ ] Password min length only 8 — consider 12 + a max (bcrypt 72-byte truncation).
|
||||
- [ ] `support_unencrypted_data: true` left enabled (TOTP secret encryption migration).
|
||||
`config/initializers/active_record_encryption.rb`.
|
||||
- [ ] All crypto keys derived from a single `SECRET_KEY_BASE` root — document setting
|
||||
independent `ACTIVE_RECORD_ENCRYPTION_*` keys in production.
|
||||
- [ ] Log injection via user `email_address` in ForwardAuth logs (strip CRLF / use
|
||||
structured logging). `app/controllers/api/forward_auth_controller.rb`.
|
||||
- [ ] WebAuthn RP ID is the registrable domain (cross-subdomain credential roaming) —
|
||||
set `CLINCH_RP_ID` to the exact host unless roaming is intended.
|
||||
`config/initializers/webauthn.rb`.
|
||||
@@ -1 +1,29 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/forms";
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@layer base {
|
||||
input:where([type="text"], [type="email"], [type="password"], [type="number"], [type="url"], [type="tel"], [type="search"]),
|
||||
textarea,
|
||||
select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.dark input:where([type="text"], [type="email"], [type="password"], [type="number"], [type="url"], [type="tel"], [type="search"]),
|
||||
.dark textarea,
|
||||
.dark select {
|
||||
background-color: var(--color-gray-800);
|
||||
border-color: var(--color-gray-600);
|
||||
color: var(--color-gray-100);
|
||||
}
|
||||
|
||||
.dark input::placeholder,
|
||||
.dark textarea::placeholder {
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
|
||||
.dark input:where([type="checkbox"], [type="radio"]) {
|
||||
background-color: var(--color-gray-700);
|
||||
border-color: var(--color-gray-500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ class ActiveSessionsController < ApplicationController
|
||||
Rails.logger.info "ActiveSessionsController: Logged out from #{application.name} - revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens"
|
||||
|
||||
# Keep the consent intact - this is the key difference from revoke_consent
|
||||
redirect_to root_path, notice: "Successfully logged out of #{application.name}."
|
||||
redirect_to root_path, notice: "Revoked access tokens for #{application.name}. Re-authentication will be required on next use."
|
||||
end
|
||||
|
||||
def revoke_all_consents
|
||||
|
||||
20
app/controllers/admin/access_checks_controller.rb
Normal file
20
app/controllers/admin/access_checks_controller.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
module Admin
|
||||
class AccessChecksController < BaseController
|
||||
def new
|
||||
load_options
|
||||
@user = User.find_by(id: params[:user_id])
|
||||
@application = Application.find_by(id: params[:application_id])
|
||||
return unless @user && @application
|
||||
|
||||
@allowed = @application.user_allowed?(@user)
|
||||
@via = @user.groups & @application.allowed_groups
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_options
|
||||
@users = User.order(:email_address)
|
||||
@applications = Application.order(:name)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,11 +3,31 @@ module Admin
|
||||
before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials]
|
||||
|
||||
def index
|
||||
@applications = Application.order(created_at: :desc)
|
||||
@applications = Application.order(created_at: :desc).includes(:allowed_groups)
|
||||
|
||||
# Distinct active users that have access to each app, preloaded to avoid N+1.
|
||||
@user_count_by_app = User.where(status: User.statuses[:active])
|
||||
.joins(groups: :applications)
|
||||
.group("applications.id")
|
||||
.distinct
|
||||
.count("users.id")
|
||||
|
||||
# Top-of-page summary
|
||||
@total_users_with_access = User.where(status: User.statuses[:active])
|
||||
.joins(groups: :applications)
|
||||
.distinct
|
||||
.count("users.id")
|
||||
@total_groups_granting_access = Group.joins(:applications).distinct.count
|
||||
end
|
||||
|
||||
def show
|
||||
@allowed_groups = @application.allowed_groups
|
||||
@users_with_access = User.where(status: User.statuses[:active])
|
||||
.joins(groups: :applications)
|
||||
.where(applications: {id: @application.id})
|
||||
.distinct
|
||||
.includes(:groups)
|
||||
.order(:email_address)
|
||||
end
|
||||
|
||||
def new
|
||||
@@ -104,7 +124,7 @@ module Admin
|
||||
permitted = params.require(:application).permit(
|
||||
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
||||
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
|
||||
:icon, :backchannel_logout_uri, :is_public_client, :require_pkce
|
||||
:icon, :icon_dark, :backchannel_logout_uri, :is_public_client, :require_pkce, :skip_consent
|
||||
)
|
||||
|
||||
# Handle headers_config - it comes as a JSON string from the text area
|
||||
|
||||
@@ -15,6 +15,7 @@ module Admin
|
||||
def new
|
||||
@group = Group.new
|
||||
@available_users = User.order(:email_address)
|
||||
@available_applications = Application.order(:name)
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -28,6 +29,7 @@ module Admin
|
||||
@group = Group.new
|
||||
@group.errors.add(:custom_claims, "must be valid JSON")
|
||||
@available_users = User.order(:email_address)
|
||||
@available_applications = Application.order(:name)
|
||||
render :new, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
@@ -45,15 +47,23 @@ module Admin
|
||||
@group.users = User.where(id: user_ids)
|
||||
end
|
||||
|
||||
# Handle application assignments
|
||||
if params[:group][:application_ids].present?
|
||||
application_ids = params[:group][:application_ids].reject(&:blank?)
|
||||
@group.applications = Application.where(id: application_ids)
|
||||
end
|
||||
|
||||
redirect_to admin_group_path(@group), notice: "Group created successfully."
|
||||
else
|
||||
@available_users = User.order(:email_address)
|
||||
@available_applications = Application.order(:name)
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@available_users = User.order(:email_address)
|
||||
@available_applications = Application.order(:name)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -66,6 +76,7 @@ module Admin
|
||||
rescue JSON::ParserError
|
||||
@group.errors.add(:custom_claims, "must be valid JSON")
|
||||
@available_users = User.order(:email_address)
|
||||
@available_applications = Application.order(:name)
|
||||
render :edit, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
@@ -83,9 +94,18 @@ module Admin
|
||||
@group.users = []
|
||||
end
|
||||
|
||||
# Handle application assignments
|
||||
if params[:group][:application_ids].present?
|
||||
application_ids = params[:group][:application_ids].reject(&:blank?)
|
||||
@group.applications = Application.where(id: application_ids)
|
||||
else
|
||||
@group.applications = []
|
||||
end
|
||||
|
||||
redirect_to admin_group_path(@group), notice: "Group updated successfully."
|
||||
else
|
||||
@available_users = User.order(:email_address)
|
||||
@available_applications = Application.order(:name)
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -102,7 +122,7 @@ module Admin
|
||||
end
|
||||
|
||||
def group_params
|
||||
params.require(:group).permit(:name, :description, :custom_claims)
|
||||
params.require(:group).permit(:name, :description, :custom_claims, :auto_assign, :admin)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,27 +7,38 @@ module Admin
|
||||
end
|
||||
|
||||
def show
|
||||
@accessible_applications = Application.where(active: true)
|
||||
.joins(:allowed_groups)
|
||||
.where(groups: {id: @user.groups})
|
||||
.distinct
|
||||
.includes(:allowed_groups)
|
||||
.order(:name)
|
||||
end
|
||||
|
||||
def new
|
||||
@user = User.new
|
||||
@available_groups = Group.order(:name)
|
||||
end
|
||||
|
||||
def create
|
||||
@user = User.new(user_params)
|
||||
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
|
||||
@user.status = :pending_invitation
|
||||
@user.skip_auto_assign = true if params[:auto_assign] == "0"
|
||||
|
||||
if @user.save
|
||||
assign_groups_from_params(@user)
|
||||
InvitationsMailer.invite_user(@user).deliver_later
|
||||
redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}."
|
||||
else
|
||||
@available_groups = Group.order(:name)
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@applications = Application.active.order(:name)
|
||||
@available_groups = Group.order(:name)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -43,6 +54,7 @@ module Admin
|
||||
rescue JSON::ParserError
|
||||
@user.errors.add(:custom_claims, "must be valid JSON")
|
||||
@applications = Application.active.order(:name)
|
||||
@available_groups = Group.order(:name)
|
||||
render :edit, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
@@ -52,9 +64,16 @@ module Admin
|
||||
end
|
||||
|
||||
if @user.update(update_params)
|
||||
unless assign_groups_from_params(@user)
|
||||
@applications = Application.active.order(:name)
|
||||
@available_groups = Group.order(:name)
|
||||
render :edit, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
redirect_to admin_users_path, notice: "User updated successfully."
|
||||
else
|
||||
@applications = Application.active.order(:name)
|
||||
@available_groups = Group.order(:name)
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -122,15 +141,28 @@ module Admin
|
||||
end
|
||||
|
||||
def user_params
|
||||
# Base attributes that all admins can modify
|
||||
base_params = params.require(:user).permit(:email_address, :username, :name, :password, :status, :totp_required, :custom_claims)
|
||||
|
||||
# Only allow modifying admin status when editing other users (prevent self-demotion)
|
||||
if params[:id] != Current.session.user.id.to_s
|
||||
base_params[:admin] = params[:user][:admin] if params[:user][:admin].present?
|
||||
params.require(:user).permit(:email_address, :username, :name, :password, :status, :totp_required, :custom_claims)
|
||||
end
|
||||
|
||||
base_params
|
||||
# Apply group_ids from the form, with a guard preventing self-demotion when
|
||||
# the user is the last member of the last admin group. Returns true on
|
||||
# success, false if a guard fired (caller should re-render).
|
||||
def assign_groups_from_params(user)
|
||||
return true unless params[:user].key?(:group_ids)
|
||||
|
||||
raw_ids = Array(params[:user][:group_ids]).reject(&:blank?).map(&:to_i)
|
||||
new_groups = Group.where(id: raw_ids)
|
||||
|
||||
if user == Current.session.user
|
||||
losing_admin = user.groups.where(admin: true).any? && new_groups.none?(&:admin?)
|
||||
if losing_admin
|
||||
user.errors.add(:base, "you cannot remove yourself from all administrator groups")
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
user.groups = new_groups
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,69 +1,58 @@
|
||||
module Api
|
||||
class ForwardAuthController < ApplicationController
|
||||
# ForwardAuth endpoints need session storage for return URL
|
||||
allow_unauthenticated_access
|
||||
skip_before_action :verify_authenticity_token
|
||||
# No rate limiting on forward_auth endpoint - proxy middleware hits this frequently
|
||||
|
||||
before_action :check_forward_auth_rate_limit
|
||||
after_action :track_failed_forward_auth_attempt
|
||||
|
||||
# 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
|
||||
# Called by reverse proxies (Traefik, Caddy, nginx) to verify authentication and authorization.
|
||||
def verify
|
||||
# Note: app_slug parameter is no longer used - we match domains directly with Application (forward_auth type)
|
||||
bearer_result = authenticate_bearer_token
|
||||
return bearer_result if bearer_result
|
||||
|
||||
# Check for one-time forward auth token first (to handle race condition)
|
||||
session_id = check_forward_auth_token
|
||||
|
||||
# If no token found, try to get session from cookie
|
||||
session_id ||= extract_session_id
|
||||
|
||||
unless session_id
|
||||
# No session cookie or token - user is not authenticated
|
||||
return render_unauthorized("No session cookie")
|
||||
end
|
||||
|
||||
# Find the session with user association (eager loading for performance)
|
||||
session = Session.includes(:user).find_by(id: session_id)
|
||||
session = Session.includes(user: :groups).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 (skip validations for performance)
|
||||
# Debounce last_activity_at updates (at most once per minute)
|
||||
if session.last_activity_at.nil? || session.last_activity_at < 1.minute.ago
|
||||
session.update_column(:last_activity_at, Time.current)
|
||||
end
|
||||
|
||||
# Get the user (already loaded via includes(:user))
|
||||
user = session.user
|
||||
unless user.active?
|
||||
return render_unauthorized("User account is not active")
|
||||
end
|
||||
|
||||
# Check for forward auth application authorization
|
||||
# Get the forwarded host for domain matching
|
||||
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
||||
app = nil
|
||||
|
||||
if forwarded_host.present?
|
||||
# Load all forward auth applications (including inactive ones) for security checks
|
||||
# Preload groups to avoid N+1 queries in user_allowed? checks
|
||||
apps = Application.forward_auth.includes(:allowed_groups)
|
||||
apps = cached_forward_auth_apps
|
||||
|
||||
# Find matching forward auth application for this domain
|
||||
app = apps.find { |a| a.matches_domain?(forwarded_host) }
|
||||
|
||||
if app
|
||||
# Check if application is active
|
||||
unless app.active?
|
||||
Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - application is inactive"
|
||||
return render_forbidden("No authentication rule configured for this domain")
|
||||
end
|
||||
|
||||
# Check if user is allowed by this application
|
||||
unless app.user_allowed?(user)
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by app #{app.domain_pattern}"
|
||||
return render_forbidden("You do not have permission to access this domain")
|
||||
@@ -71,128 +60,162 @@ module Api
|
||||
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{forwarded_host} by app #{app.domain_pattern} (policy: #{app.policy_for_user(user)})"
|
||||
else
|
||||
# No application found - DENY by default (fail-closed security)
|
||||
Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - no authentication rule configured"
|
||||
return render_forbidden("No authentication rule configured for this domain")
|
||||
end
|
||||
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 using app-specific configuration
|
||||
headers = if app
|
||||
app.headers_for_user(user)
|
||||
else
|
||||
Application::DEFAULT_HEADERS.map { |key, header_name|
|
||||
case key
|
||||
when :user, :email, :name
|
||||
[header_name, user.email_address]
|
||||
when :groups
|
||||
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
|
||||
when :admin
|
||||
[header_name, user.admin? ? "true" : "false"]
|
||||
end
|
||||
}.compact.to_h
|
||||
# Fail closed: with no host we cannot resolve an application or evaluate its
|
||||
# group policy. Emitting identity headers here would bypass all per-domain
|
||||
# access control, so reject instead.
|
||||
Rails.logger.info "ForwardAuth: Access denied - no host header present"
|
||||
return render_forbidden("No host header present")
|
||||
end
|
||||
|
||||
# Reaching here implies a matching, active application was resolved above
|
||||
# (every other path returns forbidden), so headers are always scoped to it.
|
||||
headers = app.headers_for_user(user)
|
||||
headers.each { |key, value| response.headers[key] = value }
|
||||
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}" if headers.any?
|
||||
|
||||
# Log what headers we're sending (helpful for debugging)
|
||||
if headers.any?
|
||||
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}"
|
||||
else
|
||||
Rails.logger.debug "ForwardAuth: No headers sent (access only)"
|
||||
end
|
||||
|
||||
# Return 200 OK with no body
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fa_cache
|
||||
Rails.application.config.forward_auth_cache
|
||||
end
|
||||
|
||||
def cached_forward_auth_apps
|
||||
fa_cache.fetch("fa_apps", expires_in: 5.minutes) do
|
||||
Application.forward_auth.includes(:allowed_groups).to_a
|
||||
end
|
||||
end
|
||||
|
||||
RATE_LIMIT_MAX_FAILURES = 50
|
||||
RATE_LIMIT_WINDOW = 1.minute
|
||||
|
||||
def check_forward_auth_rate_limit
|
||||
count = fa_cache.read("fa_fail:#{request.remote_ip}")
|
||||
return unless count && count >= RATE_LIMIT_MAX_FAILURES
|
||||
|
||||
response.headers["Retry-After"] = "60"
|
||||
head :too_many_requests
|
||||
end
|
||||
|
||||
def track_failed_forward_auth_attempt
|
||||
return unless response.status.in?([401, 403, 302])
|
||||
return if response.status == 302 && !response.headers["X-Auth-Reason"]
|
||||
|
||||
cache_key = "fa_fail:#{request.remote_ip}"
|
||||
# Use increment to avoid resetting TTL on each failure (fixed window)
|
||||
unless fa_cache.increment(cache_key)
|
||||
fa_cache.write(cache_key, 1, expires_in: RATE_LIMIT_WINDOW)
|
||||
end
|
||||
end
|
||||
|
||||
def authenticate_bearer_token
|
||||
auth_header = request.headers["Authorization"]
|
||||
return nil unless auth_header&.start_with?("Bearer ")
|
||||
|
||||
token = auth_header.delete_prefix("Bearer ").strip
|
||||
return render_bearer_error("Missing token") if token.blank?
|
||||
|
||||
api_key = ApiKey.find_by_token(token)
|
||||
return render_bearer_error("Invalid or expired API key") unless api_key&.active?
|
||||
|
||||
user = api_key.user
|
||||
return render_bearer_error("User account is not active") unless user.active?
|
||||
|
||||
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
||||
app = api_key.application
|
||||
|
||||
if forwarded_host.present? && !app.matches_domain?(forwarded_host)
|
||||
return render_bearer_error("API key not valid for this domain")
|
||||
end
|
||||
|
||||
unless app.active?
|
||||
return render_bearer_error("Application is inactive")
|
||||
end
|
||||
|
||||
# Re-check group membership at use-time. The ApiKey model only validates
|
||||
# access on creation, so a user removed from the app's allowed groups
|
||||
# afterwards must not keep access via an existing key.
|
||||
unless app.user_allowed?(user)
|
||||
Rails.logger.info "ForwardAuth: API key '#{api_key.name}' denied - user #{user.email_address} lacks group access to #{app.domain_pattern}"
|
||||
return render_bearer_error("Access denied: insufficient group membership")
|
||||
end
|
||||
|
||||
api_key.touch_last_used!
|
||||
|
||||
headers = app.headers_for_user(user)
|
||||
headers.each { |key, value| response.headers[key] = value }
|
||||
|
||||
Rails.logger.info "ForwardAuth: API key '#{api_key.name}' authenticated user #{user.email_address} for #{forwarded_host}"
|
||||
head :ok
|
||||
end
|
||||
|
||||
def render_bearer_error(message)
|
||||
render json: {error: message}, status: :unauthorized
|
||||
end
|
||||
|
||||
def check_forward_auth_token
|
||||
# Check for one-time token in query parameters (for race condition handling)
|
||||
token = params[:fa_token]
|
||||
return nil unless token.present?
|
||||
return nil if token.blank?
|
||||
|
||||
# Try to get session ID from cache
|
||||
session_id = Rails.cache.read("forward_auth_token:#{token}")
|
||||
return nil unless session_id
|
||||
cached = Rails.cache.read("forward_auth_token:#{token}")
|
||||
return nil unless cached.is_a?(Hash)
|
||||
|
||||
# Verify the session exists and is valid
|
||||
session = Session.find_by(id: session_id)
|
||||
# The token is bound to the host that created it. If the request is
|
||||
# arriving at a different host, refuse — and do NOT burn the cache
|
||||
# entry, so that the legitimate destination can still redeem within
|
||||
# the 60s TTL.
|
||||
request_host = (request.headers["X-Forwarded-Host"] || request.headers["Host"])
|
||||
.to_s.sub(/:\d+\z/, "").downcase
|
||||
return nil if request_host.blank?
|
||||
return nil unless cached[:host] == request_host
|
||||
|
||||
session = Session.find_by(id: cached[:session_id])
|
||||
return nil unless session && !session.expired?
|
||||
|
||||
# Delete the token immediately (one-time use)
|
||||
Rails.cache.delete("forward_auth_token:#{token}")
|
||||
|
||||
session_id
|
||||
cached[:session_id]
|
||||
end
|
||||
|
||||
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 Application (forward_auth type) 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 auth reason header for debugging (like Authelia)
|
||||
response.headers["X-Auth-Reason"] = reason if reason.present?
|
||||
|
||||
# Get the redirect URL from query params or construct default
|
||||
redirect_url = validate_redirect_url(params[:rd])
|
||||
base_url = determine_base_url(redirect_url)
|
||||
|
||||
# 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 (original behavior)
|
||||
"https://#{original_host}#{original_uri}"
|
||||
else
|
||||
# Fallback: use the validated redirect URL or default
|
||||
redirect_url || "https://clinch.aapamilne.com"
|
||||
end
|
||||
|
||||
# Debug: log what we're redirecting to after login
|
||||
Rails.logger.info "ForwardAuth: Will redirect to after login: #{original_url}"
|
||||
# X-Forwarded-Host is attacker-influenceable, so only honour the forwarded
|
||||
# URL as a post-login redirect target if it resolves to a known, active
|
||||
# forward-auth application. Otherwise this is an open redirect: a spoofed
|
||||
# host would be stored and reflected into the signin `rd`, then followed
|
||||
# (with allow_other_host) after the user authenticates. Fall back to a
|
||||
# validated `rd` or, failing that, the IdP's own base URL.
|
||||
forwarded_url = "https://#{original_host}#{original_uri}" if original_host.present?
|
||||
original_url = validate_redirect_url(forwarded_url) || redirect_url || base_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_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 auth reason header for debugging (like Authelia)
|
||||
response.headers["X-Auth-Reason"] = reason if reason.present?
|
||||
|
||||
# Return 403 Forbidden
|
||||
head :forbidden
|
||||
end
|
||||
|
||||
@@ -201,19 +224,14 @@ module Api
|
||||
|
||||
begin
|
||||
uri = URI.parse(url)
|
||||
|
||||
# Only allow HTTP/HTTPS schemes
|
||||
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
|
||||
# Only allow HTTPS in production
|
||||
return nil unless Rails.env.development? || uri.scheme == "https"
|
||||
|
||||
redirect_domain = uri.host.downcase
|
||||
return nil unless redirect_domain.present?
|
||||
|
||||
# Check against our ForwardAuth applications
|
||||
matching_app = Application.forward_auth.active.find do |app|
|
||||
app.matches_domain?(redirect_domain)
|
||||
matching_app = cached_forward_auth_apps.find do |app|
|
||||
app.active? && app.matches_domain?(redirect_domain)
|
||||
end
|
||||
|
||||
matching_app ? url : nil
|
||||
@@ -222,34 +240,16 @@ module Api
|
||||
end
|
||||
end
|
||||
|
||||
def domain_has_forward_auth_rule?(domain)
|
||||
return false if domain.blank?
|
||||
|
||||
Application.forward_auth.active.any? do |app|
|
||||
app.matches_domain?(domain.downcase)
|
||||
end
|
||||
end
|
||||
|
||||
def determine_base_url(redirect_url)
|
||||
# If we have a valid redirect URL, use it
|
||||
return redirect_url if redirect_url.present?
|
||||
|
||||
# Try CLINCH_HOST environment variable first
|
||||
if ENV["CLINCH_HOST"].present?
|
||||
host = ENV["CLINCH_HOST"]
|
||||
# Ensure URL has https:// protocol
|
||||
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
||||
else
|
||||
# Fallback to the request host
|
||||
request_host = request.host || request.headers["X-Forwarded-Host"]
|
||||
if request_host.present?
|
||||
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
|
||||
"https://#{request_host}"
|
||||
else
|
||||
# No host information available - raise exception to force proper configuration
|
||||
raise StandardError, "ForwardAuth: CLINCH_HOST environment variable not set and no request host available. Please configure CLINCH_HOST properly."
|
||||
end
|
||||
end
|
||||
# CLINCH_HOST is the IdP's canonical origin and is mandatory in deployed
|
||||
# environments (enforced at boot in config/initializers/clinch_host.rb).
|
||||
# We never fall back to the request host: a spoofed X-Forwarded-Host would
|
||||
# otherwise redirect the login flow to an attacker-controlled origin. The
|
||||
# localhost default only applies to local dev/test.
|
||||
host = ENV["CLINCH_HOST"].presence || "http://localhost:3000"
|
||||
host.match?(%r{\Ahttps?://}) ? host : "https://#{host}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
53
app/controllers/api_keys_controller.rb
Normal file
53
app/controllers/api_keys_controller.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
class ApiKeysController < ApplicationController
|
||||
before_action :set_api_key, only: :destroy
|
||||
|
||||
def index
|
||||
@api_keys = Current.session.user.api_keys.includes(:application).order(created_at: :desc)
|
||||
end
|
||||
|
||||
def new
|
||||
@api_key = ApiKey.new
|
||||
@applications = forward_auth_apps_for_user
|
||||
end
|
||||
|
||||
def create
|
||||
@api_key = Current.session.user.api_keys.build(api_key_params)
|
||||
|
||||
if @api_key.save
|
||||
SecurityMailer.api_key_created(Current.session.user, name: @api_key.name, **security_event_context).deliver_later
|
||||
flash[:api_key_token] = @api_key.plaintext_token
|
||||
redirect_to api_key_path(@api_key)
|
||||
else
|
||||
@applications = forward_auth_apps_for_user
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@api_key = Current.session.user.api_keys.find(params[:id])
|
||||
@plaintext_token = flash[:api_key_token]
|
||||
|
||||
redirect_to api_keys_path unless @plaintext_token
|
||||
end
|
||||
|
||||
def destroy
|
||||
@api_key.revoke!
|
||||
SecurityMailer.api_key_revoked(@api_key.user, name: @api_key.name, **security_event_context).deliver_later
|
||||
redirect_to api_keys_path, notice: "API key revoked."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_api_key
|
||||
@api_key = Current.session.user.api_keys.find(params[:id])
|
||||
end
|
||||
|
||||
def api_key_params
|
||||
params.require(:api_key).permit(:name, :application_id, :expires_at)
|
||||
end
|
||||
|
||||
def forward_auth_apps_for_user
|
||||
user = Current.session.user
|
||||
Application.forward_auth.active.select { |app| app.user_allowed?(user) }
|
||||
end
|
||||
end
|
||||
@@ -9,4 +9,35 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
# CSRF protection
|
||||
protect_from_forgery with: :exception
|
||||
|
||||
helper_method :remove_query_param
|
||||
|
||||
private
|
||||
|
||||
def security_event_context
|
||||
{ip: request.remote_ip, user_agent: request.user_agent, occurred_at: Time.current}
|
||||
end
|
||||
|
||||
# Remove a query parameter from a URL using proper URI parsing
|
||||
# More robust than regex - handles URL encoding, edge cases, etc.
|
||||
#
|
||||
# @param url [String] The URL to modify
|
||||
# @param param_name [String] The query parameter name to remove
|
||||
# @return [String] The URL with the parameter removed
|
||||
#
|
||||
# @example
|
||||
# remove_query_param("https://example.com?foo=bar&baz=qux", "foo")
|
||||
# # => "https://example.com?baz=qux"
|
||||
def remove_query_param(url, param_name)
|
||||
uri = URI.parse(url)
|
||||
return url unless uri.query
|
||||
|
||||
params = Rack::Utils.parse_query(uri.query)
|
||||
params.delete(param_name)
|
||||
|
||||
uri.query = params.any? ? Rack::Utils.build_query(params) : nil
|
||||
uri.to_s
|
||||
rescue URI::InvalidURIError
|
||||
url
|
||||
end
|
||||
end
|
||||
|
||||
@@ -31,7 +31,7 @@ module Authentication
|
||||
end
|
||||
|
||||
def find_session_by_cookie
|
||||
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
||||
Session.active.for_active_user.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
||||
end
|
||||
|
||||
def request_authentication
|
||||
@@ -40,29 +40,79 @@ module Authentication
|
||||
end
|
||||
|
||||
def after_authentication_url
|
||||
session[:return_to_after_authenticating]
|
||||
session.delete(:return_to_after_authenticating) || root_url
|
||||
end
|
||||
|
||||
def start_new_session_for(user, acr: "1")
|
||||
# When a sign-in form will eventually redirect through /oauth/authorize to an
|
||||
# external client, Safari enforces CSP form-action against every hop in the
|
||||
# redirect chain. With the default form-action 'self', the final cross-origin
|
||||
# hop to the OAuth client's redirect_uri gets blocked. Add the redirect_uri
|
||||
# host to form-action so the chain completes.
|
||||
def allow_oauth_redirect_in_csp
|
||||
stored = session[:return_to_after_authenticating]
|
||||
return if stored.blank?
|
||||
|
||||
uri = URI.parse(stored)
|
||||
return unless uri.path&.start_with?("/oauth/")
|
||||
|
||||
redirect_uri = Rack::Utils.parse_query(uri.query.to_s)["redirect_uri"]
|
||||
return if redirect_uri.blank?
|
||||
|
||||
redirect_host = URI.parse(redirect_uri).host
|
||||
return if redirect_host.blank?
|
||||
|
||||
csp = request.content_security_policy
|
||||
return unless csp
|
||||
|
||||
# NOTE: `csp.form_action` (no args) is destructive — it deletes the directive
|
||||
# and returns its old value, so reading it twice yields nil. Mutate the
|
||||
# underlying `directives` hash (a public reader of the real values) instead.
|
||||
form_action = (csp.directives["form-action"] ||= ["'self'"])
|
||||
host = "https://#{redirect_host}"
|
||||
form_action << host unless form_action.include?(host)
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def start_new_session_for(user, acr: "1", remember_me: false)
|
||||
user.update!(last_sign_in_at: Time.current)
|
||||
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr).tap do |session|
|
||||
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr, remember_me: remember_me).tap do |session|
|
||||
Current.session = session
|
||||
|
||||
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
||||
domain = extract_root_domain(request.host)
|
||||
|
||||
cookie_options = {
|
||||
# Set cookie options based on environment
|
||||
# Production: Use SameSite=None to allow cross-site cookies (needed for OIDC conformance testing)
|
||||
# Development: Use SameSite=Lax since HTTPS might not be available
|
||||
cookie_options = if Rails.env.production?
|
||||
{
|
||||
value: session.id,
|
||||
httponly: true,
|
||||
same_site: :lax,
|
||||
secure: Rails.env.production?
|
||||
secure: true
|
||||
}
|
||||
else
|
||||
{
|
||||
value: session.id,
|
||||
httponly: true,
|
||||
same_site: :lax,
|
||||
secure: false
|
||||
}
|
||||
end
|
||||
|
||||
# Set domain for cross-subdomain authentication if we can extract it
|
||||
cookie_options[:domain] = domain if domain.present?
|
||||
|
||||
# When "Remember me" is off, issue a browser-session cookie (no Expires)
|
||||
# so closing the browser signs the user out — especially important on
|
||||
# shared devices. The server Session#expires_at still enforces the
|
||||
# 24h / 30d window regardless.
|
||||
if remember_me
|
||||
cookies.signed.permanent[:session_id] = cookie_options
|
||||
else
|
||||
cookies.signed[:session_id] = cookie_options
|
||||
end
|
||||
|
||||
# Create a one-time token for immediate forward auth after authentication
|
||||
# This solves the race condition where browser hasn't processed cookie yet
|
||||
@@ -119,35 +169,35 @@ module Authentication
|
||||
end
|
||||
|
||||
# Create a one-time token for forward auth to handle the race condition
|
||||
# where the browser hasn't processed the session cookie yet
|
||||
# where the browser hasn't processed the session cookie yet.
|
||||
#
|
||||
# The token is bound to the destination host so that anyone who observes
|
||||
# the token (Referer leaks, access logs, JS monitors) cannot redeem it for
|
||||
# a different application within the 60-second TTL.
|
||||
def create_forward_auth_token(session_obj)
|
||||
# Generate a secure random token
|
||||
token = SecureRandom.urlsafe_base64(32)
|
||||
controller_session = session
|
||||
return unless controller_session[:return_to_after_authenticating].present?
|
||||
|
||||
# Store it with an expiry of 60 seconds
|
||||
uri = URI.parse(controller_session[:return_to_after_authenticating])
|
||||
|
||||
# OAuth flow handles its own session propagation — no fa_token needed.
|
||||
return if uri.path&.start_with?("/oauth/")
|
||||
|
||||
# Path-only URLs are same-origin on Clinch; the cookie race doesn't apply
|
||||
# and we have no destination host to bind against.
|
||||
bound_host = uri.hostname&.downcase
|
||||
return if bound_host.blank?
|
||||
|
||||
token = SecureRandom.urlsafe_base64(32)
|
||||
Rails.cache.write(
|
||||
"forward_auth_token:#{token}",
|
||||
session_obj.id,
|
||||
{session_id: session_obj.id, host: bound_host},
|
||||
expires_in: 60.seconds
|
||||
)
|
||||
|
||||
# Set the token as a query parameter on the redirect URL
|
||||
# We need to store this in the controller's session
|
||||
controller_session = session
|
||||
if controller_session[:return_to_after_authenticating].present?
|
||||
original_url = controller_session[:return_to_after_authenticating]
|
||||
uri = URI.parse(original_url)
|
||||
|
||||
# Skip adding fa_token for OAuth URLs (OAuth flow should not have forward auth tokens)
|
||||
unless uri.path&.start_with?("/oauth/")
|
||||
# Add token as query parameter
|
||||
query_params = URI.decode_www_form(uri.query || "").to_h
|
||||
query_params["fa_token"] = token
|
||||
uri.query = URI.encode_www_form(query_params)
|
||||
|
||||
# Update the session with the tokenized URL
|
||||
controller_session[:return_to_after_authenticating] = uri.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@ class InvitationsController < ApplicationController
|
||||
|
||||
allow_unauthenticated_access
|
||||
before_action :set_user_by_invitation_token, only: %i[show update]
|
||||
rate_limit to: 10, within: 10.minutes, only: :update, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
|
||||
|
||||
def show
|
||||
# Show the password setup form
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
class OidcController < ApplicationController
|
||||
SUPPORTED_SCOPES = %w[openid profile email groups offline_access].freeze
|
||||
|
||||
# Discovery and JWKS endpoints are public
|
||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout]
|
||||
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :logout]
|
||||
# authorize is also unauthenticated to handle prompt=none and prompt=login specially
|
||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout, :authorize]
|
||||
# Machine-to-machine endpoints (token/revoke/userinfo) and pure redirect handlers
|
||||
# (logout/authorize) legitimately skip CSRF. The consent endpoint is browser-facing
|
||||
# and state-changing (it grants OAuth scopes), so it MUST keep CSRF protection — the
|
||||
# consent form already embeds the token via form_with.
|
||||
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout, :authorize]
|
||||
|
||||
# RFC 6749 §4.1.2.1: client_id and redirect_uri must be validated *before* any
|
||||
# other error can be reported via redirect. Failures here render a plain page.
|
||||
before_action :set_application, only: :authorize
|
||||
before_action :validate_redirect_uri, only: :authorize
|
||||
|
||||
# Rate limiting to prevent brute force and abuse
|
||||
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
|
||||
@@ -28,12 +40,24 @@ class OidcController < ApplicationController
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
subject_types_supported: ["pairwise"],
|
||||
id_token_signing_alg_values_supported: ["RS256"],
|
||||
scopes_supported: ["openid", "profile", "email", "groups", "offline_access"],
|
||||
scopes_supported: SUPPORTED_SCOPES,
|
||||
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
||||
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin", "auth_time", "acr", "azp", "at_hash"],
|
||||
code_challenge_methods_supported: ["plain", "S256"],
|
||||
claims_supported: [
|
||||
"sub", # Always included
|
||||
"email", # email scope
|
||||
"email_verified", # email scope
|
||||
"name", # profile scope
|
||||
"preferred_username", # profile scope
|
||||
"updated_at", # profile scope
|
||||
"groups" # groups scope
|
||||
# Note: Custom claims are also supported but not listed here
|
||||
# ID-token-only claims (auth_time, acr, azp, at_hash, nonce) are not listed
|
||||
],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
backchannel_logout_supported: true,
|
||||
backchannel_logout_session_supported: true
|
||||
backchannel_logout_session_supported: true,
|
||||
request_parameter_supported: false,
|
||||
claims_parameter_supported: true
|
||||
}
|
||||
|
||||
render json: config
|
||||
@@ -46,7 +70,8 @@ class OidcController < ApplicationController
|
||||
|
||||
# GET /oauth/authorize
|
||||
def authorize
|
||||
# Get parameters (ignore forward auth tokens and other unknown params)
|
||||
# @application and a validated redirect_uri are guaranteed by the before_actions.
|
||||
# Read the remaining parameters (ignore forward auth tokens and other unknown params).
|
||||
client_id = params[:client_id]
|
||||
redirect_uri = params[:redirect_uri]
|
||||
state = params[:state]
|
||||
@@ -54,65 +79,92 @@ class OidcController < ApplicationController
|
||||
scope = params[:scope] || "openid"
|
||||
response_type = params[:response_type]
|
||||
code_challenge = params[:code_challenge]
|
||||
code_challenge_method = params[:code_challenge_method] || "plain"
|
||||
code_challenge_method = params[:code_challenge_method] || "S256"
|
||||
|
||||
# Validate required parameters
|
||||
unless client_id.present? && redirect_uri.present? && response_type == "code"
|
||||
error_details = []
|
||||
error_details << "client_id is required" unless client_id.present?
|
||||
error_details << "redirect_uri is required" unless redirect_uri.present?
|
||||
error_details << "response_type must be 'code'" unless response_type == "code"
|
||||
# ============================================================================
|
||||
# client_id and redirect_uri are already validated (see before_actions).
|
||||
# All subsequent errors should redirect back to the client with error parameters
|
||||
# per OAuth2 RFC 6749 Section 4.1.2.1
|
||||
# ============================================================================
|
||||
|
||||
render plain: "Invalid request: #{error_details.join(", ")}", status: :bad_request
|
||||
# Reject request objects (JWT-encoded authorization parameters)
|
||||
# Per OIDC Core §3.1.2.6: If request parameter is present and not supported,
|
||||
# return request_not_supported error
|
||||
if params[:request].present? || params[:request_uri].present?
|
||||
Rails.logger.error "OAuth: Request object not supported"
|
||||
error_uri = "#{redirect_uri}?error=request_not_supported"
|
||||
error_uri += "&error_description=#{CGI.escape("Request objects are not supported")}"
|
||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
# Validate PKCE parameters if present
|
||||
# Validate response_type (now we can safely redirect with error)
|
||||
unless response_type == "code"
|
||||
Rails.logger.error "OAuth: Invalid response_type: #{response_type}"
|
||||
error_uri = "#{redirect_uri}?error=unsupported_response_type"
|
||||
error_uri += "&error_description=#{CGI.escape("Only 'code' response_type is supported")}"
|
||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
# Validate PKCE parameters if present (now we can safely redirect with error)
|
||||
if code_challenge.present?
|
||||
unless %w[plain S256].include?(code_challenge_method)
|
||||
render plain: "Invalid code_challenge_method: must be 'plain' or 'S256'", status: :bad_request
|
||||
unless code_challenge_method == "S256"
|
||||
Rails.logger.error "OAuth: Invalid code_challenge_method: #{code_challenge_method}"
|
||||
error_uri = "#{redirect_uri}?error=invalid_request"
|
||||
error_uri += "&error_description=#{CGI.escape("Invalid code_challenge_method: only 'S256' is supported")}"
|
||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
# Validate code challenge format (base64url-encoded, 43-128 characters)
|
||||
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
||||
render plain: "Invalid code_challenge format: must be 43-128 characters of base64url encoding", status: :bad_request
|
||||
Rails.logger.error "OAuth: Invalid code_challenge format"
|
||||
error_uri = "#{redirect_uri}?error=invalid_request"
|
||||
error_uri += "&error_description=#{CGI.escape("Invalid code_challenge format: must be 43-128 characters of base64url encoding")}"
|
||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
# Find the application
|
||||
@application = Application.find_by(client_id: client_id, app_type: "oidc")
|
||||
unless @application
|
||||
# Log all OIDC applications for debugging
|
||||
all_oidc_apps = Application.where(app_type: "oidc")
|
||||
Rails.logger.error "OAuth: Invalid request - application not found for client_id: #{client_id}"
|
||||
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
|
||||
# Normalize requested scopes to the set we support. Needed here so claims
|
||||
# validation below can check claim→scope coverage against what will actually
|
||||
# be granted.
|
||||
requested_scopes = scope.split(" ") & SUPPORTED_SCOPES
|
||||
scope = requested_scopes.join(" ")
|
||||
|
||||
error_msg = if Rails.env.development?
|
||||
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(", ")}"
|
||||
else
|
||||
"Invalid request: Application not found"
|
||||
end
|
||||
# Parse claims parameter (JSON string) for OIDC claims request
|
||||
# Per OIDC Core §5.5: The claims parameter is a JSON object that requests
|
||||
# specific claims to be returned in the id_token and/or userinfo
|
||||
claims_parameter = params[:claims]
|
||||
parsed_claims = parse_claims_parameter(claims_parameter) if claims_parameter.present?
|
||||
|
||||
render plain: error_msg, status: :bad_request
|
||||
# Validate claims parameter format if present
|
||||
if claims_parameter.present? && parsed_claims.nil?
|
||||
Rails.logger.error "OAuth: Invalid claims parameter format"
|
||||
error_uri = "#{redirect_uri}?error=invalid_request"
|
||||
error_uri += "&error_description=#{CGI.escape("Invalid claims parameter: must be valid JSON")}"
|
||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
# Validate redirect URI first (required before we can safely redirect with errors)
|
||||
unless @application.parsed_redirect_uris.include?(redirect_uri)
|
||||
Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
|
||||
|
||||
# For development, show detailed error
|
||||
error_msg = if Rails.env.development?
|
||||
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(", ")}, but received: #{redirect_uri}"
|
||||
else
|
||||
"Invalid request: Redirect URI not registered for this application"
|
||||
end
|
||||
|
||||
render plain: error_msg, status: :bad_request
|
||||
# Validate that requested claims are covered by granted scopes
|
||||
if parsed_claims.present?
|
||||
validation_result = validate_claims_against_scopes(parsed_claims, requested_scopes)
|
||||
unless validation_result[:valid]
|
||||
Rails.logger.error "OAuth: Claims parameter requests claims not covered by scopes: #{validation_result[:errors]}"
|
||||
error_uri = "#{redirect_uri}?error=invalid_scope"
|
||||
error_uri += "&error_description=#{CGI.escape("Claims parameter requests claims not covered by granted scopes")}"
|
||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
# Check if application is active (now we can safely redirect with error)
|
||||
unless @application.active?
|
||||
@@ -125,7 +177,17 @@ class OidcController < ApplicationController
|
||||
|
||||
# Check if user is authenticated
|
||||
unless authenticated?
|
||||
# Store OAuth parameters in session and redirect to sign in
|
||||
# Handle prompt=none - no UI allowed, return error immediately
|
||||
# Per OIDC Core spec §3.1.2.6: If prompt=none and user not authenticated,
|
||||
# return login_required error without showing any UI
|
||||
if params[:prompt] == "none"
|
||||
error_uri = "#{redirect_uri}?error=login_required"
|
||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
# Normal flow: store OAuth parameters and redirect to sign in
|
||||
session[:oauth_params] = {
|
||||
client_id: client_id,
|
||||
redirect_uri: redirect_uri,
|
||||
@@ -133,12 +195,62 @@ class OidcController < ApplicationController
|
||||
nonce: nonce,
|
||||
scope: scope,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method
|
||||
code_challenge_method: code_challenge_method,
|
||||
claims_requests: parsed_claims&.to_json
|
||||
}
|
||||
# Store the current URL (with all OAuth params) for redirect after authentication
|
||||
session[:return_to_after_authenticating] = request.url
|
||||
redirect_to signin_path, alert: "Please sign in to continue"
|
||||
return
|
||||
end
|
||||
|
||||
# Handle prompt=login - force re-authentication
|
||||
# Per OIDC Core spec §3.1.2.1: If prompt=login, the Authorization Server MUST prompt
|
||||
# the End-User for reauthentication, even if the End-User is currently authenticated
|
||||
if params[:prompt] == "login"
|
||||
# Destroy current session to force re-authentication
|
||||
# This creates a fresh authentication event with a new auth_time
|
||||
Current.session&.destroy!
|
||||
|
||||
# Clear the session cookie so the user is truly logged out
|
||||
cookies.delete(:session_id)
|
||||
|
||||
# Store the current URL (which contains all OAuth params) for redirect after login
|
||||
# Remove prompt=login to prevent infinite re-auth loop
|
||||
return_url = remove_query_param(request.url, "prompt")
|
||||
session[:return_to_after_authenticating] = return_url
|
||||
|
||||
redirect_to signin_path, alert: "Please sign in to continue"
|
||||
return
|
||||
end
|
||||
|
||||
# Handle max_age - require re-authentication if session is too old
|
||||
# Per OIDC Core spec §3.1.2.1: If max_age is provided and the auth time is older,
|
||||
# the Authorization Server MUST prompt for reauthentication
|
||||
if params[:max_age].present?
|
||||
max_age_seconds = params[:max_age].to_i
|
||||
# Calculate session age
|
||||
session_age_seconds = Time.current.to_i - Current.session.created_at.to_i
|
||||
|
||||
if session_age_seconds >= max_age_seconds
|
||||
# Session is too old - require re-authentication
|
||||
# Store the return URL in Rails session, then destroy the Session record
|
||||
|
||||
# Store return URL before destroying anything
|
||||
# Remove max_age from return URL to prevent infinite re-auth loop
|
||||
return_url = remove_query_param(request.url, "max_age")
|
||||
session[:return_to_after_authenticating] = return_url
|
||||
|
||||
# Destroy the Session record and clear its cookie
|
||||
Current.session&.destroy!
|
||||
cookies.delete(:session_id)
|
||||
Current.session = nil
|
||||
|
||||
redirect_to signin_path, alert: "Please sign in to continue"
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
# Get the authenticated user
|
||||
user = Current.session.user
|
||||
|
||||
@@ -148,11 +260,48 @@ class OidcController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
requested_scopes = scope.split(" ")
|
||||
unless requested_scopes.include?("openid")
|
||||
error_uri = "#{redirect_uri}?error=invalid_scope&error_description=#{CGI.escape("The 'openid' scope is required")}"
|
||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
# Check if application is configured to skip consent
|
||||
# If so, automatically create consent and proceed without showing consent screen
|
||||
if @application.skip_consent?
|
||||
# Create or update consent record automatically for trusted applications
|
||||
consent = OidcUserConsent.find_or_initialize_by(user: user, application: @application)
|
||||
consent.scopes_granted = requested_scopes.join(" ")
|
||||
consent.claims_requests = parsed_claims || {}
|
||||
consent.granted_at = Time.current
|
||||
consent.save!
|
||||
|
||||
# Generate authorization code directly
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: user,
|
||||
redirect_uri: redirect_uri,
|
||||
scope: scope,
|
||||
nonce: nonce,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method,
|
||||
claims_requests: parsed_claims || {},
|
||||
auth_time: Current.session.created_at.to_i,
|
||||
acr: Current.session.acr,
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Redirect back to client with authorization code (plaintext)
|
||||
redirect_uri = "#{redirect_uri}?code=#{auth_code.plaintext_code}"
|
||||
redirect_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to redirect_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
# Check if user has already granted consent for these scopes
|
||||
existing_consent = user.has_oidc_consent?(@application, requested_scopes)
|
||||
if existing_consent
|
||||
if existing_consent && claims_match_consent?(parsed_claims, existing_consent)
|
||||
# User has already consented, generate authorization code directly
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
@@ -162,6 +311,7 @@ class OidcController < ApplicationController
|
||||
nonce: nonce,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method,
|
||||
claims_requests: parsed_claims || {},
|
||||
auth_time: Current.session.created_at.to_i,
|
||||
acr: Current.session.acr,
|
||||
expires_at: 10.minutes.from_now
|
||||
@@ -182,7 +332,8 @@ class OidcController < ApplicationController
|
||||
nonce: nonce,
|
||||
scope: scope,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method
|
||||
code_challenge_method: code_challenge_method,
|
||||
claims_requests: parsed_claims&.to_json
|
||||
}
|
||||
|
||||
# Render consent page with dynamic CSP for OAuth redirect
|
||||
@@ -245,10 +396,16 @@ class OidcController < ApplicationController
|
||||
|
||||
user = Current.session.user
|
||||
|
||||
# Record user consent
|
||||
requested_scopes = oauth_params["scope"].split(" ")
|
||||
requested_scopes = oauth_params["scope"].split(" ") & SUPPORTED_SCOPES
|
||||
parsed_claims = begin
|
||||
JSON.parse(oauth_params["claims_requests"])
|
||||
rescue
|
||||
{}
|
||||
end
|
||||
|
||||
consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
|
||||
consent.scopes_granted = requested_scopes.join(" ")
|
||||
consent.claims_requests = parsed_claims
|
||||
consent.granted_at = Time.current
|
||||
consent.save!
|
||||
|
||||
@@ -261,6 +418,7 @@ class OidcController < ApplicationController
|
||||
nonce: oauth_params["nonce"],
|
||||
code_challenge: oauth_params["code_challenge"],
|
||||
code_challenge_method: oauth_params["code_challenge_method"],
|
||||
claims_requests: parsed_claims,
|
||||
auth_time: Current.session.created_at.to_i,
|
||||
acr: Current.session.acr,
|
||||
expires_at: 10.minutes.from_now
|
||||
@@ -278,6 +436,16 @@ class OidcController < ApplicationController
|
||||
|
||||
# POST /oauth/token
|
||||
def token
|
||||
# Reject claims parameter - per OIDC security, claims parameter is only valid
|
||||
# in authorization requests, not at the token endpoint
|
||||
if params[:claims].present?
|
||||
render json: {
|
||||
error: "invalid_request",
|
||||
error_description: "claims parameter is not allowed at the token endpoint"
|
||||
}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
grant_type = params[:grant_type]
|
||||
|
||||
case grant_type
|
||||
@@ -346,15 +514,12 @@ class OidcController < ApplicationController
|
||||
|
||||
# Check if code has already been used (CRITICAL: check AFTER locking)
|
||||
if auth_code.used?
|
||||
# Per OAuth 2.0 spec, if an auth code is reused, revoke all tokens issued from it
|
||||
# Per OAuth 2.0 spec, if an auth code is reused, revoke every token
|
||||
# descended from it (both generations across any rotations).
|
||||
Rails.logger.warn "OAuth Security: Authorization code reuse detected for code #{auth_code.id}"
|
||||
|
||||
# Revoke all access tokens issued from this authorization code
|
||||
OidcAccessToken.where(
|
||||
application: application,
|
||||
user: auth_code.user,
|
||||
created_at: auth_code.created_at..Time.current
|
||||
).update_all(expires_at: Time.current)
|
||||
now = Time.current
|
||||
auth_code.oidc_access_tokens.where(revoked_at: nil).update_all(revoked_at: now)
|
||||
auth_code.oidc_refresh_tokens.where(revoked_at: nil).update_all(revoked_at: now)
|
||||
|
||||
render json: {
|
||||
error: "invalid_grant",
|
||||
@@ -395,7 +560,8 @@ class OidcController < ApplicationController
|
||||
access_token_record = OidcAccessToken.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
scope: auth_code.scope
|
||||
scope: auth_code.scope,
|
||||
oidc_authorization_code: auth_code
|
||||
)
|
||||
|
||||
# Generate refresh token (opaque, with hashing)
|
||||
@@ -403,6 +569,7 @@ class OidcController < ApplicationController
|
||||
application: application,
|
||||
user: user,
|
||||
oidc_access_token: access_token_record,
|
||||
oidc_authorization_code: auth_code,
|
||||
scope: auth_code.scope,
|
||||
auth_time: auth_code.auth_time,
|
||||
acr: auth_code.acr
|
||||
@@ -419,6 +586,8 @@ class OidcController < ApplicationController
|
||||
|
||||
# Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr
|
||||
# auth_time and acr come from the authorization code (captured at /authorize time)
|
||||
# scopes determine which claims are included (per OIDC Core spec)
|
||||
# claims_requests parameter filters which claims are included
|
||||
id_token = OidcJwtService.generate_id_token(
|
||||
user,
|
||||
application,
|
||||
@@ -426,9 +595,15 @@ class OidcController < ApplicationController
|
||||
nonce: auth_code.nonce,
|
||||
access_token: access_token_record.plaintext_token,
|
||||
auth_time: auth_code.auth_time,
|
||||
acr: auth_code.acr
|
||||
acr: auth_code.acr,
|
||||
scopes: auth_code.scope,
|
||||
claims_requests: auth_code.parsed_claims_requests
|
||||
)
|
||||
|
||||
# RFC6749-5.1: Token endpoint MUST return Cache-Control: no-store
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
|
||||
# Return tokens
|
||||
render json: {
|
||||
access_token: access_token_record.plaintext_token, # Opaque token
|
||||
@@ -519,10 +694,15 @@ class OidcController < ApplicationController
|
||||
refresh_token_record.revoke!
|
||||
|
||||
# Generate new access token record (opaque token with BCrypt hashing)
|
||||
# Carry the authorization-code FK forward across rotations so replay
|
||||
# revocation reaches every descendant token in the chain.
|
||||
issuing_auth_code = refresh_token_record.oidc_authorization_code
|
||||
|
||||
new_access_token = OidcAccessToken.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
scope: refresh_token_record.scope
|
||||
scope: refresh_token_record.scope,
|
||||
oidc_authorization_code: issuing_auth_code
|
||||
)
|
||||
|
||||
# Generate new refresh token (token rotation)
|
||||
@@ -530,6 +710,7 @@ class OidcController < ApplicationController
|
||||
application: application,
|
||||
user: user,
|
||||
oidc_access_token: new_access_token,
|
||||
oidc_authorization_code: issuing_auth_code,
|
||||
scope: refresh_token_record.scope,
|
||||
token_family_id: refresh_token_record.token_family_id, # Keep same family for rotation tracking
|
||||
auth_time: refresh_token_record.auth_time, # Carry over original auth_time
|
||||
@@ -547,15 +728,23 @@ class OidcController < ApplicationController
|
||||
|
||||
# Generate new ID token (JWT with pairwise SID, at_hash, auth_time, acr; no nonce for refresh grants)
|
||||
# auth_time and acr come from the original refresh token (carried over from initial auth)
|
||||
# scopes determine which claims are included (per OIDC Core spec)
|
||||
# claims_requests parameter filters which claims are included (from original consent)
|
||||
id_token = OidcJwtService.generate_id_token(
|
||||
user,
|
||||
application,
|
||||
consent: consent,
|
||||
access_token: new_access_token.plaintext_token,
|
||||
auth_time: refresh_token_record.auth_time,
|
||||
acr: refresh_token_record.acr
|
||||
acr: refresh_token_record.acr,
|
||||
scopes: refresh_token_record.scope,
|
||||
claims_requests: consent.parsed_claims_requests
|
||||
)
|
||||
|
||||
# RFC6749-5.1: Token endpoint MUST return Cache-Control: no-store
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
|
||||
# Return new tokens
|
||||
render json: {
|
||||
access_token: new_access_token.plaintext_token, # Opaque token
|
||||
@@ -569,17 +758,22 @@ class OidcController < ApplicationController
|
||||
render json: {error: "invalid_grant"}, status: :bad_request
|
||||
end
|
||||
|
||||
# GET /oauth/userinfo
|
||||
# GET/POST /oauth/userinfo
|
||||
# OIDC Core spec: UserInfo endpoint MUST support GET, SHOULD support POST
|
||||
def userinfo
|
||||
# Extract access token from Authorization header
|
||||
auth_header = request.headers["Authorization"]
|
||||
unless auth_header&.start_with?("Bearer ")
|
||||
# Extract access token from Authorization header or POST body
|
||||
# RFC 6750: Bearer token can be in Authorization header, request body, or query string
|
||||
token = if request.headers["Authorization"]&.start_with?("Bearer ")
|
||||
request.headers["Authorization"].sub("Bearer ", "")
|
||||
elsif request.params["access_token"].present?
|
||||
request.params["access_token"]
|
||||
end
|
||||
|
||||
unless token
|
||||
head :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
token = auth_header.sub("Bearer ", "")
|
||||
|
||||
# Find and validate access token (opaque token with BCrypt hashing)
|
||||
access_token = OidcAccessToken.find_by_token(token)
|
||||
unless access_token&.active?
|
||||
@@ -605,19 +799,49 @@ class OidcController < ApplicationController
|
||||
consent = OidcUserConsent.find_by(user: user, application: access_token.application)
|
||||
subject = consent&.sid || user.id.to_s
|
||||
|
||||
# Return user claims
|
||||
# Parse scopes from access token (space-separated string)
|
||||
requested_scopes = access_token.scope.to_s.split
|
||||
|
||||
# Get claims_requests from consent (if available) for UserInfo context
|
||||
userinfo_claims = consent&.parsed_claims_requests&.dig("userinfo") || {}
|
||||
|
||||
# Return user claims (filter by scope per OIDC Core spec)
|
||||
# Required claims (always included - cannot be filtered by claims parameter)
|
||||
claims = {
|
||||
sub: subject,
|
||||
email: user.email_address,
|
||||
email_verified: true,
|
||||
preferred_username: user.email_address,
|
||||
name: user.name.presence || user.email_address
|
||||
sub: subject
|
||||
}
|
||||
|
||||
# Add groups if user has any
|
||||
if user.groups.any?
|
||||
# Email claims (only if 'email' scope requested AND requested in claims parameter)
|
||||
if requested_scopes.include?("email")
|
||||
if should_include_claim_for_userinfo?("email", userinfo_claims)
|
||||
claims[:email] = user.email_address
|
||||
end
|
||||
if should_include_claim_for_userinfo?("email_verified", userinfo_claims)
|
||||
claims[:email_verified] = true
|
||||
end
|
||||
end
|
||||
|
||||
# Profile claims (only if 'profile' scope requested)
|
||||
# Per OIDC Core spec section 5.4, include available profile claims
|
||||
# Only include claims we have data for - omit unknown claims rather than returning null
|
||||
if requested_scopes.include?("profile")
|
||||
if should_include_claim_for_userinfo?("preferred_username", userinfo_claims)
|
||||
claims[:preferred_username] = user.username.presence || user.email_address
|
||||
end
|
||||
if should_include_claim_for_userinfo?("name", userinfo_claims)
|
||||
claims[:name] = user.name.presence || user.email_address
|
||||
end
|
||||
if should_include_claim_for_userinfo?("updated_at", userinfo_claims)
|
||||
claims[:updated_at] = user.updated_at.to_i
|
||||
end
|
||||
end
|
||||
|
||||
# Groups claim (only if 'groups' scope requested AND requested in claims parameter)
|
||||
if requested_scopes.include?("groups") && user.groups.any?
|
||||
if should_include_claim_for_userinfo?("groups", userinfo_claims)
|
||||
claims[:groups] = user.groups.pluck(:name)
|
||||
end
|
||||
end
|
||||
|
||||
# Merge custom claims from groups
|
||||
user.groups.each do |group|
|
||||
@@ -631,6 +855,16 @@ class OidcController < ApplicationController
|
||||
application = access_token.application
|
||||
claims.merge!(application.custom_claims_for_user(user))
|
||||
|
||||
# Filter custom claims based on claims parameter
|
||||
# If claims parameter is present, only include requested custom claims
|
||||
if userinfo_claims.any?
|
||||
claims = filter_custom_claims_for_userinfo(claims, userinfo_claims)
|
||||
end
|
||||
|
||||
# Security: Don't cache user data responses
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
|
||||
render json: claims
|
||||
end
|
||||
|
||||
@@ -746,6 +980,55 @@ class OidcController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
# Look up @application from client_id. RFC 6749 §4.1.2.1 requires that an
|
||||
# invalid client_id be reported on-page, not via redirect.
|
||||
def set_application
|
||||
client_id = params[:client_id]
|
||||
|
||||
unless client_id.present?
|
||||
render plain: "Invalid request: client_id is required", status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@application = Application.find_by(client_id: client_id, app_type: "oidc")
|
||||
return if @application
|
||||
|
||||
Rails.logger.error "OAuth: Invalid request - application not found for client_id: #{client_id}"
|
||||
|
||||
error_msg = if Rails.env.development?
|
||||
all_oidc_apps = Application.where(app_type: "oidc")
|
||||
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
|
||||
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(", ")}"
|
||||
else
|
||||
"Invalid request: Application not found"
|
||||
end
|
||||
|
||||
render plain: error_msg, status: :bad_request
|
||||
end
|
||||
|
||||
# Confirm the redirect_uri param is present and registered on @application.
|
||||
# Must run after set_application. Errors render on-page per RFC 6749 §4.1.2.1.
|
||||
def validate_redirect_uri
|
||||
redirect_uri = params[:redirect_uri]
|
||||
|
||||
unless redirect_uri.present?
|
||||
render plain: "Invalid request: redirect_uri is required", status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
return if @application.parsed_redirect_uris.include?(redirect_uri)
|
||||
|
||||
Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
|
||||
|
||||
error_msg = if Rails.env.development?
|
||||
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(", ")}, but received: #{redirect_uri}"
|
||||
else
|
||||
"Invalid request: Redirect URI not registered for this application"
|
||||
end
|
||||
|
||||
render plain: error_msg, status: :bad_request
|
||||
end
|
||||
|
||||
def validate_pkce(application, auth_code, code_verifier)
|
||||
# Check if PKCE is required for this application
|
||||
pkce_required = application.requires_pkce?
|
||||
@@ -775,28 +1058,26 @@ class OidcController < ApplicationController
|
||||
}
|
||||
end
|
||||
|
||||
# Validate code verifier format (base64url-encoded, 43-128 characters)
|
||||
unless code_verifier.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
||||
# Validate code verifier format (per RFC 7636: [A-Za-z0-9\-._~], 43-128 characters)
|
||||
unless code_verifier.match?(/\A[A-Za-z0-9.\-_~]{43,128}\z/)
|
||||
return {
|
||||
valid: false,
|
||||
error: "invalid_request",
|
||||
error_description: "Invalid code_verifier format. Must be 43-128 characters of base64url encoding",
|
||||
error_description: "Invalid code_verifier format. Must be 43-128 characters [A-Z/a-z/0-9/-/./_/~]",
|
||||
status: :bad_request
|
||||
}
|
||||
end
|
||||
|
||||
# Recreate code challenge based on method
|
||||
expected_challenge = case auth_code.code_challenge_method
|
||||
when "plain"
|
||||
code_verifier
|
||||
when "S256"
|
||||
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
||||
else
|
||||
return {
|
||||
valid: false,
|
||||
error: "server_error",
|
||||
error_description: "Unsupported code challenge method",
|
||||
status: :internal_server_error
|
||||
error: "invalid_request",
|
||||
error_description: "Unsupported code challenge method: only 'S256' is supported",
|
||||
status: :bad_request
|
||||
}
|
||||
end
|
||||
|
||||
@@ -896,4 +1177,134 @@ class OidcController < ApplicationController
|
||||
# Log error but don't block logout
|
||||
Rails.logger.error "OidcController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
|
||||
end
|
||||
|
||||
# Parse claims parameter JSON string
|
||||
# Per OIDC Core §5.5: The claims parameter is a JSON object containing
|
||||
# id_token and/or userinfo keys, each mapping to claim requests
|
||||
def parse_claims_parameter(claims_string)
|
||||
return {} if claims_string.blank?
|
||||
return nil if claims_string.length > 4096
|
||||
|
||||
parsed = JSON.parse(claims_string)
|
||||
return nil unless parsed.is_a?(Hash)
|
||||
|
||||
# Validate structure: can have id_token, userinfo, or both
|
||||
valid_keys = parsed.keys & ["id_token", "userinfo"]
|
||||
return nil if valid_keys.empty?
|
||||
|
||||
# Validate each claim request has proper structure
|
||||
valid_keys.each do |key|
|
||||
next unless parsed[key].is_a?(Hash)
|
||||
|
||||
parsed[key].each do |_claim_name, claim_spec|
|
||||
# Claim spec can be null (requested), true (essential), or a hash with specific keys
|
||||
next if claim_spec.nil? || claim_spec == true || claim_spec == false
|
||||
next if claim_spec.is_a?(Hash) && claim_spec.keys.all? { |k| ["essential", "value", "values"].include?(k) }
|
||||
|
||||
# Invalid claim specification
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
parsed
|
||||
rescue JSON::ParserError
|
||||
nil
|
||||
end
|
||||
|
||||
# Validate that requested claims are covered by granted scopes
|
||||
# Per OIDC Core §5.5: Claims can only be requested if the corresponding scope is granted
|
||||
def validate_claims_against_scopes(parsed_claims, granted_scopes)
|
||||
granted = Array(granted_scopes).map(&:to_s)
|
||||
errors = []
|
||||
|
||||
# Standard claim-to-scope mapping
|
||||
claim_scope_mapping = {
|
||||
"email" => "email",
|
||||
"email_verified" => "email",
|
||||
"preferred_username" => "profile",
|
||||
"name" => "profile",
|
||||
"updated_at" => "profile",
|
||||
"groups" => "groups"
|
||||
}
|
||||
|
||||
# Check both id_token and userinfo claims
|
||||
["id_token", "userinfo"].each do |context|
|
||||
next unless parsed_claims[context]&.is_a?(Hash)
|
||||
|
||||
parsed_claims[context].each do |claim_name, _claim_spec|
|
||||
# Skip custom claims (not in standard mapping)
|
||||
# Custom claims are allowed since they're configured in the IdP
|
||||
next unless claim_scope_mapping.key?(claim_name)
|
||||
|
||||
required_scope = claim_scope_mapping[claim_name]
|
||||
unless granted.include?(required_scope)
|
||||
errors << "#{claim_name} requires #{required_scope} scope"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if errors.any?
|
||||
{valid: false, errors: errors}
|
||||
else
|
||||
{valid: true}
|
||||
end
|
||||
end
|
||||
|
||||
# Check if claims match existing consent
|
||||
# For MVP: treat any claims request as requiring new consent if consent has no claims stored
|
||||
def claims_match_consent?(parsed_claims, consent)
|
||||
return true if parsed_claims.nil? || parsed_claims.empty?
|
||||
|
||||
# If consent has no claims stored, this is a new claims request
|
||||
# Require fresh consent
|
||||
return false if consent.parsed_claims_requests.empty?
|
||||
|
||||
# If both have claims, they must match exactly
|
||||
consent.parsed_claims_requests == parsed_claims
|
||||
end
|
||||
|
||||
# Check if a claim should be included in UserInfo response
|
||||
# Returns true if no claims filtering or claim is explicitly requested
|
||||
def should_include_claim_for_userinfo?(claim_name, userinfo_claims)
|
||||
return true if userinfo_claims.empty?
|
||||
userinfo_claims.key?(claim_name)
|
||||
end
|
||||
|
||||
# Filter custom claims for UserInfo endpoint
|
||||
# Removes claims not explicitly requested
|
||||
# Applies value/values filtering if specified
|
||||
def filter_custom_claims_for_userinfo(claims, userinfo_claims)
|
||||
# Get all claim names that are NOT standard OIDC claims
|
||||
standard_claims = %w[sub email email_verified name preferred_username updated_at groups]
|
||||
custom_claim_names = claims.keys.map(&:to_s) - standard_claims
|
||||
|
||||
filtered = claims.dup
|
||||
|
||||
custom_claim_names.each do |claim_name|
|
||||
claim_sym = claim_name.to_sym
|
||||
|
||||
unless userinfo_claims.key?(claim_name) || userinfo_claims.key?(claim_sym)
|
||||
filtered.delete(claim_sym)
|
||||
next
|
||||
end
|
||||
|
||||
# Apply value/values filtering if specified
|
||||
claim_spec = userinfo_claims[claim_name] || userinfo_claims[claim_sym]
|
||||
next unless claim_spec.is_a?(Hash)
|
||||
|
||||
current_value = filtered[claim_sym]
|
||||
|
||||
# Check value constraint
|
||||
if claim_spec["value"].present?
|
||||
filtered.delete(claim_sym) unless current_value == claim_spec["value"]
|
||||
end
|
||||
|
||||
# Check values constraint (array of allowed values)
|
||||
if claim_spec["values"].is_a?(Array)
|
||||
filtered.delete(claim_sym) unless claim_spec["values"].include?(current_value)
|
||||
end
|
||||
end
|
||||
|
||||
filtered
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,7 @@ class PasswordsController < ApplicationController
|
||||
allow_unauthenticated_access
|
||||
before_action :set_user_by_token, only: %i[edit update]
|
||||
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
|
||||
rate_limit to: 10, within: 10.minutes, only: :update, with: -> { redirect_to new_password_path, alert: "Too many attempts. Try again later." }
|
||||
|
||||
def new
|
||||
end
|
||||
@@ -19,6 +20,7 @@ class PasswordsController < ApplicationController
|
||||
|
||||
def update
|
||||
if @user.update(params.permit(:password, :password_confirmation))
|
||||
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
|
||||
@user.sessions.destroy_all
|
||||
redirect_to signin_path, notice: "Password has been reset."
|
||||
else
|
||||
|
||||
@@ -15,6 +15,7 @@ class ProfilesController < ApplicationController
|
||||
end
|
||||
|
||||
if @user.update(password_params)
|
||||
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
|
||||
redirect_to profile_path, notice: "Password updated successfully."
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
@@ -27,7 +28,15 @@ class ProfilesController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
old_email = @user.email_address
|
||||
if @user.update(email_params)
|
||||
new_email = @user.email_address
|
||||
if old_email != new_email
|
||||
context = security_event_context
|
||||
[old_email, new_email].uniq.each do |recipient|
|
||||
SecurityMailer.email_address_changed(@user, recipient: recipient, old_email: old_email, new_email: new_email, **context).deliver_later
|
||||
end
|
||||
end
|
||||
redirect_to profile_path, notice: "Email updated successfully."
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
|
||||
@@ -14,6 +14,22 @@ class SessionsController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Extract login_hint from the return URL for pre-filling the email field (OIDC spec)
|
||||
@login_hint = nil
|
||||
if session[:return_to_after_authenticating].present?
|
||||
begin
|
||||
uri = URI.parse(session[:return_to_after_authenticating])
|
||||
if uri.query.present?
|
||||
query_params = Rack::Utils.parse_query(uri.query)
|
||||
@login_hint = query_params["login_hint"]
|
||||
end
|
||||
rescue URI::InvalidURIError
|
||||
# Ignore parsing errors
|
||||
end
|
||||
end
|
||||
|
||||
allow_oauth_redirect_in_csp
|
||||
|
||||
respond_to do |format|
|
||||
format.html # render HTML login page
|
||||
format.json { render json: {error: "Authentication required"}, status: :unauthorized }
|
||||
@@ -62,6 +78,7 @@ class SessionsController < ApplicationController
|
||||
# TOTP is enabled, proceed to verification
|
||||
# Store user ID in session temporarily for TOTP verification
|
||||
session[:pending_totp_user_id] = user.id
|
||||
session[:pending_remember_me] = remember_me?
|
||||
# Preserve the redirect URL through TOTP verification (after validation)
|
||||
if params[:rd].present?
|
||||
validated_url = validate_redirect_url(params[:rd])
|
||||
@@ -72,8 +89,11 @@ class SessionsController < ApplicationController
|
||||
end
|
||||
|
||||
# Sign in successful (password only)
|
||||
start_new_session_for user, acr: "1"
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
||||
start_new_session_for user, acr: "1", remember_me: remember_me?
|
||||
|
||||
# Use status: :see_other to ensure browser makes a GET request
|
||||
# This prevents Turbo from converting it to a TURBO_STREAM request
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true, status: :see_other
|
||||
end
|
||||
|
||||
def verify_totp
|
||||
@@ -101,6 +121,18 @@ class SessionsController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Re-check account status: active? was verified at the password step, but an
|
||||
# admin may have disabled the account while the user sat on this 2FA screen.
|
||||
# Without this, a disabled account could still mint a valid session here.
|
||||
unless user.active?
|
||||
session.delete(:pending_totp_user_id)
|
||||
session.delete(:pending_remember_me)
|
||||
redirect_to signin_path, alert: "Your account is not active. Please contact an administrator."
|
||||
return
|
||||
end
|
||||
|
||||
remember_me = session.delete(:pending_remember_me) || false
|
||||
|
||||
# Try TOTP verification first (password + TOTP = 2FA)
|
||||
if user.verify_totp(code)
|
||||
session.delete(:pending_totp_user_id)
|
||||
@@ -108,7 +140,7 @@ class SessionsController < ApplicationController
|
||||
if session[:totp_redirect_url].present?
|
||||
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||
end
|
||||
start_new_session_for user, acr: "2"
|
||||
start_new_session_for user, acr: "2", remember_me: remember_me
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
||||
return
|
||||
end
|
||||
@@ -120,7 +152,7 @@ class SessionsController < ApplicationController
|
||||
if session[:totp_redirect_url].present?
|
||||
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||
end
|
||||
start_new_session_for user, acr: "2"
|
||||
start_new_session_for user, acr: "2", remember_me: remember_me
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true
|
||||
return
|
||||
end
|
||||
@@ -130,6 +162,12 @@ class SessionsController < ApplicationController
|
||||
nil
|
||||
end
|
||||
|
||||
# Pass data to the view for passkey option
|
||||
@user_has_webauthn = user&.can_authenticate_with_webauthn?
|
||||
@pending_email = user&.email_address
|
||||
|
||||
allow_oauth_redirect_in_csp
|
||||
|
||||
# Just render the form
|
||||
end
|
||||
|
||||
@@ -168,6 +206,7 @@ class SessionsController < ApplicationController
|
||||
|
||||
# Store user ID in session for verification
|
||||
session[:pending_webauthn_user_id] = user.id
|
||||
session[:pending_remember_me] = remember_me?
|
||||
|
||||
# Store redirect URL if present
|
||||
if params[:rd].present?
|
||||
@@ -212,6 +251,14 @@ class SessionsController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Re-check account status: an admin may have disabled the account between the
|
||||
# password step and this passkey verification. Reject before creating a session.
|
||||
unless user.active?
|
||||
session.delete(:pending_webauthn_user_id)
|
||||
render json: {error: "Your account is not active."}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Get the credential and assertion from params
|
||||
credential_data = params[:credential]
|
||||
if credential_data.blank?
|
||||
@@ -248,10 +295,14 @@ class SessionsController < ApplicationController
|
||||
sign_count: stored_credential.sign_count
|
||||
)
|
||||
|
||||
# Check for suspicious sign count (possible clone)
|
||||
# Clone detection: a non-advancing signature counter signals the credential
|
||||
# may have been copied. Reject the sign-in (do NOT create a session or update
|
||||
# the stored counter) and alert the user, per WebAuthn §6.1.1.
|
||||
if stored_credential.suspicious_sign_count?(webauthn_credential.sign_count)
|
||||
Rails.logger.warn "Suspicious WebAuthn sign count for user #{user.id}, credential #{stored_credential.id}"
|
||||
# You might want to notify admins or temporarily disable the credential
|
||||
Rails.logger.warn "Suspicious WebAuthn sign count for user #{user.id}, credential #{stored_credential.id} (stored=#{stored_credential.sign_count}, presented=#{webauthn_credential.sign_count})"
|
||||
SecurityMailer.suspicious_passkey_used(user, nickname: stored_credential.display_name, **security_event_context).deliver_later
|
||||
render json: {error: "Passkey authentication could not be completed. Please contact support."}, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
# Update credential usage
|
||||
@@ -263,12 +314,13 @@ class SessionsController < ApplicationController
|
||||
|
||||
# Clean up session
|
||||
session.delete(:pending_webauthn_user_id)
|
||||
remember_me = session.delete(:pending_remember_me) || false
|
||||
if session[:webauthn_redirect_url].present?
|
||||
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
|
||||
end
|
||||
|
||||
# Create session (WebAuthn/passkey = phishing-resistant, ACR = "2")
|
||||
start_new_session_for user, acr: "2"
|
||||
start_new_session_for user, acr: "2", remember_me: remember_me
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
@@ -289,6 +341,10 @@ class SessionsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def remember_me?
|
||||
ActiveModel::Type::Boolean.new.cast(params[:remember_me]) || false
|
||||
end
|
||||
|
||||
def validate_redirect_url(url)
|
||||
return nil unless url.present?
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ class TotpController < ApplicationController
|
||||
@totp_secret = ROTP::Base32.random
|
||||
@provisioning_uri = ROTP::TOTP.new(@totp_secret, issuer: "Clinch").provisioning_uri(@user.email_address)
|
||||
|
||||
# Hold the secret server-side until the user confirms it with a valid code,
|
||||
# so an attacker with session access cannot substitute one they control.
|
||||
session[:pending_totp_secret] = @totp_secret
|
||||
|
||||
# Generate QR code
|
||||
require "rqrcode"
|
||||
@qr_code = RQRCode::QRCode.new(@provisioning_uri)
|
||||
@@ -19,9 +23,14 @@ class TotpController < ApplicationController
|
||||
|
||||
# POST /totp - Verify TOTP code and enable 2FA
|
||||
def create
|
||||
totp_secret = params[:totp_secret]
|
||||
totp_secret = session[:pending_totp_secret]
|
||||
code = params[:code]
|
||||
|
||||
unless totp_secret
|
||||
redirect_to new_totp_path, alert: "Your TOTP setup session expired. Please start again."
|
||||
return
|
||||
end
|
||||
|
||||
# Verify the code works
|
||||
totp = ROTP::TOTP.new(totp_secret)
|
||||
if totp.verify(code, drift_behind: 30, drift_ahead: 30)
|
||||
@@ -30,6 +39,9 @@ class TotpController < ApplicationController
|
||||
plain_codes = @user.send(:generate_backup_codes) # Use private method from User model
|
||||
@user.save!
|
||||
|
||||
session.delete(:pending_totp_secret)
|
||||
TotpMailer.enabled(@user).deliver_later
|
||||
|
||||
# Store plain codes temporarily in session for display after redirect
|
||||
session[:temp_backup_codes] = plain_codes
|
||||
|
||||
@@ -91,6 +103,7 @@ class TotpController < ApplicationController
|
||||
# Generate new backup codes and store BCrypt hashes
|
||||
plain_codes = @user.send(:generate_backup_codes)
|
||||
@user.save!
|
||||
SecurityMailer.backup_codes_regenerated(@user, **security_event_context).deliver_later
|
||||
|
||||
# Store plain codes temporarily in session for display
|
||||
session[:temp_backup_codes] = plain_codes
|
||||
@@ -124,6 +137,7 @@ class TotpController < ApplicationController
|
||||
end
|
||||
|
||||
@user.disable_totp!
|
||||
SecurityMailer.totp_disabled(@user, **security_event_context).deliver_later
|
||||
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
|
||||
end
|
||||
|
||||
|
||||
@@ -8,12 +8,16 @@ class UsersController < ApplicationController
|
||||
|
||||
def create
|
||||
@user = User.new(user_params)
|
||||
|
||||
# First user becomes admin automatically
|
||||
@user.admin = true if User.count.zero?
|
||||
@user.status = "active"
|
||||
first_user = User.count.zero?
|
||||
|
||||
if @user.save
|
||||
# First user automatically becomes a member of every admin group, so they
|
||||
# can reach the admin panel without an existing admin to grant access.
|
||||
if first_user
|
||||
Group.where(admin: true).each { |g| @user.groups << g }
|
||||
end
|
||||
|
||||
start_new_session_for @user
|
||||
redirect_to root_path, notice: "Welcome to Clinch! Your account has been created."
|
||||
else
|
||||
|
||||
@@ -91,6 +91,8 @@ class WebauthnController < ApplicationController
|
||||
backup_state: backup_state
|
||||
)
|
||||
|
||||
SecurityMailer.passkey_added(user, nickname: @webauthn_credential.nickname, **security_event_context).deliver_later
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
message: "Passkey '#{nickname}' registered successfully",
|
||||
@@ -109,8 +111,11 @@ class WebauthnController < ApplicationController
|
||||
# Remove a passkey
|
||||
def destroy
|
||||
nickname = @webauthn_credential.nickname
|
||||
user = @webauthn_credential.user
|
||||
@webauthn_credential.destroy
|
||||
|
||||
SecurityMailer.passkey_removed(user, nickname: nickname, **security_event_context).deliver_later
|
||||
|
||||
respond_to do |format|
|
||||
format.html {
|
||||
redirect_to profile_path,
|
||||
@@ -148,7 +153,8 @@ class WebauthnController < ApplicationController
|
||||
# Only return minimal necessary info - no user_id or preferred_method
|
||||
render json: {
|
||||
has_webauthn: user.can_authenticate_with_webauthn?,
|
||||
requires_webauthn: user.require_webauthn?
|
||||
requires_webauthn: user.require_webauthn?,
|
||||
has_totp: user.totp_enabled?
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -20,13 +20,73 @@ module ApplicationHelper
|
||||
end
|
||||
end
|
||||
|
||||
def oidc_env_lines(application, client_secret: nil)
|
||||
lines = ["OIDC_CLIENT_ID=#{application.client_id}"]
|
||||
lines << if client_secret
|
||||
"OIDC_CLIENT_SECRET=#{client_secret}"
|
||||
elsif application.public_client?
|
||||
"OIDC_CLIENT_SECRET="
|
||||
else
|
||||
"OIDC_CLIENT_SECRET=<your-client-secret>"
|
||||
end
|
||||
lines << "OIDC_DISCOVERY_URL=#{OidcJwtService.issuer_url}"
|
||||
lines << "OIDC_PROVIDER_NAME='Clinch'"
|
||||
lines << "OIDC_REQUIRE_PKCE=#{application.requires_pkce? ? "true" : "false"}"
|
||||
lines
|
||||
end
|
||||
|
||||
def border_class_for(type)
|
||||
case type.to_s
|
||||
when "notice" then "border-green-200"
|
||||
when "alert", "error" then "border-red-200"
|
||||
when "warning" then "border-yellow-200"
|
||||
when "info" then "border-blue-200"
|
||||
else "border-gray-200"
|
||||
when "notice" then "border-green-200 dark:border-green-700"
|
||||
when "alert", "error" then "border-red-200 dark:border-red-700"
|
||||
when "warning" then "border-yellow-200 dark:border-yellow-700"
|
||||
when "info" then "border-blue-200 dark:border-blue-700"
|
||||
else "border-gray-200 dark:border-gray-700"
|
||||
end
|
||||
end
|
||||
|
||||
# Picks 1-2 character initials for a monogram fallback when an Application
|
||||
# has no icon. Prefers capital letters (ShelfLife -> SL); falls back to the
|
||||
# first two letters of the name (Audiobookshelf -> AU).
|
||||
MONOGRAM_PALETTE = %w[
|
||||
#4f46e5 #0891b2 #16a34a #ca8a04
|
||||
#db2777 #9333ea #ea580c #475569
|
||||
].freeze
|
||||
|
||||
def monogram_initials(name)
|
||||
return "?" if name.blank?
|
||||
caps = name.scan(/[A-Z]/)
|
||||
initials = if caps.size >= 2
|
||||
caps.first(2).join
|
||||
else
|
||||
name.upcase.gsub(/[^A-Z0-9]/, "").first(2)
|
||||
end
|
||||
initials.presence || "?"
|
||||
end
|
||||
|
||||
def monogram_color(name)
|
||||
return MONOGRAM_PALETTE.first if name.blank?
|
||||
index = Digest::MD5.hexdigest(name).to_i(16) % MONOGRAM_PALETTE.size
|
||||
MONOGRAM_PALETTE[index]
|
||||
end
|
||||
|
||||
# Renders an application icon with optional dark-mode variant. If
|
||||
# `icon_dark` is attached, we render both <img> tags and Tailwind's class-
|
||||
# based `dark:` modifier hides the inactive one — so it follows the in-app
|
||||
# theme toggle (.dark on <html>), not the OS preference. If only `icon` is
|
||||
# attached, the same image is used in both modes. Caller must ensure at
|
||||
# least app.icon is attached; the monogram fallback handles no-icon.
|
||||
def app_icon_picture(app, class:, alt: nil)
|
||||
img_class = binding.local_variable_get(:class)
|
||||
alt ||= "#{app.name} icon"
|
||||
|
||||
if app.icon_dark.attached?
|
||||
safe_join([
|
||||
image_tag(app.icon, class: "#{img_class} dark:hidden", alt: alt),
|
||||
image_tag(app.icon_dark, class: "#{img_class} hidden dark:block", alt: alt)
|
||||
])
|
||||
else
|
||||
image_tag(app.icon, class: img_class, alt: alt)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
15
app/javascript/controllers/clipboard_controller.js
Normal file
15
app/javascript/controllers/clipboard_controller.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["source", "label"]
|
||||
|
||||
async copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.sourceTarget.value)
|
||||
this.labelTarget.textContent = "Copied!"
|
||||
setTimeout(() => { this.labelTarget.textContent = "Copy" }, 2000)
|
||||
} catch {
|
||||
this.sourceTarget.select()
|
||||
}
|
||||
}
|
||||
}
|
||||
27
app/javascript/controllers/dark_mode_controller.js
Normal file
27
app/javascript/controllers/dark_mode_controller.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["icon"]
|
||||
|
||||
connect() {
|
||||
this.updateIcon()
|
||||
}
|
||||
|
||||
toggle() {
|
||||
document.documentElement.classList.toggle("dark")
|
||||
const isDark = document.documentElement.classList.contains("dark")
|
||||
localStorage.setItem("theme", isDark ? "dark" : "light")
|
||||
this.updateIcon()
|
||||
}
|
||||
|
||||
updateIcon() {
|
||||
const isDark = document.documentElement.classList.contains("dark")
|
||||
this.iconTargets.forEach(icon => {
|
||||
if (icon.dataset.mode === "dark") {
|
||||
icon.classList.toggle("hidden", !isDark)
|
||||
} else {
|
||||
icon.classList.toggle("hidden", isDark)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -49,10 +49,9 @@ export default class extends Controller {
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-trigger passkey authentication if required
|
||||
if (data.requires_webauthn) {
|
||||
setTimeout(() => this.authenticate(), 100);
|
||||
}
|
||||
// Don't auto-trigger navigator.credentials.get() — Safari's WebAuthn
|
||||
// dialog can become undismissable when invoked without a user gesture.
|
||||
// Always let the user click "Continue with Passkey" instead.
|
||||
} else {
|
||||
console.debug("No WebAuthn credentials found for this email");
|
||||
}
|
||||
@@ -181,7 +180,8 @@ export default class extends Controller {
|
||||
"X-CSRF-Token": this.getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: this.getUserEmail()
|
||||
email: this.getUserEmail(),
|
||||
remember_me: this.getRememberMe()
|
||||
})
|
||||
});
|
||||
|
||||
@@ -289,9 +289,18 @@ export default class extends Controller {
|
||||
if (!emailInput) {
|
||||
emailInput = document.querySelector('input[name="user[email_address]"]');
|
||||
}
|
||||
// Fallback to hidden webauthn_email field (e.g., on TOTP verification page)
|
||||
if (!emailInput) {
|
||||
emailInput = document.querySelector('input[name="webauthn_email"]');
|
||||
}
|
||||
return emailInput ? emailInput.value.trim() : "";
|
||||
}
|
||||
|
||||
getRememberMe() {
|
||||
const checkbox = document.querySelector('input[name="remember_me"][type="checkbox"]');
|
||||
return checkbox ? checkbox.checked : false;
|
||||
}
|
||||
|
||||
isValidEmail(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
@@ -311,7 +320,7 @@ export default class extends Controller {
|
||||
return "This authenticator has already been registered.";
|
||||
}
|
||||
|
||||
// Fallback to error message
|
||||
return error.message || "An unexpected error occurred";
|
||||
// Fallback to a user-friendly message
|
||||
return "Passkey authentication failed. A browser extension may be interfering — try using your password instead.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,14 @@ class BackchannelLogoutJob < ApplicationJob
|
||||
# Send HTTP POST to the application's backchannel logout URI
|
||||
uri = URI.parse(application.backchannel_logout_uri)
|
||||
|
||||
# SSRF guard: re-check at request time (with DNS resolution) in case the URI
|
||||
# predates the validation, or a public hostname now resolves to an internal
|
||||
# address. Abort without retrying — retries would not change the outcome.
|
||||
if PrivateAddressCheck.internal_host?(uri.host) || PrivateAddressCheck.resolves_to_internal?(uri.host)
|
||||
Rails.logger.error "BackchannelLogout: Refusing to send logout to #{application.name} - #{uri.host} is or resolves to a non-public address (SSRF guard)"
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
|
||||
request = Net::HTTP::Post.new(uri.path.presence || "/")
|
||||
|
||||
45
app/lib/duration_parser.rb
Normal file
45
app/lib/duration_parser.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
class DurationParser
|
||||
UNITS = {
|
||||
"s" => 1, # seconds
|
||||
"m" => 60, # minutes
|
||||
"h" => 3600, # hours
|
||||
"d" => 86400, # days
|
||||
"w" => 604800, # weeks
|
||||
"M" => 2592000, # months (30 days)
|
||||
"y" => 31536000 # years (365 days)
|
||||
}
|
||||
|
||||
# Parse a duration string into seconds
|
||||
# Accepts formats: "1h", "30m", "1d", "1M" (month), "3600" (plain number)
|
||||
# Returns integer seconds or nil if invalid
|
||||
# Case-sensitive: 1s, 1m, 1h, 1d, 1w, 1M (month), 1y
|
||||
def self.parse(input)
|
||||
# Handle integers directly
|
||||
return input if input.is_a?(Integer)
|
||||
|
||||
# Convert to string and strip whitespace
|
||||
str = input.to_s.strip
|
||||
|
||||
# Return nil for blank input
|
||||
return nil if str.blank?
|
||||
|
||||
# Try to parse as plain number (already in seconds)
|
||||
if str.match?(/^\d+$/)
|
||||
return str.to_i
|
||||
end
|
||||
|
||||
# Try to parse with unit (e.g., "1h", "30m", "1M")
|
||||
# Allow optional space between number and unit
|
||||
# Case-sensitive to avoid confusion (1m = minute, 1M = month)
|
||||
match = str.match(/^(\d+)\s*([smhdwMy])$/)
|
||||
return nil unless match
|
||||
|
||||
number = match[1].to_i
|
||||
unit = match[2]
|
||||
|
||||
multiplier = UNITS[unit]
|
||||
return nil unless multiplier
|
||||
|
||||
number * multiplier
|
||||
end
|
||||
end
|
||||
57
app/lib/private_address_check.rb
Normal file
57
app/lib/private_address_check.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
require "ipaddr"
|
||||
require "resolv"
|
||||
|
||||
# SSRF guard for outbound requests to admin-configured URLs (currently the OIDC
|
||||
# backchannel logout endpoint). Blocks hosts that are, or resolve to, private,
|
||||
# loopback, link-local (incl. the cloud metadata address 169.254.169.254) or
|
||||
# otherwise non-public address space.
|
||||
module PrivateAddressCheck
|
||||
module_function
|
||||
|
||||
# Hostnames that are internal by definition and must never be dialled.
|
||||
BLOCKED_HOSTNAMES = %w[localhost metadata.google.internal].freeze
|
||||
|
||||
# Fast, DNS-free check: catches IP literals and well-known internal hostnames.
|
||||
# Suitable for model validation (deterministic, immediate admin feedback).
|
||||
def internal_host?(host)
|
||||
host = host.to_s.downcase
|
||||
return true if host.blank?
|
||||
return true if BLOCKED_HOSTNAMES.include?(host)
|
||||
return true if host.end_with?(".localhost")
|
||||
|
||||
ip = parse_ip(host)
|
||||
ip ? internal_ip?(ip) : false
|
||||
end
|
||||
|
||||
# Authoritative check: resolves the hostname and blocks if ANY address is
|
||||
# internal. Suitable for request time — also defeats a public hostname that
|
||||
# has been pointed at an internal IP (DNS rebinding to internal space).
|
||||
def resolves_to_internal?(host)
|
||||
addresses(host).any? { |ip| internal_ip?(ip) }
|
||||
end
|
||||
|
||||
def addresses(host)
|
||||
ip = parse_ip(host)
|
||||
return [ip] if ip
|
||||
|
||||
Resolv.getaddresses(host.to_s).filter_map { |a| parse_ip(a) }
|
||||
rescue
|
||||
# Resolution failure: surface no addresses. Callers treat "can't resolve" as
|
||||
# not-provably-internal; the dial itself will then fail safely.
|
||||
[]
|
||||
end
|
||||
|
||||
def internal_ip?(ip)
|
||||
ip.loopback? || ip.private? || ip.link_local? || unspecified?(ip)
|
||||
end
|
||||
|
||||
def parse_ip(str)
|
||||
IPAddr.new(str.to_s)
|
||||
rescue IPAddr::Error
|
||||
nil
|
||||
end
|
||||
|
||||
def unspecified?(ip)
|
||||
ip == IPAddr.new("0.0.0.0") || ip == IPAddr.new("::")
|
||||
end
|
||||
end
|
||||
65
app/mailers/security_mailer.rb
Normal file
65
app/mailers/security_mailer.rb
Normal file
@@ -0,0 +1,65 @@
|
||||
class SecurityMailer < ApplicationMailer
|
||||
SUBJECT_PREFIX = "[Clinch security alert] ".freeze
|
||||
|
||||
def password_changed(user, ip:, user_agent:, occurred_at:)
|
||||
assign_context(user, ip, user_agent, occurred_at)
|
||||
mail subject: "#{SUBJECT_PREFIX}Your password was changed", to: user.email_address
|
||||
end
|
||||
|
||||
def totp_disabled(user, ip:, user_agent:, occurred_at:)
|
||||
assign_context(user, ip, user_agent, occurred_at)
|
||||
mail subject: "#{SUBJECT_PREFIX}Two-factor authentication was disabled", to: user.email_address
|
||||
end
|
||||
|
||||
def backup_codes_regenerated(user, ip:, user_agent:, occurred_at:)
|
||||
assign_context(user, ip, user_agent, occurred_at)
|
||||
mail subject: "#{SUBJECT_PREFIX}Two-factor backup codes were regenerated", to: user.email_address
|
||||
end
|
||||
|
||||
def passkey_added(user, nickname:, ip:, user_agent:, occurred_at:)
|
||||
assign_context(user, ip, user_agent, occurred_at)
|
||||
@nickname = nickname
|
||||
mail subject: "#{SUBJECT_PREFIX}A passkey was added to your account", to: user.email_address
|
||||
end
|
||||
|
||||
def passkey_removed(user, nickname:, ip:, user_agent:, occurred_at:)
|
||||
assign_context(user, ip, user_agent, occurred_at)
|
||||
@nickname = nickname
|
||||
mail subject: "#{SUBJECT_PREFIX}A passkey was removed from your account", to: user.email_address
|
||||
end
|
||||
|
||||
def api_key_created(user, name:, ip:, user_agent:, occurred_at:)
|
||||
assign_context(user, ip, user_agent, occurred_at)
|
||||
@api_key_name = name
|
||||
mail subject: "#{SUBJECT_PREFIX}An API key was created on your account", to: user.email_address
|
||||
end
|
||||
|
||||
def api_key_revoked(user, name:, ip:, user_agent:, occurred_at:)
|
||||
assign_context(user, ip, user_agent, occurred_at)
|
||||
@api_key_name = name
|
||||
mail subject: "#{SUBJECT_PREFIX}An API key was revoked on your account", to: user.email_address
|
||||
end
|
||||
|
||||
def suspicious_passkey_used(user, nickname:, ip:, user_agent:, occurred_at:)
|
||||
assign_context(user, ip, user_agent, occurred_at)
|
||||
@nickname = nickname
|
||||
mail subject: "#{SUBJECT_PREFIX}A passkey sign-in was blocked", to: user.email_address
|
||||
end
|
||||
|
||||
def email_address_changed(user, recipient:, old_email:, new_email:, ip:, user_agent:, occurred_at:)
|
||||
assign_context(user, ip, user_agent, occurred_at)
|
||||
@recipient = recipient
|
||||
@old_email = old_email
|
||||
@new_email = new_email
|
||||
mail subject: "#{SUBJECT_PREFIX}Your account email address was changed", to: recipient
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assign_context(user, ip, user_agent, occurred_at)
|
||||
@user = user
|
||||
@ip = ip
|
||||
@user_agent = user_agent
|
||||
@occurred_at = occurred_at
|
||||
end
|
||||
end
|
||||
7
app/mailers/totp_mailer.rb
Normal file
7
app/mailers/totp_mailer.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class TotpMailer < ApplicationMailer
|
||||
def enabled(user)
|
||||
@user = user
|
||||
mail subject: "Two-factor authentication enabled on your account",
|
||||
to: user.email_address
|
||||
end
|
||||
end
|
||||
66
app/models/api_key.rb
Normal file
66
app/models/api_key.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
class ApiKey < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :application
|
||||
|
||||
before_validation :generate_token, on: :create
|
||||
|
||||
validates :name, presence: true
|
||||
validates :token_hmac, presence: true, uniqueness: true
|
||||
validate :application_must_be_forward_auth
|
||||
validate :user_must_have_access
|
||||
|
||||
scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||
scope :revoked, -> { where.not(revoked_at: nil) }
|
||||
|
||||
attr_accessor :plaintext_token
|
||||
|
||||
def self.find_by_token(plaintext_token)
|
||||
return nil if plaintext_token.blank?
|
||||
|
||||
token_hmac = compute_token_hmac(plaintext_token)
|
||||
find_by(token_hmac: token_hmac)
|
||||
end
|
||||
|
||||
def self.compute_token_hmac(plaintext_token)
|
||||
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
|
||||
end
|
||||
|
||||
def expired?
|
||||
expires_at.present? && expires_at <= Time.current
|
||||
end
|
||||
|
||||
def revoked?
|
||||
revoked_at.present?
|
||||
end
|
||||
|
||||
def active?
|
||||
!expired? && !revoked?
|
||||
end
|
||||
|
||||
def revoke!
|
||||
update!(revoked_at: Time.current)
|
||||
end
|
||||
|
||||
def touch_last_used!
|
||||
update_column(:last_used_at, Time.current)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_token
|
||||
self.plaintext_token ||= "clk_#{SecureRandom.urlsafe_base64(48)}"
|
||||
self.token_hmac ||= self.class.compute_token_hmac(plaintext_token)
|
||||
end
|
||||
|
||||
def application_must_be_forward_auth
|
||||
if application && !application.forward_auth?
|
||||
errors.add(:application, "must be a forward auth application")
|
||||
end
|
||||
end
|
||||
|
||||
def user_must_have_access
|
||||
if user && application && !application.user_allowed?(user)
|
||||
errors.add(:user, "does not have access to this application")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,10 +5,32 @@ class Application < ApplicationRecord
|
||||
# When true, no client_secret will be generated (public client)
|
||||
attr_accessor :is_public_client
|
||||
|
||||
has_one_attached :icon
|
||||
# Virtual setters for TTL fields - accept human-friendly durations
|
||||
# e.g., "1h", "30m", "1d", or plain numbers "3600"
|
||||
def access_token_ttl=(value)
|
||||
parsed = DurationParser.parse(value)
|
||||
super(parsed)
|
||||
end
|
||||
|
||||
# Fix SVG content type after attachment
|
||||
after_save :fix_icon_content_type, if: -> { icon.attached? && saved_change_to_attribute?(:id) == false }
|
||||
def refresh_token_ttl=(value)
|
||||
parsed = DurationParser.parse(value)
|
||||
super(parsed)
|
||||
end
|
||||
|
||||
def id_token_ttl=(value)
|
||||
parsed = DurationParser.parse(value)
|
||||
super(parsed)
|
||||
end
|
||||
|
||||
after_commit :bust_forward_auth_cache, if: :forward_auth?
|
||||
|
||||
has_one_attached :icon
|
||||
has_one_attached :icon_dark
|
||||
|
||||
ICON_ATTACHMENTS = %i[icon icon_dark].freeze
|
||||
|
||||
before_validation :sanitize_svg_icons
|
||||
after_save :fix_icon_content_types
|
||||
|
||||
has_many :application_groups, dependent: :destroy
|
||||
has_many :allowed_groups, through: :application_groups, source: :group
|
||||
@@ -17,6 +39,7 @@ class Application < ApplicationRecord
|
||||
has_many :oidc_access_tokens, dependent: :destroy
|
||||
has_many :oidc_refresh_tokens, dependent: :destroy
|
||||
has_many :oidc_user_consents, dependent: :destroy
|
||||
has_many :api_keys, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
validates :slug, presence: true, uniqueness: {case_sensitive: false},
|
||||
@@ -33,13 +56,14 @@ class Application < ApplicationRecord
|
||||
message: "must be a valid HTTP or HTTPS URL"
|
||||
}
|
||||
validate :backchannel_logout_uri_must_be_https_in_production, if: -> { backchannel_logout_uri.present? }
|
||||
validate :backchannel_logout_uri_not_internal, if: -> { backchannel_logout_uri.present? }
|
||||
|
||||
# Icon validation using ActiveStorage validators
|
||||
validate :icon_validation, if: -> { icon.attached? }
|
||||
validate :icon_validation
|
||||
|
||||
# Token TTL validations (for OIDC apps)
|
||||
validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
||||
validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000}, if: :oidc? # 1 day - 90 days
|
||||
validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 7776000}, if: :oidc? # 5 min - 90 days
|
||||
validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
||||
|
||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||
@@ -59,6 +83,7 @@ class Application < ApplicationRecord
|
||||
user: "X-Remote-User",
|
||||
email: "X-Remote-Email",
|
||||
name: "X-Remote-Name",
|
||||
username: "X-Remote-Username",
|
||||
groups: "X-Remote-Groups",
|
||||
admin: "X-Remote-Admin"
|
||||
}.freeze
|
||||
@@ -97,14 +122,12 @@ class Application < ApplicationRecord
|
||||
end
|
||||
|
||||
# Access control
|
||||
# Default-deny: an empty allowed_groups list means no one gets in.
|
||||
# To make an app accessible to "everyone", attach the seeded auto-assign
|
||||
# group (or any group every user is in).
|
||||
def user_allowed?(user)
|
||||
return false unless active?
|
||||
return false unless user.active?
|
||||
|
||||
# If no groups are specified, allow all active users
|
||||
return true if allowed_groups.empty?
|
||||
|
||||
# Otherwise, user must be in at least one of the allowed groups
|
||||
(user.groups & allowed_groups).any?
|
||||
end
|
||||
|
||||
@@ -147,10 +170,6 @@ class Application < ApplicationRecord
|
||||
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"
|
||||
@@ -178,8 +197,10 @@ class Application < ApplicationRecord
|
||||
headers[header_name] = user.email_address
|
||||
when :name
|
||||
headers[header_name] = user.name.presence || user.email_address
|
||||
when :username
|
||||
headers[header_name] = user.username if user.username.present?
|
||||
when :groups
|
||||
headers[header_name] = user.groups.pluck(:name).join(",") if user.groups.any?
|
||||
headers[header_name] = user.groups.map(&:name).join(",") if user.groups.any?
|
||||
when :admin
|
||||
headers[header_name] = user.admin? ? "true" : "false"
|
||||
end
|
||||
@@ -247,27 +268,88 @@ class Application < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def fix_icon_content_type
|
||||
return unless icon.attached?
|
||||
def bust_forward_auth_cache
|
||||
Rails.application.config.forward_auth_cache&.delete("fa_apps")
|
||||
end
|
||||
|
||||
def fix_icon_content_types
|
||||
ICON_ATTACHMENTS.each do |attr|
|
||||
attachment = public_send(attr)
|
||||
next unless attachment.attached?
|
||||
# Fix SVG content type if it was detected incorrectly
|
||||
if icon.filename.extension == "svg" && icon.content_type == "application/octet-stream"
|
||||
icon.blob.update(content_type: "image/svg+xml")
|
||||
if attachment.filename.extension == "svg" && attachment.content_type == "application/octet-stream"
|
||||
attachment.blob.update(content_type: "image/svg+xml")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sanitize_svg_icons
|
||||
# Runs in before_validation. The blob has NOT yet been uploaded to disk at
|
||||
# this point (Active Storage uploads in before_save), so we cannot call
|
||||
# download — we must read from the pending attachable.
|
||||
#
|
||||
# attach below re-sets attachment_changes and would re-fire this callback;
|
||||
# we skip if the pending attachable is the cleaned hash we just installed
|
||||
# (tracked by object identity, per-attribute).
|
||||
@svg_sanitized_attachables ||= {}
|
||||
|
||||
ICON_ATTACHMENTS.each do |attr|
|
||||
change = attachment_changes[attr.to_s]
|
||||
next unless change
|
||||
attachable = change.attachable
|
||||
next if attachable.equal?(@svg_sanitized_attachables[attr])
|
||||
|
||||
raw_svg, filename, content_type = read_pending_icon(attachable)
|
||||
next unless raw_svg
|
||||
next unless content_type == "image/svg+xml" || filename.to_s.downcase.end_with?(".svg")
|
||||
|
||||
doc = Loofah.xml_document(raw_svg)
|
||||
doc.scrub!(SvgScrubber.new)
|
||||
clean_svg = doc.to_xml
|
||||
|
||||
sanitized = {
|
||||
io: StringIO.new(clean_svg),
|
||||
filename: filename,
|
||||
content_type: "image/svg+xml"
|
||||
}
|
||||
@svg_sanitized_attachables[attr] = sanitized
|
||||
public_send(attr).attach(sanitized)
|
||||
end
|
||||
end
|
||||
|
||||
def read_pending_icon(attachable)
|
||||
case attachable
|
||||
when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
|
||||
content = attachable.read
|
||||
attachable.rewind
|
||||
[content, attachable.original_filename, attachable.content_type]
|
||||
when Hash
|
||||
io = attachable[:io] || attachable["io"]
|
||||
return [nil, nil, nil] unless io
|
||||
content = io.read
|
||||
io.rewind if io.respond_to?(:rewind)
|
||||
[content,
|
||||
attachable[:filename] || attachable["filename"],
|
||||
attachable[:content_type] || attachable["content_type"]]
|
||||
else
|
||||
[nil, nil, nil]
|
||||
end
|
||||
end
|
||||
|
||||
def icon_validation
|
||||
return unless icon.attached?
|
||||
|
||||
# Check content type
|
||||
allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
|
||||
unless allowed_types.include?(icon.content_type)
|
||||
errors.add(:icon, "must be a PNG, JPG, GIF, or SVG image")
|
||||
|
||||
ICON_ATTACHMENTS.each do |attr|
|
||||
attachment = public_send(attr)
|
||||
next unless attachment.attached?
|
||||
|
||||
unless allowed_types.include?(attachment.content_type)
|
||||
errors.add(attr, "must be a PNG, JPG, GIF, or SVG image")
|
||||
end
|
||||
|
||||
# Check file size (2MB limit)
|
||||
if icon.blob.byte_size > 2.megabytes
|
||||
errors.add(:icon, "must be less than 2MB")
|
||||
if attachment.blob.byte_size > 2.megabytes
|
||||
errors.add(attr, "must be less than 2MB")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -309,4 +391,17 @@ class Application < ApplicationRecord
|
||||
# Let the format validator handle invalid URIs
|
||||
end
|
||||
end
|
||||
|
||||
# SSRF guard: the backchannel logout URI is dialled server-side on every user
|
||||
# logout, so it must not target internal infrastructure (loopback, private
|
||||
# ranges, or the link-local cloud metadata endpoint). This is the fast,
|
||||
# config-time check; BackchannelLogoutJob re-checks with DNS resolution.
|
||||
def backchannel_logout_uri_not_internal
|
||||
uri = URI.parse(backchannel_logout_uri)
|
||||
if uri.host.present? && PrivateAddressCheck.internal_host?(uri.host)
|
||||
errors.add(:backchannel_logout_uri, "must not point to a private, loopback, or link-local address")
|
||||
end
|
||||
rescue URI::InvalidURIError
|
||||
# Let the format validator handle invalid URIs
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,4 +3,12 @@ class ApplicationGroup < ApplicationRecord
|
||||
belongs_to :group
|
||||
|
||||
validates :application_id, uniqueness: {scope: :group_id}
|
||||
|
||||
after_commit :bust_forward_auth_cache
|
||||
|
||||
private
|
||||
|
||||
def bust_forward_auth_cache
|
||||
Rails.application.config.forward_auth_cache&.delete("fa_apps")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,6 +15,11 @@ class Group < ApplicationRecord
|
||||
normalizes :name, with: ->(name) { name.strip.downcase }
|
||||
validate :no_reserved_claim_names
|
||||
|
||||
scope :auto_assign, -> { where(auto_assign: true) }
|
||||
scope :admin, -> { where(admin: true) }
|
||||
|
||||
before_destroy :ensure_other_admin_group_exists
|
||||
|
||||
# Parse custom_claims JSON field
|
||||
def parsed_custom_claims
|
||||
return {} if custom_claims.blank?
|
||||
@@ -23,6 +28,13 @@ class Group < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def ensure_other_admin_group_exists
|
||||
return unless admin?
|
||||
return if Group.where(admin: true).where.not(id: id).exists?
|
||||
errors.add(:base, "cannot delete the last administrators group")
|
||||
throw :abort
|
||||
end
|
||||
|
||||
def no_reserved_claim_names
|
||||
return if custom_claims.blank?
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class OidcAccessToken < ApplicationRecord
|
||||
belongs_to :application
|
||||
belongs_to :user
|
||||
belongs_to :oidc_authorization_code, optional: true
|
||||
has_many :oidc_refresh_tokens, dependent: :destroy
|
||||
|
||||
before_validation :generate_token, on: :create
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class OidcAuthorizationCode < ApplicationRecord
|
||||
belongs_to :application
|
||||
belongs_to :user
|
||||
has_many :oidc_access_tokens
|
||||
has_many :oidc_refresh_tokens
|
||||
|
||||
attr_accessor :plaintext_code
|
||||
|
||||
@@ -9,7 +11,7 @@ class OidcAuthorizationCode < ApplicationRecord
|
||||
|
||||
validates :code_hmac, presence: true, uniqueness: true
|
||||
validates :redirect_uri, presence: true
|
||||
validates :code_challenge_method, inclusion: {in: %w[plain S256], allow_nil: true}
|
||||
validates :code_challenge_method, inclusion: {in: %w[S256], allow_nil: true}
|
||||
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
|
||||
|
||||
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
||||
@@ -44,6 +46,12 @@ class OidcAuthorizationCode < ApplicationRecord
|
||||
code_challenge.present?
|
||||
end
|
||||
|
||||
# Parse claims_requests JSON field
|
||||
def parsed_claims_requests
|
||||
return {} if claims_requests.blank?
|
||||
claims_requests.is_a?(Hash) ? claims_requests : {}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_code
|
||||
|
||||
@@ -2,6 +2,7 @@ class OidcRefreshToken < ApplicationRecord
|
||||
belongs_to :application
|
||||
belongs_to :user
|
||||
belongs_to :oidc_access_token
|
||||
belongs_to :oidc_authorization_code, optional: true
|
||||
|
||||
before_validation :generate_token, on: :create
|
||||
before_validation :set_expiry, on: :create
|
||||
@@ -48,11 +49,21 @@ class OidcRefreshToken < ApplicationRecord
|
||||
update!(revoked_at: Time.current)
|
||||
end
|
||||
|
||||
# Revoke all refresh tokens in the same family (token rotation security)
|
||||
# Revoke all refresh tokens in the same family (token rotation security).
|
||||
# Also revoke every access token issued within the family: on a detected reuse
|
||||
# attack the stolen chain's access tokens must not remain usable at /userinfo
|
||||
# until they expire.
|
||||
def revoke_family!
|
||||
return unless token_family_id.present?
|
||||
|
||||
OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current)
|
||||
now = Time.current
|
||||
family = OidcRefreshToken.in_family(token_family_id)
|
||||
access_token_ids = family.pluck(:oidc_access_token_id).compact.uniq
|
||||
|
||||
family.update_all(revoked_at: now)
|
||||
if access_token_ids.any?
|
||||
OidcAccessToken.where(id: access_token_ids, revoked_at: nil).update_all(revoked_at: now)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -50,6 +50,12 @@ class OidcUserConsent < ApplicationRecord
|
||||
find_by(sid: sid)
|
||||
end
|
||||
|
||||
# Parse claims_requests JSON field
|
||||
def parsed_claims_requests
|
||||
return {} if claims_requests.blank?
|
||||
claims_requests.is_a?(Hash) ? claims_requests : {}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_granted_at
|
||||
|
||||
@@ -7,6 +7,9 @@ class Session < ApplicationRecord
|
||||
# Scopes
|
||||
scope :active, -> { where("expires_at > ?", Time.current) }
|
||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||
# Sessions whose owning user is currently active. Used at request time so a
|
||||
# disabled account cannot continue to authenticate with an existing session.
|
||||
scope :for_active_user, -> { joins(:user).where(users: {status: User.statuses[:active]}) }
|
||||
|
||||
def expired?
|
||||
expires_at.present? && expires_at <= Time.current
|
||||
|
||||
73
app/models/svg_scrubber.rb
Normal file
73
app/models/svg_scrubber.rb
Normal file
@@ -0,0 +1,73 @@
|
||||
# Loofah scrubber that strips dangerous content from SVG files
|
||||
# while preserving safe SVG elements and attributes for icon display.
|
||||
class SvgScrubber < Loofah::Scrubber
|
||||
ALLOWED_ELEMENTS = %w[
|
||||
svg g defs use symbol
|
||||
circle ellipse line path polygon polyline rect
|
||||
text tspan textPath
|
||||
clipPath mask pattern
|
||||
linearGradient radialGradient stop
|
||||
filter feBlend feColorMatrix feComponentTransfer feComposite
|
||||
feConvolveMatrix feDiffuseLighting feDisplacementMap feFlood
|
||||
feGaussianBlur feImage feMerge feMergeNode feMorphology
|
||||
feOffset feSpecularLighting feTile feTurbulence
|
||||
title desc metadata
|
||||
].freeze
|
||||
|
||||
ALLOWED_ATTRIBUTES = %w[
|
||||
id class style
|
||||
x y x1 y1 x2 y2 cx cy r rx ry
|
||||
width height viewBox preserveAspectRatio
|
||||
d points
|
||||
fill stroke stroke-width stroke-linecap stroke-linejoin stroke-dasharray
|
||||
opacity fill-opacity stroke-opacity
|
||||
transform translate rotate scale
|
||||
font-family font-size font-weight text-anchor
|
||||
clip-path mask filter
|
||||
gradientUnits gradientTransform spreadMethod
|
||||
offset stop-color stop-opacity
|
||||
dx dy textLength lengthAdjust
|
||||
xmlns xmlns:xlink
|
||||
color display visibility overflow
|
||||
fill-rule clip-rule
|
||||
marker-start marker-mid marker-end
|
||||
].freeze
|
||||
|
||||
# Loofah hands attribute names back in their source case (e.g. "viewBox").
|
||||
# Compare against a downcased copy so SVG-spec camelCase attributes aren't
|
||||
# stripped from legitimate icons.
|
||||
ALLOWED_ATTRIBUTES_LOOKUP = ALLOWED_ATTRIBUTES.map(&:downcase).to_set.freeze
|
||||
|
||||
# Event handler attributes that must always be removed
|
||||
EVENT_HANDLER_PATTERN = /\Aon/i
|
||||
|
||||
def initialize
|
||||
@direction = :top_down
|
||||
end
|
||||
|
||||
def scrub(node)
|
||||
return CONTINUE if node.text? || node.cdata?
|
||||
|
||||
if node.element?
|
||||
if ALLOWED_ELEMENTS.include?(node.name)
|
||||
# Remove disallowed and event handler attributes
|
||||
node.attribute_nodes.each do |attr|
|
||||
attr.remove unless safe_attribute?(attr)
|
||||
end
|
||||
return CONTINUE
|
||||
end
|
||||
end
|
||||
|
||||
node.remove
|
||||
STOP
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def safe_attribute?(attr)
|
||||
name = attr.name.downcase
|
||||
return false if name.match?(EVENT_HANDLER_PATTERN)
|
||||
return false if attr.value&.match?(/javascript:|data:/i)
|
||||
ALLOWED_ATTRIBUTES_LOOKUP.include?(name)
|
||||
end
|
||||
end
|
||||
@@ -9,6 +9,7 @@ class User < ApplicationRecord
|
||||
has_many :application_user_claims, dependent: :destroy
|
||||
has_many :oidc_user_consents, dependent: :destroy
|
||||
has_many :webauthn_credentials, dependent: :destroy
|
||||
has_many :api_keys, dependent: :destroy
|
||||
|
||||
# Token generation for passwordless flows
|
||||
generates_token_for :invitation_login, expires_in: 24.hours do
|
||||
@@ -40,8 +41,24 @@ class User < ApplicationRecord
|
||||
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
|
||||
enum :status, {active: 0, disabled: 1, pending_invitation: 2}
|
||||
|
||||
# When an account stops being active (e.g. an admin disables it), immediately
|
||||
# terminate its sessions so access is revoked everywhere, not just on expiry.
|
||||
# Defence-in-depth: session lookup also filters by active status at request time.
|
||||
after_update_commit :revoke_sessions_when_deactivated
|
||||
|
||||
# Scopes
|
||||
scope :admins, -> { where(admin: true) }
|
||||
scope :admins, -> { joins(:groups).where(groups: {admin: true}).distinct }
|
||||
|
||||
# Set true on a user (or on the user_params) to skip the auto-assign callback
|
||||
# for that record. Used by the admin invite form (opt-out checkbox) and by
|
||||
# tests that want a clean slate.
|
||||
attr_accessor :skip_auto_assign
|
||||
|
||||
after_create :add_to_auto_assign_groups, unless: :skip_auto_assign
|
||||
|
||||
def admin?
|
||||
groups.any?(&:admin?)
|
||||
end
|
||||
|
||||
# TOTP methods
|
||||
def totp_enabled?
|
||||
@@ -51,7 +68,10 @@ class User < ApplicationRecord
|
||||
def enable_totp!
|
||||
require "rotp"
|
||||
self.totp_secret = ROTP::Base32.random
|
||||
self.backup_codes = generate_backup_codes
|
||||
# generate_backup_codes assigns the BCrypt hashes to self.backup_codes and
|
||||
# returns the plaintext codes for display. Do NOT reassign backup_codes to the
|
||||
# return value — that would store the plaintext codes and break verification.
|
||||
generate_backup_codes
|
||||
save!
|
||||
end
|
||||
|
||||
@@ -74,7 +94,13 @@ class User < ApplicationRecord
|
||||
|
||||
require "rotp"
|
||||
totp = ROTP::TOTP.new(totp_secret)
|
||||
totp.verify(code, drift_behind: 30, drift_ahead: 30)
|
||||
# Pass `after:` so a code can only be accepted once: ROTP rejects any timestep
|
||||
# at or before the last accepted one, closing the ~90s drift-window replay.
|
||||
verified_at = totp.verify(code, drift_behind: 30, drift_ahead: 30, after: last_otp_at)
|
||||
return false unless verified_at
|
||||
|
||||
update_column(:last_otp_at, verified_at)
|
||||
true
|
||||
end
|
||||
|
||||
# Console/debug helper: get current TOTP code
|
||||
@@ -106,12 +132,12 @@ class User < ApplicationRecord
|
||||
save! # Save the updated array
|
||||
|
||||
# Log successful backup code usage for security monitoring
|
||||
Rails.logger.info "Backup code used successfully - User ID: #{id}, IP: #{Current.session&.client_ip}"
|
||||
Rails.logger.info "Backup code used successfully - User ID: #{id}, IP: #{Current.session&.ip_address}"
|
||||
true
|
||||
else
|
||||
# Increment failed attempt counter and log for security monitoring
|
||||
increment_backup_code_failed_attempts
|
||||
Rails.logger.warn "Failed backup code attempt - User ID: #{id}, IP: #{Current.session&.client_ip}"
|
||||
Rails.logger.warn "Failed backup code attempt - User ID: #{id}, IP: #{Current.session&.ip_address}"
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -221,6 +247,17 @@ class User < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def add_to_auto_assign_groups
|
||||
Group.auto_assign.each { |g| groups << g }
|
||||
end
|
||||
|
||||
def revoke_sessions_when_deactivated
|
||||
return unless saved_change_to_status?
|
||||
return if active?
|
||||
|
||||
sessions.destroy_all
|
||||
end
|
||||
|
||||
def no_reserved_claim_names
|
||||
return if custom_claims.blank?
|
||||
|
||||
|
||||
@@ -52,13 +52,17 @@ class WebauthnCredential < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
# Check if sign count is suspicious (clone detection)
|
||||
# Check if sign count is suspicious (clone detection).
|
||||
#
|
||||
# Per WebAuthn §6.1.1, a signature counter of 0 means the authenticator does
|
||||
# not implement a counter (true of most synced passkeys — Apple/Google report
|
||||
# 0 every time), so it cannot be used for clone detection. Only when BOTH the
|
||||
# stored and presented counts are non-zero does a non-increasing value signal
|
||||
# a possible clone.
|
||||
def suspicious_sign_count?(new_sign_count)
|
||||
return false if sign_count.zero? && new_sign_count > 0 # First use
|
||||
return false if new_sign_count > sign_count # Normal increment
|
||||
return false if sign_count.zero? || new_sign_count.zero?
|
||||
|
||||
# Sign count didn't increase - possible clone
|
||||
true
|
||||
new_sign_count <= sign_count
|
||||
end
|
||||
|
||||
# Format for display in UI
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
class OidcJwtService
|
||||
extend ClaimsMerger
|
||||
|
||||
RESERVED_CLAIMS = %i[iss sub aud exp iat nbf jti nonce azp].freeze
|
||||
|
||||
class << self
|
||||
# Generate an ID token (JWT) for the user
|
||||
def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil)
|
||||
def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil, scopes: "openid", claims_requests: {})
|
||||
now = Time.current.to_i
|
||||
# Use application's configured ID token TTL (defaults to 1 hour)
|
||||
ttl = application.id_token_expiry_seconds
|
||||
@@ -11,18 +13,44 @@ class OidcJwtService
|
||||
# Use pairwise SID from consent if available, fallback to user ID
|
||||
subject = consent&.sid || user.id.to_s
|
||||
|
||||
# Parse scopes (space-separated string)
|
||||
requested_scopes = scopes.to_s.split
|
||||
|
||||
# Parse claims_requests parameter for id_token context
|
||||
id_token_claims = claims_requests["id_token"] || {}
|
||||
|
||||
# Required claims (always included per OIDC Core spec)
|
||||
payload = {
|
||||
iss: issuer_url,
|
||||
sub: subject,
|
||||
aud: application.client_id,
|
||||
exp: now + ttl,
|
||||
iat: now,
|
||||
email: user.email_address,
|
||||
email_verified: true,
|
||||
preferred_username: user.username.presence || user.email_address,
|
||||
name: user.name.presence || user.email_address
|
||||
iat: now
|
||||
}
|
||||
|
||||
# Email claims (only if 'email' scope requested AND either no claims filter OR email requested)
|
||||
if requested_scopes.include?("email")
|
||||
if should_include_claim?("email", id_token_claims)
|
||||
payload[:email] = user.email_address
|
||||
end
|
||||
if should_include_claim?("email_verified", id_token_claims)
|
||||
payload[:email_verified] = true
|
||||
end
|
||||
end
|
||||
|
||||
# Profile claims (only if 'profile' scope requested)
|
||||
if requested_scopes.include?("profile")
|
||||
if should_include_claim?("preferred_username", id_token_claims)
|
||||
payload[:preferred_username] = user.username.presence || user.email_address
|
||||
end
|
||||
if should_include_claim?("name", id_token_claims)
|
||||
payload[:name] = user.name.presence || user.email_address
|
||||
end
|
||||
if should_include_claim?("updated_at", id_token_claims)
|
||||
payload[:updated_at] = user.updated_at.to_i
|
||||
end
|
||||
end
|
||||
|
||||
# Add nonce if provided (OIDC requires this for implicit flow)
|
||||
payload[:nonce] = nonce if nonce.present?
|
||||
|
||||
@@ -44,21 +72,31 @@ class OidcJwtService
|
||||
payload[:at_hash] = at_hash
|
||||
end
|
||||
|
||||
# Add groups if user has any
|
||||
if user.groups.any?
|
||||
# Groups claims (only if 'groups' scope requested AND requested in claims parameter)
|
||||
if requested_scopes.include?("groups") && user.groups.any?
|
||||
if should_include_claim?("groups", id_token_claims)
|
||||
payload[:groups] = user.groups.pluck(:name)
|
||||
end
|
||||
end
|
||||
|
||||
# Merge custom claims from groups (arrays are combined, not overwritten)
|
||||
# Note: Custom claims from groups are always merged (not scope-dependent)
|
||||
# Reserved claims are stripped as defense-in-depth (also validated at model layer)
|
||||
user.groups.each do |group|
|
||||
payload = deep_merge_claims(payload, group.parsed_custom_claims)
|
||||
payload = deep_merge_claims(payload, group.parsed_custom_claims.except(*RESERVED_CLAIMS))
|
||||
end
|
||||
|
||||
# Merge custom claims from user (arrays are combined, other values override)
|
||||
payload = deep_merge_claims(payload, user.parsed_custom_claims)
|
||||
payload = deep_merge_claims(payload, user.parsed_custom_claims.except(*RESERVED_CLAIMS))
|
||||
|
||||
# Merge app-specific custom claims (highest priority, arrays are combined)
|
||||
payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
|
||||
payload = deep_merge_claims(payload, application.custom_claims_for_user(user).except(*RESERVED_CLAIMS))
|
||||
|
||||
# Filter custom claims based on claims parameter
|
||||
# If claims parameter is present, only include requested custom claims
|
||||
if id_token_claims.any?
|
||||
payload = filter_custom_claims(payload, id_token_claims)
|
||||
end
|
||||
|
||||
JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
|
||||
end
|
||||
@@ -172,5 +210,69 @@ class OidcJwtService
|
||||
def key_id
|
||||
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
|
||||
end
|
||||
|
||||
# Check if a claim should be included based on claims parameter
|
||||
# Returns true if:
|
||||
# - No claims parameter specified (include all scope-based claims)
|
||||
# - Claim is explicitly requested (even with null spec or essential: true)
|
||||
def should_include_claim?(claim_name, id_token_claims)
|
||||
# No claims parameter = include all scope-based claims
|
||||
return true if id_token_claims.empty?
|
||||
|
||||
# Check if claim is requested
|
||||
return false unless id_token_claims.key?(claim_name)
|
||||
|
||||
# Claim specification can be:
|
||||
# - null (requested)
|
||||
# - true (essential, requested)
|
||||
# - false (not requested)
|
||||
# - Hash with essential/value/values
|
||||
|
||||
claim_spec = id_token_claims[claim_name]
|
||||
return true if claim_spec.nil? || claim_spec == true
|
||||
return false if claim_spec == false
|
||||
|
||||
# If it's a hash, the claim is requested (filtering happens later)
|
||||
true if claim_spec.is_a?(Hash)
|
||||
end
|
||||
|
||||
# Filter custom claims based on claims parameter
|
||||
# Removes claims not explicitly requested
|
||||
# Applies value/values filtering if specified
|
||||
def filter_custom_claims(payload, id_token_claims)
|
||||
# Get all claim names that are NOT standard OIDC claims
|
||||
standard_claims = %w[iss sub aud exp iat nbf jti nonce azp at_hash auth_time acr email email_verified name preferred_username updated_at groups]
|
||||
custom_claim_names = payload.keys.map(&:to_s) - standard_claims
|
||||
|
||||
filtered = payload.dup
|
||||
|
||||
custom_claim_names.each do |claim_name|
|
||||
claim_sym = claim_name.to_sym
|
||||
|
||||
# If claim is not requested, remove it
|
||||
unless id_token_claims.key?(claim_name) || id_token_claims.key?(claim_sym)
|
||||
filtered.delete(claim_sym)
|
||||
next
|
||||
end
|
||||
|
||||
# Apply value/values filtering if specified
|
||||
claim_spec = id_token_claims[claim_name] || id_token_claims[claim_sym]
|
||||
next unless claim_spec.is_a?(Hash)
|
||||
|
||||
current_value = filtered[claim_sym]
|
||||
|
||||
# Check value constraint
|
||||
if claim_spec["value"].present?
|
||||
filtered.delete(claim_sym) unless current_value == claim_spec["value"]
|
||||
end
|
||||
|
||||
# Check values constraint (array of allowed values)
|
||||
if claim_spec["values"].is_a?(Array)
|
||||
filtered.delete(claim_sym) unless claim_spec["values"].include?(current_value)
|
||||
end
|
||||
end
|
||||
|
||||
filtered
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Sessions</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Manage your active sessions and connected applications.</p>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Sessions</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Manage your active sessions and connected applications.</p>
|
||||
</div>
|
||||
|
||||
<!-- Connected Applications -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Connected Applications</h3>
|
||||
<div class="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Connected Applications</h3>
|
||||
<div class="mt-2 max-w-xl text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>These applications have access to your account. You can revoke access at any time.</p>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<% if @connected_applications.any? %>
|
||||
<ul role="list" class="divide-y divide-gray-200">
|
||||
<ul role="list" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<% @connected_applications.each do |consent| %>
|
||||
<li class="py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
<%= consent.application.name %>
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Access to: <%= consent.formatted_scopes %>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
Authorized <%= time_ago_in_words(consent.granted_at) %> ago
|
||||
</p>
|
||||
</div>
|
||||
<%= button_to "Revoke Access", revoke_consent_active_sessions_path(application_id: consent.application.id), method: :delete,
|
||||
class: "inline-flex items-center rounded-md border border-red-300 bg-white px-3 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2",
|
||||
class: "inline-flex items-center rounded-md border border-red-300 bg-white dark:bg-gray-700 dark:ring-gray-600 dark:text-gray-200 px-3 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900",
|
||||
form: { data: { turbo_confirm: "Are you sure you want to revoke access to #{consent.application.name}? You'll need to re-authorize this application to use it again." } } %>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500">No connected applications.</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No connected applications.</p>
|
||||
<% end %>
|
||||
|
||||
<% if @connected_applications.any? %>
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex justify-end">
|
||||
<div class="inline-block">
|
||||
<%= button_to "Revoke All App Access", revoke_all_consents_active_sessions_path, method: :delete,
|
||||
class: "inline-flex items-center rounded-md border border-red-300 bg-white px-3 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 whitespace-nowrap",
|
||||
class: "inline-flex items-center rounded-md border border-red-300 bg-white dark:bg-gray-700 dark:ring-gray-600 dark:text-gray-200 px-3 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap",
|
||||
form: { data: { turbo_confirm: "This will revoke access from all connected applications. You'll need to re-authorize each application to use them again. Are you sure?" } } %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,37 +55,37 @@
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Active Sessions</h3>
|
||||
<div class="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Active Sessions</h3>
|
||||
<div class="mt-2 max-w-xl text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>These devices are currently signed in to your account. Revoke any sessions that you don't recognize.</p>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<% if @active_sessions.any? %>
|
||||
<ul role="list" class="divide-y divide-gray-200">
|
||||
<ul role="list" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<% @active_sessions.each do |session| %>
|
||||
<li class="py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
<%= session.device_name || "Unknown Device" %>
|
||||
<% if session.id == Current.session.id %>
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:text-green-200">
|
||||
This device
|
||||
</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<%= session.ip_address %>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
Last active <%= time_ago_in_words(session.last_activity_at || session.updated_at) %> ago
|
||||
</p>
|
||||
</div>
|
||||
<% if session.id != Current.session.id %>
|
||||
<%= button_to "Revoke", session_path(session), method: :delete,
|
||||
class: "inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
|
||||
class: "inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 dark:text-gray-200 px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900",
|
||||
form: { data: { turbo_confirm: "Are you sure you want to revoke this session?" } } %>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -93,15 +93,15 @@
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500">No other active sessions.</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No other active sessions.</p>
|
||||
<% end %>
|
||||
|
||||
<% if @active_sessions.count > 1 %>
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex justify-end">
|
||||
<div class="inline-block">
|
||||
<%= button_to "Sign Out Everywhere Else", session_path(Current.session), method: :delete,
|
||||
class: "inline-flex items-center rounded-md border border-orange-300 bg-white px-3 py-2 text-sm font-medium text-orange-700 shadow-sm hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 whitespace-nowrap",
|
||||
class: "inline-flex items-center rounded-md border border-orange-300 bg-white dark:bg-gray-700 dark:ring-gray-600 dark:text-gray-200 px-3 py-2 text-sm font-medium text-orange-700 shadow-sm hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap",
|
||||
form: { data: { turbo_confirm: "This will sign you out from all other devices except this one. Are you sure?" } } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
77
app/views/admin/access_checks/new.html.erb
Normal file
77
app/views/admin/access_checks/new.html.erb
Normal file
@@ -0,0 +1,77 @@
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Access check</h1>
|
||||
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">Pick a user and an application to see whether the user can access it and, if so, which group(s) grant that access.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<%= form_with url: admin_access_path, method: :get, class: "space-y-4" do |form| %>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<%= form.label :user_id, "User", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.select :user_id,
|
||||
@users.map { |u| [u.email_address, u.id] },
|
||||
{ include_blank: "Select a user…", selected: @user&.id },
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.label :application_id, "Application", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.select :application_id,
|
||||
@applications.map { |a| [a.name, a.id] },
|
||||
{ include_blank: "Select an application…", selected: @application&.id },
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.submit "Check access", 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 %>
|
||||
|
||||
<% if @user && @application %>
|
||||
<div class="mt-6 rounded-md border <%= @allowed ? "border-green-200 dark:border-green-700 bg-green-50 dark:bg-green-900/30" : "border-red-200 dark:border-red-700 bg-red-50 dark:bg-red-900/30" %> p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<% if @allowed %>
|
||||
<svg class="h-6 w-6 text-green-600 dark:text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<% else %>
|
||||
<svg class="h-6 w-6 text-red-600 dark:text-red-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
<% end %>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium <%= @allowed ? "text-green-800 dark:text-green-200" : "text-red-800 dark:text-red-200" %>">
|
||||
<%= @user.email_address %> <%= @allowed ? "can access" : "cannot access" %> <%= @application.name %>.
|
||||
</p>
|
||||
<% if @allowed %>
|
||||
<p class="mt-1 text-xs text-green-700 dark:text-green-300">
|
||||
Granted via:
|
||||
<% @via.each_with_index do |g, i| %>
|
||||
<%= link_to g.name, admin_group_path(g), class: "underline" %><%= "," unless i == @via.size - 1 %>
|
||||
<% end %>
|
||||
</p>
|
||||
<% else %>
|
||||
<p class="mt-1 text-xs text-red-700 dark:text-red-300">
|
||||
<% reasons = [] %>
|
||||
<% reasons << "the application is inactive" unless @application.active? %>
|
||||
<% reasons << "the user is #{@user.status.humanize.downcase}" unless @user.active? %>
|
||||
<% if @application.active? && @user.active? %>
|
||||
<% if @application.allowed_groups.empty? %>
|
||||
<% reasons << "the application has no allowed groups (default deny)" %>
|
||||
<% else %>
|
||||
<% reasons << "the user shares no group with the application's allowed groups" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
Reason: <%= reasons.join("; ") %>.
|
||||
</p>
|
||||
<% end %>
|
||||
<p class="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<%= link_to "View user", admin_user_path(@user), class: "underline" %> ·
|
||||
<%= link_to "View application", admin_application_path(@application), class: "underline" %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,24 +2,43 @@
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<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" %>
|
||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 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>
|
||||
<%= form.label :slug, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.text_field :slug, required: true, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 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 dark:text-gray-400">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" %>
|
||||
<% if application.persisted? %>
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Application Type</span>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span class="inline-flex items-center rounded-md bg-blue-50 dark:bg-blue-900/30 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300 ring-1 ring-inset ring-blue-600/20">
|
||||
<%= application.oidc? ? "OpenID Connect (OIDC)" : "Forward Auth (Reverse Proxy)" %>
|
||||
</span>
|
||||
</div>
|
||||
<%= form.hidden_field :app_type %>
|
||||
<select class="hidden" data-application-form-target="appTypeSelect"><option value="<%= application.app_type %>" selected></option></select>
|
||||
<% else %>
|
||||
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, {
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||
data: { action: "change->application-form#updateFieldVisibility", application_form_target: "appTypeSelect" }
|
||||
} %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<%= form.label :icon, "Application Icon", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.label :description, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between -mb-2">
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Application Icons</span>
|
||||
<a href="https://dashboardicons.com" target="_blank" rel="noopener noreferrer" class="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||
@@ -27,116 +46,51 @@
|
||||
Browse icons at dashboardicons.com
|
||||
</a>
|
||||
</div>
|
||||
<% if application.icon.attached? && application.persisted? %>
|
||||
<% begin %>
|
||||
<%# Only show icon if we can successfully get its URL (blob is persisted) %>
|
||||
<% if application.icon.blob&.persisted? && application.icon.blob.key.present? %>
|
||||
<div class="mt-2 mb-3 flex items-center gap-4">
|
||||
<%= image_tag application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200", alt: "Current icon" %>
|
||||
<div class="text-sm text-gray-600">
|
||||
<p class="font-medium">Current icon</p>
|
||||
<p class="text-xs"><%= number_to_human_size(application.icon.blob.byte_size) %></p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% rescue ArgumentError => e %>
|
||||
<%# Handle case where icon attachment exists but can't generate signed_id %>
|
||||
<% if e.message.include?("Cannot get a signed_id for a new record") %>
|
||||
<div class="mt-2 mb-3 text-sm text-gray-600">
|
||||
<p class="font-medium">Icon uploaded</p>
|
||||
<p class="text-xs">File will be processed shortly</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<%# Re-raise if it's a different error %>
|
||||
<% raise e %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-2" data-controller="file-drop image-paste">
|
||||
<div class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-blue-400 transition-colors"
|
||||
data-file-drop-target="dropzone"
|
||||
data-image-paste-target="dropzone"
|
||||
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste"
|
||||
tabindex="0">
|
||||
<div class="space-y-1 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<div class="flex text-sm text-gray-600">
|
||||
<label for="<%= form.field_id(:icon) %>" class="relative cursor-pointer bg-white rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-blue-500">
|
||||
<span>Upload a file</span>
|
||||
<%= form.file_field :icon,
|
||||
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
|
||||
class: "sr-only",
|
||||
data: {
|
||||
file_drop_target: "input",
|
||||
image_paste_target: "input",
|
||||
action: "change->file-drop#handleFiles"
|
||||
} %>
|
||||
</label>
|
||||
<p class="pl-1">or drag and drop</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">PNG, JPG, GIF, or SVG up to 2MB</p>
|
||||
<p class="text-xs text-blue-600 font-medium mt-2">💡 Tip: Click here and press Ctrl+V (or Cmd+V) to paste an image from your clipboard</p>
|
||||
</div>
|
||||
</div>
|
||||
<div data-file-drop-target="preview" class="mt-3 hidden">
|
||||
<div class="flex items-center gap-3 p-3 bg-blue-50 rounded-md border border-blue-200">
|
||||
<img data-file-drop-target="previewImage" class="h-12 w-12 rounded object-cover" alt="Preview">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900" data-file-drop-target="filename"></p>
|
||||
<p class="text-xs text-gray-500" data-file-drop-target="filesize"></p>
|
||||
</div>
|
||||
<button type="button" data-action="click->file-drop#clear" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%= render "icon_uploader",
|
||||
form: form,
|
||||
field: :icon,
|
||||
label: "Icon",
|
||||
current_attached: (application.persisted? ? application.icon : nil),
|
||||
current_label: "Current icon" %>
|
||||
|
||||
<%= render "icon_uploader",
|
||||
form: form,
|
||||
field: :icon_dark,
|
||||
label: "Dark mode icon (optional)",
|
||||
help: "Used in place of the main icon when the user's theme is dark. If omitted, the main icon is used in both modes.",
|
||||
current_attached: (application.persisted? ? application.icon_dark : nil),
|
||||
current_label: "Current dark-mode icon",
|
||||
preview_extra_class: "bg-gray-900" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.url_field :landing_url, 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: "https://app.example.com" %>
|
||||
<p class="mt-1 text-sm text-gray-500">The main URL users will visit to access this application. This will be shown as a link on their dashboard.</p>
|
||||
</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"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, {
|
||||
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?,
|
||||
data: { action: "change->application-form#updateFieldVisibility", application_form_target: "appTypeSelect" }
|
||||
} %>
|
||||
<% if application.persisted? %>
|
||||
<p class="mt-1 text-sm text-gray-500">Application type cannot be changed after creation.</p>
|
||||
<% end %>
|
||||
<%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.url_field :landing_url, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">The main URL users will visit to access this application. This will be shown as a link on their dashboard.</p>
|
||||
</div>
|
||||
|
||||
<!-- OIDC-specific fields -->
|
||||
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.oidc? || !application.persisted? %>" data-application-form-target="oidcFields">
|
||||
<h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3>
|
||||
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 dark:border-gray-700 pt-6 <%= 'hidden' unless application.oidc? || !application.persisted? %>" data-application-form-target="oidcFields">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">OIDC Configuration</h3>
|
||||
|
||||
<!-- Client Type Selection (only for new applications) -->
|
||||
<% unless application.persisted? %>
|
||||
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
||||
<h4 class="text-sm font-semibold text-gray-900 mb-3">Client Type</h4>
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-gray-800">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Client Type</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start">
|
||||
<%= form.radio_button :is_public_client, "false", checked: !application.is_public_client, class: "mt-1 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500", data: { action: "change->application-form#updatePkceVisibility" } %>
|
||||
<%= form.radio_button :is_public_client, "false", checked: !application.is_public_client, class: "mt-1 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500", data: { action: "change->application-form#updatePkceVisibility" } %>
|
||||
<div class="ml-3">
|
||||
<label for="application_is_public_client_false" class="block text-sm font-medium text-gray-900">Confidential Client (Recommended)</label>
|
||||
<p class="text-sm text-gray-500">Backend server app that can securely store a client secret. Examples: traditional web apps, server-to-server APIs.</p>
|
||||
<label for="application_is_public_client_false" class="block text-sm font-medium text-gray-900 dark:text-gray-100">Confidential Client (Recommended)</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Backend server app that can securely store a client secret. Examples: traditional web apps, server-to-server APIs.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<%= form.radio_button :is_public_client, "true", checked: application.is_public_client, class: "mt-1 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500", data: { action: "change->application-form#updatePkceVisibility" } %>
|
||||
<%= form.radio_button :is_public_client, "true", checked: application.is_public_client, class: "mt-1 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500", data: { action: "change->application-form#updatePkceVisibility" } %>
|
||||
<div class="ml-3">
|
||||
<label for="application_is_public_client_true" class="block text-sm font-medium text-gray-900">Public Client</label>
|
||||
<p class="text-sm text-gray-500">Frontend-only app that cannot store secrets securely. Examples: SPAs (React/Vue), mobile apps, CLI tools. <strong class="text-amber-600">PKCE is required.</strong></p>
|
||||
<label for="application_is_public_client_true" class="block text-sm font-medium text-gray-900 dark:text-gray-100">Public Client</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Frontend-only app that cannot store secrets securely. Examples: SPAs (React/Vue), mobile apps, CLI tools. <strong class="text-amber-600">PKCE is required.</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,129 +98,207 @@
|
||||
<% else %>
|
||||
<!-- Show client type for existing applications (read-only) -->
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="font-medium text-gray-700">Client Type:</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">Client Type:</span>
|
||||
<% if application.public_client? %>
|
||||
<span class="inline-flex items-center rounded-md bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700 ring-1 ring-inset ring-amber-600/20">Public Client (PKCE Required)</span>
|
||||
<span class="inline-flex items-center rounded-md bg-amber-50 dark:bg-amber-900/30 px-2 py-1 text-xs font-medium text-amber-700 dark:text-amber-300 ring-1 ring-inset ring-amber-600/20">Public Client (PKCE Required)</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">Confidential Client</span>
|
||||
<span class="inline-flex items-center rounded-md bg-green-50 dark:bg-green-900/30 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300 ring-1 ring-inset ring-green-600/20">Confidential Client</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- OAuth2/OIDC Flow Information -->
|
||||
<div class="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg p-4 space-y-3">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">OAuth2 Flow</h4>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Clinch uses the <code class="bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono">authorization_code</code> flow with <code class="bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono">response_type=code</code> (the modern, secure standard).
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Deprecated flows like Implicit (<code class="bg-white dark:bg-gray-800 px-1 rounded text-xs font-mono">id_token</code>, <code class="bg-white dark:bg-gray-800 px-1 rounded text-xs font-mono">token</code>) are not supported for security reasons.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-blue-200 dark:border-blue-700 pt-3">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">Client Authentication</h4>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Clinch supports both <code class="bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono">client_secret_basic</code> (HTTP Basic Auth) and <code class="bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono">client_secret_post</code> (POST parameters) authentication methods.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PKCE Requirement (only for confidential clients) -->
|
||||
<div id="pkce-options" data-application-form-target="pkceOptions" class="<%= 'hidden' if application.persisted? && application.public_client? %>">
|
||||
<div class="flex items-center">
|
||||
<%= form.check_box :require_pkce, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :require_pkce, "Require PKCE (Proof Key for Code Exchange)", class: "ml-2 block text-sm font-medium text-gray-900" %>
|
||||
<%= form.check_box :require_pkce, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :require_pkce, "Require PKCE (Proof Key for Code Exchange)", class: "ml-2 block text-sm font-medium text-gray-900 dark:text-gray-100" %>
|
||||
</div>
|
||||
<p class="ml-6 text-sm text-gray-500">
|
||||
<p class="ml-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
Recommended for enhanced security (OAuth 2.1 best practice).
|
||||
<br><span class="text-xs text-gray-400">Note: Public clients always require PKCE regardless of this setting.</span>
|
||||
<br><span class="text-xs text-gray-400 dark:text-gray-500">Note: Public clients always require PKCE regardless of this setting.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Skip Consent -->
|
||||
<div class="flex items-center">
|
||||
<%= form.check_box :skip_consent, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :skip_consent, "Skip Consent Screen", class: "ml-2 block text-sm font-medium text-gray-900 dark:text-gray-100" %>
|
||||
</div>
|
||||
<p class="ml-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
Automatically grant consent for all users. Useful for first-party or trusted applications.
|
||||
<br><span class="text-xs text-amber-600">Only enable for applications you fully trust. Consent is still recorded in the database.</span>
|
||||
</p>
|
||||
|
||||
<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>
|
||||
<%= form.label :redirect_uris, "Redirect URIs", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 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 dark:text-gray-400">One URI per line. These are the allowed callback URLs for your application.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :backchannel_logout_uri, "Backchannel Logout URI (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.url_field :backchannel_logout_uri, 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://app.example.com/oidc/backchannel-logout" %>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
<%= form.label :backchannel_logout_uri, "Backchannel Logout URI (Optional)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.url_field :backchannel_logout_uri, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://app.example.com/oidc/backchannel-logout" %>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
If the application supports OpenID Connect Backchannel Logout, enter the logout endpoint URL.
|
||||
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
|
||||
Leave blank if the application doesn't support backchannel logout.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 mt-4">
|
||||
<h4 class="text-sm font-semibold text-gray-900 mb-3">Token Expiration Settings</h4>
|
||||
<p class="text-sm text-gray-500 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Token Expiration Settings</h4>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<%= form.label :access_token_ttl, "Access Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.number_field :access_token_ttl, value: application.access_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Range: 5 min - 24 hours
|
||||
<br>Default: 1 hour (3600s)
|
||||
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human || "1 hour" %></span>
|
||||
<%= form.label :access_token_ttl, "Access Token TTL", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.text_field :access_token_ttl,
|
||||
value: application.access_token_ttl || "1h",
|
||||
placeholder: "e.g., 1h, 30m, 3600",
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Range: 5m - 24h
|
||||
<br>Default: 1h
|
||||
<% if application.access_token_ttl.present? %>
|
||||
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human %> (<%= application.access_token_ttl %>s)</span>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :refresh_token_ttl, "Refresh Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.number_field :refresh_token_ttl, value: application.refresh_token_ttl || 2592000, min: 86400, max: 7776000, step: 86400, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Range: 1 day - 90 days
|
||||
<br>Default: 30 days (2592000s)
|
||||
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human || "30 days" %></span>
|
||||
<%= form.label :refresh_token_ttl, "Refresh Token TTL", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.text_field :refresh_token_ttl,
|
||||
value: application.refresh_token_ttl || "30d",
|
||||
placeholder: "e.g., 30d, 1M, 2592000",
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Range: 5m - 90d
|
||||
<br>Default: 30d
|
||||
<% if application.refresh_token_ttl.present? %>
|
||||
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human %> (<%= application.refresh_token_ttl %>s)</span>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :id_token_ttl, "ID Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.number_field :id_token_ttl, value: application.id_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Range: 5 min - 24 hours
|
||||
<br>Default: 1 hour (3600s)
|
||||
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human || "1 hour" %></span>
|
||||
<%= form.label :id_token_ttl, "ID Token TTL", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.text_field :id_token_ttl,
|
||||
value: application.id_token_ttl || "1h",
|
||||
placeholder: "e.g., 1h, 30m, 3600",
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Range: 5m - 24h
|
||||
<br>Default: 1h
|
||||
<% if application.id_token_ttl.present? %>
|
||||
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human %> (<%= application.id_token_ttl %>s)</span>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="mt-3">
|
||||
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types</summary>
|
||||
<div class="mt-2 ml-4 space-y-2 text-sm text-gray-600">
|
||||
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types & Session Length</summary>
|
||||
<div class="mt-2 ml-4 space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">Token Types:</p>
|
||||
<p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p>
|
||||
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Longer lifetime = better UX (less re-logins).</p>
|
||||
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Each refresh issues a new refresh token (token rotation).</p>
|
||||
<p><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p>
|
||||
<p class="text-xs italic mt-2">💡 Tip: Banking apps use 5-15 min access tokens. Internal tools use 1-4 hours.</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">How Session Length Works:</p>
|
||||
<p><strong>Refresh Token TTL = Maximum Inactivity Period</strong></p>
|
||||
<p class="ml-3">Because refresh tokens are automatically rotated (new token = new expiry), active users can stay logged in indefinitely. The TTL controls how long they can be <em>inactive</em> before requiring re-authentication.</p>
|
||||
|
||||
<p class="mt-2"><strong>Example:</strong> Refresh TTL = 30 days</p>
|
||||
<ul class="ml-6 list-disc space-y-1 text-xs">
|
||||
<li>User logs in on Day 0, uses app daily → stays logged in forever (tokens keep rotating)</li>
|
||||
<li>User logs in on Day 0, stops using app → must re-login after 30 days of inactivity</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">Forcing Re-Authentication:</p>
|
||||
<p class="ml-3 text-xs">Because of token rotation, there's no way to force periodic re-authentication using TTL settings alone. Active users can stay logged in indefinitely by refreshing tokens before they expire.</p>
|
||||
|
||||
<p class="mt-2 ml-3 text-xs"><strong>To enforce absolute session limits:</strong> Clients can include the <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">max_age</code> parameter in their authorization requests to require re-authentication after a specific time, regardless of token rotation.</p>
|
||||
|
||||
<p class="mt-2 ml-3 text-xs"><strong>Example:</strong> A banking app might set <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">max_age=900</code> (15 minutes) in the authorization request to force re-authentication every 15 minutes, even if refresh tokens are still valid.</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">Common Configurations:</p>
|
||||
<ul class="ml-3 space-y-1 text-xs">
|
||||
<li><strong>Banking/High Security:</strong> Access TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">5m</code>, Refresh TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">5m</code> → Re-auth every 5 minutes</li>
|
||||
<li><strong>Corporate Tools:</strong> Access TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">1h</code>, Refresh TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">8h</code> → Re-auth after 8 hours inactive</li>
|
||||
<li><strong>Personal Apps:</strong> Access TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">1h</code>, Refresh TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">30d</code> → Re-auth after 30 days inactive</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Forward Auth-specific fields -->
|
||||
<div id="forward-auth-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.forward_auth? %>" data-application-form-target="forwardAuthFields">
|
||||
<h3 class="text-base font-semibold text-gray-900">Forward Auth Configuration</h3>
|
||||
<div id="forward-auth-fields" class="space-y-6 border-t border-gray-200 dark:border-gray-700 pt-6 <%= 'hidden' unless application.forward_auth? %>" data-application-form-target="forwardAuthFields">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Forward Auth Configuration</h3>
|
||||
|
||||
<div>
|
||||
<%= form.label :domain_pattern, "Domain Pattern", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :domain_pattern, 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: "*.example.com or app.example.com" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
|
||||
<%= form.label :domain_pattern, "Domain Pattern", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.text_field :domain_pattern, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "*.example.com or app.example.com" %>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
|
||||
</div>
|
||||
|
||||
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
|
||||
<%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.text_area :headers_config, value: (application.headers_config.present? && application.headers_config.any? ? JSON.pretty_generate(application.headers_config) : ""), rows: 10,
|
||||
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",
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
|
||||
placeholder: '{"user": "Remote-User", "groups": "Remote-Groups"}',
|
||||
data: {
|
||||
action: "input->json-validator#validate blur->json-validator#format",
|
||||
json_validator_target: "textarea"
|
||||
} %>
|
||||
<div class="mt-2 text-sm text-gray-600 space-y-1">
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-medium">Optional: Customize header names sent to your application.</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
|
||||
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"user": "Remote-User", "groups": "Remote-Groups", "email": "Remote-Email", "name": "Remote-Name", "admin": "Remote-Admin"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
|
||||
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 px-2 py-1 rounded">Format JSON</button>
|
||||
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"user": "Remote-User", "groups": "Remote-Groups", "email": "Remote-Email", "name": "Remote-Name", "username": "Remote-Username", "admin": "Remote-Admin"}' class="text-xs bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-1 rounded">Insert Example</button>
|
||||
</div>
|
||||
</div>
|
||||
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p>
|
||||
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Username, X-Remote-Groups, X-Remote-Admin</p>
|
||||
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||
<details class="mt-2">
|
||||
<summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary>
|
||||
<div class="mt-2 ml-4 space-y-1 text-xs">
|
||||
<p><code class="bg-gray-100 px-1 rounded">user</code> - User's email address</p>
|
||||
<p><code class="bg-gray-100 px-1 rounded">email</code> - User's email address</p>
|
||||
<p><code class="bg-gray-100 px-1 rounded">name</code> - User's display name (falls back to email if not set)</p>
|
||||
<p><code class="bg-gray-100 px-1 rounded">groups</code> - Comma-separated list of group names (e.g., "admin,developers")</p>
|
||||
<p><code class="bg-gray-100 px-1 rounded">admin</code> - "true" or "false" indicating admin status</p>
|
||||
<p class="mt-2 italic">Example: <code class="bg-gray-100 px-1 rounded">{"user": "Remote-User", "groups": "Remote-Groups"}</code></p>
|
||||
<p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">user</code> - User's email address</p>
|
||||
<p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">email</code> - User's email address</p>
|
||||
<p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">name</code> - User's display name (falls back to email if not set)</p>
|
||||
<p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">username</code> - User's login username (only sent if set)</p>
|
||||
<p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">groups</code> - Comma-separated list of group names (e.g., "admin,developers")</p>
|
||||
<p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">admin</code> - "true" or "false" indicating admin status</p>
|
||||
<p class="mt-2 italic">Example: <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">{"user": "Remote-User", "groups": "Remote-Groups", "username": "Remote-Username"}</code></p>
|
||||
<p class="italic">Need custom user fields? Add them to user's custom_claims for OIDC tokens</p>
|
||||
</div>
|
||||
</details>
|
||||
@@ -275,31 +307,30 @@
|
||||
</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">
|
||||
<%= form.label :group_ids, "Allowed Groups (Optional)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-700 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>
|
||||
<%= check_box_tag "application[group_ids][]", group.id, application.allowed_groups.include?(group), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= label_tag "application_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">(<%= 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>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">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>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">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" %>
|
||||
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900 dark:text-gray-100" %>
|
||||
</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" %>
|
||||
<%= link_to "Cancel", admin_applications_path, class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
66
app/views/admin/applications/_icon_uploader.html.erb
Normal file
66
app/views/admin/applications/_icon_uploader.html.erb
Normal file
@@ -0,0 +1,66 @@
|
||||
<%# Compact icon uploader. Locals:
|
||||
form - the form builder
|
||||
field - symbol for the file field (:icon or :icon_dark)
|
||||
label - heading text
|
||||
help - small helper paragraph (optional)
|
||||
current_attached - the attachment to show as "current" preview
|
||||
current_label - text for the preview row (e.g. "Current icon")
|
||||
preview_extra_class - extra css for the preview img (e.g. "bg-gray-900")
|
||||
%>
|
||||
<div>
|
||||
<%= form.label field, label, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<% if local_assigns[:help].present? %>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"><%= help %></p>
|
||||
<% end %>
|
||||
|
||||
<% if current_attached&.attached? && current_attached.blob&.persisted? && current_attached.blob.key.present? %>
|
||||
<div class="mt-2 mb-3 flex items-center gap-3">
|
||||
<%= image_tag current_attached, class: "h-12 w-12 rounded-md object-cover border border-gray-200 dark:border-gray-700 #{local_assigns[:preview_extra_class]}", alt: current_label %>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<p class="font-medium"><%= current_label %></p>
|
||||
<p class="text-xs"><%= number_to_human_size(current_attached.blob.byte_size) %></p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-2" data-controller="file-drop image-paste">
|
||||
<div class="flex items-center gap-3 px-3 py-2 border border-dashed border-gray-300 dark:border-gray-600 rounded-md hover:border-blue-400 focus-within:border-blue-500 focus-within:ring-1 focus-within:ring-blue-500 transition-colors"
|
||||
data-file-drop-target="dropzone"
|
||||
data-image-paste-target="dropzone"
|
||||
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste"
|
||||
tabindex="0">
|
||||
<svg class="h-5 w-5 text-gray-400 dark:text-gray-500 shrink-0" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M12 4v12m0-12l-4 4m4-4l4 4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/>
|
||||
</svg>
|
||||
<div class="flex-1 text-sm">
|
||||
<label for="<%= form.field_id(field) %>" class="cursor-pointer font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none">
|
||||
<span>Upload</span>
|
||||
<%= form.file_field field,
|
||||
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
|
||||
class: "sr-only",
|
||||
data: {
|
||||
file_drop_target: "input",
|
||||
image_paste_target: "input",
|
||||
action: "change->file-drop#handleFiles"
|
||||
} %>
|
||||
</label>
|
||||
<span class="text-gray-600 dark:text-gray-400"> · drag and drop · or click and paste (⌘V)</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">PNG, JPG, GIF or SVG · max 2MB</p>
|
||||
</div>
|
||||
</div>
|
||||
<div data-file-drop-target="preview" class="mt-2 hidden">
|
||||
<div class="flex items-center gap-3 p-2 bg-blue-50 dark:bg-blue-900/30 rounded-md border border-blue-200 dark:border-blue-700">
|
||||
<img data-file-drop-target="previewImage" class="h-10 w-10 rounded object-cover" alt="Preview">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100" data-file-drop-target="filename"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400" data-file-drop-target="filesize"></p>
|
||||
</div>
|
||||
<button type="button" data-action="click->file-drop#clear" class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +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>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-6">Edit Application</h1>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">Editing: <%= @application.name %></p>
|
||||
<%= render "form", application: @application %>
|
||||
</div>
|
||||
|
||||
@@ -1,71 +1,85 @@
|
||||
<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 Clients.</p>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Applications</h1>
|
||||
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">Manage OIDC Clients.</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>
|
||||
|
||||
<dl class="mt-4 grid grid-cols-3 gap-4">
|
||||
<div class="rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||
<dt class="text-xs text-gray-500 dark:text-gray-400">Applications</dt>
|
||||
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @applications.size %></dd>
|
||||
</div>
|
||||
<div class="rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||
<dt class="text-xs text-gray-500 dark:text-gray-400">Users with access</dt>
|
||||
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @total_users_with_access %></dd>
|
||||
</div>
|
||||
<div class="rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||
<dt class="text-xs text-gray-500 dark:text-gray-400">Groups granting access</dt>
|
||||
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @total_groups_granting_access %></dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<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">
|
||||
<table class="min-w-full divide-y divide-gray-300 dark:divide-gray-600">
|
||||
<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">Application</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="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-100 sm:pl-0">Application</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Slug</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Type</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Status</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Access</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">
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<% @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">
|
||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<% if application.icon.attached? %>
|
||||
<%= image_tag application.icon, class: "h-10 w-10 rounded-lg object-cover border border-gray-200 flex-shrink-0", alt: "#{application.name} icon" %>
|
||||
<%= app_icon_picture application, class: "h-10 w-10 rounded-lg object-cover border border-gray-200 dark:border-gray-700 flex-shrink-0" %>
|
||||
<% else %>
|
||||
<div class="h-10 w-10 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<%= render "shared/app_monogram", name: application.name, class: "h-10 w-10 rounded-lg flex-shrink-0" %>
|
||||
<% end %>
|
||||
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
|
||||
</div>
|
||||
</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 class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<code class="text-xs bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-2 py-1 rounded"><%= application.slug %></code>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<% 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>
|
||||
<span class="inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/50 px-2 py-1 text-xs font-medium text-purple-700 dark:text-purple-300">OIDC</span>
|
||||
<% when "forward_auth" %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Forward Auth</span>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300">Forward Auth</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>
|
||||
<span class="inline-flex items-center rounded-full bg-orange-100 dark:bg-orange-900/50 px-2 py-1 text-xs font-medium text-orange-700 dark:text-orange-300">SAML</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<% 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>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">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>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-300">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>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<% groups_count = application.allowed_groups.size %>
|
||||
<% users_count = @user_count_by_app[application.id] || 0 %>
|
||||
<% if groups_count.zero? %>
|
||||
<span class="inline-flex items-center rounded-full bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300">No one</span>
|
||||
<% else %>
|
||||
<%= application.allowed_groups.count %>
|
||||
<span class="text-gray-700 dark:text-gray-200"><%= pluralize(users_count, "user") %></span>
|
||||
<span class="text-gray-400 dark:text-gray-500"> · <%= pluralize(groups_count, "group") %></span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="max-w-3xl">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 mb-6">New Application</h1>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-6">New Application</h1>
|
||||
<%= render "form", application: @application %>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,47 @@
|
||||
<div class="mb-6">
|
||||
<% if flash[:client_id] %>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||
<h4 class="text-sm font-medium text-yellow-800 mb-2">🔐 OIDC Client Credentials</h4>
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-700 rounded-md p-4 mb-6">
|
||||
<h4 class="text-sm font-medium text-yellow-800 dark:text-yellow-200 mb-2">🔐 OIDC Client Credentials</h4>
|
||||
<% if flash[:public_client] %>
|
||||
<p class="text-xs text-yellow-700 mb-3">This is a public client. Copy the client ID below.</p>
|
||||
<p class="text-xs text-yellow-700 dark:text-yellow-300 mb-3">This is a public client. Copy the client ID below.</p>
|
||||
<% else %>
|
||||
<p class="text-xs text-yellow-700 mb-3">Copy these credentials now. The client secret will not be shown again.</p>
|
||||
<p class="text-xs text-yellow-700 dark:text-yellow-300 mb-3">Copy these credentials now. The client secret will not be shown again.</p>
|
||||
<% end %>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<span class="text-xs font-medium text-yellow-700">Client ID:</span>
|
||||
<span class="text-xs font-medium text-yellow-700 dark:text-yellow-300">Client ID:</span>
|
||||
</div>
|
||||
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_id] %></code>
|
||||
<code class="block bg-yellow-100 dark:bg-yellow-900/50 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_id] %></code>
|
||||
<% if flash[:client_secret] %>
|
||||
<div class="mt-3">
|
||||
<span class="text-xs font-medium text-yellow-700">Client Secret:</span>
|
||||
<span class="text-xs font-medium text-yellow-700 dark:text-yellow-300">Client Secret:</span>
|
||||
</div>
|
||||
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_secret] %></code>
|
||||
<code class="block bg-yellow-100 dark:bg-yellow-900/50 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_secret] %></code>
|
||||
<% elsif flash[:public_client] %>
|
||||
<div class="mt-3">
|
||||
<span class="text-xs font-medium text-yellow-700">Client Secret:</span>
|
||||
<span class="text-xs font-medium text-yellow-700 dark:text-yellow-300">Client Secret:</span>
|
||||
</div>
|
||||
<div class="bg-yellow-100 px-3 py-2 rounded text-xs text-yellow-600">
|
||||
<div class="bg-yellow-100 dark:bg-yellow-900/50 px-3 py-2 rounded text-xs text-yellow-600 dark:text-yellow-400">
|
||||
Public clients do not have a client secret. PKCE is required.
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% env_lines = oidc_env_lines(@application, client_secret: flash[:client_secret]) %>
|
||||
|
||||
<div class="mt-4" data-controller="clipboard">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-yellow-700 dark:text-yellow-300">Environment variables (copy & paste):</span>
|
||||
<button type="button"
|
||||
data-action="clipboard#copy"
|
||||
class="text-xs font-medium text-yellow-700 dark:text-yellow-300 hover:text-yellow-900 dark:hover:text-yellow-100 underline">
|
||||
<span data-clipboard-target="label">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
<textarea data-clipboard-target="source"
|
||||
readonly
|
||||
rows="<%= env_lines.length %>"
|
||||
class="block w-full bg-yellow-100 dark:bg-yellow-900/50 px-3 py-2 rounded font-mono text-xs text-gray-900 dark:text-gray-100 resize-none focus:outline-none focus:ring-1 focus:ring-yellow-500"><%= env_lines.join("\n") %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -32,21 +49,17 @@
|
||||
<div class="sm:flex sm:items-start sm:justify-between">
|
||||
<div class="flex items-start gap-4">
|
||||
<% if @application.icon.attached? %>
|
||||
<%= image_tag @application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 shrink-0", alt: "#{@application.name} icon" %>
|
||||
<%= app_icon_picture @application, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0" %>
|
||||
<% else %>
|
||||
<div class="h-16 w-16 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0">
|
||||
<svg class="h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<%= render "shared/app_monogram", name: @application.name, class: "h-16 w-16 rounded-lg shrink-0" %>
|
||||
<% end %>
|
||||
<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>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @application.name %></h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @application.description %></p>
|
||||
</div>
|
||||
</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" %>
|
||||
<%= link_to "Edit", edit_admin_application_path(@application), class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
|
||||
<%= 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>
|
||||
@@ -54,42 +67,42 @@
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Basic Information -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="bg-white dark:bg-gray-800 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>
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 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>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Slug</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100"><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 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">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Type</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
<% 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>
|
||||
<span class="inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/50 px-2 py-1 text-xs font-medium text-purple-700 dark:text-purple-300">OIDC</span>
|
||||
<% when "forward_auth" %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Forward Auth</span>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300">Forward Auth</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">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Status</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
<% 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>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">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>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-300">Inactive</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-sm font-medium text-gray-500">Landing URL</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Landing URL</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
<% if @application.landing_url.present? %>
|
||||
<%= link_to @application.landing_url, @application.landing_url, target: "_blank", rel: "noopener noreferrer", class: "text-blue-600 hover:text-blue-800 underline" %>
|
||||
<% else %>
|
||||
<span class="text-gray-400 italic">Not configured</span>
|
||||
<span class="text-gray-400 dark:text-gray-500 italic">Not configured</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
@@ -99,93 +112,117 @@
|
||||
|
||||
<!-- OIDC Configuration (only for OIDC apps) -->
|
||||
<% if @application.oidc? %>
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="bg-white dark:bg-gray-800 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 Configuration</h3>
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100">OIDC Configuration</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 class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Client Type</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Client Type</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
<% if @application.public_client? %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Public</span>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300">Public</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Confidential</span>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-300">Confidential</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">PKCE</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">PKCE</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
<% if @application.requires_pkce? %>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Required</span>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">Required</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Optional</span>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-300">Optional</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<% unless flash[:client_id] %>
|
||||
<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>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Client ID</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
<code class="block bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.client_id %></code>
|
||||
</dd>
|
||||
</div>
|
||||
<% if @application.confidential_client? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500 italic">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Client Secret</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
<div class="bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded text-xs text-gray-500 dark:text-gray-400 italic">
|
||||
🔒 Client secret is stored securely and cannot be displayed
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
To get a new client secret, use the "Regenerate Credentials" button above.
|
||||
</p>
|
||||
</dd>
|
||||
</div>
|
||||
<% else %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<div class="bg-blue-50 px-3 py-2 rounded text-xs text-blue-600">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Client Secret</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/30 px-3 py-2 rounded text-xs text-blue-600 dark:text-blue-400">
|
||||
Public clients do not use a client secret. PKCE is required for authorization.
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<div>
|
||||
<details class="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<summary class="cursor-pointer bg-gray-50 dark:bg-gray-700 px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Environment variables
|
||||
</summary>
|
||||
<div class="px-4 py-3" data-controller="clipboard">
|
||||
<% env_lines = oidc_env_lines(@application) %>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<%= @application.confidential_client? ? "Replace <your-client-secret> with your saved secret." : "Public client — no secret required." %>
|
||||
</span>
|
||||
<button type="button"
|
||||
data-action="clipboard#copy"
|
||||
class="text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 underline">
|
||||
<span data-clipboard-target="label">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
<textarea data-clipboard-target="source"
|
||||
readonly
|
||||
rows="<%= env_lines.length %>"
|
||||
class="block w-full bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded font-mono text-xs text-gray-900 dark:text-gray-100 resize-none focus:outline-none focus:ring-1 focus:ring-gray-500"><%= env_lines.join("\n") %></textarea>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<% end %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Redirect URIs</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Redirect URIs</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
<% 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>
|
||||
<code class="block bg-gray-100 dark:bg-gray-700 dark:text-gray-200 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>
|
||||
<span class="text-gray-400 dark:text-gray-500">No redirect URIs configured</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Backchannel Logout URI
|
||||
<% if @application.supports_backchannel_logout? %>
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">Enabled</span>
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Enabled</span>
|
||||
<% end %>
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
<% if @application.backchannel_logout_uri.present? %>
|
||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.backchannel_logout_uri %></code>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
<code class="block bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.backchannel_logout_uri %></code>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
|
||||
</p>
|
||||
<% else %>
|
||||
<span class="text-gray-400 italic">Not configured</span>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
<span class="text-gray-400 dark:text-gray-500 italic">Not configured</span>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Backchannel logout is optional. Configure it if the application supports OpenID Connect Backchannel Logout.
|
||||
</p>
|
||||
<% end %>
|
||||
@@ -198,24 +235,24 @@
|
||||
|
||||
<!-- Forward Auth Configuration (only for Forward Auth apps) -->
|
||||
<% if @application.forward_auth? %>
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="bg-white dark:bg-gray-800 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">Forward Auth Configuration</h3>
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">Forward Auth Configuration</h3>
|
||||
<dl class="space-y-4">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Domain Pattern</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"><%= @application.domain_pattern %></code>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Domain Pattern</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
<code class="block bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-3 py-2 rounded font-mono text-xs"><%= @application.domain_pattern %></code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Headers Configuration</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Headers Configuration</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
<% if @application.headers_config.present? && @application.headers_config.any? %>
|
||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs whitespace-pre-wrap"><%= JSON.pretty_generate(@application.headers_config) %></code>
|
||||
<code class="block bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-3 py-2 rounded font-mono text-xs whitespace-pre-wrap"><%= JSON.pretty_generate(@application.headers_config) %></code>
|
||||
<% else %>
|
||||
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500">
|
||||
Using default headers: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin
|
||||
<div class="bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded text-xs text-gray-500 dark:text-gray-400">
|
||||
Using default headers: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Username, X-Remote-Groups, X-Remote-Admin
|
||||
</div>
|
||||
<% end %>
|
||||
</dd>
|
||||
@@ -226,29 +263,29 @@
|
||||
<% end %>
|
||||
|
||||
<!-- Group Access Control -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="bg-white dark:bg-gray-800 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>
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 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">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Allowed Groups</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
<% if @allowed_groups.empty? %>
|
||||
<div class="rounded-md bg-blue-50 p-4">
|
||||
<div class="rounded-md bg-amber-50 dark:bg-amber-900/30 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 class="text-sm text-amber-700 dark:text-amber-300">
|
||||
No groups assigned — no one can access this application. Attach a group to grant access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<ul class="divide-y divide-gray-200 border border-gray-200 rounded-md">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 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>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= group.name %></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400"><%= pluralize(group.users.count, "member") %></p>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
@@ -258,4 +295,35 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users with access -->
|
||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
|
||||
Users with access (<%= @users_with_access.count %>)
|
||||
</h3>
|
||||
<% if @users_with_access.any? %>
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||
<% @users_with_access.each do |user| %>
|
||||
<% via = user.groups & @application.allowed_groups %>
|
||||
<li class="px-4 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= user.email_address %></p>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<% via.each do |g| %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">via <%= g.name %></span>
|
||||
<% end %>
|
||||
</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 dark:bg-gray-700 p-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No users currently have access. Attach a group to grant access.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
<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>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Admin Dashboard</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">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="bg-white dark:bg-gray-800 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">
|
||||
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" 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">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
|
||||
Total Users
|
||||
</dt>
|
||||
<dd class="flex items-baseline">
|
||||
<div class="text-2xl font-semibold text-gray-900">
|
||||
<div class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
<%= @user_count %>
|
||||
</div>
|
||||
<div class="ml-2 text-sm text-gray-600">
|
||||
<div class="ml-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
(<%= @active_user_count %> active)
|
||||
</div>
|
||||
</dd>
|
||||
@@ -30,30 +30,30 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="bg-gray-50 dark:bg-gray-700 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="bg-white dark:bg-gray-800 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">
|
||||
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" 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">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
|
||||
Applications
|
||||
</dt>
|
||||
<dd class="flex items-baseline">
|
||||
<div class="text-2xl font-semibold text-gray-900">
|
||||
<div class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
<%= @application_count %>
|
||||
</div>
|
||||
<div class="ml-2 text-sm text-gray-600">
|
||||
<div class="ml-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
(<%= @active_application_count %> active)
|
||||
</div>
|
||||
</dd>
|
||||
@@ -61,33 +61,33 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="bg-gray-50 dark:bg-gray-700 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="bg-white dark:bg-gray-800 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">
|
||||
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" 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">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
|
||||
Groups
|
||||
</dt>
|
||||
<dd class="text-2xl font-semibold text-gray-900">
|
||||
<dd class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
<%= @group_count %>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="bg-gray-50 dark:bg-gray-700 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>
|
||||
@@ -95,26 +95,26 @@
|
||||
|
||||
<!-- 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">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Recent Users</h2>
|
||||
<div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<% @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">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= user.email_address %></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
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>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300">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>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">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>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-300"><%= user.status.titleize %></span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -125,21 +125,21 @@
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Quick Actions</h2>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 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>
|
||||
<%= link_to new_admin_user_path, class: "block p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 hover:shadow-md transition" do %>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Create User</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">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>
|
||||
<%= link_to new_admin_application_path, class: "block p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 hover:shadow-md transition" do %>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Register Application</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">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>
|
||||
<%= link_to new_admin_group_path, class: "block p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 hover:shadow-md transition" do %>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Create Group</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Organize users into a new group</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,51 +2,90 @@
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<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>
|
||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "developers" %>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">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" %>
|
||||
<%= form.label :description, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 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">
|
||||
<div class="flex items-center">
|
||||
<%= form.check_box :auto_assign, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :auto_assign, "Auto Assign", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">New users will be automatically added to this group when invited. You can mark multiple groups as auto-assigned.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
<%= form.check_box :admin, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :admin, "Administrators", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Members of this group can access the admin panel. Does not grant automatic access to applications.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :user_ids, "Group Members", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 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" %>
|
||||
<%= check_box_tag "group[user_ids][]", user.id, group.users.include?(user), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 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 dark:text-gray-100" %>
|
||||
<% 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>
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500">No users available.</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">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>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Select which users should be members of this group.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :application_ids, "Assigned Applications", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
|
||||
<% if @available_applications.any? %>
|
||||
<% @available_applications.each do |application| %>
|
||||
<div class="flex items-center">
|
||||
<%= check_box_tag "group[application_ids][]", application.id, group.applications.include?(application), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= label_tag "group_application_ids_#{application.id}", application.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||
<% case application.app_type %>
|
||||
<% when "oidc" %>
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/50 px-2 py-0.5 text-xs font-medium text-purple-700 dark:text-purple-300">OIDC</span>
|
||||
<% when "trusted_header" %>
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-indigo-100 dark:bg-indigo-900/50 px-2 py-0.5 text-xs font-medium text-indigo-700 dark:text-indigo-300">ForwardAuth</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No applications available.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Select which applications this group grants access to.</p>
|
||||
</div>
|
||||
|
||||
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
|
||||
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8,
|
||||
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",
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
|
||||
placeholder: '{"roles": ["admin", "editor"]}',
|
||||
data: {
|
||||
action: "input->json-validator#validate blur->json-validator#format",
|
||||
json_validator_target: "textarea"
|
||||
} %>
|
||||
<div class="mt-2 text-sm text-gray-600 space-y-1">
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<p>Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
|
||||
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"roles": ["admin", "editor"], "permissions": ["read", "write"], "team": "backend"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
|
||||
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 px-2 py-1 rounded">Format JSON</button>
|
||||
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"roles": ["admin", "editor"], "permissions": ["read", "write"], "team": "backend"}' class="text-xs bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-1 rounded">Insert Example</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||
@@ -55,6 +94,6 @@
|
||||
|
||||
<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" %>
|
||||
<%= link_to "Cancel", admin_groups_path, class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,5 +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>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-6">Edit Group</h1>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">Editing: <%= @group.name %></p>
|
||||
<%= render "form", group: @group %>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Groups</h1>
|
||||
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">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" %>
|
||||
@@ -11,31 +11,31 @@
|
||||
<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">
|
||||
<table class="min-w-full divide-y divide-gray-300 dark:divide-gray-600">
|
||||
<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="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-100 sm:pl-0">Name</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Description</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Members</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">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">
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<% @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">
|
||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 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 class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<%= truncate(group.description, length: 80) || content_tag(:span, "No description", class: "text-gray-400 dark:text-gray-500") %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<%= pluralize(group.users.count, "member") %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<%= 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">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 mb-6">New Group</h1>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-6">New Group</h1>
|
||||
<%= render "form", group: @group %>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<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>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @group.name %></h1>
|
||||
<% if @group.auto_assign? %>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Auto Assign</span>
|
||||
<% end %>
|
||||
<% if @group.admin? %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Administrators</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @group.description.present? %>
|
||||
<p class="mt-1 text-sm text-gray-500"><%= @group.description %></p>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @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" %>
|
||||
<%= link_to "Edit", edit_admin_group_path(@group), class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
|
||||
<%= 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>
|
||||
@@ -15,25 +23,25 @@
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Members -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="bg-white dark:bg-gray-800 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">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
|
||||
Members (<%= @members.count %>)
|
||||
</h3>
|
||||
<% if @members.any? %>
|
||||
<ul class="divide-y divide-gray-200 border border-gray-200 rounded-md">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 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>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= 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>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">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>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">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>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300"><%= user.status.titleize %></span>
|
||||
</div>
|
||||
</div>
|
||||
<%= link_to "View", admin_user_path(user), class: "text-blue-600 hover:text-blue-900 text-sm" %>
|
||||
@@ -41,36 +49,36 @@
|
||||
<% 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 class="rounded-md bg-gray-50 dark:bg-gray-700 p-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No members in this group yet.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Applications -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="bg-white dark:bg-gray-800 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">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
|
||||
Assigned Applications (<%= @applications.count %>)
|
||||
</h3>
|
||||
<% if @applications.any? %>
|
||||
<ul class="divide-y divide-gray-200 border border-gray-200 rounded-md">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 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>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= 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>
|
||||
<span class="inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/50 px-2 py-0.5 text-xs font-medium text-purple-700 dark:text-purple-300">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>
|
||||
<span class="inline-flex items-center rounded-full bg-indigo-100 dark:bg-indigo-900/50 px-2 py-0.5 text-xs font-medium text-indigo-700 dark:text-indigo-300">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>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">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>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">Inactive</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,8 +87,8 @@
|
||||
<% 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 class="rounded-md bg-gray-50 dark:bg-gray-700 p-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">This group is not assigned to any applications.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -3,29 +3,29 @@
|
||||
|
||||
<!-- OIDC Apps: Custom Claims -->
|
||||
<% if oidc_apps.any? %>
|
||||
<div class="mt-12 border-t pt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">OIDC App-Specific Claims</h2>
|
||||
<p class="text-sm text-gray-600 mb-6">
|
||||
<div class="mt-12 border-t dark:border-gray-700 pt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">OIDC App-Specific Claims</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure custom claims that apply only to specific OIDC applications. These override both group and user global claims and are included in ID tokens.
|
||||
</p>
|
||||
|
||||
<div class="space-y-6">
|
||||
<% oidc_apps.each do |app| %>
|
||||
<% app_claim = user.application_user_claims.find_by(application: app) %>
|
||||
<details class="border rounded-lg" <%= "open" if app_claim&.custom_claims&.any? %>>
|
||||
<summary class="cursor-pointer bg-gray-50 px-4 py-3 hover:bg-gray-100 rounded-t-lg flex items-center justify-between">
|
||||
<details class="border dark:border-gray-700 rounded-lg" <%= "open" if app_claim&.custom_claims&.any? %>>
|
||||
<summary class="cursor-pointer bg-gray-50 dark:bg-gray-800 px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-t-lg flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-gray-900"><%= app.name %></span>
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-blue-100 text-blue-700">
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100"><%= app.name %></span>
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300">
|
||||
OIDC
|
||||
</span>
|
||||
<% if app_claim&.custom_claims&.any? %>
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-amber-100 text-amber-700">
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300">
|
||||
<%= app_claim.custom_claims.keys.count %> claim(s)
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="h-5 w-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</summary>
|
||||
@@ -35,22 +35,22 @@
|
||||
<%= hidden_field_tag :application_id, app.id %>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Custom Claims (JSON)</label>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Custom Claims (JSON)</label>
|
||||
<%= text_area_tag :custom_claims,
|
||||
(app_claim&.custom_claims.present? ? JSON.pretty_generate(app_claim.custom_claims) : ""),
|
||||
rows: 8,
|
||||
class: "w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
|
||||
class: "w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
|
||||
placeholder: '{"kavita_groups": ["admin"], "library_access": "all"}',
|
||||
data: {
|
||||
action: "input->json-validator#validate blur->json-validator#format",
|
||||
json_validator_target: "textarea"
|
||||
} %>
|
||||
<div class="mt-2 space-y-1">
|
||||
<p class="text-xs text-gray-600">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
Example for <%= app.name %>: Add claims that this app specifically needs to read.
|
||||
</p>
|
||||
<p class="text-xs text-amber-600">
|
||||
<strong>Note:</strong> Do not use reserved claim names (<code class="bg-amber-50 px-1 rounded">groups</code>, <code class="bg-amber-50 px-1 rounded">email</code>, <code class="bg-amber-50 px-1 rounded">name</code>, etc.). Use app-specific names like <code class="bg-amber-50 px-1 rounded">kavita_groups</code> instead.
|
||||
<strong>Note:</strong> Do not use reserved claim names (<code class="bg-amber-50 dark:bg-amber-900/30 px-1 rounded">groups</code>, <code class="bg-amber-50 dark:bg-amber-900/30 px-1 rounded">email</code>, <code class="bg-amber-50 dark:bg-amber-900/30 px-1 rounded">name</code>, etc.). Use app-specific names like <code class="bg-amber-50 dark:bg-amber-900/30 px-1 rounded">kavita_groups</code> instead.
|
||||
</p>
|
||||
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||
</div>
|
||||
@@ -66,27 +66,27 @@
|
||||
delete_application_claims_admin_user_path(user, application_id: app.id),
|
||||
method: :delete,
|
||||
data: { turbo_confirm: "Remove app-specific claims for #{app.name}?" },
|
||||
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" %>
|
||||
class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Preview merged claims -->
|
||||
<div class="mt-4 border-t pt-4">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Preview: Final ID Token Claims for <%= app.name %></h4>
|
||||
<div class="bg-gray-50 rounded-lg p-3">
|
||||
<pre class="text-xs font-mono text-gray-800 overflow-x-auto"><%= JSON.pretty_generate(preview_user_claims(user, app)) %></pre>
|
||||
<div class="mt-4 border-t dark:border-gray-700 pt-4">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Preview: Final ID Token Claims for <%= app.name %></h4>
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<pre class="text-xs font-mono text-gray-800 dark:text-gray-200 overflow-x-auto"><%= JSON.pretty_generate(preview_user_claims(user, app)) %></pre>
|
||||
</div>
|
||||
|
||||
<details class="mt-2">
|
||||
<summary class="cursor-pointer text-xs text-gray-600 hover:text-gray-900">Show claim sources</summary>
|
||||
<summary class="cursor-pointer text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">Show claim sources</summary>
|
||||
<div class="mt-2 space-y-1">
|
||||
<% claim_sources(user, app).each do |source| %>
|
||||
<div class="flex gap-2 items-start text-xs">
|
||||
<span class="px-2 py-1 rounded <%= source[:type] == :group ? 'bg-blue-100 text-blue-700' : (source[:type] == :user ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700') %>">
|
||||
<span class="px-2 py-1 rounded <%= source[:type] == :group ? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300' : (source[:type] == :user ? 'bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300' : 'bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300') %>">
|
||||
<%= source[:name] %>
|
||||
</span>
|
||||
<code class="text-gray-700"><%= source[:claims].to_json %></code>
|
||||
<code class="text-gray-700 dark:text-gray-300"><%= source[:claims].to_json %></code>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -101,32 +101,32 @@
|
||||
|
||||
<!-- ForwardAuth Apps: Headers Preview -->
|
||||
<% if forward_auth_apps.any? %>
|
||||
<div class="mt-12 border-t pt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">ForwardAuth Headers Preview</h2>
|
||||
<p class="text-sm text-gray-600 mb-6">
|
||||
<div class="mt-12 border-t dark:border-gray-700 pt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">ForwardAuth Headers Preview</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
ForwardAuth applications receive HTTP headers (not OIDC tokens). Headers are based on user's email, name, groups, and admin status.
|
||||
</p>
|
||||
|
||||
<div class="space-y-6">
|
||||
<% forward_auth_apps.each do |app| %>
|
||||
<details class="border rounded-lg">
|
||||
<summary class="cursor-pointer bg-gray-50 px-4 py-3 hover:bg-gray-100 rounded-t-lg flex items-center justify-between">
|
||||
<details class="border dark:border-gray-700 rounded-lg">
|
||||
<summary class="cursor-pointer bg-gray-50 dark:bg-gray-800 px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-t-lg flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-gray-900"><%= app.name %></span>
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700">
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100"><%= app.name %></span>
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300">
|
||||
FORWARD AUTH
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<%= app.domain_pattern %>
|
||||
</span>
|
||||
</div>
|
||||
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="h-5 w-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</summary>
|
||||
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg p-3">
|
||||
<div class="flex items-start">
|
||||
<svg class="h-5 w-5 text-blue-400 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<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" />
|
||||
@@ -135,33 +135,33 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Headers Sent to <%= app.name %></h4>
|
||||
<div class="bg-gray-50 rounded-lg p-3 border">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Headers Sent to <%= app.name %></h4>
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 border dark:border-gray-700">
|
||||
<% headers = app.headers_for_user(user) %>
|
||||
<% if headers.any? %>
|
||||
<dl class="space-y-2 text-xs font-mono">
|
||||
<% headers.each do |header_name, value| %>
|
||||
<div class="flex">
|
||||
<dt class="text-blue-600 font-semibold w-48"><%= header_name %>:</dt>
|
||||
<dd class="text-gray-800 flex-1"><%= value %></dd>
|
||||
<dt class="text-blue-600 dark:text-blue-400 font-semibold w-48"><%= header_name %>:</dt>
|
||||
<dd class="text-gray-800 dark:text-gray-200 flex-1"><%= value %></dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</dl>
|
||||
<% else %>
|
||||
<p class="text-xs text-gray-500 italic">All headers disabled for this application.</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 italic">All headers disabled for this application.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
These headers are configured in the application settings and sent by your reverse proxy (Caddy/Traefik) to the upstream application.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<% if user.groups.any? %>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">User's Groups</h4>
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">User's Groups</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% user.groups.each do |group| %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-200">
|
||||
<%= group.name %>
|
||||
</span>
|
||||
<% end %>
|
||||
@@ -176,10 +176,10 @@
|
||||
<% end %>
|
||||
|
||||
<% if oidc_apps.empty? && forward_auth_apps.empty? %>
|
||||
<div class="mt-12 border-t pt-8">
|
||||
<div class="text-center py-12 bg-gray-50 rounded-lg">
|
||||
<p class="text-gray-500">No active applications found.</p>
|
||||
<p class="text-sm text-gray-400 mt-1">Create applications in the Admin panel first.</p>
|
||||
<div class="mt-12 border-t dark:border-gray-700 pt-8">
|
||||
<div class="text-center py-12 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<p class="text-gray-500 dark:text-gray-400">No active applications found.</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Create applications in the Admin panel first.</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -2,49 +2,74 @@
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<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" %>
|
||||
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.email_field :email_address, required: true, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :username, "Username (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :username, 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: "jsmith" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Optional: Short username/handle for login. Can only contain letters, numbers, underscores, and hyphens.</p>
|
||||
<%= form.label :username, "Username (Optional)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.text_field :username, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "jsmith" %>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Optional: Short username/handle for login. Can only contain letters, numbers, underscores, and hyphens.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :name, "Display Name (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "John Smith" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Optional: Full name shown in applications. Defaults to email address if not set.</p>
|
||||
<%= form.label :name, "Display Name (Optional)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "John Smith" %>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Optional: Full name shown in applications. Defaults to email address if not set.</p>
|
||||
</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" %>
|
||||
<%= form.label :password, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.password_field :password, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 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>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">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>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">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" %>
|
||||
<%= form.label :status, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.select :status, User.statuses.keys.map { |s| [s.titleize, s] }, {}, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :group_ids, "Group Memberships", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
|
||||
<% if @available_groups.any? %>
|
||||
<% @available_groups.each do |group| %>
|
||||
<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>
|
||||
<%= check_box_tag "user[group_ids][]", group.id, user.groups.include?(group), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= label_tag "user_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||
<% if group.admin? %>
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
|
||||
<% end %>
|
||||
<% if group.auto_assign? %>
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Auto Assign</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No groups available. Create a group first.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Administrators are members of any group with the Admin flag set. You cannot remove yourself from your last administrator group.</p>
|
||||
<% unless user.persisted? %>
|
||||
<% auto_names = Group.where(auto_assign: true).pluck(:name) %>
|
||||
<% if auto_names.any? %>
|
||||
<div class="mt-2 flex items-center">
|
||||
<%= check_box_tag "auto_assign", "1", true, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= label_tag "auto_assign", "Auto-assign to default groups (#{auto_names.join(", ")})", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Uncheck to invite this user without auto-assigning the default group(s) — useful for restricted accounts.</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
<%= form.check_box :totp_required, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :totp_required, "Require Two-Factor Authentication", class: "ml-2 block text-sm text-gray-900" %>
|
||||
<%= form.check_box :totp_required, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :totp_required, "Require Two-Factor Authentication", class: "ml-2 block text-sm text-gray-900 dark:text-gray-100" %>
|
||||
<% if user.totp_required? && !user.totp_enabled? %>
|
||||
<span class="ml-2 text-xs text-amber-600">(User has not set up 2FA yet)</span>
|
||||
<% end %>
|
||||
@@ -57,24 +82,24 @@
|
||||
Warning: This user will be prompted to set up 2FA on their next login.
|
||||
</p>
|
||||
<% end %>
|
||||
<p class="mt-1 text-sm text-gray-500">When enabled, this user must use two-factor authentication to sign in.</p>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">When enabled, this user must use two-factor authentication to sign in.</p>
|
||||
</div>
|
||||
|
||||
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
|
||||
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.text_area :custom_claims, value: (user.custom_claims.present? ? JSON.pretty_generate(user.custom_claims) : ""), rows: 8,
|
||||
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",
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
|
||||
placeholder: '{"department": "engineering", "level": "senior"}',
|
||||
data: {
|
||||
action: "input->json-validator#validate blur->json-validator#format",
|
||||
json_validator_target: "textarea"
|
||||
} %>
|
||||
<div class="mt-2 text-sm text-gray-600 space-y-1">
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<p>Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
|
||||
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"department": "engineering", "level": "senior", "location": "remote"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
|
||||
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 px-2 py-1 rounded">Format JSON</button>
|
||||
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"department": "engineering", "level": "senior", "location": "remote"}' class="text-xs bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-1 rounded">Insert Example</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||
@@ -83,6 +108,6 @@
|
||||
|
||||
<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" %>
|
||||
<%= link_to "Cancel", admin_users_path, class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="max-w-4xl">
|
||||
<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>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-6">Edit User</h1>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">Editing: <%= @user.email_address %></p>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<%= render "form", user: @user %>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Users</h1>
|
||||
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">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" %>
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
|
||||
<% unless smtp_configured? %>
|
||||
<div class="mt-6 rounded-md bg-yellow-50 p-4">
|
||||
<div class="mt-6 rounded-md bg-yellow-50 dark:bg-yellow-900/30 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
@@ -17,10 +17,10 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">
|
||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
Email delivery not configured
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
|
||||
<p>
|
||||
<% if Rails.env.development? %>
|
||||
Emails are being delivered using <span class="font-mono"><%= email_delivery_method %></span> and will open in your browser.
|
||||
@@ -44,63 +44,63 @@
|
||||
<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">
|
||||
<table class="min-w-full divide-y divide-gray-300 dark:divide-gray-600">
|
||||
<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="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-100 sm:pl-0">Email</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Status</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Role</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">2FA</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">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">
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<% @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 class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-0">
|
||||
<%= link_to user.email_address, admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<% 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>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">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>
|
||||
<span class="inline-flex items-center rounded-full bg-red-100 dark:bg-red-900/50 px-2 py-1 text-xs font-medium text-red-700 dark:text-red-300">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>
|
||||
<span class="inline-flex items-center rounded-full bg-yellow-100 dark:bg-yellow-900/50 px-2 py-1 text-xs font-medium text-yellow-700 dark:text-yellow-300">Pending</span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="text-gray-400">-</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">-</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<% 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>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
|
||||
<% else %>
|
||||
<span class="text-gray-500">User</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">User</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div class="flex items-center gap-2">
|
||||
<% if user.totp_enabled? %>
|
||||
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="2FA Enabled">
|
||||
<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" title="2FA Not Enabled">
|
||||
<svg class="h-5 w-5 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="2FA Not Enabled">
|
||||
<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 %>
|
||||
<% if user.totp_required? %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700" title="2FA Required by Admin">Required</span>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300" title="2FA Required by Admin">Required</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<%= user.groups.count %>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
@@ -110,6 +110,7 @@
|
||||
data: { turbo_method: :post },
|
||||
class: "text-yellow-600 hover:text-yellow-900" %>
|
||||
<% end %>
|
||||
<%= link_to "View", admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
|
||||
<%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
|
||||
<%= link_to "Delete", admin_user_path(user),
|
||||
data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this user?" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 mb-6">New User</h1>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-6">New User</h1>
|
||||
<%= render "form", user: @user %>
|
||||
</div>
|
||||
|
||||
95
app/views/admin/users/show.html.erb
Normal file
95
app/views/admin/users/show.html.erb
Normal file
@@ -0,0 +1,95 @@
|
||||
<div class="mb-6">
|
||||
<div class="sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @user.email_address %></h1>
|
||||
<% if @user.admin? %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
|
||||
<% end %>
|
||||
<% case @user.status %>
|
||||
<% when "active" %>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Active</span>
|
||||
<% when "disabled" %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">Disabled</span>
|
||||
<% when "pending_invitation" %>
|
||||
<span class="inline-flex items-center rounded-full bg-amber-100 dark:bg-amber-900/50 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300">Pending Invitation</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @user.name.present? %>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @user.name %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="mt-4 sm:mt-0 flex gap-3">
|
||||
<%= link_to "Edit", edit_admin_user_path(@user), class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Group memberships -->
|
||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
|
||||
Group memberships (<%= @user.groups.count %>)
|
||||
</h3>
|
||||
<% if @user.groups.any? %>
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||
<% @user.groups.order(:name).each do |group| %>
|
||||
<li class="px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= group.name %></p>
|
||||
<% if group.admin? %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
|
||||
<% end %>
|
||||
<% if group.auto_assign? %>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Auto Assign</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 text-sm" %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">This user is in no groups.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Accessible applications -->
|
||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
|
||||
Accessible applications (<%= @accessible_applications.count %>)
|
||||
</h3>
|
||||
<% unless @user.active? %>
|
||||
<div class="rounded-md bg-amber-50 dark:bg-amber-900/30 p-4">
|
||||
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||
User is <%= @user.status.humanize.downcase %> — access is denied regardless of group memberships.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @accessible_applications.any? %>
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||
<% @accessible_applications.each do |app| %>
|
||||
<% via = app.allowed_groups & @user.groups %>
|
||||
<li class="px-4 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= app.name %></p>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<% via.each do |g| %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">via <%= g.name %></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 dark:bg-gray-700 p-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No accessible applications. Add the user to a group that's attached to one or more applications.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
71
app/views/api_keys/index.html.erb
Normal file
71
app/views/api_keys/index.html.erb
Normal file
@@ -0,0 +1,71 @@
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">API Keys</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Bearer tokens for server-to-server access to forward auth applications.
|
||||
</p>
|
||||
</div>
|
||||
<%= link_to "New API Key", new_api_key_path,
|
||||
class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
|
||||
</div>
|
||||
|
||||
<% if @api_keys.any? %>
|
||||
<div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Application</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Created</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Used</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Expires</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<% @api_keys.each do |key| %>
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100"><%= key.name %></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"><%= key.application.name %></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"><%= key.created_at.strftime("%b %d, %Y") %></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"><%= key.last_used_at ? time_ago_in_words(key.last_used_at) + " ago" : "Never" %></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"><%= key.expires_at ? key.expires_at.strftime("%b %d, %Y") : "Never" %></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<% if key.revoked? %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-200">Revoked</span>
|
||||
<% elsif key.expired? %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900/50 text-yellow-800 dark:text-yellow-200">Expired</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200">Active</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<% if key.active? %>
|
||||
<%= button_to "Revoke", api_key_path(key), method: :delete,
|
||||
class: "text-red-600 hover:text-red-900",
|
||||
form: { data: { turbo_confirm: "Revoke this API key? This cannot be undone." } } %>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-8 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||
</svg>
|
||||
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-gray-100">No API keys</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Create an API key to authenticate server-to-server requests.
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<%= link_to "Create API Key", new_api_key_path,
|
||||
class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
55
app/views/api_keys/new.html.erb
Normal file
55
app/views/api_keys/new.html.erb
Normal file
@@ -0,0 +1,55 @@
|
||||
<div class="max-w-lg mx-auto">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">New API Key</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Create a bearer token for server-to-server access to a forward auth application.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<%= form_with(model: @api_key, class: "space-y-6") do |f| %>
|
||||
<% if @api_key.errors.any? %>
|
||||
<div class="rounded-md bg-red-50 dark:bg-red-900/30 p-4">
|
||||
<div class="text-sm text-red-700 dark:text-red-300">
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<% @api_key.errors.full_messages.each do |msg| %>
|
||||
<li><%= msg %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<%= f.label :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= f.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||
placeholder: "e.g., Video Player WebDAV" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :application_id, "Application", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<% if @applications.any? %>
|
||||
<%= f.collection_select :application_id, @applications, :id, :name,
|
||||
{ prompt: "Select an application" },
|
||||
{ class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||
<% else %>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No forward auth applications available.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :expires_at, "Expiration (optional)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= f.datetime_local_field :expires_at, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave blank for no expiration.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<%= link_to "Cancel", api_keys_path, class: "text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-500 dark:hover:text-gray-400" %>
|
||||
<%= f.submit "Create API Key",
|
||||
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
59
app/views/api_keys/show.html.erb
Normal file
59
app/views/api_keys/show.html.erb
Normal file
@@ -0,0 +1,59 @@
|
||||
<div class="max-w-2xl mx-auto" data-controller="clipboard">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">API Key Created</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Copy your API key now. You won't be able to see it again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/30 p-4 mb-6">
|
||||
<div class="flex">
|
||||
<svg class="h-5 w-5 text-yellow-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<p class="font-medium">Save this key now!</p>
|
||||
<p class="mt-1">This is the only time you'll see the full API key. Store it securely.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">API Key</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="text" readonly value="<%= @plaintext_token %>"
|
||||
data-clipboard-target="source"
|
||||
class="flex-1 rounded-md border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 dark:text-gray-100 font-mono text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500" />
|
||||
<button data-action="click->clipboard#copy"
|
||||
data-clipboard-target="button"
|
||||
class="inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 py-2 px-3 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
|
||||
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||
</svg>
|
||||
<span data-clipboard-target="label">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p><strong>Name:</strong> <%= @api_key.name %></p>
|
||||
<p><strong>Application:</strong> <%= @api_key.application.name %></p>
|
||||
<p><strong>Expires:</strong> <%= @api_key.expires_at ? @api_key.expires_at.strftime("%b %d, %Y %H:%M") : "Never" %></p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-md bg-gray-50 dark:bg-gray-700 p-4">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Usage example:</p>
|
||||
<pre class="text-xs text-gray-600 dark:text-gray-200 overflow-x-auto">curl -H "Authorization: Bearer <%= @plaintext_token %>" \
|
||||
-H "X-Forwarded-Host: your-app.example.com" \
|
||||
<%= request.base_url %>/api/verify</pre>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<%= link_to "Done", api_keys_path,
|
||||
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,8 +1,8 @@
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Welcome, <%= @user.email_address %>
|
||||
</h1>
|
||||
<p class="mt-2 text-gray-600">
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
<% if @user.admin? %>
|
||||
Administrator
|
||||
<% else %>
|
||||
@@ -13,34 +13,34 @@
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Active Sessions Card -->
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="bg-white dark:bg-gray-800 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">
|
||||
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
|
||||
Active Sessions
|
||||
</dt>
|
||||
<dd class="text-lg font-semibold text-gray-900">
|
||||
<dd class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
<%= @user.sessions.active.count %>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="bg-gray-50 dark:bg-gray-700 px-5 py-3">
|
||||
<%= link_to "View all sessions", profile_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @user.totp_enabled? %>
|
||||
<!-- 2FA Status Card -->
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
@@ -50,7 +50,7 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
|
||||
Two-Factor Authentication
|
||||
</dt>
|
||||
<dd class="text-lg font-semibold text-green-600">
|
||||
@@ -60,13 +60,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="bg-gray-50 dark:bg-gray-700 px-5 py-3">
|
||||
<%= link_to "Manage 2FA", profile_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- 2FA Disabled Card -->
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg border-2 border-yellow-200">
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg border-2 border-yellow-200">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
@@ -76,7 +76,7 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
|
||||
Two-Factor Authentication
|
||||
</dt>
|
||||
<dd class="text-lg font-semibold text-yellow-600">
|
||||
@@ -86,48 +86,70 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="bg-gray-50 dark:bg-gray-700 px-5 py-3">
|
||||
<%= link_to "Enable 2FA", profile_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- API Keys Card -->
|
||||
<div class="bg-white dark:bg-gray-800 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 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
|
||||
API Keys
|
||||
</dt>
|
||||
<dd class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
<%= @user.api_keys.active.count %>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-700 px-5 py-3">
|
||||
<%= link_to "Manage API Keys", api_keys_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Your Applications Section -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Your Applications</h2>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Your Applications</h2>
|
||||
|
||||
<% if @applications.any? %>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<% @applications.each do |app| %>
|
||||
<div class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition">
|
||||
<div class="p-6">
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<% if app.icon.attached? %>
|
||||
<%= image_tag app.icon, class: "h-12 w-12 rounded-lg object-cover border border-gray-200 shrink-0", alt: "#{app.name} icon" %>
|
||||
<%= app_icon_picture app, class: "h-12 w-12 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0" %>
|
||||
<% else %>
|
||||
<div class="h-12 w-12 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0">
|
||||
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<%= render "shared/app_monogram", name: app.name, class: "h-12 w-12 rounded-lg shrink-0" %>
|
||||
<% end %>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 truncate">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
<%= app.name %>
|
||||
</h3>
|
||||
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium shrink-0
|
||||
<% if app.oidc? %>
|
||||
bg-blue-100 text-blue-800
|
||||
bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-200
|
||||
<% else %>
|
||||
bg-green-100 text-green-800
|
||||
bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200
|
||||
<% end %>">
|
||||
<%= app.app_type.humanize %>
|
||||
</span>
|
||||
</div>
|
||||
<% if app.description.present? %>
|
||||
<p class="text-sm text-gray-600 mt-1 line-clamp-2">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
|
||||
<%= app.description %>
|
||||
</p>
|
||||
<% end %>
|
||||
@@ -139,17 +161,27 @@
|
||||
<%= link_to "Open Application", app.landing_url,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
class: "w-full flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition" %>
|
||||
class: "w-full flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 focus:ring-blue-500 transition" %>
|
||||
<% else %>
|
||||
<div class="text-sm text-gray-500 italic">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
No landing URL configured
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if app.user_has_active_session?(@user) %>
|
||||
<%= button_to "Logout", logout_from_app_active_sessions_path(application_id: app.id), method: :delete,
|
||||
class: "w-full flex justify-center items-center px-4 py-2 border border-orange-300 text-sm font-medium rounded-md text-orange-700 bg-white hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 transition",
|
||||
form: { data: { turbo_confirm: "This will log you out of #{app.name}. You can sign back in without re-authorizing. Continue?" } } %>
|
||||
<%= button_to "Require Re-Auth", logout_from_app_active_sessions_path(application_id: app.id), method: :delete,
|
||||
class: "w-full flex justify-center items-center px-4 py-2 border border-orange-300 text-sm font-medium rounded-md text-orange-700 bg-white dark:bg-gray-700 dark:ring-gray-600 dark:text-gray-200 hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 focus:ring-orange-500 transition",
|
||||
form: { data: { turbo_confirm: "This will revoke #{app.name}'s access tokens. The next time #{app.name} needs to authenticate, you'll sign in again (no re-authorization needed). Continue?" } } %>
|
||||
<% end %>
|
||||
|
||||
<% if @user.admin? %>
|
||||
<div class="flex gap-2 mt-1">
|
||||
<%= link_to "View", admin_application_path(app),
|
||||
class: "text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-blue-600 transition" %>
|
||||
<span class="text-gray-300 dark:text-gray-600">|</span>
|
||||
<%= link_to "Edit", edit_admin_application_path(app),
|
||||
class: "text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-blue-600 transition" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,12 +189,12 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-gray-50 rounded-lg border border-gray-200 p-8 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-8 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||
</svg>
|
||||
<h3 class="mt-4 text-lg font-medium text-gray-900">No applications available</h3>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-gray-100">No applications available</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
You don't have access to any applications yet. Contact your administrator if you think this is an error.
|
||||
</p>
|
||||
</div>
|
||||
@@ -171,21 +203,21 @@
|
||||
|
||||
<% if @user.admin? %>
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Admin Quick Actions</h2>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Admin Quick Actions</h2>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<%= link_to admin_users_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">Manage Users</h3>
|
||||
<p class="text-sm text-gray-600">View, edit, and invite users</p>
|
||||
<%= link_to admin_users_path, class: "block p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 hover:shadow-md transition" do %>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Manage Users</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">View, edit, and invite users</p>
|
||||
<% end %>
|
||||
|
||||
<%= link_to admin_applications_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">Manage Applications</h3>
|
||||
<p class="text-sm text-gray-600">Register and configure applications</p>
|
||||
<%= link_to admin_applications_path, class: "block p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 hover:shadow-md transition" do %>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Manage Applications</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Register and configure applications</p>
|
||||
<% end %>
|
||||
|
||||
<%= link_to admin_groups_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">Manage Groups</h3>
|
||||
<p class="text-sm text-gray-600">Create and organize user groups</p>
|
||||
<%= link_to admin_groups_path, class: "block p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 hover:shadow-md transition" do %>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Manage Groups</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Create and organize user groups</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
<% end %>
|
||||
|
||||
<h1 class="font-bold text-4xl">Welcome to Clinch!</h1>
|
||||
<p class="mt-2 text-gray-600">You've been invited to join Clinch. Please create your password to complete your account setup.</p>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">You've been invited to join Clinch. Please create your password to complete your account setup.</p>
|
||||
|
||||
<%= form_with url: invitation_path(params[:token]), method: :put, class: "contents" do |form| %>
|
||||
<div class="my-5">
|
||||
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
|
||||
</div>
|
||||
|
||||
<div class="inline">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
You've been invited to join Clinch!
|
||||
|
||||
To set up your account and create your password, please visit:
|
||||
#{invite_url(@user.invitation_login_token)}
|
||||
<%= invitation_url(@user.generate_token_for(:invitation_login)) %>
|
||||
|
||||
This invitation link will expire in #{distance_of_time_in_words(0, @user.invitation_login_token_expires_in)}.
|
||||
This invitation link will expire in 24 hours.
|
||||
|
||||
If you didn't expect this invitation, you can safely ignore this email.
|
||||
@@ -9,6 +9,15 @@
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
|
||||
<script nonce="<%= content_security_policy_nonce %>">
|
||||
(function() {
|
||||
var theme = localStorage.getItem('theme');
|
||||
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<%= yield :head %>
|
||||
|
||||
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
|
||||
@@ -23,15 +32,15 @@
|
||||
<%= javascript_importmap_tags %>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body class="dark:bg-gray-900 dark:text-gray-100">
|
||||
<% if authenticated? %>
|
||||
<div data-controller="mobile-sidebar">
|
||||
<%= render "shared/sidebar" %>
|
||||
<div class="lg:pl-64">
|
||||
<!-- Mobile menu button -->
|
||||
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden">
|
||||
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden">
|
||||
<button type="button"
|
||||
class="-m-2.5 p-2.5 text-gray-700"
|
||||
class="-m-2.5 p-2.5 text-gray-700 dark:text-gray-300"
|
||||
id="mobile-menu-button"
|
||||
data-action="click->mobile-sidebar#openSidebar">
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
@@ -51,6 +60,16 @@
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Public layout (signup/signin) -->
|
||||
<div class="absolute top-4 right-4" data-controller="dark-mode">
|
||||
<button type="button" data-action="click->dark-mode#toggle" class="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800">
|
||||
<svg data-dark-mode-target="icon" data-mode="light" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
<svg data-dark-mode-target="icon" data-mode="dark" class="hidden h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<main class="container mx-auto mt-28 px-5">
|
||||
<%= render "shared/flash" %>
|
||||
<%= yield %>
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="bg-white py-8 px-6 shadow rounded-lg sm:px-10">
|
||||
<div class="bg-white dark:bg-gray-800 py-8 px-6 shadow rounded-lg sm:px-10">
|
||||
<div class="mb-8 text-center">
|
||||
<% if @application.icon.attached? %>
|
||||
<%= image_tag @application.icon, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 shadow-sm mb-4", alt: "#{@application.name} icon" %>
|
||||
<div class="mx-auto h-20 w-20 mb-4">
|
||||
<%= app_icon_picture @application, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 dark:border-gray-700 shadow-sm" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="mx-auto h-20 w-20 rounded-xl bg-gray-100 border-2 border-gray-200 flex items-center justify-center mb-4">
|
||||
<svg class="h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<div class="mx-auto mb-4">
|
||||
<%= render "shared/app_monogram", name: @application.name, class: "h-20 w-20 rounded-xl shadow-sm" %>
|
||||
</div>
|
||||
<% end %>
|
||||
<h2 class="text-2xl font-bold text-gray-900">Authorize Application</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Authorize Application</h2>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong><%= @application.name %></strong> is requesting access to your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-900 mb-3">This application will be able to:</h3>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">This application will be able to:</h3>
|
||||
<ul class="space-y-2">
|
||||
<% if @scopes.include?("openid") %>
|
||||
<li class="flex items-start">
|
||||
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-sm text-gray-700">Verify your identity</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Verify your identity</span>
|
||||
</li>
|
||||
<% end %>
|
||||
<% if @scopes.include?("email") %>
|
||||
@@ -32,7 +32,7 @@
|
||||
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-sm text-gray-700">Access your email address (<%= Current.session.user.email_address %>)</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Access your email address (<%= Current.session.user.email_address %>)</span>
|
||||
</li>
|
||||
<% end %>
|
||||
<% if @scopes.include?("profile") %>
|
||||
@@ -40,7 +40,7 @@
|
||||
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-sm text-gray-700">Access your profile information</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Access your profile information</span>
|
||||
</li>
|
||||
<% end %>
|
||||
<% if @scopes.include?("groups") %>
|
||||
@@ -48,18 +48,18 @@
|
||||
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-sm text-gray-700">Access your group memberships</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Access your group memberships</span>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md bg-blue-50 p-4 mb-6">
|
||||
<div class="rounded-md bg-blue-50 dark:bg-blue-900/30 p-4 mb-6">
|
||||
<div class="flex">
|
||||
<svg class="h-5 w-5 text-blue-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<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 class="text-sm text-blue-700">
|
||||
<div class="text-sm text-blue-700 dark:text-blue-300">
|
||||
<p>You'll be redirected to:</p>
|
||||
<p class="mt-1 font-mono text-xs break-all"><%= @redirect_uri %></p>
|
||||
</div>
|
||||
@@ -68,13 +68,13 @@
|
||||
|
||||
<%= form_with url: "/oauth/authorize/consent", method: :post, class: "space-y-3", data: { turbo: false }, local: true do |form| %>
|
||||
<%= form.submit "Authorize",
|
||||
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 focus:ring-blue-500" %>
|
||||
|
||||
<%= button_tag "Deny",
|
||||
type: :submit,
|
||||
name: :deny,
|
||||
value: "1",
|
||||
class: "w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||
class: "w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 focus:ring-blue-500" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
|
||||
<%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %>
|
||||
<div class="my-5">
|
||||
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
|
||||
</div>
|
||||
|
||||
<div class="inline">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<%= form_with url: passwords_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
||||
<div class="my-5">
|
||||
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
|
||||
</div>
|
||||
|
||||
<div class="inline">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<p>
|
||||
You can reset your password on
|
||||
<%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.
|
||||
<%= link_to "this password reset page", edit_password_url(@user.generate_token_for(:password_reset)) %>.
|
||||
|
||||
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
|
||||
This link will expire in 1 hour.
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
You can reset your password on
|
||||
<%= edit_password_url(@user.password_reset_token) %>
|
||||
<%= edit_password_url(@user.generate_token_for(:password_reset)) %>
|
||||
|
||||
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
|
||||
This link will expire in 1 hour.
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<div class="space-y-8" data-controller="modal">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Account Security</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Manage your account settings, active sessions, and connected applications.</p>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Account Security</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Manage your account settings, active sessions, and connected applications.</p>
|
||||
</div>
|
||||
|
||||
<!-- Account Information -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Account Information</h3>
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Account Information</h3>
|
||||
<div class="mt-5 space-y-6">
|
||||
<%= form_with model: @user, url: profile_path, method: :patch, class: "space-y-6" do |form| %>
|
||||
<% if @user.errors.any? %>
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
<div class="rounded-md bg-red-50 dark:bg-red-900/30 p-4">
|
||||
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
<%= pluralize(@user.errors.count, "error") %> prohibited this from being saved:
|
||||
</h3>
|
||||
<ul class="mt-2 list-disc list-inside text-sm text-red-700">
|
||||
<ul class="mt-2 list-disc list-inside text-sm text-red-700 dark:text-red-300">
|
||||
<% @user.errors.each do |error| %>
|
||||
<li><%= error.full_message %></li>
|
||||
<% end %>
|
||||
@@ -24,24 +24,24 @@
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<%= form.label :email_address, "Email Address", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.label :email_address, "Email Address", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.email_field :email_address,
|
||||
required: true,
|
||||
autocomplete: "email",
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :current_password, "Current Password", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.label :current_password, "Current Password", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.password_field :current_password,
|
||||
autocomplete: "current-password",
|
||||
placeholder: "Required to change email",
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Enter your current password to confirm this change</p>
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Enter your current password to confirm this change</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.submit "Update Email", class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
|
||||
<%= form.submit "Update Email", class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -49,38 +49,38 @@
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Change Password</h3>
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Change Password</h3>
|
||||
<div class="mt-5">
|
||||
<%= form_with model: @user, url: profile_path, method: :patch, class: "space-y-6" do |form| %>
|
||||
<div>
|
||||
<%= form.label :current_password, "Current Password", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.label :current_password, "Current Password", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.password_field :current_password,
|
||||
autocomplete: "current-password",
|
||||
placeholder: "Enter current 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" %>
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :password, "New Password", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.label :password, "New Password", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.password_field :password,
|
||||
autocomplete: "new-password",
|
||||
placeholder: "Enter new 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" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Must be at least 8 characters</p>
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Must be at least 8 characters</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :password_confirmation, "Confirm New Password", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.label :password_confirmation, "Confirm New Password", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<%= form.password_field :password_confirmation,
|
||||
autocomplete: "new-password",
|
||||
placeholder: "Confirm new 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" %>
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.submit "Update Password", class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
|
||||
<%= form.submit "Update Password", class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -88,15 +88,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Two-Factor Authentication -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Two-Factor Authentication</h3>
|
||||
<div class="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Two-Factor Authentication</h3>
|
||||
<div class="mt-2 max-w-xl text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>Add an extra layer of security to your account by enabling two-factor authentication.</p>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<% if @user.totp_enabled? %>
|
||||
<div class="rounded-md bg-green-50 p-4">
|
||||
<div class="rounded-md bg-green-50 dark:bg-green-900/30 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
@@ -104,11 +104,11 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<p class="text-sm font-medium text-green-800">
|
||||
<p class="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
Two-factor authentication is enabled
|
||||
</p>
|
||||
<% if @user.totp_required? %>
|
||||
<p class="mt-1 text-sm text-green-700">
|
||||
<p class="mt-1 text-sm text-green-700 dark:text-green-300">
|
||||
<svg class="inline h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
@@ -119,12 +119,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<% if @user.totp_required? %>
|
||||
<div class="mt-4 rounded-md bg-blue-50 p-4">
|
||||
<div class="mt-4 rounded-md bg-blue-50 dark:bg-blue-900/30 p-4">
|
||||
<div class="flex">
|
||||
<svg class="h-5 w-5 text-blue-400 mr-2 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<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>
|
||||
<p class="text-sm text-blue-800">
|
||||
<p class="text-sm text-blue-800 dark:text-blue-200">
|
||||
Your administrator requires two-factor authentication. You cannot disable it.
|
||||
</p>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@
|
||||
<button type="button"
|
||||
data-action="click->modal#show"
|
||||
data-modal-id="view-backup-codes-modal"
|
||||
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
class="inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
|
||||
View Backup Codes
|
||||
</button>
|
||||
</div>
|
||||
@@ -142,19 +142,19 @@
|
||||
<button type="button"
|
||||
data-action="click->modal#show"
|
||||
data-modal-id="disable-2fa-modal"
|
||||
class="inline-flex items-center rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
|
||||
class="inline-flex items-center rounded-md border border-red-300 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
|
||||
Disable 2FA
|
||||
</button>
|
||||
<button type="button"
|
||||
data-action="click->modal#show"
|
||||
data-modal-id="view-backup-codes-modal"
|
||||
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
class="inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
|
||||
View Backup Codes
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= link_to new_totp_path, class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" do %>
|
||||
<%= link_to new_totp_path, class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" do %>
|
||||
Enable 2FA
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -166,17 +166,17 @@
|
||||
<div id="disable-2fa-modal"
|
||||
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape"
|
||||
class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/50 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg class="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Disable Two-Factor Authentication</h3>
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Disable Two-Factor Authentication</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500">Enter your password to disable 2FA. This will make your account less secure.</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Enter your password to disable 2FA. This will make your account less secure.</p>
|
||||
</div>
|
||||
<%= form_with url: totp_path, method: :delete, class: "mt-4" do |form| %>
|
||||
<div>
|
||||
@@ -184,14 +184,14 @@
|
||||
placeholder: "Enter your password",
|
||||
autocomplete: "current-password",
|
||||
required: true,
|
||||
class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-red-500 focus:ring-red-500 sm:text-sm" %>
|
||||
class: "block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-red-500 focus:ring-red-500 sm:text-sm" %>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-3">
|
||||
<%= form.submit "Disable 2FA",
|
||||
class: "inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2" %>
|
||||
class: "inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
|
||||
<button type="button"
|
||||
data-action="click->modal#hide"
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -205,18 +205,18 @@
|
||||
<div id="view-backup-codes-modal"
|
||||
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape"
|
||||
class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Generate New Backup Codes</h3>
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Generate New Backup Codes</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500">Due to security improvements, you need to generate new backup codes. Your old codes have been invalidated.</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Due to security improvements, you need to generate new backup codes. Your old codes have been invalidated.</p>
|
||||
</div>
|
||||
<div class="mt-3 p-3 bg-yellow-50 rounded-md">
|
||||
<div class="mt-3 p-3 bg-yellow-50 dark:bg-yellow-900/30 rounded-md">
|
||||
<div class="flex">
|
||||
<svg class="h-5 w-5 text-yellow-400 mr-2 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<p class="text-sm text-yellow-800">
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Important:</strong> Save the new codes immediately after generation. You won't be able to see them again without regenerating.
|
||||
</p>
|
||||
</div>
|
||||
@@ -227,14 +227,14 @@
|
||||
placeholder: "Enter your password",
|
||||
autocomplete: "current-password",
|
||||
required: true,
|
||||
class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
class: "block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-3">
|
||||
<%= form.submit "Generate New Codes",
|
||||
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
|
||||
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
|
||||
<button type="button"
|
||||
data-action="click->modal#hide"
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -244,10 +244,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Passkeys (WebAuthn) -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6" data-controller="webauthn" data-webauthn-challenge-url-value="/webauthn/challenge" data-webauthn-create-url-value="/webauthn/create">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Passkeys</h3>
|
||||
<div class="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Passkeys</h3>
|
||||
<div class="mt-2 max-w-xl text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>Use your fingerprint, face recognition, or security key to sign in without passwords.</p>
|
||||
</div>
|
||||
|
||||
@@ -255,20 +255,20 @@
|
||||
<div class="mt-5">
|
||||
<div id="add-passkey-form" class="space-y-4">
|
||||
<div>
|
||||
<label for="passkey-nickname" class="block text-sm font-medium text-gray-700">Passkey Name</label>
|
||||
<label for="passkey-nickname" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Passkey Name</label>
|
||||
<input type="text"
|
||||
id="passkey-nickname"
|
||||
data-webauthn-target="nickname"
|
||||
placeholder="e.g., MacBook Touch ID, iPhone Face ID"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
|
||||
<p class="mt-1 text-sm text-gray-500">Give this passkey a memorable name so you can identify it later.</p>
|
||||
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Give this passkey a memorable name so you can identify it later.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="button"
|
||||
data-action="click->webauthn#register"
|
||||
data-webauthn-target="submitButton"
|
||||
class="inline-flex items-center rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2">
|
||||
class="inline-flex items-center rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
@@ -284,11 +284,11 @@
|
||||
|
||||
<!-- Existing Passkeys List -->
|
||||
<div class="mt-8">
|
||||
<h4 class="text-md font-medium text-gray-900 mb-4">Your Passkeys</h4>
|
||||
<h4 class="text-md font-medium text-gray-900 dark:text-gray-100 mb-4">Your Passkeys</h4>
|
||||
<% if @user.webauthn_credentials.exists? %>
|
||||
<div class="space-y-3">
|
||||
<% @user.webauthn_credentials.order(created_at: :desc).each do |credential| %>
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<% if credential.platform_authenticator? %>
|
||||
@@ -304,10 +304,10 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
<%= credential.nickname %>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<%= credential.authenticator_type.humanize %> •
|
||||
Last used <%= credential.last_used_ago %>
|
||||
<% if credential.backed_up? %>
|
||||
@@ -318,7 +318,7 @@
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<% if credential.created_recently? %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200">
|
||||
New
|
||||
</span>
|
||||
<% end %>
|
||||
@@ -338,7 +338,7 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-3 bg-blue-50 rounded-lg">
|
||||
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-900/30 rounded-lg">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
@@ -346,7 +346,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-800">
|
||||
<p class="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>Tip:</strong> Add passkeys on multiple devices for easy access. Platform authenticators (like Touch ID) are synced across your devices if you use iCloud Keychain or Google Password Manager.
|
||||
</p>
|
||||
</div>
|
||||
@@ -354,11 +354,11 @@
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-8">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No passkeys</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by adding your first passkey for passwordless sign-in.</p>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No passkeys</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by adding your first passkey for passwordless sign-in.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
11
app/views/security_mailer/_event_metadata.html.erb
Normal file
11
app/views/security_mailer/_event_metadata.html.erb
Normal file
@@ -0,0 +1,11 @@
|
||||
<hr>
|
||||
<p>
|
||||
This action was recorded at <strong><%= @occurred_at.to_fs(:long) %></strong>
|
||||
from IP <strong><%= @ip %></strong>
|
||||
using <strong><%= @user_agent.presence || "an unknown client" %></strong>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you did <strong>not</strong> perform this action, reset your password
|
||||
immediately and contact your administrator.
|
||||
</p>
|
||||
7
app/views/security_mailer/_event_metadata.text.erb
Normal file
7
app/views/security_mailer/_event_metadata.text.erb
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
This action was recorded at <%= @occurred_at.to_fs(:long) %>
|
||||
from IP <%= @ip %>
|
||||
using <%= @user_agent.presence || "an unknown client" %>.
|
||||
|
||||
If you did not perform this action, reset your password immediately
|
||||
and contact your administrator.
|
||||
8
app/views/security_mailer/api_key_created.html.erb
Normal file
8
app/views/security_mailer/api_key_created.html.erb
Normal file
@@ -0,0 +1,8 @@
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>
|
||||
A new API key (<strong><%= @api_key_name %></strong>) was just created
|
||||
on your Clinch account (<strong><%= @user.email_address %></strong>).
|
||||
</p>
|
||||
|
||||
<%= render "event_metadata" %>
|
||||
6
app/views/security_mailer/api_key_created.text.erb
Normal file
6
app/views/security_mailer/api_key_created.text.erb
Normal file
@@ -0,0 +1,6 @@
|
||||
Hello,
|
||||
|
||||
A new API key ("<%= @api_key_name %>") was just created on your Clinch
|
||||
account (<%= @user.email_address %>).
|
||||
|
||||
<%= render "event_metadata" %>
|
||||
8
app/views/security_mailer/api_key_revoked.html.erb
Normal file
8
app/views/security_mailer/api_key_revoked.html.erb
Normal file
@@ -0,0 +1,8 @@
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>
|
||||
The API key <strong><%= @api_key_name %></strong> was just revoked
|
||||
on your Clinch account (<strong><%= @user.email_address %></strong>).
|
||||
</p>
|
||||
|
||||
<%= render "event_metadata" %>
|
||||
6
app/views/security_mailer/api_key_revoked.text.erb
Normal file
6
app/views/security_mailer/api_key_revoked.text.erb
Normal file
@@ -0,0 +1,6 @@
|
||||
Hello,
|
||||
|
||||
The API key "<%= @api_key_name %>" was just revoked on your Clinch
|
||||
account (<%= @user.email_address %>).
|
||||
|
||||
<%= render "event_metadata" %>
|
||||
@@ -0,0 +1,9 @@
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>
|
||||
A new set of two-factor backup codes was generated on your Clinch
|
||||
account (<strong><%= @user.email_address %></strong>).
|
||||
Any previous backup codes are now invalid.
|
||||
</p>
|
||||
|
||||
<%= render "event_metadata" %>
|
||||
@@ -0,0 +1,6 @@
|
||||
Hello,
|
||||
|
||||
A new set of two-factor backup codes was generated on your Clinch account
|
||||
(<%= @user.email_address %>). Any previous backup codes are now invalid.
|
||||
|
||||
<%= render "event_metadata" %>
|
||||
22
app/views/security_mailer/email_address_changed.html.erb
Normal file
22
app/views/security_mailer/email_address_changed.html.erb
Normal file
@@ -0,0 +1,22 @@
|
||||
<p>Hello,</p>
|
||||
|
||||
<% if @recipient == @new_email %>
|
||||
<p>
|
||||
The email address on your Clinch account is now
|
||||
<strong><%= @new_email %></strong>.
|
||||
It was previously <strong><%= @old_email %></strong>.
|
||||
</p>
|
||||
<% else %>
|
||||
<p>
|
||||
The email address on your Clinch account was changed away from this
|
||||
address (<strong><%= @old_email %></strong>) to
|
||||
<strong><%= @new_email %></strong>.
|
||||
</p>
|
||||
<p>
|
||||
If this was <strong>not</strong> you, contact your administrator
|
||||
immediately — whoever made the change can now receive password
|
||||
reset emails for the account.
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<%= render "event_metadata" %>
|
||||
14
app/views/security_mailer/email_address_changed.text.erb
Normal file
14
app/views/security_mailer/email_address_changed.text.erb
Normal file
@@ -0,0 +1,14 @@
|
||||
Hello,
|
||||
|
||||
<% if @recipient == @new_email %>
|
||||
The email address on your Clinch account is now <%= @new_email %>.
|
||||
It was previously <%= @old_email %>.
|
||||
<% else %>
|
||||
The email address on your Clinch account was changed away from this
|
||||
address (<%= @old_email %>) to <%= @new_email %>.
|
||||
|
||||
If this was not you, contact your administrator immediately — whoever
|
||||
made the change can now receive password reset emails for the account.
|
||||
<% end %>
|
||||
|
||||
<%= render "event_metadata" %>
|
||||
8
app/views/security_mailer/passkey_added.html.erb
Normal file
8
app/views/security_mailer/passkey_added.html.erb
Normal file
@@ -0,0 +1,8 @@
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>
|
||||
A new passkey (<strong><%= @nickname %></strong>) was just added to your
|
||||
Clinch account (<strong><%= @user.email_address %></strong>).
|
||||
</p>
|
||||
|
||||
<%= render "event_metadata" %>
|
||||
6
app/views/security_mailer/passkey_added.text.erb
Normal file
6
app/views/security_mailer/passkey_added.text.erb
Normal file
@@ -0,0 +1,6 @@
|
||||
Hello,
|
||||
|
||||
A new passkey ("<%= @nickname %>") was just added to your Clinch account
|
||||
(<%= @user.email_address %>).
|
||||
|
||||
<%= render "event_metadata" %>
|
||||
8
app/views/security_mailer/passkey_removed.html.erb
Normal file
8
app/views/security_mailer/passkey_removed.html.erb
Normal file
@@ -0,0 +1,8 @@
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>
|
||||
A passkey (<strong><%= @nickname %></strong>) was just removed from your
|
||||
Clinch account (<strong><%= @user.email_address %></strong>).
|
||||
</p>
|
||||
|
||||
<%= render "event_metadata" %>
|
||||
6
app/views/security_mailer/passkey_removed.text.erb
Normal file
6
app/views/security_mailer/passkey_removed.text.erb
Normal file
@@ -0,0 +1,6 @@
|
||||
Hello,
|
||||
|
||||
A passkey ("<%= @nickname %>") was just removed from your Clinch account
|
||||
(<%= @user.email_address %>).
|
||||
|
||||
<%= render "event_metadata" %>
|
||||
8
app/views/security_mailer/password_changed.html.erb
Normal file
8
app/views/security_mailer/password_changed.html.erb
Normal file
@@ -0,0 +1,8 @@
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>
|
||||
The password on your Clinch account
|
||||
(<strong><%= @user.email_address %></strong>) was just changed.
|
||||
</p>
|
||||
|
||||
<%= render "event_metadata" %>
|
||||
5
app/views/security_mailer/password_changed.text.erb
Normal file
5
app/views/security_mailer/password_changed.text.erb
Normal file
@@ -0,0 +1,5 @@
|
||||
Hello,
|
||||
|
||||
The password on your Clinch account (<%= @user.email_address %>) was just changed.
|
||||
|
||||
<%= render "event_metadata" %>
|
||||
16
app/views/security_mailer/suspicious_passkey_used.html.erb
Normal file
16
app/views/security_mailer/suspicious_passkey_used.html.erb
Normal file
@@ -0,0 +1,16 @@
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>
|
||||
A sign-in to your Clinch account (<strong><%= @user.email_address %></strong>)
|
||||
using your passkey (<strong><%= @nickname %></strong>) was <strong>blocked</strong>
|
||||
because its security counter did not advance as expected. This can indicate the
|
||||
passkey has been copied (cloned).
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If this was you and you are unable to sign in, remove this passkey and register
|
||||
a new one. If you do not recognise this activity, treat it as a compromise:
|
||||
remove the passkey and review your account security.
|
||||
</p>
|
||||
|
||||
<%= render "event_metadata" %>
|
||||
11
app/views/security_mailer/suspicious_passkey_used.text.erb
Normal file
11
app/views/security_mailer/suspicious_passkey_used.text.erb
Normal file
@@ -0,0 +1,11 @@
|
||||
Hello,
|
||||
|
||||
A sign-in to your Clinch account (<%= @user.email_address %>) using your passkey
|
||||
("<%= @nickname %>") was BLOCKED because its security counter did not advance as
|
||||
expected. This can indicate the passkey has been copied (cloned).
|
||||
|
||||
If this was you and you are unable to sign in, remove this passkey and register a
|
||||
new one. If you do not recognise this activity, treat it as a compromise: remove
|
||||
the passkey and review your account security.
|
||||
|
||||
<%= render "event_metadata" %>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user