Compare commits
50 Commits
v0.8.8
...
2843790cef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1 +1 @@
|
|||||||
3.4.8
|
4.0.3
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
|
# 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
|
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
|
||||||
ARG RUBY_VERSION=3.4.8
|
ARG RUBY_VERSION=4.0.3
|
||||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
|
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
|
||||||
|
|||||||
2
Gemfile
2
Gemfile
@@ -1,7 +1,7 @@
|
|||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
|
# 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]
|
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
|
||||||
gem "propshaft"
|
gem "propshaft"
|
||||||
# Use sqlite3 as the database for Active Record
|
# Use sqlite3 as the database for Active Record
|
||||||
|
|||||||
272
Gemfile.lock
272
Gemfile.lock
@@ -1,31 +1,31 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
action_text-trix (2.1.16)
|
action_text-trix (2.1.18)
|
||||||
railties
|
railties
|
||||||
actioncable (8.1.1)
|
actioncable (8.1.3)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.3)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.3)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
actionmailbox (8.1.1)
|
actionmailbox (8.1.3)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.3)
|
||||||
activejob (= 8.1.1)
|
activejob (= 8.1.3)
|
||||||
activerecord (= 8.1.1)
|
activerecord (= 8.1.3)
|
||||||
activestorage (= 8.1.1)
|
activestorage (= 8.1.3)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.3)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
actionmailer (8.1.1)
|
actionmailer (8.1.3)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.3)
|
||||||
actionview (= 8.1.1)
|
actionview (= 8.1.3)
|
||||||
activejob (= 8.1.1)
|
activejob (= 8.1.3)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.3)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (8.1.1)
|
actionpack (8.1.3)
|
||||||
actionview (= 8.1.1)
|
actionview (= 8.1.3)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.3)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
rack (>= 2.2.4)
|
rack (>= 2.2.4)
|
||||||
rack-session (>= 1.0.1)
|
rack-session (>= 1.0.1)
|
||||||
@@ -33,36 +33,36 @@ GEM
|
|||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
useragent (~> 0.16)
|
useragent (~> 0.16)
|
||||||
actiontext (8.1.1)
|
actiontext (8.1.3)
|
||||||
action_text-trix (~> 2.1.15)
|
action_text-trix (~> 2.1.15)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.3)
|
||||||
activerecord (= 8.1.1)
|
activerecord (= 8.1.3)
|
||||||
activestorage (= 8.1.1)
|
activestorage (= 8.1.3)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.3)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (8.1.1)
|
actionview (8.1.3)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.3)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
activejob (8.1.1)
|
activejob (8.1.3)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.3)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (8.1.1)
|
activemodel (8.1.3)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.3)
|
||||||
activerecord (8.1.1)
|
activerecord (8.1.3)
|
||||||
activemodel (= 8.1.1)
|
activemodel (= 8.1.3)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.3)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activestorage (8.1.1)
|
activestorage (8.1.3)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.3)
|
||||||
activejob (= 8.1.1)
|
activejob (= 8.1.3)
|
||||||
activerecord (= 8.1.1)
|
activerecord (= 8.1.3)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.3)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (8.1.1)
|
activesupport (8.1.3)
|
||||||
base64
|
base64
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||||
@@ -75,19 +75,19 @@ GEM
|
|||||||
securerandom (>= 0.3)
|
securerandom (>= 0.3)
|
||||||
tzinfo (~> 2.0, >= 2.0.5)
|
tzinfo (~> 2.0, >= 2.0.5)
|
||||||
uri (>= 0.13.1)
|
uri (>= 0.13.1)
|
||||||
addressable (2.8.8)
|
addressable (2.9.0)
|
||||||
public_suffix (>= 2.0.2, < 8.0)
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
android_key_attestation (0.3.0)
|
android_key_attestation (0.3.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.21)
|
bcrypt (3.1.22)
|
||||||
bcrypt_pbkdf (1.1.2)
|
bcrypt_pbkdf (1.1.2)
|
||||||
bigdecimal (4.0.1)
|
bigdecimal (4.1.2)
|
||||||
bindata (2.5.1)
|
bindata (2.5.1)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.20.1)
|
bootsnap (1.24.1)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (7.1.2)
|
brakeman (8.0.4)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bundler-audit (0.9.3)
|
bundler-audit (0.9.3)
|
||||||
@@ -102,7 +102,7 @@ GEM
|
|||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
cbor (0.5.10.1)
|
cbor (0.5.10.2)
|
||||||
childprocess (5.1.0)
|
childprocess (5.1.0)
|
||||||
logger (~> 1.5)
|
logger (~> 1.5)
|
||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
@@ -120,17 +120,17 @@ GEM
|
|||||||
dotenv (3.2.0)
|
dotenv (3.2.0)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
ed25519 (1.4.0)
|
ed25519 (1.4.0)
|
||||||
erb (6.0.1)
|
erb (6.0.4)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.4.0)
|
et-orbi (1.4.0)
|
||||||
tzinfo
|
tzinfo
|
||||||
ffi (1.17.3-aarch64-linux-gnu)
|
ffi (1.17.4-aarch64-linux-gnu)
|
||||||
ffi (1.17.3-aarch64-linux-musl)
|
ffi (1.17.4-aarch64-linux-musl)
|
||||||
ffi (1.17.3-arm-linux-gnu)
|
ffi (1.17.4-arm-linux-gnu)
|
||||||
ffi (1.17.3-arm-linux-musl)
|
ffi (1.17.4-arm-linux-musl)
|
||||||
ffi (1.17.3-arm64-darwin)
|
ffi (1.17.4-arm64-darwin)
|
||||||
ffi (1.17.3-x86_64-linux-gnu)
|
ffi (1.17.4-x86_64-linux-gnu)
|
||||||
ffi (1.17.3-x86_64-linux-musl)
|
ffi (1.17.4-x86_64-linux-musl)
|
||||||
fugit (1.12.1)
|
fugit (1.12.1)
|
||||||
et-orbi (~> 1.4)
|
et-orbi (~> 1.4)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
@@ -141,22 +141,23 @@ GEM
|
|||||||
image_processing (1.14.0)
|
image_processing (1.14.0)
|
||||||
mini_magick (>= 4.9.5, < 6)
|
mini_magick (>= 4.9.5, < 6)
|
||||||
ruby-vips (>= 2.0.17, < 3)
|
ruby-vips (>= 2.0.17, < 3)
|
||||||
importmap-rails (2.2.2)
|
importmap-rails (2.2.3)
|
||||||
actionpack (>= 6.0.0)
|
actionpack (>= 6.0.0)
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
io-console (0.8.2)
|
io-console (0.8.2)
|
||||||
irb (1.16.0)
|
irb (1.18.0)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
|
prism (>= 1.3.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
jbuilder (2.14.1)
|
jbuilder (2.14.1)
|
||||||
actionview (>= 7.0.0)
|
actionview (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
json (2.18.0)
|
json (2.19.4)
|
||||||
jwt (3.1.2)
|
jwt (3.1.2)
|
||||||
base64
|
base64
|
||||||
kamal (2.10.1)
|
kamal (2.11.0)
|
||||||
activesupport (>= 7.0)
|
activesupport (>= 7.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
bcrypt_pbkdf (~> 1.0)
|
bcrypt_pbkdf (~> 1.0)
|
||||||
@@ -176,7 +177,7 @@ GEM
|
|||||||
launchy (>= 2.2, < 4)
|
launchy (>= 2.2, < 4)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
loofah (2.25.0)
|
loofah (2.25.1)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
mail (2.9.0)
|
mail (2.9.0)
|
||||||
@@ -192,7 +193,7 @@ GEM
|
|||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.27.0)
|
minitest (5.27.0)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
net-imap (0.6.2)
|
net-imap (0.6.4)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
@@ -205,78 +206,78 @@ GEM
|
|||||||
net-ssh (>= 5.0.0, < 8.0.0)
|
net-ssh (>= 5.0.0, < 8.0.0)
|
||||||
net-smtp (0.5.1)
|
net-smtp (0.5.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ssh (7.3.0)
|
net-ssh (7.3.2)
|
||||||
nio4r (2.7.5)
|
nio4r (2.7.5)
|
||||||
nokogiri (1.19.0-aarch64-linux-gnu)
|
nokogiri (1.19.3-aarch64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.0-aarch64-linux-musl)
|
nokogiri (1.19.3-aarch64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.0-arm-linux-gnu)
|
nokogiri (1.19.3-arm-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.0-arm-linux-musl)
|
nokogiri (1.19.3-arm-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.0-arm64-darwin)
|
nokogiri (1.19.3-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.0-x86_64-linux-gnu)
|
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.0-x86_64-linux-musl)
|
nokogiri (1.19.3-x86_64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
openssl (4.0.0)
|
openssl (4.0.1)
|
||||||
openssl-signature_algorithm (1.3.0)
|
openssl-signature_algorithm (1.3.0)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
parallel (1.27.0)
|
parallel (1.28.0)
|
||||||
parser (3.3.10.0)
|
parser (3.3.11.1)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
pp (0.6.3)
|
pp (0.6.3)
|
||||||
prettyprint
|
prettyprint
|
||||||
prettyprint (0.2.0)
|
prettyprint (0.2.0)
|
||||||
prism (1.7.0)
|
prism (1.9.0)
|
||||||
propshaft (1.3.1)
|
propshaft (1.3.2)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
rack
|
rack
|
||||||
psych (5.3.1)
|
psych (5.3.1)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (7.0.0)
|
public_suffix (7.0.5)
|
||||||
puma (7.1.0)
|
puma (8.0.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.4)
|
rack (3.2.6)
|
||||||
rack-session (2.1.1)
|
rack-session (2.1.2)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
rack (>= 3.0.0)
|
rack (>= 3.0.0)
|
||||||
rack-test (2.2.0)
|
rack-test (2.2.0)
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rackup (2.3.1)
|
rackup (2.3.1)
|
||||||
rack (>= 3)
|
rack (>= 3)
|
||||||
rails (8.1.1)
|
rails (8.1.3)
|
||||||
actioncable (= 8.1.1)
|
actioncable (= 8.1.3)
|
||||||
actionmailbox (= 8.1.1)
|
actionmailbox (= 8.1.3)
|
||||||
actionmailer (= 8.1.1)
|
actionmailer (= 8.1.3)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.3)
|
||||||
actiontext (= 8.1.1)
|
actiontext (= 8.1.3)
|
||||||
actionview (= 8.1.1)
|
actionview (= 8.1.3)
|
||||||
activejob (= 8.1.1)
|
activejob (= 8.1.3)
|
||||||
activemodel (= 8.1.1)
|
activemodel (= 8.1.3)
|
||||||
activerecord (= 8.1.1)
|
activerecord (= 8.1.3)
|
||||||
activestorage (= 8.1.1)
|
activestorage (= 8.1.3)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.3)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.1.1)
|
railties (= 8.1.3)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
rails-html-sanitizer (1.6.2)
|
rails-html-sanitizer (1.7.0)
|
||||||
loofah (~> 2.21)
|
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)
|
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)
|
railties (8.1.3)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.3)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.3)
|
||||||
irb (~> 1.13)
|
irb (~> 1.13)
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
@@ -284,21 +285,21 @@ GEM
|
|||||||
tsort (>= 0.2)
|
tsort (>= 0.2)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.1)
|
rake (13.4.2)
|
||||||
rdoc (7.0.3)
|
rdoc (7.2.0)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
tsort
|
tsort
|
||||||
regexp_parser (2.11.3)
|
regexp_parser (2.12.0)
|
||||||
reline (0.6.3)
|
reline (0.6.3)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rqrcode (3.1.1)
|
rqrcode (3.2.0)
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
rqrcode_core (~> 2.0)
|
rqrcode_core (~> 2.0)
|
||||||
rqrcode_core (2.0.1)
|
rqrcode_core (2.1.0)
|
||||||
rubocop (1.81.7)
|
rubocop (1.84.2)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
@@ -306,10 +307,10 @@ GEM
|
|||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 2.9.3, < 3.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)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.49.0)
|
rubocop-ast (1.49.1)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.7)
|
prism (~> 1.7)
|
||||||
rubocop-performance (1.26.1)
|
rubocop-performance (1.26.1)
|
||||||
@@ -324,18 +325,19 @@ GEM
|
|||||||
safety_net_attestation (0.5.0)
|
safety_net_attestation (0.5.0)
|
||||||
jwt (>= 2.0, < 4.0)
|
jwt (>= 2.0, < 4.0)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
selenium-webdriver (4.39.0)
|
selenium-webdriver (4.43.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
logger (~> 1.4)
|
logger (~> 1.4)
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 4.0)
|
rubyzip (>= 1.2.2, < 4.0)
|
||||||
websocket (~> 1.0)
|
websocket (~> 1.0)
|
||||||
sentry-rails (6.2.0)
|
sentry-rails (6.5.0)
|
||||||
railties (>= 5.2.0)
|
railties (>= 5.2.0)
|
||||||
sentry-ruby (~> 6.2.0)
|
sentry-ruby (~> 6.5.0)
|
||||||
sentry-ruby (6.2.0)
|
sentry-ruby (6.5.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
|
logger
|
||||||
simplecov (0.22.0)
|
simplecov (0.22.0)
|
||||||
docile (~> 1.1)
|
docile (~> 1.1)
|
||||||
simplecov-html (~> 0.11)
|
simplecov-html (~> 0.11)
|
||||||
@@ -351,20 +353,20 @@ GEM
|
|||||||
activejob (>= 7.2)
|
activejob (>= 7.2)
|
||||||
activerecord (>= 7.2)
|
activerecord (>= 7.2)
|
||||||
railties (>= 7.2)
|
railties (>= 7.2)
|
||||||
solid_queue (1.2.4)
|
solid_queue (1.4.0)
|
||||||
activejob (>= 7.1)
|
activejob (>= 7.1)
|
||||||
activerecord (>= 7.1)
|
activerecord (>= 7.1)
|
||||||
concurrent-ruby (>= 1.3.1)
|
concurrent-ruby (>= 1.3.1)
|
||||||
fugit (~> 1.11)
|
fugit (~> 1.11)
|
||||||
railties (>= 7.1)
|
railties (>= 7.1)
|
||||||
thor (>= 1.3.1)
|
thor (>= 1.3.1)
|
||||||
sqlite3 (2.9.0-aarch64-linux-gnu)
|
sqlite3 (2.9.3-aarch64-linux-gnu)
|
||||||
sqlite3 (2.9.0-aarch64-linux-musl)
|
sqlite3 (2.9.3-aarch64-linux-musl)
|
||||||
sqlite3 (2.9.0-arm-linux-gnu)
|
sqlite3 (2.9.3-arm-linux-gnu)
|
||||||
sqlite3 (2.9.0-arm-linux-musl)
|
sqlite3 (2.9.3-arm-linux-musl)
|
||||||
sqlite3 (2.9.0-arm64-darwin)
|
sqlite3 (2.9.3-arm64-darwin)
|
||||||
sqlite3 (2.9.0-x86_64-linux-gnu)
|
sqlite3 (2.9.3-x86_64-linux-gnu)
|
||||||
sqlite3 (2.9.0-x86_64-linux-musl)
|
sqlite3 (2.9.3-x86_64-linux-musl)
|
||||||
sshkit (1.25.0)
|
sshkit (1.25.0)
|
||||||
base64
|
base64
|
||||||
logger
|
logger
|
||||||
@@ -372,10 +374,10 @@ GEM
|
|||||||
net-sftp (>= 2.1.2)
|
net-sftp (>= 2.1.2)
|
||||||
net-ssh (>= 2.8.0)
|
net-ssh (>= 2.8.0)
|
||||||
ostruct
|
ostruct
|
||||||
standard (1.52.0)
|
standard (1.54.0)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.0)
|
lint_roller (~> 1.0)
|
||||||
rubocop (~> 1.81.7)
|
rubocop (~> 1.84.0)
|
||||||
standard-custom (~> 1.0.0)
|
standard-custom (~> 1.0.0)
|
||||||
standard-performance (~> 1.8)
|
standard-performance (~> 1.8)
|
||||||
standard-custom (1.0.2)
|
standard-custom (1.0.2)
|
||||||
@@ -390,24 +392,24 @@ GEM
|
|||||||
tailwindcss-rails (4.4.0)
|
tailwindcss-rails (4.4.0)
|
||||||
railties (>= 7.0.0)
|
railties (>= 7.0.0)
|
||||||
tailwindcss-ruby (~> 4.0)
|
tailwindcss-ruby (~> 4.0)
|
||||||
tailwindcss-ruby (4.1.18)
|
tailwindcss-ruby (4.2.4)
|
||||||
tailwindcss-ruby (4.1.18-aarch64-linux-gnu)
|
tailwindcss-ruby (4.2.4-aarch64-linux-gnu)
|
||||||
tailwindcss-ruby (4.1.18-aarch64-linux-musl)
|
tailwindcss-ruby (4.2.4-aarch64-linux-musl)
|
||||||
tailwindcss-ruby (4.1.18-arm64-darwin)
|
tailwindcss-ruby (4.2.4-arm64-darwin)
|
||||||
tailwindcss-ruby (4.1.18-x86_64-linux-gnu)
|
tailwindcss-ruby (4.2.4-x86_64-linux-gnu)
|
||||||
tailwindcss-ruby (4.1.18-x86_64-linux-musl)
|
tailwindcss-ruby (4.2.4-x86_64-linux-musl)
|
||||||
thor (1.4.0)
|
thor (1.5.0)
|
||||||
thruster (0.1.17)
|
thruster (0.1.20)
|
||||||
thruster (0.1.17-aarch64-linux)
|
thruster (0.1.20-aarch64-linux)
|
||||||
thruster (0.1.17-arm64-darwin)
|
thruster (0.1.20-arm64-darwin)
|
||||||
thruster (0.1.17-x86_64-linux)
|
thruster (0.1.20-x86_64-linux)
|
||||||
timeout (0.6.0)
|
timeout (0.6.1)
|
||||||
tpm-key_attestation (0.14.1)
|
tpm-key_attestation (0.14.1)
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
openssl-signature_algorithm (~> 1.0)
|
openssl-signature_algorithm (~> 1.0)
|
||||||
tsort (0.2.0)
|
tsort (0.2.0)
|
||||||
turbo-rails (2.0.20)
|
turbo-rails (2.0.23)
|
||||||
actionpack (>= 7.1.0)
|
actionpack (>= 7.1.0)
|
||||||
railties (>= 7.1.0)
|
railties (>= 7.1.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
@@ -417,11 +419,10 @@ GEM
|
|||||||
unicode-emoji (4.2.0)
|
unicode-emoji (4.2.0)
|
||||||
uri (1.1.1)
|
uri (1.1.1)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
web-console (4.2.1)
|
web-console (4.3.0)
|
||||||
actionview (>= 6.0.0)
|
actionview (>= 8.0.0)
|
||||||
activemodel (>= 6.0.0)
|
|
||||||
bindex (>= 0.4.0)
|
bindex (>= 0.4.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 8.0.0)
|
||||||
webauthn (3.4.3)
|
webauthn (3.4.3)
|
||||||
android_key_attestation (~> 0.3.0)
|
android_key_attestation (~> 0.3.0)
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
@@ -437,7 +438,7 @@ GEM
|
|||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
xpath (3.2.0)
|
xpath (3.2.0)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
zeitwerk (2.7.4)
|
zeitwerk (2.7.5)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
aarch64-linux
|
aarch64-linux
|
||||||
@@ -446,6 +447,7 @@ PLATFORMS
|
|||||||
arm-linux-gnu
|
arm-linux-gnu
|
||||||
arm-linux-musl
|
arm-linux-musl
|
||||||
arm64-darwin-24
|
arm64-darwin-24
|
||||||
|
arm64-darwin-25
|
||||||
x86_64-linux
|
x86_64-linux
|
||||||
x86_64-linux-gnu
|
x86_64-linux-gnu
|
||||||
x86_64-linux-musl
|
x86_64-linux-musl
|
||||||
@@ -467,7 +469,7 @@ DEPENDENCIES
|
|||||||
propshaft
|
propshaft
|
||||||
public_suffix (~> 7.0)
|
public_suffix (~> 7.0)
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
rails (~> 8.1.1)
|
rails (~> 8.1.3)
|
||||||
rotp (~> 6.3)
|
rotp (~> 6.3)
|
||||||
rqrcode (~> 3.1)
|
rqrcode (~> 3.1)
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
@@ -488,4 +490,4 @@ DEPENDENCIES
|
|||||||
webauthn (~> 3.0)
|
webauthn (~> 3.0)
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
4.0.3
|
4.0.6
|
||||||
|
|||||||
42
README.md
42
README.md
@@ -15,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 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.
|
**[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.
|
**[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.
|
**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
|
## Screenshots
|
||||||
@@ -76,8 +82,6 @@ Apps that only need "who is it?", or you want available from the internet behind
|
|||||||
|
|
||||||
#### OpenID Connect (OIDC)
|
#### OpenID Connect (OIDC)
|
||||||
|
|
||||||
**[OpenID Connect Conformance](https://www.certification.openid.net/plan-detail.html?plan=FbQNTJuYVzrzs&public=true)** - Clinch passes the official OpenID Connect conformance tests (valid as of [v0.8.6](https://github.com/dkam/clinch/releases/tag/0.8.6)).
|
|
||||||
|
|
||||||
Standard OAuth2/OIDC provider with endpoints:
|
Standard OAuth2/OIDC provider with endpoints:
|
||||||
- `/.well-known/openid-configuration` - Discovery endpoint
|
- `/.well-known/openid-configuration` - Discovery endpoint
|
||||||
- `/authorize` - Authorization endpoint with PKCE support
|
- `/authorize` - Authorization endpoint with PKCE support
|
||||||
@@ -131,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.
|
**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
|
### SMTP Integration
|
||||||
Send emails for:
|
Send emails for:
|
||||||
- Invitation links (one-time token, 7-day expiry)
|
- Invitation links (one-time token, 7-day expiry)
|
||||||
@@ -287,7 +317,7 @@ This is transparent to end users and requires no configuration.
|
|||||||
## Setup & Installation
|
## Setup & Installation
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
- Ruby 3.3+
|
- Ruby 4.0+
|
||||||
- SQLite 3.8+
|
- SQLite 3.8+
|
||||||
- SMTP server (for sending emails)
|
- SMTP server (for sending emails)
|
||||||
|
|
||||||
@@ -701,7 +731,7 @@ user.revoke_all_consents!
|
|||||||
|
|
||||||
### Running Tests
|
### 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
|
```bash
|
||||||
# Run all tests
|
# Run all tests
|
||||||
@@ -761,7 +791,7 @@ All security scans run automatically on every pull request and push to main via
|
|||||||
|
|
||||||
**Current Status:**
|
**Current Status:**
|
||||||
- ✅ All security scans passing
|
- ✅ All security scans passing
|
||||||
- ✅ 341 tests, 1349 assertions, 0 failures
|
- ✅ 450 tests, 1818 assertions, 0 failures
|
||||||
- ✅ No known dependency vulnerabilities
|
- ✅ No known dependency vulnerabilities
|
||||||
- ✅ Phases 1-4 security hardening complete (18+ vulnerabilities fixed)
|
- ✅ Phases 1-4 security hardening complete (18+ vulnerabilities fixed)
|
||||||
- 🟡 3 outstanding security issues (all MEDIUM/LOW priority)
|
- 🟡 3 outstanding security issues (all MEDIUM/LOW priority)
|
||||||
|
|||||||
@@ -1 +1,29 @@
|
|||||||
@import "tailwindcss";
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
25
app/controllers/admin/access_checks_controller.rb
Normal file
25
app/controllers/admin/access_checks_controller.rb
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
module Admin
|
||||||
|
class AccessChecksController < BaseController
|
||||||
|
def new
|
||||||
|
load_options
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
load_options
|
||||||
|
@user = User.find_by(id: params[:user_id])
|
||||||
|
@application = Application.find_by(id: params[:application_id])
|
||||||
|
return render :new unless @user && @application
|
||||||
|
|
||||||
|
@allowed = @application.user_allowed?(@user)
|
||||||
|
@via = @user.groups & @application.allowed_groups
|
||||||
|
render :new
|
||||||
|
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]
|
before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials]
|
||||||
|
|
||||||
def index
|
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
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@allowed_groups = @application.allowed_groups
|
@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
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@@ -104,7 +124,7 @@ module Admin
|
|||||||
permitted = params.require(:application).permit(
|
permitted = params.require(:application).permit(
|
||||||
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
||||||
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
|
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
|
||||||
:icon, :backchannel_logout_uri, :is_public_client, :require_pkce, :skip_consent
|
: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
|
# Handle headers_config - it comes as a JSON string from the text area
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ module Admin
|
|||||||
def new
|
def new
|
||||||
@group = Group.new
|
@group = Group.new
|
||||||
@available_users = User.order(:email_address)
|
@available_users = User.order(:email_address)
|
||||||
|
@available_applications = Application.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -28,6 +29,7 @@ module Admin
|
|||||||
@group = Group.new
|
@group = Group.new
|
||||||
@group.errors.add(:custom_claims, "must be valid JSON")
|
@group.errors.add(:custom_claims, "must be valid JSON")
|
||||||
@available_users = User.order(:email_address)
|
@available_users = User.order(:email_address)
|
||||||
|
@available_applications = Application.order(:name)
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -45,15 +47,23 @@ module Admin
|
|||||||
@group.users = User.where(id: user_ids)
|
@group.users = User.where(id: user_ids)
|
||||||
end
|
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."
|
redirect_to admin_group_path(@group), notice: "Group created successfully."
|
||||||
else
|
else
|
||||||
@available_users = User.order(:email_address)
|
@available_users = User.order(:email_address)
|
||||||
|
@available_applications = Application.order(:name)
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
@available_users = User.order(:email_address)
|
@available_users = User.order(:email_address)
|
||||||
|
@available_applications = Application.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -66,6 +76,7 @@ module Admin
|
|||||||
rescue JSON::ParserError
|
rescue JSON::ParserError
|
||||||
@group.errors.add(:custom_claims, "must be valid JSON")
|
@group.errors.add(:custom_claims, "must be valid JSON")
|
||||||
@available_users = User.order(:email_address)
|
@available_users = User.order(:email_address)
|
||||||
|
@available_applications = Application.order(:name)
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -83,9 +94,18 @@ module Admin
|
|||||||
@group.users = []
|
@group.users = []
|
||||||
end
|
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."
|
redirect_to admin_group_path(@group), notice: "Group updated successfully."
|
||||||
else
|
else
|
||||||
@available_users = User.order(:email_address)
|
@available_users = User.order(:email_address)
|
||||||
|
@available_applications = Application.order(:name)
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -102,7 +122,7 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def group_params
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,27 +7,38 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
@accessible_applications = Application.where(active: true)
|
||||||
|
.joins(:allowed_groups)
|
||||||
|
.where(groups: {id: @user.groups})
|
||||||
|
.distinct
|
||||||
|
.includes(:allowed_groups)
|
||||||
|
.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@user = User.new
|
@user = User.new
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@user = User.new(user_params)
|
@user = User.new(user_params)
|
||||||
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
|
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
|
||||||
@user.status = :pending_invitation
|
@user.status = :pending_invitation
|
||||||
|
@user.skip_auto_assign = true if params[:auto_assign] == "0"
|
||||||
|
|
||||||
if @user.save
|
if @user.save
|
||||||
|
assign_groups_from_params(@user)
|
||||||
InvitationsMailer.invite_user(@user).deliver_later
|
InvitationsMailer.invite_user(@user).deliver_later
|
||||||
redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}."
|
redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}."
|
||||||
else
|
else
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
@applications = Application.active.order(:name)
|
@applications = Application.active.order(:name)
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -43,6 +54,7 @@ module Admin
|
|||||||
rescue JSON::ParserError
|
rescue JSON::ParserError
|
||||||
@user.errors.add(:custom_claims, "must be valid JSON")
|
@user.errors.add(:custom_claims, "must be valid JSON")
|
||||||
@applications = Application.active.order(:name)
|
@applications = Application.active.order(:name)
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -52,9 +64,16 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
if @user.update(update_params)
|
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."
|
redirect_to admin_users_path, notice: "User updated successfully."
|
||||||
else
|
else
|
||||||
@applications = Application.active.order(:name)
|
@applications = Application.active.order(:name)
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -122,15 +141,28 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def user_params
|
def user_params
|
||||||
# Base attributes that all admins can modify
|
params.require(:user).permit(:email_address, :username, :name, :password, :status, :totp_required, :custom_claims)
|
||||||
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?
|
|
||||||
end
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,73 +1,58 @@
|
|||||||
module Api
|
module Api
|
||||||
class ForwardAuthController < ApplicationController
|
class ForwardAuthController < ApplicationController
|
||||||
# ForwardAuth endpoints need session storage for return URL
|
|
||||||
allow_unauthenticated_access
|
allow_unauthenticated_access
|
||||||
skip_before_action :verify_authenticity_token
|
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
|
# GET /api/verify
|
||||||
# This endpoint is called by reverse proxies (Traefik, Caddy, nginx)
|
# Called by reverse proxies (Traefik, Caddy, nginx) to verify authentication and authorization.
|
||||||
# to verify if a user is authenticated and authorized to access a domain
|
|
||||||
def verify
|
def verify
|
||||||
# Note: app_slug parameter is no longer used - we match domains directly with Application (forward_auth type)
|
|
||||||
|
|
||||||
# Check for bearer token first (API keys for server-to-server auth)
|
|
||||||
bearer_result = authenticate_bearer_token
|
bearer_result = authenticate_bearer_token
|
||||||
return bearer_result if bearer_result
|
return bearer_result if bearer_result
|
||||||
|
|
||||||
# Check for one-time forward auth token first (to handle race condition)
|
|
||||||
session_id = check_forward_auth_token
|
session_id = check_forward_auth_token
|
||||||
|
|
||||||
# If no token found, try to get session from cookie
|
|
||||||
session_id ||= extract_session_id
|
session_id ||= extract_session_id
|
||||||
|
|
||||||
unless session_id
|
unless session_id
|
||||||
# No session cookie or token - user is not authenticated
|
|
||||||
return render_unauthorized("No session cookie")
|
return render_unauthorized("No session cookie")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find the session with user association (eager loading for performance)
|
session = Session.includes(user: :groups).find_by(id: session_id)
|
||||||
session = Session.includes(:user).find_by(id: session_id)
|
|
||||||
unless session
|
unless session
|
||||||
# Invalid session
|
|
||||||
return render_unauthorized("Invalid session")
|
return render_unauthorized("Invalid session")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if session is expired
|
|
||||||
if session.expired?
|
if session.expired?
|
||||||
session.destroy
|
session.destroy
|
||||||
return render_unauthorized("Session expired")
|
return render_unauthorized("Session expired")
|
||||||
end
|
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)
|
session.update_column(:last_activity_at, Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
# Get the user (already loaded via includes(:user))
|
|
||||||
user = session.user
|
user = session.user
|
||||||
unless user.active?
|
unless user.active?
|
||||||
return render_unauthorized("User account is not active")
|
return render_unauthorized("User account is not active")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check for forward auth application authorization
|
|
||||||
# Get the forwarded host for domain matching
|
|
||||||
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
||||||
|
app = nil
|
||||||
|
|
||||||
if forwarded_host.present?
|
if forwarded_host.present?
|
||||||
# Load all forward auth applications (including inactive ones) for security checks
|
apps = cached_forward_auth_apps
|
||||||
# Preload groups to avoid N+1 queries in user_allowed? checks
|
|
||||||
apps = Application.forward_auth.includes(:allowed_groups)
|
|
||||||
|
|
||||||
# Find matching forward auth application for this domain
|
|
||||||
app = apps.find { |a| a.matches_domain?(forwarded_host) }
|
app = apps.find { |a| a.matches_domain?(forwarded_host) }
|
||||||
|
|
||||||
if app
|
if app
|
||||||
# Check if application is active
|
|
||||||
unless app.active?
|
unless app.active?
|
||||||
Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - application is inactive"
|
Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - application is inactive"
|
||||||
return render_forbidden("No authentication rule configured for this domain")
|
return render_forbidden("No authentication rule configured for this domain")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if user is allowed by this application
|
|
||||||
unless app.user_allowed?(user)
|
unless app.user_allowed?(user)
|
||||||
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by app #{app.domain_pattern}"
|
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")
|
return render_forbidden("You do not have permission to access this domain")
|
||||||
@@ -75,7 +60,6 @@ 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)})"
|
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
|
else
|
||||||
# No application found - DENY by default (fail-closed security)
|
|
||||||
Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - no authentication rule configured"
|
Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - no authentication rule configured"
|
||||||
return render_forbidden("No authentication rule configured for this domain")
|
return render_forbidden("No authentication rule configured for this domain")
|
||||||
end
|
end
|
||||||
@@ -83,8 +67,6 @@ module Api
|
|||||||
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
|
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
|
||||||
end
|
end
|
||||||
|
|
||||||
# User is authenticated and authorized
|
|
||||||
# Return 200 with user information headers using app-specific configuration
|
|
||||||
headers = if app
|
headers = if app
|
||||||
app.headers_for_user(user)
|
app.headers_for_user(user)
|
||||||
else
|
else
|
||||||
@@ -95,7 +77,7 @@ module Api
|
|||||||
when :username
|
when :username
|
||||||
[header_name, user.username] if user.username.present?
|
[header_name, user.username] if user.username.present?
|
||||||
when :groups
|
when :groups
|
||||||
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
|
user.groups.any? ? [header_name, user.groups.map(&:name).join(",")] : nil
|
||||||
when :admin
|
when :admin
|
||||||
[header_name, user.admin? ? "true" : "false"]
|
[header_name, user.admin? ? "true" : "false"]
|
||||||
end
|
end
|
||||||
@@ -103,20 +85,45 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
headers.each { |key, value| response.headers[key] = value }
|
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
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
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
|
def authenticate_bearer_token
|
||||||
auth_header = request.headers["Authorization"]
|
auth_header = request.headers["Authorization"]
|
||||||
return nil unless auth_header&.start_with?("Bearer ")
|
return nil unless auth_header&.start_with?("Bearer ")
|
||||||
@@ -155,87 +162,59 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
def check_forward_auth_token
|
def check_forward_auth_token
|
||||||
# Check for one-time token in query parameters (for race condition handling)
|
|
||||||
token = params[:fa_token]
|
token = params[:fa_token]
|
||||||
return nil unless token.present?
|
return nil if token.blank?
|
||||||
|
|
||||||
# Try to get session ID from cache
|
cached = Rails.cache.read("forward_auth_token:#{token}")
|
||||||
session_id = Rails.cache.read("forward_auth_token:#{token}")
|
return nil unless cached.is_a?(Hash)
|
||||||
return nil unless session_id
|
|
||||||
|
|
||||||
# Verify the session exists and is valid
|
# The token is bound to the host that created it. If the request is
|
||||||
session = Session.find_by(id: session_id)
|
# 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?
|
return nil unless session && !session.expired?
|
||||||
|
|
||||||
# Delete the token immediately (one-time use)
|
|
||||||
Rails.cache.delete("forward_auth_token:#{token}")
|
Rails.cache.delete("forward_auth_token:#{token}")
|
||||||
|
cached[:session_id]
|
||||||
session_id
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def extract_session_id
|
def extract_session_id
|
||||||
# Extract session ID from cookie
|
|
||||||
# Rails uses signed cookies by default
|
|
||||||
cookies.signed[:session_id]
|
cookies.signed[:session_id]
|
||||||
end
|
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)
|
def render_unauthorized(reason = nil)
|
||||||
Rails.logger.info "ForwardAuth: Unauthorized - #{reason}"
|
Rails.logger.info "ForwardAuth: Unauthorized - #{reason}"
|
||||||
|
|
||||||
# Set auth reason header for debugging (like Authelia)
|
|
||||||
response.headers["X-Auth-Reason"] = reason if reason.present?
|
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])
|
redirect_url = validate_redirect_url(params[:rd])
|
||||||
base_url = determine_base_url(redirect_url)
|
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_host = request.headers["X-Forwarded-Host"]
|
||||||
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
|
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
|
original_url = if original_host
|
||||||
# Use the forwarded host and URI (original behavior)
|
|
||||||
"https://#{original_host}#{original_uri}"
|
"https://#{original_host}#{original_uri}"
|
||||||
else
|
else
|
||||||
# Fallback: use the validated redirect URL or default
|
redirect_url || base_url
|
||||||
redirect_url || "https://clinch.aapamilne.com"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Debug: log what we're redirecting to after login
|
|
||||||
Rails.logger.info "ForwardAuth: Will redirect to after login: #{original_url}"
|
|
||||||
|
|
||||||
session[:return_to_after_authenticating] = original_url
|
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}"
|
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
|
redirect_to login_url, allow_other_host: true, status: :found
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_forbidden(reason = nil)
|
def render_forbidden(reason = nil)
|
||||||
Rails.logger.info "ForwardAuth: Forbidden - #{reason}"
|
Rails.logger.info "ForwardAuth: Forbidden - #{reason}"
|
||||||
|
|
||||||
# Set auth reason header for debugging (like Authelia)
|
|
||||||
response.headers["X-Auth-Reason"] = reason if reason.present?
|
response.headers["X-Auth-Reason"] = reason if reason.present?
|
||||||
|
|
||||||
# Return 403 Forbidden
|
|
||||||
head :forbidden
|
head :forbidden
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -244,19 +223,14 @@ module Api
|
|||||||
|
|
||||||
begin
|
begin
|
||||||
uri = URI.parse(url)
|
uri = URI.parse(url)
|
||||||
|
|
||||||
# Only allow HTTP/HTTPS schemes
|
|
||||||
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
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"
|
return nil unless Rails.env.development? || uri.scheme == "https"
|
||||||
|
|
||||||
redirect_domain = uri.host.downcase
|
redirect_domain = uri.host.downcase
|
||||||
return nil unless redirect_domain.present?
|
return nil unless redirect_domain.present?
|
||||||
|
|
||||||
# Check against our ForwardAuth applications
|
matching_app = cached_forward_auth_apps.find do |app|
|
||||||
matching_app = Application.forward_auth.active.find do |app|
|
app.active? && app.matches_domain?(redirect_domain)
|
||||||
app.matches_domain?(redirect_domain)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
matching_app ? url : nil
|
matching_app ? url : nil
|
||||||
@@ -265,32 +239,19 @@ module Api
|
|||||||
end
|
end
|
||||||
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)
|
def determine_base_url(redirect_url)
|
||||||
# If we have a valid redirect URL, use it
|
|
||||||
return redirect_url if redirect_url.present?
|
return redirect_url if redirect_url.present?
|
||||||
|
|
||||||
# Try CLINCH_HOST environment variable first
|
|
||||||
if ENV["CLINCH_HOST"].present?
|
if ENV["CLINCH_HOST"].present?
|
||||||
host = ENV["CLINCH_HOST"]
|
host = ENV["CLINCH_HOST"]
|
||||||
# Ensure URL has https:// protocol
|
|
||||||
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
||||||
else
|
else
|
||||||
# Fallback to the request host
|
|
||||||
request_host = request.host || request.headers["X-Forwarded-Host"]
|
request_host = request.host || request.headers["X-Forwarded-Host"]
|
||||||
if request_host.present?
|
if request_host.present?
|
||||||
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
|
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
|
||||||
"https://#{request_host}"
|
"https://#{request_host}"
|
||||||
else
|
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."
|
||||||
raise StandardError, "ForwardAuth: CLINCH_HOST environment variable not set and no request host available. Please configure CLINCH_HOST properly."
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class ApiKeysController < ApplicationController
|
|||||||
@api_key = Current.session.user.api_keys.build(api_key_params)
|
@api_key = Current.session.user.api_keys.build(api_key_params)
|
||||||
|
|
||||||
if @api_key.save
|
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
|
flash[:api_key_token] = @api_key.plaintext_token
|
||||||
redirect_to api_key_path(@api_key)
|
redirect_to api_key_path(@api_key)
|
||||||
else
|
else
|
||||||
@@ -31,6 +32,7 @@ class ApiKeysController < ApplicationController
|
|||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@api_key.revoke!
|
@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."
|
redirect_to api_keys_path, notice: "API key revoked."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ class ApplicationController < ActionController::Base
|
|||||||
|
|
||||||
private
|
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
|
# Remove a query parameter from a URL using proper URI parsing
|
||||||
# More robust than regex - handles URL encoding, edge cases, etc.
|
# More robust than regex - handles URL encoding, edge cases, etc.
|
||||||
#
|
#
|
||||||
@@ -28,12 +32,10 @@ class ApplicationController < ActionController::Base
|
|||||||
uri = URI.parse(url)
|
uri = URI.parse(url)
|
||||||
return url unless uri.query
|
return url unless uri.query
|
||||||
|
|
||||||
# Parse query string into hash
|
params = Rack::Utils.parse_query(uri.query)
|
||||||
params = CGI.parse(uri.query)
|
|
||||||
params.delete(param_name)
|
params.delete(param_name)
|
||||||
|
|
||||||
# Rebuild query string (empty string if no params left)
|
uri.query = params.any? ? Rack::Utils.build_query(params) : nil
|
||||||
uri.query = params.any? ? URI.encode_www_form(params) : nil
|
|
||||||
uri.to_s
|
uri.to_s
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
url
|
url
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ module Authentication
|
|||||||
end
|
end
|
||||||
|
|
||||||
def find_session_by_cookie
|
def find_session_by_cookie
|
||||||
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
Session.active.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
||||||
end
|
end
|
||||||
|
|
||||||
def request_authentication
|
def request_authentication
|
||||||
@@ -43,9 +43,35 @@ module Authentication
|
|||||||
session.delete(:return_to_after_authenticating) || root_url
|
session.delete(:return_to_after_authenticating) || root_url
|
||||||
end
|
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&.respond_to?(:form_action) && csp.form_action.respond_to?(:<<)
|
||||||
|
|
||||||
|
csp.form_action << "https://#{redirect_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.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
|
Current.session = session
|
||||||
|
|
||||||
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
||||||
@@ -58,8 +84,8 @@ module Authentication
|
|||||||
{
|
{
|
||||||
value: session.id,
|
value: session.id,
|
||||||
httponly: true,
|
httponly: true,
|
||||||
same_site: :none, # Allow cross-site cookies for OIDC testing
|
same_site: :lax,
|
||||||
secure: true # Required for SameSite=None
|
secure: true
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -73,7 +99,15 @@ module Authentication
|
|||||||
# Set domain for cross-subdomain authentication if we can extract it
|
# Set domain for cross-subdomain authentication if we can extract it
|
||||||
cookie_options[:domain] = domain if domain.present?
|
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
|
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
|
# Create a one-time token for immediate forward auth after authentication
|
||||||
# This solves the race condition where browser hasn't processed cookie yet
|
# This solves the race condition where browser hasn't processed cookie yet
|
||||||
@@ -130,35 +164,35 @@ module Authentication
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Create a one-time token for forward auth to handle the race condition
|
# 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)
|
def create_forward_auth_token(session_obj)
|
||||||
# Generate a secure random token
|
controller_session = session
|
||||||
token = SecureRandom.urlsafe_base64(32)
|
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(
|
Rails.cache.write(
|
||||||
"forward_auth_token:#{token}",
|
"forward_auth_token:#{token}",
|
||||||
session_obj.id,
|
{ session_id: session_obj.id, host: bound_host },
|
||||||
expires_in: 60.seconds
|
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 = URI.decode_www_form(uri.query || "").to_h
|
||||||
query_params["fa_token"] = token
|
query_params["fa_token"] = token
|
||||||
uri.query = URI.encode_www_form(query_params)
|
uri.query = URI.encode_www_form(query_params)
|
||||||
|
|
||||||
# Update the session with the tokenized URL
|
|
||||||
controller_session[:return_to_after_authenticating] = uri.to_s
|
controller_session[:return_to_after_authenticating] = uri.to_s
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
class OidcController < ApplicationController
|
class OidcController < ApplicationController
|
||||||
|
SUPPORTED_SCOPES = %w[openid profile email groups offline_access].freeze
|
||||||
|
|
||||||
# Discovery and JWKS endpoints are public
|
# Discovery and JWKS endpoints are public
|
||||||
# authorize is also unauthenticated to handle prompt=none and prompt=login specially
|
# authorize is also unauthenticated to handle prompt=none and prompt=login specially
|
||||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout, :authorize]
|
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout, :authorize]
|
||||||
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout, :authorize, :consent]
|
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout, :authorize, :consent]
|
||||||
|
|
||||||
|
# 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 limiting to prevent brute force and abuse
|
||||||
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
|
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
|
||||||
render json: {error: "too_many_requests", error_description: "Rate limit exceeded. Try again later."}, status: :too_many_requests
|
render json: {error: "too_many_requests", error_description: "Rate limit exceeded. Try again later."}, status: :too_many_requests
|
||||||
@@ -29,7 +36,7 @@ class OidcController < ApplicationController
|
|||||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||||
subject_types_supported: ["pairwise"],
|
subject_types_supported: ["pairwise"],
|
||||||
id_token_signing_alg_values_supported: ["RS256"],
|
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"],
|
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
||||||
claims_supported: [
|
claims_supported: [
|
||||||
"sub", # Always included
|
"sub", # Always included
|
||||||
@@ -42,7 +49,7 @@ class OidcController < ApplicationController
|
|||||||
# Note: Custom claims are also supported but not listed here
|
# Note: Custom claims are also supported but not listed here
|
||||||
# ID-token-only claims (auth_time, acr, azp, at_hash, nonce) are not listed
|
# ID-token-only claims (auth_time, acr, azp, at_hash, nonce) are not listed
|
||||||
],
|
],
|
||||||
code_challenge_methods_supported: ["plain", "S256"],
|
code_challenge_methods_supported: ["S256"],
|
||||||
backchannel_logout_supported: true,
|
backchannel_logout_supported: true,
|
||||||
backchannel_logout_session_supported: true,
|
backchannel_logout_session_supported: true,
|
||||||
request_parameter_supported: false,
|
request_parameter_supported: false,
|
||||||
@@ -59,7 +66,8 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# GET /oauth/authorize
|
# GET /oauth/authorize
|
||||||
def 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]
|
client_id = params[:client_id]
|
||||||
redirect_uri = params[:redirect_uri]
|
redirect_uri = params[:redirect_uri]
|
||||||
state = params[:state]
|
state = params[:state]
|
||||||
@@ -67,57 +75,10 @@ class OidcController < ApplicationController
|
|||||||
scope = params[:scope] || "openid"
|
scope = params[:scope] || "openid"
|
||||||
response_type = params[:response_type]
|
response_type = params[:response_type]
|
||||||
code_challenge = params[:code_challenge]
|
code_challenge = params[:code_challenge]
|
||||||
code_challenge_method = params[:code_challenge_method] || "plain"
|
code_challenge_method = params[:code_challenge_method] || "S256"
|
||||||
|
|
||||||
# Validate client_id first (required before we can look up the application)
|
|
||||||
# OAuth2 RFC 6749 Section 4.1.2.1: If client_id is missing/invalid, show error page (don't redirect)
|
|
||||||
unless client_id.present?
|
|
||||||
render plain: "Invalid request: client_id is required", status: :bad_request
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Find the application by client_id
|
|
||||||
@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)}"
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
render plain: error_msg, status: :bad_request
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Validate redirect_uri presence and format
|
|
||||||
# OAuth2 RFC 6749 Section 4.1.2.1: If redirect_uri is missing/invalid, show error page (don't redirect)
|
|
||||||
unless redirect_uri.present?
|
|
||||||
render plain: "Invalid request: redirect_uri is required", status: :bad_request
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Validate redirect URI matches one of the registered URIs
|
|
||||||
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
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# At this point we have a valid client_id and redirect_uri
|
# client_id and redirect_uri are already validated (see before_actions).
|
||||||
# All subsequent errors should redirect back to the client with error parameters
|
# All subsequent errors should redirect back to the client with error parameters
|
||||||
# per OAuth2 RFC 6749 Section 4.1.2.1
|
# per OAuth2 RFC 6749 Section 4.1.2.1
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -146,10 +107,10 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Validate PKCE parameters if present (now we can safely redirect with error)
|
# Validate PKCE parameters if present (now we can safely redirect with error)
|
||||||
if code_challenge.present?
|
if code_challenge.present?
|
||||||
unless %w[plain S256].include?(code_challenge_method)
|
unless code_challenge_method == "S256"
|
||||||
Rails.logger.error "OAuth: Invalid code_challenge_method: #{code_challenge_method}"
|
Rails.logger.error "OAuth: Invalid code_challenge_method: #{code_challenge_method}"
|
||||||
error_uri = "#{redirect_uri}?error=invalid_request"
|
error_uri = "#{redirect_uri}?error=invalid_request"
|
||||||
error_uri += "&error_description=#{CGI.escape("Invalid code_challenge_method: must be 'plain' or 'S256'")}"
|
error_uri += "&error_description=#{CGI.escape("Invalid code_challenge_method: only 'S256' is supported")}"
|
||||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||||
redirect_to error_uri, allow_other_host: true
|
redirect_to error_uri, allow_other_host: true
|
||||||
return
|
return
|
||||||
@@ -166,6 +127,12 @@ class OidcController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# 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(" ")
|
||||||
|
|
||||||
# Parse claims parameter (JSON string) for OIDC claims request
|
# Parse claims parameter (JSON string) for OIDC claims request
|
||||||
# Per OIDC Core §5.5: The claims parameter is a JSON object that requests
|
# 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
|
# specific claims to be returned in the id_token and/or userinfo
|
||||||
@@ -289,7 +256,12 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
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
|
# Check if application is configured to skip consent
|
||||||
# If so, automatically create consent and proceed without showing consent screen
|
# If so, automatically create consent and proceed without showing consent screen
|
||||||
@@ -420,8 +392,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
user = Current.session.user
|
user = Current.session.user
|
||||||
|
|
||||||
# Record user consent
|
requested_scopes = oauth_params["scope"].split(" ") & SUPPORTED_SCOPES
|
||||||
requested_scopes = oauth_params["scope"].split(" ")
|
|
||||||
parsed_claims = begin
|
parsed_claims = begin
|
||||||
JSON.parse(oauth_params["claims_requests"])
|
JSON.parse(oauth_params["claims_requests"])
|
||||||
rescue
|
rescue
|
||||||
@@ -539,15 +510,12 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Check if code has already been used (CRITICAL: check AFTER locking)
|
# Check if code has already been used (CRITICAL: check AFTER locking)
|
||||||
if auth_code.used?
|
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}"
|
Rails.logger.warn "OAuth Security: Authorization code reuse detected for code #{auth_code.id}"
|
||||||
|
now = Time.current
|
||||||
# Revoke all access tokens issued from this authorization code
|
auth_code.oidc_access_tokens.where(revoked_at: nil).update_all(revoked_at: now)
|
||||||
OidcAccessToken.where(
|
auth_code.oidc_refresh_tokens.where(revoked_at: nil).update_all(revoked_at: now)
|
||||||
application: application,
|
|
||||||
user: auth_code.user,
|
|
||||||
created_at: auth_code.created_at..Time.current
|
|
||||||
).update_all(expires_at: Time.current)
|
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
error: "invalid_grant",
|
error: "invalid_grant",
|
||||||
@@ -588,7 +556,8 @@ class OidcController < ApplicationController
|
|||||||
access_token_record = OidcAccessToken.create!(
|
access_token_record = OidcAccessToken.create!(
|
||||||
application: application,
|
application: application,
|
||||||
user: user,
|
user: user,
|
||||||
scope: auth_code.scope
|
scope: auth_code.scope,
|
||||||
|
oidc_authorization_code: auth_code
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate refresh token (opaque, with hashing)
|
# Generate refresh token (opaque, with hashing)
|
||||||
@@ -596,6 +565,7 @@ class OidcController < ApplicationController
|
|||||||
application: application,
|
application: application,
|
||||||
user: user,
|
user: user,
|
||||||
oidc_access_token: access_token_record,
|
oidc_access_token: access_token_record,
|
||||||
|
oidc_authorization_code: auth_code,
|
||||||
scope: auth_code.scope,
|
scope: auth_code.scope,
|
||||||
auth_time: auth_code.auth_time,
|
auth_time: auth_code.auth_time,
|
||||||
acr: auth_code.acr
|
acr: auth_code.acr
|
||||||
@@ -720,10 +690,15 @@ class OidcController < ApplicationController
|
|||||||
refresh_token_record.revoke!
|
refresh_token_record.revoke!
|
||||||
|
|
||||||
# Generate new access token record (opaque token with BCrypt hashing)
|
# 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!(
|
new_access_token = OidcAccessToken.create!(
|
||||||
application: application,
|
application: application,
|
||||||
user: user,
|
user: user,
|
||||||
scope: refresh_token_record.scope
|
scope: refresh_token_record.scope,
|
||||||
|
oidc_authorization_code: issuing_auth_code
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate new refresh token (token rotation)
|
# Generate new refresh token (token rotation)
|
||||||
@@ -731,6 +706,7 @@ class OidcController < ApplicationController
|
|||||||
application: application,
|
application: application,
|
||||||
user: user,
|
user: user,
|
||||||
oidc_access_token: new_access_token,
|
oidc_access_token: new_access_token,
|
||||||
|
oidc_authorization_code: issuing_auth_code,
|
||||||
scope: refresh_token_record.scope,
|
scope: refresh_token_record.scope,
|
||||||
token_family_id: refresh_token_record.token_family_id, # Keep same family for rotation tracking
|
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
|
auth_time: refresh_token_record.auth_time, # Carry over original auth_time
|
||||||
@@ -1000,6 +976,55 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
private
|
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)
|
def validate_pkce(application, auth_code, code_verifier)
|
||||||
# Check if PKCE is required for this application
|
# Check if PKCE is required for this application
|
||||||
pkce_required = application.requires_pkce?
|
pkce_required = application.requires_pkce?
|
||||||
@@ -1041,16 +1066,14 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Recreate code challenge based on method
|
# Recreate code challenge based on method
|
||||||
expected_challenge = case auth_code.code_challenge_method
|
expected_challenge = case auth_code.code_challenge_method
|
||||||
when "plain"
|
|
||||||
code_verifier
|
|
||||||
when "S256"
|
when "S256"
|
||||||
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
||||||
else
|
else
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: "server_error",
|
error: "invalid_request",
|
||||||
error_description: "Unsupported code challenge method",
|
error_description: "Unsupported code challenge method: only 'S256' is supported",
|
||||||
status: :internal_server_error
|
status: :bad_request
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1156,6 +1179,7 @@ class OidcController < ApplicationController
|
|||||||
# id_token and/or userinfo keys, each mapping to claim requests
|
# id_token and/or userinfo keys, each mapping to claim requests
|
||||||
def parse_claims_parameter(claims_string)
|
def parse_claims_parameter(claims_string)
|
||||||
return {} if claims_string.blank?
|
return {} if claims_string.blank?
|
||||||
|
return nil if claims_string.length > 4096
|
||||||
|
|
||||||
parsed = JSON.parse(claims_string)
|
parsed = JSON.parse(claims_string)
|
||||||
return nil unless parsed.is_a?(Hash)
|
return nil unless parsed.is_a?(Hash)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class PasswordsController < ApplicationController
|
|||||||
|
|
||||||
def update
|
def update
|
||||||
if @user.update(params.permit(:password, :password_confirmation))
|
if @user.update(params.permit(:password, :password_confirmation))
|
||||||
|
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
|
||||||
@user.sessions.destroy_all
|
@user.sessions.destroy_all
|
||||||
redirect_to signin_path, notice: "Password has been reset."
|
redirect_to signin_path, notice: "Password has been reset."
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class ProfilesController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
if @user.update(password_params)
|
if @user.update(password_params)
|
||||||
|
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
|
||||||
redirect_to profile_path, notice: "Password updated successfully."
|
redirect_to profile_path, notice: "Password updated successfully."
|
||||||
else
|
else
|
||||||
render :show, status: :unprocessable_entity
|
render :show, status: :unprocessable_entity
|
||||||
@@ -27,7 +28,15 @@ class ProfilesController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
old_email = @user.email_address
|
||||||
if @user.update(email_params)
|
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."
|
redirect_to profile_path, notice: "Email updated successfully."
|
||||||
else
|
else
|
||||||
render :show, status: :unprocessable_entity
|
render :show, status: :unprocessable_entity
|
||||||
|
|||||||
@@ -20,14 +20,16 @@ class SessionsController < ApplicationController
|
|||||||
begin
|
begin
|
||||||
uri = URI.parse(session[:return_to_after_authenticating])
|
uri = URI.parse(session[:return_to_after_authenticating])
|
||||||
if uri.query.present?
|
if uri.query.present?
|
||||||
query_params = CGI.parse(uri.query)
|
query_params = Rack::Utils.parse_query(uri.query)
|
||||||
@login_hint = query_params["login_hint"]&.first
|
@login_hint = query_params["login_hint"]
|
||||||
end
|
end
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
# Ignore parsing errors
|
# Ignore parsing errors
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
allow_oauth_redirect_in_csp
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html # render HTML login page
|
format.html # render HTML login page
|
||||||
format.json { render json: {error: "Authentication required"}, status: :unauthorized }
|
format.json { render json: {error: "Authentication required"}, status: :unauthorized }
|
||||||
@@ -76,6 +78,7 @@ class SessionsController < ApplicationController
|
|||||||
# TOTP is enabled, proceed to verification
|
# TOTP is enabled, proceed to verification
|
||||||
# Store user ID in session temporarily for TOTP verification
|
# Store user ID in session temporarily for TOTP verification
|
||||||
session[:pending_totp_user_id] = user.id
|
session[:pending_totp_user_id] = user.id
|
||||||
|
session[:pending_remember_me] = remember_me?
|
||||||
# Preserve the redirect URL through TOTP verification (after validation)
|
# Preserve the redirect URL through TOTP verification (after validation)
|
||||||
if params[:rd].present?
|
if params[:rd].present?
|
||||||
validated_url = validate_redirect_url(params[:rd])
|
validated_url = validate_redirect_url(params[:rd])
|
||||||
@@ -86,7 +89,7 @@ class SessionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Sign in successful (password only)
|
# Sign in successful (password only)
|
||||||
start_new_session_for user, acr: "1"
|
start_new_session_for user, acr: "1", remember_me: remember_me?
|
||||||
|
|
||||||
# Use status: :see_other to ensure browser makes a GET request
|
# Use status: :see_other to ensure browser makes a GET request
|
||||||
# This prevents Turbo from converting it to a TURBO_STREAM request
|
# This prevents Turbo from converting it to a TURBO_STREAM request
|
||||||
@@ -118,6 +121,8 @@ class SessionsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
remember_me = session.delete(:pending_remember_me) || false
|
||||||
|
|
||||||
# Try TOTP verification first (password + TOTP = 2FA)
|
# Try TOTP verification first (password + TOTP = 2FA)
|
||||||
if user.verify_totp(code)
|
if user.verify_totp(code)
|
||||||
session.delete(:pending_totp_user_id)
|
session.delete(:pending_totp_user_id)
|
||||||
@@ -125,7 +130,7 @@ class SessionsController < ApplicationController
|
|||||||
if session[:totp_redirect_url].present?
|
if session[:totp_redirect_url].present?
|
||||||
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||||
end
|
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
|
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -137,7 +142,7 @@ class SessionsController < ApplicationController
|
|||||||
if session[:totp_redirect_url].present?
|
if session[:totp_redirect_url].present?
|
||||||
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||||
end
|
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
|
redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -147,6 +152,12 @@ class SessionsController < ApplicationController
|
|||||||
nil
|
nil
|
||||||
end
|
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
|
# Just render the form
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -185,6 +196,7 @@ class SessionsController < ApplicationController
|
|||||||
|
|
||||||
# Store user ID in session for verification
|
# Store user ID in session for verification
|
||||||
session[:pending_webauthn_user_id] = user.id
|
session[:pending_webauthn_user_id] = user.id
|
||||||
|
session[:pending_remember_me] = remember_me?
|
||||||
|
|
||||||
# Store redirect URL if present
|
# Store redirect URL if present
|
||||||
if params[:rd].present?
|
if params[:rd].present?
|
||||||
@@ -280,12 +292,13 @@ class SessionsController < ApplicationController
|
|||||||
|
|
||||||
# Clean up session
|
# Clean up session
|
||||||
session.delete(:pending_webauthn_user_id)
|
session.delete(:pending_webauthn_user_id)
|
||||||
|
remember_me = session.delete(:pending_remember_me) || false
|
||||||
if session[:webauthn_redirect_url].present?
|
if session[:webauthn_redirect_url].present?
|
||||||
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
|
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Create session (WebAuthn/passkey = phishing-resistant, ACR = "2")
|
# 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: {
|
render json: {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -306,6 +319,10 @@ class SessionsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def remember_me?
|
||||||
|
ActiveModel::Type::Boolean.new.cast(params[:remember_me]) || false
|
||||||
|
end
|
||||||
|
|
||||||
def validate_redirect_url(url)
|
def validate_redirect_url(url)
|
||||||
return nil unless url.present?
|
return nil unless url.present?
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ class TotpController < ApplicationController
|
|||||||
@totp_secret = ROTP::Base32.random
|
@totp_secret = ROTP::Base32.random
|
||||||
@provisioning_uri = ROTP::TOTP.new(@totp_secret, issuer: "Clinch").provisioning_uri(@user.email_address)
|
@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
|
# Generate QR code
|
||||||
require "rqrcode"
|
require "rqrcode"
|
||||||
@qr_code = RQRCode::QRCode.new(@provisioning_uri)
|
@qr_code = RQRCode::QRCode.new(@provisioning_uri)
|
||||||
@@ -19,9 +23,14 @@ class TotpController < ApplicationController
|
|||||||
|
|
||||||
# POST /totp - Verify TOTP code and enable 2FA
|
# POST /totp - Verify TOTP code and enable 2FA
|
||||||
def create
|
def create
|
||||||
totp_secret = params[:totp_secret]
|
totp_secret = session[:pending_totp_secret]
|
||||||
code = params[:code]
|
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
|
# Verify the code works
|
||||||
totp = ROTP::TOTP.new(totp_secret)
|
totp = ROTP::TOTP.new(totp_secret)
|
||||||
if totp.verify(code, drift_behind: 30, drift_ahead: 30)
|
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
|
plain_codes = @user.send(:generate_backup_codes) # Use private method from User model
|
||||||
@user.save!
|
@user.save!
|
||||||
|
|
||||||
|
session.delete(:pending_totp_secret)
|
||||||
|
TotpMailer.enabled(@user).deliver_later
|
||||||
|
|
||||||
# Store plain codes temporarily in session for display after redirect
|
# Store plain codes temporarily in session for display after redirect
|
||||||
session[:temp_backup_codes] = plain_codes
|
session[:temp_backup_codes] = plain_codes
|
||||||
|
|
||||||
@@ -91,6 +103,7 @@ class TotpController < ApplicationController
|
|||||||
# Generate new backup codes and store BCrypt hashes
|
# Generate new backup codes and store BCrypt hashes
|
||||||
plain_codes = @user.send(:generate_backup_codes)
|
plain_codes = @user.send(:generate_backup_codes)
|
||||||
@user.save!
|
@user.save!
|
||||||
|
SecurityMailer.backup_codes_regenerated(@user, **security_event_context).deliver_later
|
||||||
|
|
||||||
# Store plain codes temporarily in session for display
|
# Store plain codes temporarily in session for display
|
||||||
session[:temp_backup_codes] = plain_codes
|
session[:temp_backup_codes] = plain_codes
|
||||||
@@ -124,6 +137,7 @@ class TotpController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
@user.disable_totp!
|
@user.disable_totp!
|
||||||
|
SecurityMailer.totp_disabled(@user, **security_event_context).deliver_later
|
||||||
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
|
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ class UsersController < ApplicationController
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
@user = User.new(user_params)
|
@user = User.new(user_params)
|
||||||
|
|
||||||
# First user becomes admin automatically
|
|
||||||
@user.admin = true if User.count.zero?
|
|
||||||
@user.status = "active"
|
@user.status = "active"
|
||||||
|
first_user = User.count.zero?
|
||||||
|
|
||||||
if @user.save
|
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
|
start_new_session_for @user
|
||||||
redirect_to root_path, notice: "Welcome to Clinch! Your account has been created."
|
redirect_to root_path, notice: "Welcome to Clinch! Your account has been created."
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ class WebauthnController < ApplicationController
|
|||||||
backup_state: backup_state
|
backup_state: backup_state
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SecurityMailer.passkey_added(user, nickname: @webauthn_credential.nickname, **security_event_context).deliver_later
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Passkey '#{nickname}' registered successfully",
|
message: "Passkey '#{nickname}' registered successfully",
|
||||||
@@ -109,8 +111,11 @@ class WebauthnController < ApplicationController
|
|||||||
# Remove a passkey
|
# Remove a passkey
|
||||||
def destroy
|
def destroy
|
||||||
nickname = @webauthn_credential.nickname
|
nickname = @webauthn_credential.nickname
|
||||||
|
user = @webauthn_credential.user
|
||||||
@webauthn_credential.destroy
|
@webauthn_credential.destroy
|
||||||
|
|
||||||
|
SecurityMailer.passkey_removed(user, nickname: nickname, **security_event_context).deliver_later
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html {
|
format.html {
|
||||||
redirect_to profile_path,
|
redirect_to profile_path,
|
||||||
@@ -148,7 +153,8 @@ class WebauthnController < ApplicationController
|
|||||||
# Only return minimal necessary info - no user_id or preferred_method
|
# Only return minimal necessary info - no user_id or preferred_method
|
||||||
render json: {
|
render json: {
|
||||||
has_webauthn: user.can_authenticate_with_webauthn?,
|
has_webauthn: user.can_authenticate_with_webauthn?,
|
||||||
requires_webauthn: user.require_webauthn?
|
requires_webauthn: user.require_webauthn?,
|
||||||
|
has_totp: user.totp_enabled?
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -20,13 +20,73 @@ module ApplicationHelper
|
|||||||
end
|
end
|
||||||
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)
|
def border_class_for(type)
|
||||||
case type.to_s
|
case type.to_s
|
||||||
when "notice" then "border-green-200"
|
when "notice" then "border-green-200 dark:border-green-700"
|
||||||
when "alert", "error" then "border-red-200"
|
when "alert", "error" then "border-red-200 dark:border-red-700"
|
||||||
when "warning" then "border-yellow-200"
|
when "warning" then "border-yellow-200 dark:border-yellow-700"
|
||||||
when "info" then "border-blue-200"
|
when "info" then "border-blue-200 dark:border-blue-700"
|
||||||
else "border-gray-200"
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
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
|
// Don't auto-trigger navigator.credentials.get() — Safari's WebAuthn
|
||||||
if (data.requires_webauthn) {
|
// dialog can become undismissable when invoked without a user gesture.
|
||||||
setTimeout(() => this.authenticate(), 100);
|
// Always let the user click "Continue with Passkey" instead.
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.debug("No WebAuthn credentials found for this email");
|
console.debug("No WebAuthn credentials found for this email");
|
||||||
}
|
}
|
||||||
@@ -181,7 +180,8 @@ export default class extends Controller {
|
|||||||
"X-CSRF-Token": this.getCSRFToken()
|
"X-CSRF-Token": this.getCSRFToken()
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: this.getUserEmail()
|
email: this.getUserEmail(),
|
||||||
|
remember_me: this.getRememberMe()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -289,9 +289,18 @@ export default class extends Controller {
|
|||||||
if (!emailInput) {
|
if (!emailInput) {
|
||||||
emailInput = document.querySelector('input[name="user[email_address]"]');
|
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() : "";
|
return emailInput ? emailInput.value.trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRememberMe() {
|
||||||
|
const checkbox = document.querySelector('input[name="remember_me"][type="checkbox"]');
|
||||||
|
return checkbox ? checkbox.checked : false;
|
||||||
|
}
|
||||||
|
|
||||||
isValidEmail(email) {
|
isValidEmail(email) {
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
}
|
}
|
||||||
@@ -311,7 +320,7 @@ export default class extends Controller {
|
|||||||
return "This authenticator has already been registered.";
|
return "This authenticator has already been registered.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to error message
|
// Fallback to a user-friendly message
|
||||||
return error.message || "An unexpected error occurred";
|
return "Passkey authentication failed. A browser extension may be interfering — try using your password instead.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
app/mailers/security_mailer.rb
Normal file
59
app/mailers/security_mailer.rb
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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 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
|
||||||
@@ -22,10 +22,15 @@ class Application < ApplicationRecord
|
|||||||
super(parsed)
|
super(parsed)
|
||||||
end
|
end
|
||||||
|
|
||||||
has_one_attached :icon
|
after_commit :bust_forward_auth_cache, if: :forward_auth?
|
||||||
|
|
||||||
# Fix SVG content type after attachment
|
has_one_attached :icon
|
||||||
after_save :fix_icon_content_type, if: -> { icon.attached? && saved_change_to_attribute?(:id) == false }
|
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 :application_groups, dependent: :destroy
|
||||||
has_many :allowed_groups, through: :application_groups, source: :group
|
has_many :allowed_groups, through: :application_groups, source: :group
|
||||||
@@ -53,7 +58,7 @@ class Application < ApplicationRecord
|
|||||||
validate :backchannel_logout_uri_must_be_https_in_production, if: -> { backchannel_logout_uri.present? }
|
validate :backchannel_logout_uri_must_be_https_in_production, if: -> { backchannel_logout_uri.present? }
|
||||||
|
|
||||||
# Icon validation using ActiveStorage validators
|
# Icon validation using ActiveStorage validators
|
||||||
validate :icon_validation, if: -> { icon.attached? }
|
validate :icon_validation
|
||||||
|
|
||||||
# Token TTL validations (for OIDC apps)
|
# 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 :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
||||||
@@ -116,14 +121,12 @@ class Application < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Access control
|
# 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)
|
def user_allowed?(user)
|
||||||
return false unless active?
|
return false unless active?
|
||||||
return false unless user.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?
|
(user.groups & allowed_groups).any?
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -166,10 +169,6 @@ class Application < ApplicationRecord
|
|||||||
return "deny" unless active?
|
return "deny" unless active?
|
||||||
return "deny" unless user.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)
|
if user_allowed?(user)
|
||||||
# Require 2FA if user has TOTP configured, otherwise one factor
|
# Require 2FA if user has TOTP configured, otherwise one factor
|
||||||
user.totp_enabled? ? "two_factor" : "one_factor"
|
user.totp_enabled? ? "two_factor" : "one_factor"
|
||||||
@@ -200,7 +199,7 @@ class Application < ApplicationRecord
|
|||||||
when :username
|
when :username
|
||||||
headers[header_name] = user.username if user.username.present?
|
headers[header_name] = user.username if user.username.present?
|
||||||
when :groups
|
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
|
when :admin
|
||||||
headers[header_name] = user.admin? ? "true" : "false"
|
headers[header_name] = user.admin? ? "true" : "false"
|
||||||
end
|
end
|
||||||
@@ -268,27 +267,88 @@ class Application < ApplicationRecord
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def fix_icon_content_type
|
def bust_forward_auth_cache
|
||||||
return unless icon.attached?
|
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
|
# Fix SVG content type if it was detected incorrectly
|
||||||
if icon.filename.extension == "svg" && icon.content_type == "application/octet-stream"
|
if attachment.filename.extension == "svg" && attachment.content_type == "application/octet-stream"
|
||||||
icon.blob.update(content_type: "image/svg+xml")
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
def icon_validation
|
def icon_validation
|
||||||
return unless icon.attached?
|
|
||||||
|
|
||||||
# Check content type
|
|
||||||
allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
|
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
|
end
|
||||||
|
|
||||||
# Check file size (2MB limit)
|
if attachment.blob.byte_size > 2.megabytes
|
||||||
if icon.blob.byte_size > 2.megabytes
|
errors.add(attr, "must be less than 2MB")
|
||||||
errors.add(:icon, "must be less than 2MB")
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,12 @@ class ApplicationGroup < ApplicationRecord
|
|||||||
belongs_to :group
|
belongs_to :group
|
||||||
|
|
||||||
validates :application_id, uniqueness: {scope: :group_id}
|
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
|
end
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ class Group < ApplicationRecord
|
|||||||
normalizes :name, with: ->(name) { name.strip.downcase }
|
normalizes :name, with: ->(name) { name.strip.downcase }
|
||||||
validate :no_reserved_claim_names
|
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
|
# Parse custom_claims JSON field
|
||||||
def parsed_custom_claims
|
def parsed_custom_claims
|
||||||
return {} if custom_claims.blank?
|
return {} if custom_claims.blank?
|
||||||
@@ -23,6 +28,13 @@ class Group < ApplicationRecord
|
|||||||
|
|
||||||
private
|
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
|
def no_reserved_claim_names
|
||||||
return if custom_claims.blank?
|
return if custom_claims.blank?
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
class OidcAccessToken < ApplicationRecord
|
class OidcAccessToken < ApplicationRecord
|
||||||
belongs_to :application
|
belongs_to :application
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
belongs_to :oidc_authorization_code, optional: true
|
||||||
has_many :oidc_refresh_tokens, dependent: :destroy
|
has_many :oidc_refresh_tokens, dependent: :destroy
|
||||||
|
|
||||||
before_validation :generate_token, on: :create
|
before_validation :generate_token, on: :create
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
class OidcAuthorizationCode < ApplicationRecord
|
class OidcAuthorizationCode < ApplicationRecord
|
||||||
belongs_to :application
|
belongs_to :application
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
has_many :oidc_access_tokens
|
||||||
|
has_many :oidc_refresh_tokens
|
||||||
|
|
||||||
attr_accessor :plaintext_code
|
attr_accessor :plaintext_code
|
||||||
|
|
||||||
@@ -9,7 +11,7 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
|
|
||||||
validates :code_hmac, presence: true, uniqueness: true
|
validates :code_hmac, presence: true, uniqueness: true
|
||||||
validates :redirect_uri, presence: 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? }
|
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
|
||||||
|
|
||||||
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ class OidcRefreshToken < ApplicationRecord
|
|||||||
belongs_to :application
|
belongs_to :application
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :oidc_access_token
|
belongs_to :oidc_access_token
|
||||||
|
belongs_to :oidc_authorization_code, optional: true
|
||||||
|
|
||||||
before_validation :generate_token, on: :create
|
before_validation :generate_token, on: :create
|
||||||
before_validation :set_expiry, on: :create
|
before_validation :set_expiry, on: :create
|
||||||
|
|||||||
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
|
||||||
@@ -42,7 +42,18 @@ class User < ApplicationRecord
|
|||||||
enum :status, {active: 0, disabled: 1, pending_invitation: 2}
|
enum :status, {active: 0, disabled: 1, pending_invitation: 2}
|
||||||
|
|
||||||
# Scopes
|
# 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
|
# TOTP methods
|
||||||
def totp_enabled?
|
def totp_enabled?
|
||||||
@@ -107,12 +118,12 @@ class User < ApplicationRecord
|
|||||||
save! # Save the updated array
|
save! # Save the updated array
|
||||||
|
|
||||||
# Log successful backup code usage for security monitoring
|
# 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
|
true
|
||||||
else
|
else
|
||||||
# Increment failed attempt counter and log for security monitoring
|
# Increment failed attempt counter and log for security monitoring
|
||||||
increment_backup_code_failed_attempts
|
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
|
false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -222,6 +233,10 @@ class User < ApplicationRecord
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def add_to_auto_assign_groups
|
||||||
|
Group.auto_assign.each { |g| groups << g }
|
||||||
|
end
|
||||||
|
|
||||||
def no_reserved_claim_names
|
def no_reserved_claim_names
|
||||||
return if custom_claims.blank?
|
return if custom_claims.blank?
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
class OidcJwtService
|
class OidcJwtService
|
||||||
extend ClaimsMerger
|
extend ClaimsMerger
|
||||||
|
|
||||||
|
RESERVED_CLAIMS = %i[iss sub aud exp iat nbf jti nonce azp].freeze
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
# Generate an ID token (JWT) for the user
|
# 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, scopes: "openid", claims_requests: {})
|
def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil, scopes: "openid", claims_requests: {})
|
||||||
@@ -79,15 +81,16 @@ class OidcJwtService
|
|||||||
|
|
||||||
# Merge custom claims from groups (arrays are combined, not overwritten)
|
# Merge custom claims from groups (arrays are combined, not overwritten)
|
||||||
# Note: Custom claims from groups are always merged (not scope-dependent)
|
# 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|
|
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
|
end
|
||||||
|
|
||||||
# Merge custom claims from user (arrays are combined, other values override)
|
# 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)
|
# 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
|
# Filter custom claims based on claims parameter
|
||||||
# If claims parameter is present, only include requested custom claims
|
# If claims parameter is present, only include requested custom claims
|
||||||
|
|||||||
@@ -1,50 +1,50 @@
|
|||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Sessions</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Sessions</h1>
|
||||||
<p class="mt-2 text-sm text-gray-600">Manage your active sessions and connected applications.</p>
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Manage your active sessions and connected applications.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Connected Applications -->
|
<!-- 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">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Connected Applications</h3>
|
<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">
|
<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>
|
<p>These applications have access to your account. You can revoke access at any time.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
<% if @connected_applications.any? %>
|
<% 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| %>
|
<% @connected_applications.each do |consent| %>
|
||||||
<li class="py-4">
|
<li class="py-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex flex-col">
|
<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 %>
|
<%= consent.application.name %>
|
||||||
</p>
|
</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 %>
|
Access to: <%= consent.formatted_scopes %>
|
||||||
</p>
|
</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
|
Authorized <%= time_ago_in_words(consent.granted_at) %> ago
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<%= button_to "Revoke Access", revoke_consent_active_sessions_path(application_id: consent.application.id), method: :delete,
|
<%= 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." } } %>
|
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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
<% else %>
|
<% 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 %>
|
<% end %>
|
||||||
|
|
||||||
<% if @connected_applications.any? %>
|
<% 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="flex justify-end">
|
||||||
<div class="inline-block">
|
<div class="inline-block">
|
||||||
<%= button_to "Revoke All App Access", revoke_all_consents_active_sessions_path, method: :delete,
|
<%= 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?" } } %>
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,37 +55,37 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Active Sessions -->
|
<!-- 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">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Active Sessions</h3>
|
<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">
|
<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>
|
<p>These devices are currently signed in to your account. Revoke any sessions that you don't recognize.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
<% if @active_sessions.any? %>
|
<% 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| %>
|
<% @active_sessions.each do |session| %>
|
||||||
<li class="py-4">
|
<li class="py-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex flex-col">
|
<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" %>
|
<%= session.device_name || "Unknown Device" %>
|
||||||
<% if session.id == Current.session.id %>
|
<% 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
|
This device
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</p>
|
</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 %>
|
<%= session.ip_address %>
|
||||||
</p>
|
</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
|
Last active <%= time_ago_in_words(session.last_activity_at || session.updated_at) %> ago
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% if session.id != Current.session.id %>
|
<% if session.id != Current.session.id %>
|
||||||
<%= button_to "Revoke", session_path(session), method: :delete,
|
<%= 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?" } } %>
|
form: { data: { turbo_confirm: "Are you sure you want to revoke this session?" } } %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,15 +93,15 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
<% else %>
|
<% 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 %>
|
<% end %>
|
||||||
|
|
||||||
<% if @active_sessions.count > 1 %>
|
<% 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="flex justify-end">
|
||||||
<div class="inline-block">
|
<div class="inline-block">
|
||||||
<%= button_to "Sign Out Everywhere Else", session_path(Current.session), method: :delete,
|
<%= 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?" } } %>
|
form: { data: { turbo_confirm: "This will sign you out from all other devices except this one. Are you sure?" } } %>
|
||||||
</div>
|
</div>
|
||||||
</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: :post, 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 %>
|
<%= render "shared/form_errors", form: form %>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
<%= 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 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "My Application" %>
|
<%= 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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :slug, class: "block text-sm font-medium text-gray-700" %>
|
<%= 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 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "my-app" %>
|
<%= 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">Lowercase letters, numbers, and hyphens only. Used in URLs and API calls.</p>
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
|
<% if application.persisted? %>
|
||||||
<%= 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" %>
|
<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>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<%= form.label :description, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
<%= form.label :icon, "Application Icon", 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 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">
|
<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">
|
<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>
|
<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
|
Browse icons at dashboardicons.com
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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">
|
<%= render "icon_uploader",
|
||||||
<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"
|
form: form,
|
||||||
data-file-drop-target="dropzone"
|
field: :icon,
|
||||||
data-image-paste-target="dropzone"
|
label: "Icon",
|
||||||
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste"
|
current_attached: (application.persisted? ? application.icon : nil),
|
||||||
tabindex="0">
|
current_label: "Current icon" %>
|
||||||
<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">
|
<%= render "icon_uploader",
|
||||||
<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" />
|
form: form,
|
||||||
</svg>
|
field: :icon_dark,
|
||||||
<div class="flex text-sm text-gray-600">
|
label: "Dark mode icon (optional)",
|
||||||
<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">
|
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.",
|
||||||
<span>Upload a file</span>
|
current_attached: (application.persisted? ? application.icon_dark : nil),
|
||||||
<%= form.file_field :icon,
|
current_label: "Current dark-mode icon",
|
||||||
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
|
preview_extra_class: "bg-gray-900" %>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700" %>
|
<%= 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 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %>
|
<%= 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">The main URL users will visit to access this application. This will be shown as a link on their dashboard.</p>
|
<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>
|
|
||||||
|
|
||||||
<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 %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- OIDC-specific fields -->
|
<!-- 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">
|
<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">OIDC Configuration</h3>
|
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">OIDC Configuration</h3>
|
||||||
|
|
||||||
<!-- Client Type Selection (only for new applications) -->
|
<!-- Client Type Selection (only for new applications) -->
|
||||||
<% unless application.persisted? %>
|
<% unless application.persisted? %>
|
||||||
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
<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 mb-3">Client Type</h4>
|
<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="space-y-3">
|
||||||
<div class="flex items-start">
|
<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">
|
<div class="ml-3">
|
||||||
<label for="application_is_public_client_false" class="block text-sm font-medium text-gray-900">Confidential Client (Recommended)</label>
|
<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">Backend server app that can securely store a client secret. Examples: traditional web apps, server-to-server APIs.</p>
|
<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>
|
</div>
|
||||||
<div class="flex items-start">
|
<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">
|
<div class="ml-3">
|
||||||
<label for="application_is_public_client_true" class="block text-sm font-medium text-gray-900">Public Client</label>
|
<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">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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,31 +98,31 @@
|
|||||||
<% else %>
|
<% else %>
|
||||||
<!-- Show client type for existing applications (read-only) -->
|
<!-- Show client type for existing applications (read-only) -->
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<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? %>
|
<% 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 %>
|
<% 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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- OAuth2/OIDC Flow Information -->
|
<!-- OAuth2/OIDC Flow Information -->
|
||||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-3">
|
<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>
|
<div>
|
||||||
<h4 class="text-sm font-semibold text-gray-900 mb-2">OAuth2 Flow</h4>
|
<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">
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
Clinch uses the <code class="bg-white px-1.5 py-0.5 rounded text-xs font-mono">authorization_code</code> flow with <code class="bg-white px-1.5 py-0.5 rounded text-xs font-mono">response_type=code</code> (the modern, secure standard).
|
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>
|
||||||
<p class="text-sm text-gray-600 mt-1">
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
Deprecated flows like Implicit (<code class="bg-white px-1 rounded text-xs font-mono">id_token</code>, <code class="bg-white px-1 rounded text-xs font-mono">token</code>) are not supported for security reasons.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-blue-200 pt-3">
|
<div class="border-t border-blue-200 dark:border-blue-700 pt-3">
|
||||||
<h4 class="text-sm font-semibold text-gray-900 mb-2">Client Authentication</h4>
|
<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">
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
Clinch supports both <code class="bg-white px-1.5 py-0.5 rounded text-xs font-mono">client_secret_basic</code> (HTTP Basic Auth) and <code class="bg-white px-1.5 py-0.5 rounded text-xs font-mono">client_secret_post</code> (POST parameters) authentication methods.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,53 +130,53 @@
|
|||||||
<!-- PKCE Requirement (only for confidential clients) -->
|
<!-- PKCE Requirement (only for confidential clients) -->
|
||||||
<div id="pkce-options" data-application-form-target="pkceOptions" class="<%= 'hidden' if application.persisted? && application.public_client? %>">
|
<div id="pkce-options" data-application-form-target="pkceOptions" class="<%= 'hidden' if application.persisted? && application.public_client? %>">
|
||||||
<div class="flex items-center">
|
<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.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" %>
|
<%= 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>
|
</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).
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Skip Consent -->
|
<!-- Skip Consent -->
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<%= form.check_box :skip_consent, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
<%= 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" %>
|
<%= form.label :skip_consent, "Skip Consent Screen", class: "ml-2 block text-sm font-medium text-gray-900 dark:text-gray-100" %>
|
||||||
</div>
|
</div>
|
||||||
<p class="ml-6 text-sm text-gray-500">
|
<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.
|
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>
|
<br><span class="text-xs text-amber-600">Only enable for applications you fully trust. Consent is still recorded in the database.</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :redirect_uris, "Redirect URIs", class: "block text-sm font-medium text-gray-700" %>
|
<%= 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 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" %>
|
<%= 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">One URI per line. These are the allowed callback URLs for your application.</p>
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :backchannel_logout_uri, "Backchannel Logout URI (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
<%= 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 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://app.example.com/oidc/backchannel-logout" %>
|
<%= 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">
|
<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.
|
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.
|
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.
|
Leave blank if the application doesn't support backchannel logout.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 pt-4 mt-4">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
|
||||||
<h4 class="text-sm font-semibold text-gray-900 mb-3">Token Expiration Settings</h4>
|
<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 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p>
|
<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 class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :access_token_ttl, "Access Token TTL", class: "block text-sm font-medium text-gray-700" %>
|
<%= 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,
|
<%= form.text_field :access_token_ttl,
|
||||||
value: application.access_token_ttl || "1h",
|
value: application.access_token_ttl || "1h",
|
||||||
placeholder: "e.g., 1h, 30m, 3600",
|
placeholder: "e.g., 1h, 30m, 3600",
|
||||||
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" %>
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
Range: 5m - 24h
|
Range: 5m - 24h
|
||||||
<br>Default: 1h
|
<br>Default: 1h
|
||||||
<% if application.access_token_ttl.present? %>
|
<% if application.access_token_ttl.present? %>
|
||||||
@@ -232,12 +186,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :refresh_token_ttl, "Refresh Token TTL", class: "block text-sm font-medium text-gray-700" %>
|
<%= 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,
|
<%= form.text_field :refresh_token_ttl,
|
||||||
value: application.refresh_token_ttl || "30d",
|
value: application.refresh_token_ttl || "30d",
|
||||||
placeholder: "e.g., 30d, 1M, 2592000",
|
placeholder: "e.g., 30d, 1M, 2592000",
|
||||||
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" %>
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
Range: 5m - 90d
|
Range: 5m - 90d
|
||||||
<br>Default: 30d
|
<br>Default: 30d
|
||||||
<% if application.refresh_token_ttl.present? %>
|
<% if application.refresh_token_ttl.present? %>
|
||||||
@@ -247,12 +201,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :id_token_ttl, "ID Token TTL", class: "block text-sm font-medium text-gray-700" %>
|
<%= 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,
|
<%= form.text_field :id_token_ttl,
|
||||||
value: application.id_token_ttl || "1h",
|
value: application.id_token_ttl || "1h",
|
||||||
placeholder: "e.g., 1h, 30m, 3600",
|
placeholder: "e.g., 1h, 30m, 3600",
|
||||||
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" %>
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
Range: 5m - 24h
|
Range: 5m - 24h
|
||||||
<br>Default: 1h
|
<br>Default: 1h
|
||||||
<% if application.id_token_ttl.present? %>
|
<% if application.id_token_ttl.present? %>
|
||||||
@@ -264,16 +218,16 @@
|
|||||||
|
|
||||||
<details class="mt-3">
|
<details class="mt-3">
|
||||||
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types & Session Length</summary>
|
<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">
|
<div class="mt-2 ml-4 space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-gray-900 mb-1">Token Types:</p>
|
<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>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. Each refresh issues a new refresh token (token rotation).</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><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 pt-2">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||||
<p class="font-medium text-gray-900 mb-1">How Session Length Works:</p>
|
<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><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="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>
|
||||||
|
|
||||||
@@ -284,21 +238,21 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 pt-2">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||||
<p class="font-medium text-gray-900 mb-1">Forcing Re-Authentication:</p>
|
<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="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 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>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 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>
|
<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>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 pt-2">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||||
<p class="font-medium text-gray-900 mb-1">Common Configurations:</p>
|
<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">
|
<ul class="ml-3 space-y-1 text-xs">
|
||||||
<li><strong>Banking/High Security:</strong> Access TTL = <code class="bg-gray-100 px-1 rounded">5m</code>, Refresh TTL = <code class="bg-gray-100 px-1 rounded">5m</code> → Re-auth every 5 minutes</li>
|
<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 px-1 rounded">1h</code>, Refresh TTL = <code class="bg-gray-100 px-1 rounded">8h</code> → Re-auth after 8 hours inactive</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 px-1 rounded">1h</code>, Refresh TTL = <code class="bg-gray-100 px-1 rounded">30d</code> → Re-auth after 30 days 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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -307,30 +261,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Forward Auth-specific fields -->
|
<!-- 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">
|
<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">Forward Auth Configuration</h3>
|
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Forward Auth Configuration</h3>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :domain_pattern, "Domain Pattern", class: "block text-sm font-medium text-gray-700" %>
|
<%= 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 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "*.example.com or app.example.com" %>
|
<%= 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">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
|
<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>
|
||||||
|
|
||||||
<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">
|
<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,
|
<%= 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"}',
|
placeholder: '{"user": "Remote-User", "groups": "Remote-Groups"}',
|
||||||
data: {
|
data: {
|
||||||
action: "input->json-validator#validate blur->json-validator#format",
|
action: "input->json-validator#validate blur->json-validator#format",
|
||||||
json_validator_target: "textarea"
|
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">
|
<div class="flex items-center justify-between">
|
||||||
<p class="font-medium">Optional: Customize header names sent to your application.</p>
|
<p class="font-medium">Optional: Customize header names sent to your application.</p>
|
||||||
<div class="flex items-center gap-2">
|
<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#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 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</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>
|
||||||
</div>
|
</div>
|
||||||
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Username, 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>
|
||||||
@@ -338,13 +292,13 @@
|
|||||||
<details class="mt-2">
|
<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>
|
<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">
|
<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 dark:bg-gray-700 dark:text-gray-200 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 dark:bg-gray-700 dark:text-gray-200 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 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 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">username</code> - User's login username (only sent if 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 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 px-1 rounded">admin</code> - "true" or "false" indicating admin status</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 px-1 rounded">{"user": "Remote-User", "groups": "Remote-Groups", "username": "Remote-Username"}</code></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>
|
<p class="italic">Need custom user fields? Add them to user's custom_claims for OIDC tokens</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -353,31 +307,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :group_ids, "Allowed Groups (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
<%= 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 rounded-md p-3">
|
<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? %>
|
<% if @available_groups.any? %>
|
||||||
<% @available_groups.each do |group| %>
|
<% @available_groups.each do |group| %>
|
||||||
<div class="flex items-center">
|
<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" %>
|
<%= 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" %>
|
<%= 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">(<%= pluralize(group.users.count, "member") %>)</span>
|
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">(<%= pluralize(group.users.count, "member") %>)</span>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% else %>
|
<% 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 %>
|
<% end %>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<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.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" %>
|
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900 dark:text-gray-100" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<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" %>
|
<%= 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>
|
</div>
|
||||||
<% end %>
|
<% 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">
|
<div class="max-w-3xl">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit Application</h1>
|
<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 mb-6">Editing: <%= @application.name %></p>
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">Editing: <%= @application.name %></p>
|
||||||
<%= render "form", application: @application %>
|
<%= render "form", application: @application %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,71 +1,85 @@
|
|||||||
<div class="sm:flex sm:items-center">
|
<div class="sm:flex sm:items-center">
|
||||||
<div class="sm:flex-auto">
|
<div class="sm:flex-auto">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900">Applications</h1>
|
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Applications</h1>
|
||||||
<p class="mt-2 text-sm text-gray-700">Manage OIDC Clients.</p>
|
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">Manage OIDC Clients.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
<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" %>
|
<%= link_to "New Application", new_admin_application_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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="mt-8 flow-root">
|
||||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
<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">
|
<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>
|
<thead>
|
||||||
<tr>
|
<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="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">Slug</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">Type</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">Status</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">Groups</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">
|
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
|
||||||
<span class="sr-only">Actions</span>
|
<span class="sr-only">Actions</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<% @applications.each do |application| %>
|
<% @applications.each do |application| %>
|
||||||
<tr>
|
<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">
|
<div class="flex items-center gap-3">
|
||||||
<% if application.icon.attached? %>
|
<% 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 %>
|
<% else %>
|
||||||
<div class="h-10 w-10 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center flex-shrink-0">
|
<%= render "shared/app_monogram", name: application.name, class: "h-10 w-10 rounded-lg 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>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
|
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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">
|
||||||
<code class="text-xs bg-gray-100 px-2 py-1 rounded"><%= application.slug %></code>
|
<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>
|
||||||
<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 %>
|
<% case application.app_type %>
|
||||||
<% when "oidc" %>
|
<% 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" %>
|
<% 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" %>
|
<% 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 %>
|
<% end %>
|
||||||
</td>
|
</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? %>
|
<% 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 %>
|
<% 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 %>
|
<% end %>
|
||||||
</td>
|
</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.allowed_groups.empty? %>
|
<% groups_count = application.allowed_groups.size %>
|
||||||
<span class="text-gray-400">All users</span>
|
<% 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 %>
|
<% 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 %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
<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">
|
<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 %>
|
<%= render "form", application: @application %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +1,47 @@
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<% if flash[:client_id] %>
|
<% if flash[:client_id] %>
|
||||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
<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 mb-2">🔐 OIDC Client Credentials</h4>
|
<h4 class="text-sm font-medium text-yellow-800 dark:text-yellow-200 mb-2">🔐 OIDC Client Credentials</h4>
|
||||||
<% if flash[:public_client] %>
|
<% 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 %>
|
<% 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 %>
|
<% end %>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div>
|
<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>
|
</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] %>
|
<% if flash[:client_secret] %>
|
||||||
<div class="mt-3">
|
<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>
|
||||||
<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] %>
|
<% elsif flash[:public_client] %>
|
||||||
<div class="mt-3">
|
<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>
|
||||||
<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.
|
Public clients do not have a client secret. PKCE is required.
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% 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>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -32,21 +49,17 @@
|
|||||||
<div class="sm:flex sm:items-start sm:justify-between">
|
<div class="sm:flex sm:items-start sm:justify-between">
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<% if @application.icon.attached? %>
|
<% 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 %>
|
<% else %>
|
||||||
<div class="h-16 w-16 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0">
|
<%= render "shared/app_monogram", name: @application.name, class: "h-16 w-16 rounded-lg 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>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
|
<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"><%= @application.description %></p>
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @application.description %></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:mt-0 flex gap-3">
|
<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" %>
|
<%= button_to "Delete", admin_application_path(@application), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,42 +67,42 @@
|
|||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Basic Information -->
|
<!-- 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">
|
<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">
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Slug</dt>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">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>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Type</dt>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Type</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||||
<% case @application.app_type %>
|
<% case @application.app_type %>
|
||||||
<% when "oidc" %>
|
<% 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" %>
|
<% 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 %>
|
<% end %>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Status</dt>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Status</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.active? %>
|
<% 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 %>
|
<% 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 %>
|
<% end %>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="sm:col-span-2">
|
<div class="sm:col-span-2">
|
||||||
<dt class="text-sm font-medium text-gray-500">Landing URL</dt>
|
<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">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||||
<% if @application.landing_url.present? %>
|
<% 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" %>
|
<%= link_to @application.landing_url, @application.landing_url, target: "_blank", rel: "noopener noreferrer", class: "text-blue-600 hover:text-blue-800 underline" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="text-gray-400 italic">Not configured</span>
|
<span class="text-gray-400 dark:text-gray-500 italic">Not configured</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,93 +112,117 @@
|
|||||||
|
|
||||||
<!-- OIDC Configuration (only for OIDC apps) -->
|
<!-- OIDC Configuration (only for OIDC apps) -->
|
||||||
<% if @application.oidc? %>
|
<% 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="px-4 py-5 sm:p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<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" %>
|
<%= 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>
|
</div>
|
||||||
<dl class="space-y-4">
|
<dl class="space-y-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Client Type</dt>
|
<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">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||||
<% if @application.public_client? %>
|
<% 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 %>
|
<% 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 %>
|
<% end %>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">PKCE</dt>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">PKCE</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.requires_pkce? %>
|
<% 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 %>
|
<% 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 %>
|
<% end %>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% unless flash[:client_id] %>
|
<% unless flash[:client_id] %>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Client ID</dt>
|
<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">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.client_id %></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"><%= @application.client_id %></code>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<% if @application.confidential_client? %>
|
<% if @application.confidential_client? %>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
|
<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">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||||
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500 italic">
|
<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
|
🔒 Client secret is stored securely and cannot be displayed
|
||||||
</div>
|
</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.
|
To get a new client secret, use the "Regenerate Credentials" button above.
|
||||||
</p>
|
</p>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
|
<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">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||||
<div class="bg-blue-50 px-3 py-2 rounded text-xs text-blue-600">
|
<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.
|
Public clients do not use a client secret. PKCE is required for authorization.
|
||||||
</div>
|
</div>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% 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 %>
|
<% end %>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Redirect URIs</dt>
|
<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">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||||
<% if @application.redirect_uris.present? %>
|
<% if @application.redirect_uris.present? %>
|
||||||
<% @application.parsed_redirect_uris.each do |uri| %>
|
<% @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 %>
|
<% end %>
|
||||||
<% else %>
|
<% 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 %>
|
<% end %>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<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
|
Backchannel Logout URI
|
||||||
<% if @application.supports_backchannel_logout? %>
|
<% 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 %>
|
<% end %>
|
||||||
</dt>
|
</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? %>
|
<% 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>
|
<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">
|
<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.
|
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
|
||||||
</p>
|
</p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="text-gray-400 italic">Not configured</span>
|
<span class="text-gray-400 dark:text-gray-500 italic">Not configured</span>
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
<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.
|
Backchannel logout is optional. Configure it if the application supports OpenID Connect Backchannel Logout.
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -198,23 +235,23 @@
|
|||||||
|
|
||||||
<!-- Forward Auth Configuration (only for Forward Auth apps) -->
|
<!-- Forward Auth Configuration (only for Forward Auth apps) -->
|
||||||
<% if @application.forward_auth? %>
|
<% 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">
|
<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">
|
<dl class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Domain Pattern</dt>
|
<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">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs"><%= @application.domain_pattern %></code>
|
<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>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Headers Configuration</dt>
|
<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">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||||
<% if @application.headers_config.present? && @application.headers_config.any? %>
|
<% 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 %>
|
<% else %>
|
||||||
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500">
|
<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
|
Using default headers: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Username, X-Remote-Groups, X-Remote-Admin
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -226,29 +263,29 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- Group Access Control -->
|
<!-- 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">
|
<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>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500 mb-2">Allowed Groups</dt>
|
<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">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||||
<% if @allowed_groups.empty? %>
|
<% 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="flex">
|
||||||
<div class="ml-3">
|
<div class="ml-3">
|
||||||
<p class="text-sm text-blue-700">
|
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||||
No groups assigned - all active users can access this application.
|
No groups assigned — no one can access this application. Attach a group to grant access.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% 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| %>
|
<% @allowed_groups.each do |group| %>
|
||||||
<li class="px-4 py-3 flex items-center justify-between">
|
<li class="px-4 py-3 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-900"><%= group.name %></p>
|
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= group.name %></p>
|
||||||
<p class="text-xs text-gray-500"><%= pluralize(group.users.count, "member") %></p>
|
<p class="text-xs text-gray-500 dark:text-gray-400"><%= pluralize(group.users.count, "member") %></p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -258,4 +295,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Admin Dashboard</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Admin Dashboard</h1>
|
||||||
<p class="mt-2 text-gray-600">System overview and quick actions</p>
|
<p class="mt-2 text-gray-600 dark:text-gray-400">System overview and quick actions</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<!-- Users Card -->
|
<!-- 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="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<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>
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<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
|
Total Users
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="flex items-baseline">
|
<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 %>
|
<%= @user_count %>
|
||||||
</div>
|
</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)
|
(<%= @active_user_count %> active)
|
||||||
</div>
|
</div>
|
||||||
</dd>
|
</dd>
|
||||||
@@ -30,30 +30,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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" %>
|
<%= link_to "Manage users", admin_users_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Applications Card -->
|
<!-- 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="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<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>
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<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
|
Applications
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="flex items-baseline">
|
<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 %>
|
<%= @application_count %>
|
||||||
</div>
|
</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)
|
(<%= @active_application_count %> active)
|
||||||
</div>
|
</div>
|
||||||
</dd>
|
</dd>
|
||||||
@@ -61,33 +61,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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" %>
|
<%= link_to "Manage applications", admin_applications_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Groups Card -->
|
<!-- 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="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<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>
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<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
|
Groups
|
||||||
</dt>
|
</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 %>
|
<%= @group_count %>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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" %>
|
<%= link_to "Manage groups", admin_groups_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,26 +95,26 @@
|
|||||||
|
|
||||||
<!-- Recent Users -->
|
<!-- Recent Users -->
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Recent Users</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Recent Users</h2>
|
||||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
<div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg">
|
||||||
<ul class="divide-y divide-gray-200">
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<% @recent_users.each do |user| %>
|
<% @recent_users.each do |user| %>
|
||||||
<li class="px-6 py-4">
|
<li class="px-6 py-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<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>
|
||||||
<p class="text-xs text-gray-500">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Created <%= time_ago_in_words(user.created_at) %> ago
|
Created <%= time_ago_in_words(user.created_at) %> ago
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<% if user.admin? %>
|
<% 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 %>
|
<% end %>
|
||||||
<% if user.totp_enabled? %>
|
<% 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 %>
|
<% 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>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -125,21 +125,21 @@
|
|||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
<div class="mt-8">
|
<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">
|
<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 %>
|
<%= 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 mb-2">Create User</h3>
|
<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">Add a new user to the system</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400">Add a new user to the system</p>
|
||||||
<% end %>
|
<% 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 %>
|
<%= 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 mb-2">Register Application</h3>
|
<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">Add a new OIDC or ForwardAuth app</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400">Add a new OIDC or ForwardAuth app</p>
|
||||||
<% end %>
|
<% 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 %>
|
<%= 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 mb-2">Create Group</h3>
|
<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">Organize users into a new group</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400">Organize users into a new group</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,51 +2,90 @@
|
|||||||
<%= render "shared/form_errors", form: form %>
|
<%= render "shared/form_errors", form: form %>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
<%= 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 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "developers" %>
|
<%= 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">Group names are automatically normalized to lowercase.</p>
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Group names are automatically normalized to lowercase.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :description, 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 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this group" %>
|
<%= 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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :user_ids, "Group Members", class: "block text-sm font-medium text-gray-700" %>
|
<div class="flex items-center">
|
||||||
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 rounded-md p-3">
|
<%= 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? %>
|
<% if @available_users.any? %>
|
||||||
<% @available_users.each do |user| %>
|
<% @available_users.each do |user| %>
|
||||||
<div class="flex items-center">
|
<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" %>
|
<%= 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" %>
|
<%= 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? %>
|
<% 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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% else %>
|
<% 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 %>
|
<% end %>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<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">
|
<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,
|
<%= 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"]}',
|
placeholder: '{"roles": ["admin", "editor"]}',
|
||||||
data: {
|
data: {
|
||||||
action: "input->json-validator#validate blur->json-validator#format",
|
action: "input->json-validator#validate blur->json-validator#format",
|
||||||
json_validator_target: "textarea"
|
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">
|
<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>
|
<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">
|
<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#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 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</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>
|
</div>
|
||||||
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||||
@@ -55,6 +94,6 @@
|
|||||||
|
|
||||||
<div class="flex gap-3">
|
<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" %>
|
<%= 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>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div class="max-w-2xl">
|
<div class="max-w-2xl">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit Group</h1>
|
<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 mb-6">Editing: <%= @group.name %></p>
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">Editing: <%= @group.name %></p>
|
||||||
<%= render "form", group: @group %>
|
<%= render "form", group: @group %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div class="sm:flex sm:items-center">
|
<div class="sm:flex sm:items-center">
|
||||||
<div class="sm:flex-auto">
|
<div class="sm:flex-auto">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900">Groups</h1>
|
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Groups</h1>
|
||||||
<p class="mt-2 text-sm text-gray-700">Organize users into groups for application access control.</p>
|
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">Organize users into groups for application access control.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
<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" %>
|
<%= 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="mt-8 flow-root">
|
||||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
<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">
|
<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>
|
<thead>
|
||||||
<tr>
|
<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="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">Description</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">Members</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">Applications</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">
|
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
|
||||||
<span class="sr-only">Actions</span>
|
<span class="sr-only">Actions</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<% @groups.each do |group| %>
|
<% @groups.each do |group| %>
|
||||||
<tr>
|
<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" %>
|
<%= link_to group.name, admin_group_path(group), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-4 text-sm text-gray-500">
|
<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") %>
|
<%= truncate(group.description, length: 80) || content_tag(:span, "No description", class: "text-gray-400 dark:text-gray-500") %>
|
||||||
</td>
|
</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") %>
|
<%= pluralize(group.users.count, "member") %>
|
||||||
</td>
|
</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") %>
|
<%= pluralize(group.applications.count, "app") %>
|
||||||
</td>
|
</td>
|
||||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
<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">
|
<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 %>
|
<%= render "form", group: @group %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="sm:flex sm:items-center sm:justify-between">
|
<div class="sm:flex sm:items-center sm:justify-between">
|
||||||
<div>
|
<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? %>
|
<% 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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:mt-0 flex gap-3">
|
<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" %>
|
<%= button_to "Delete", admin_group_path(@group), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -15,25 +23,25 @@
|
|||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Members -->
|
<!-- 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">
|
<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 %>)
|
Members (<%= @members.count %>)
|
||||||
</h3>
|
</h3>
|
||||||
<% if @members.any? %>
|
<% 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| %>
|
<% @members.each do |user| %>
|
||||||
<li class="px-4 py-3 flex items-center justify-between">
|
<li class="px-4 py-3 flex items-center justify-between">
|
||||||
<div>
|
<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">
|
<div class="flex gap-2 mt-1">
|
||||||
<% if user.admin? %>
|
<% 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 %>
|
<% end %>
|
||||||
<% if user.totp_enabled? %>
|
<% 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 %>
|
<% 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>
|
||||||
</div>
|
</div>
|
||||||
<%= link_to "View", admin_user_path(user), class: "text-blue-600 hover:text-blue-900 text-sm" %>
|
<%= link_to "View", admin_user_path(user), class: "text-blue-600 hover:text-blue-900 text-sm" %>
|
||||||
@@ -41,36 +49,36 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="rounded-md bg-gray-50 p-4">
|
<div class="rounded-md bg-gray-50 dark:bg-gray-700 p-4">
|
||||||
<p class="text-sm text-gray-500">No members in this group yet.</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">No members in this group yet.</p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Applications -->
|
<!-- 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">
|
<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 %>)
|
Assigned Applications (<%= @applications.count %>)
|
||||||
</h3>
|
</h3>
|
||||||
<% if @applications.any? %>
|
<% 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| %>
|
<% @applications.each do |app| %>
|
||||||
<li class="px-4 py-3 flex items-center justify-between">
|
<li class="px-4 py-3 flex items-center justify-between">
|
||||||
<div>
|
<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">
|
<div class="flex gap-2 mt-1">
|
||||||
<% case app.app_type %>
|
<% case app.app_type %>
|
||||||
<% when "oidc" %>
|
<% 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" %>
|
<% 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 %>
|
<% end %>
|
||||||
<% if app.active? %>
|
<% 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 %>
|
<% 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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,8 +87,8 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="rounded-md bg-gray-50 p-4">
|
<div class="rounded-md bg-gray-50 dark:bg-gray-700 p-4">
|
||||||
<p class="text-sm text-gray-500">This group is not assigned to any applications.</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">This group is not assigned to any applications.</p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,29 +3,29 @@
|
|||||||
|
|
||||||
<!-- OIDC Apps: Custom Claims -->
|
<!-- OIDC Apps: Custom Claims -->
|
||||||
<% if oidc_apps.any? %>
|
<% if oidc_apps.any? %>
|
||||||
<div class="mt-12 border-t pt-8">
|
<div class="mt-12 border-t dark:border-gray-700 pt-8">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">OIDC App-Specific Claims</h2>
|
<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 mb-6">
|
<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.
|
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>
|
</p>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<% oidc_apps.each do |app| %>
|
<% oidc_apps.each do |app| %>
|
||||||
<% app_claim = user.application_user_claims.find_by(application: app) %>
|
<% app_claim = user.application_user_claims.find_by(application: app) %>
|
||||||
<details class="border rounded-lg" <%= "open" if app_claim&.custom_claims&.any? %>>
|
<details class="border dark:border-gray-700 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">
|
<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">
|
<div class="flex items-center gap-3">
|
||||||
<span class="font-medium text-gray-900"><%= app.name %></span>
|
<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 text-blue-700">
|
<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
|
OIDC
|
||||||
</span>
|
</span>
|
||||||
<% if app_claim&.custom_claims&.any? %>
|
<% 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)
|
<%= app_claim.custom_claims.keys.count %> claim(s)
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</summary>
|
</summary>
|
||||||
@@ -35,22 +35,22 @@
|
|||||||
<%= hidden_field_tag :application_id, app.id %>
|
<%= hidden_field_tag :application_id, app.id %>
|
||||||
|
|
||||||
<div>
|
<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,
|
<%= text_area_tag :custom_claims,
|
||||||
(app_claim&.custom_claims.present? ? JSON.pretty_generate(app_claim.custom_claims) : ""),
|
(app_claim&.custom_claims.present? ? JSON.pretty_generate(app_claim.custom_claims) : ""),
|
||||||
rows: 8,
|
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"}',
|
placeholder: '{"kavita_groups": ["admin"], "library_access": "all"}',
|
||||||
data: {
|
data: {
|
||||||
action: "input->json-validator#validate blur->json-validator#format",
|
action: "input->json-validator#validate blur->json-validator#format",
|
||||||
json_validator_target: "textarea"
|
json_validator_target: "textarea"
|
||||||
} %>
|
} %>
|
||||||
<div class="mt-2 space-y-1">
|
<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.
|
Example for <%= app.name %>: Add claims that this app specifically needs to read.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-amber-600">
|
<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>
|
</p>
|
||||||
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,27 +66,27 @@
|
|||||||
delete_application_claims_admin_user_path(user, application_id: app.id),
|
delete_application_claims_admin_user_path(user, application_id: app.id),
|
||||||
method: :delete,
|
method: :delete,
|
||||||
data: { turbo_confirm: "Remove app-specific claims for #{app.name}?" },
|
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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- Preview merged claims -->
|
<!-- Preview merged claims -->
|
||||||
<div class="mt-4 border-t pt-4">
|
<div class="mt-4 border-t dark:border-gray-700 pt-4">
|
||||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Preview: Final ID Token Claims for <%= app.name %></h4>
|
<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 rounded-lg p-3">
|
<div class="bg-gray-50 dark:bg-gray-800 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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<details class="mt-2">
|
<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">
|
<div class="mt-2 space-y-1">
|
||||||
<% claim_sources(user, app).each do |source| %>
|
<% claim_sources(user, app).each do |source| %>
|
||||||
<div class="flex gap-2 items-start text-xs">
|
<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] %>
|
<%= source[:name] %>
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,32 +101,32 @@
|
|||||||
|
|
||||||
<!-- ForwardAuth Apps: Headers Preview -->
|
<!-- ForwardAuth Apps: Headers Preview -->
|
||||||
<% if forward_auth_apps.any? %>
|
<% if forward_auth_apps.any? %>
|
||||||
<div class="mt-12 border-t pt-8">
|
<div class="mt-12 border-t dark:border-gray-700 pt-8">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">ForwardAuth Headers Preview</h2>
|
<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 mb-6">
|
<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.
|
ForwardAuth applications receive HTTP headers (not OIDC tokens). Headers are based on user's email, name, groups, and admin status.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<% forward_auth_apps.each do |app| %>
|
<% forward_auth_apps.each do |app| %>
|
||||||
<details class="border rounded-lg">
|
<details class="border dark:border-gray-700 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">
|
<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">
|
<div class="flex items-center gap-3">
|
||||||
<span class="font-medium text-gray-900"><%= app.name %></span>
|
<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 text-green-700">
|
<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
|
FORWARD AUTH
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs text-gray-500">
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
<%= app.domain_pattern %>
|
<%= app.domain_pattern %>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div class="p-4 space-y-4">
|
<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">
|
<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">
|
<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" />
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Headers Sent to <%= app.name %></h4>
|
<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 rounded-lg p-3 border">
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 border dark:border-gray-700">
|
||||||
<% headers = app.headers_for_user(user) %>
|
<% headers = app.headers_for_user(user) %>
|
||||||
<% if headers.any? %>
|
<% if headers.any? %>
|
||||||
<dl class="space-y-2 text-xs font-mono">
|
<dl class="space-y-2 text-xs font-mono">
|
||||||
<% headers.each do |header_name, value| %>
|
<% headers.each do |header_name, value| %>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<dt class="text-blue-600 font-semibold w-48"><%= header_name %>:</dt>
|
<dt class="text-blue-600 dark:text-blue-400 font-semibold w-48"><%= header_name %>:</dt>
|
||||||
<dd class="text-gray-800 flex-1"><%= value %></dd>
|
<dd class="text-gray-800 dark:text-gray-200 flex-1"><%= value %></dd>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</dl>
|
</dl>
|
||||||
<% else %>
|
<% 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 %>
|
<% end %>
|
||||||
</div>
|
</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.
|
These headers are configured in the application settings and sent by your reverse proxy (Caddy/Traefik) to the upstream application.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if user.groups.any? %>
|
<% if user.groups.any? %>
|
||||||
<div>
|
<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">
|
<div class="flex flex-wrap gap-2">
|
||||||
<% user.groups.each do |group| %>
|
<% 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 %>
|
<%= group.name %>
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -176,10 +176,10 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if oidc_apps.empty? && forward_auth_apps.empty? %>
|
<% if oidc_apps.empty? && forward_auth_apps.empty? %>
|
||||||
<div class="mt-12 border-t pt-8">
|
<div class="mt-12 border-t dark:border-gray-700 pt-8">
|
||||||
<div class="text-center py-12 bg-gray-50 rounded-lg">
|
<div class="text-center py-12 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
<p class="text-gray-500">No active applications found.</p>
|
<p class="text-gray-500 dark:text-gray-400">No active applications found.</p>
|
||||||
<p class="text-sm text-gray-400 mt-1">Create applications in the Admin panel first.</p>
|
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Create applications in the Admin panel first.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -2,49 +2,74 @@
|
|||||||
<%= render "shared/form_errors", form: form %>
|
<%= render "shared/form_errors", form: form %>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %>
|
<%= 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 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %>
|
<%= 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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :username, "Username (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
<%= 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 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "jsmith" %>
|
<%= 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">Optional: Short username/handle for login. Can only contain letters, numbers, underscores, and hyphens.</p>
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :name, "Display Name (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
<%= 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 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "John Smith" %>
|
<%= 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">Optional: Full name shown in applications. Defaults to email address if not set.</p>
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :password, class: "block text-sm font-medium text-gray-700" %>
|
<%= 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 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.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? %>
|
<% 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 %>
|
<% 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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :status, class: "block text-sm font-medium text-gray-700" %>
|
<%= 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 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
<%= 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>
|
||||||
|
|
||||||
|
<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">
|
<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) %>
|
<%= 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" %>
|
||||||
<%= form.label :admin, "Administrator", class: "ml-2 block text-sm text-gray-900" %>
|
<%= label_tag "user_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||||
<% if user == Current.session.user %>
|
<% if group.admin? %>
|
||||||
<span class="ml-2 text-xs text-gray-500">(Cannot change your own admin status)</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 %>
|
||||||
|
<% 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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center">
|
<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.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" %>
|
<%= 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? %>
|
<% if user.totp_required? && !user.totp_enabled? %>
|
||||||
<span class="ml-2 text-xs text-amber-600">(User has not set up 2FA yet)</span>
|
<span class="ml-2 text-xs text-amber-600">(User has not set up 2FA yet)</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -57,24 +82,24 @@
|
|||||||
Warning: This user will be prompted to set up 2FA on their next login.
|
Warning: This user will be prompted to set up 2FA on their next login.
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% 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>
|
||||||
|
|
||||||
<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">
|
<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,
|
<%= 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"}',
|
placeholder: '{"department": "engineering", "level": "senior"}',
|
||||||
data: {
|
data: {
|
||||||
action: "input->json-validator#validate blur->json-validator#format",
|
action: "input->json-validator#validate blur->json-validator#format",
|
||||||
json_validator_target: "textarea"
|
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">
|
<div class="flex items-center justify-between">
|
||||||
<p>Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p>
|
<p>Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p>
|
||||||
<div class="flex items-center gap-2">
|
<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#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 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</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>
|
</div>
|
||||||
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||||
@@ -83,6 +108,6 @@
|
|||||||
|
|
||||||
<div class="flex gap-3">
|
<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" %>
|
<%= 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>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="max-w-4xl">
|
<div class="max-w-4xl">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit User</h1>
|
<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 mb-6">Editing: <%= @user.email_address %></p>
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">Editing: <%= @user.email_address %></p>
|
||||||
|
|
||||||
<div class="max-w-2xl">
|
<div class="max-w-2xl">
|
||||||
<%= render "form", user: @user %>
|
<%= render "form", user: @user %>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div class="sm:flex sm:items-center">
|
<div class="sm:flex sm:items-center">
|
||||||
<div class="sm:flex-auto">
|
<div class="sm:flex-auto">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900">Users</h1>
|
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Users</h1>
|
||||||
<p class="mt-2 text-sm text-gray-700">A list of all users in the system.</p>
|
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">A list of all users in the system.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
<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" %>
|
<%= 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>
|
</div>
|
||||||
|
|
||||||
<% unless smtp_configured? %>
|
<% 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">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
@@ -17,10 +17,10 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3">
|
<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
|
Email delivery not configured
|
||||||
</h3>
|
</h3>
|
||||||
<div class="mt-2 text-sm text-yellow-700">
|
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
|
||||||
<p>
|
<p>
|
||||||
<% if Rails.env.development? %>
|
<% if Rails.env.development? %>
|
||||||
Emails are being delivered using <span class="font-mono"><%= email_delivery_method %></span> and will open in your browser.
|
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="mt-8 flow-root">
|
||||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
<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">
|
<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>
|
<thead>
|
||||||
<tr>
|
<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="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">Status</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">Role</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">2FA</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">Groups</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">
|
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
|
||||||
<span class="sr-only">Actions</span>
|
<span class="sr-only">Actions</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<% @users.each do |user| %>
|
<% @users.each do |user| %>
|
||||||
<tr>
|
<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">
|
||||||
<%= user.email_address %>
|
<%= link_to user.email_address, admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
</td>
|
</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? %>
|
<% if user.status.present? %>
|
||||||
<% case user.status.to_sym %>
|
<% case user.status.to_sym %>
|
||||||
<% when :active %>
|
<% 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 %>
|
<% 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 %>
|
<% 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 %>
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="text-gray-400">-</span>
|
<span class="text-gray-400 dark:text-gray-500">-</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</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? %>
|
<% 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 %>
|
<% else %>
|
||||||
<span class="text-gray-500">User</span>
|
<span class="text-gray-500 dark:text-gray-400">User</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</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">
|
<div class="flex items-center gap-2">
|
||||||
<% if user.totp_enabled? %>
|
<% 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">
|
<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>
|
<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>
|
</svg>
|
||||||
<% else %>
|
<% 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>
|
<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>
|
</svg>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if user.totp_required? %>
|
<% 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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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 %>
|
<%= user.groups.count %>
|
||||||
</td>
|
</td>
|
||||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
<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 },
|
data: { turbo_method: :post },
|
||||||
class: "text-yellow-600 hover:text-yellow-900" %>
|
class: "text-yellow-600 hover:text-yellow-900" %>
|
||||||
<% end %>
|
<% 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 "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
<%= link_to "Delete", admin_user_path(user),
|
<%= link_to "Delete", admin_user_path(user),
|
||||||
data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this 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">
|
<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 %>
|
<%= render "form", user: @user %>
|
||||||
</div>
|
</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>
|
||||||
@@ -1,44 +1,44 @@
|
|||||||
<div class="max-w-4xl mx-auto">
|
<div class="max-w-4xl mx-auto">
|
||||||
<div class="mb-8 flex items-center justify-between">
|
<div class="mb-8 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900">API Keys</h1>
|
<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">
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
Bearer tokens for server-to-server access to forward auth applications.
|
Bearer tokens for server-to-server access to forward auth applications.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<%= link_to "New API Key", new_api_key_path,
|
<%= 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" %>
|
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>
|
</div>
|
||||||
|
|
||||||
<% if @api_keys.any? %>
|
<% if @api_keys.any? %>
|
||||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
<div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 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">Name</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 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">Application</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 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">Created</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 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">Last Used</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 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">Expires</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</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 uppercase tracking-wider"></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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<% @api_keys.each do |key| %>
|
<% @api_keys.each do |key| %>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"><%= key.name %></td>
|
<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"><%= key.application.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"><%= 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.created_at.strftime("%b %d, %Y") %></td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= 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.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"><%= key.expires_at ? key.expires_at.strftime("%b %d, %Y") : "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">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<% if key.revoked? %>
|
<% if key.revoked? %>
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Revoked</span>
|
<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? %>
|
<% elsif key.expired? %>
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Expired</span>
|
<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 %>
|
<% else %>
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Active</span>
|
<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 %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
@@ -54,12 +54,12 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="bg-gray-50 rounded-lg border border-gray-200 p-8 text-center">
|
<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">
|
<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>
|
<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>
|
</svg>
|
||||||
<h3 class="mt-4 text-lg font-medium text-gray-900">No API keys</h3>
|
<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">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Create an API key to authenticate server-to-server requests.
|
Create an API key to authenticate server-to-server requests.
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<div class="max-w-lg mx-auto">
|
<div class="max-w-lg mx-auto">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">New API Key</h1>
|
<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">
|
<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.
|
Create a bearer token for server-to-server access to a forward auth application.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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="px-4 py-5 sm:p-6">
|
||||||
<%= form_with(model: @api_key, class: "space-y-6") do |f| %>
|
<%= form_with(model: @api_key, class: "space-y-6") do |f| %>
|
||||||
<% if @api_key.errors.any? %>
|
<% if @api_key.errors.any? %>
|
||||||
<div class="rounded-md bg-red-50 p-4">
|
<div class="rounded-md bg-red-50 dark:bg-red-900/30 p-4">
|
||||||
<div class="text-sm text-red-700">
|
<div class="text-sm text-red-700 dark:text-red-300">
|
||||||
<ul class="list-disc pl-5 space-y-1">
|
<ul class="list-disc pl-5 space-y-1">
|
||||||
<% @api_key.errors.full_messages.each do |msg| %>
|
<% @api_key.errors.full_messages.each do |msg| %>
|
||||||
<li><%= msg %></li>
|
<li><%= msg %></li>
|
||||||
@@ -22,32 +22,32 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= f.label :name, class: "block text-sm font-medium text-gray-700" %>
|
<%= 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 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
<%= 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" %>
|
placeholder: "e.g., Video Player WebDAV" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= f.label :application_id, "Application", class: "block text-sm font-medium text-gray-700" %>
|
<%= f.label :application_id, "Application", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
<% if @applications.any? %>
|
<% if @applications.any? %>
|
||||||
<%= f.collection_select :application_id, @applications, :id, :name,
|
<%= f.collection_select :application_id, @applications, :id, :name,
|
||||||
{ prompt: "Select an application" },
|
{ prompt: "Select an application" },
|
||||||
{ 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" } %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="mt-1 text-sm text-gray-500">No forward auth applications available.</p>
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No forward auth applications available.</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= f.label :expires_at, "Expiration (optional)", class: "block text-sm font-medium text-gray-700" %>
|
<%= 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 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
<%= 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">Leave blank for no expiration.</p>
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave blank for no expiration.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-3">
|
<div class="flex items-center justify-end gap-3">
|
||||||
<%= link_to "Cancel", api_keys_path, class: "text-sm font-medium text-gray-700 hover:text-gray-500" %>
|
<%= 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",
|
<%= 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" %>
|
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>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<div class="max-w-2xl mx-auto" data-controller="clipboard">
|
<div class="max-w-2xl mx-auto" data-controller="clipboard">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">API Key Created</h1>
|
<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">
|
<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.
|
Copy your API key now. You won't be able to see it again.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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="px-4 py-5 sm:p-6">
|
||||||
<div class="rounded-md bg-yellow-50 p-4 mb-6">
|
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/30 p-4 mb-6">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<svg class="h-5 w-5 text-yellow-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
<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" />
|
<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>
|
</svg>
|
||||||
<div class="text-sm text-yellow-800">
|
<div class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
<p class="font-medium">Save this key now!</p>
|
<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>
|
<p class="mt-1">This is the only time you'll see the full API key. Store it securely.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -21,14 +21,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">API Key</label>
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
<input type="text" readonly value="<%= @plaintext_token %>"
|
<input type="text" readonly value="<%= @plaintext_token %>"
|
||||||
data-clipboard-target="source"
|
data-clipboard-target="source"
|
||||||
class="flex-1 rounded-md border-gray-300 bg-gray-50 font-mono text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500" />
|
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"
|
<button data-action="click->clipboard#copy"
|
||||||
data-clipboard-target="button"
|
data-clipboard-target="button"
|
||||||
class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-3 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 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">
|
<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" />
|
<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>
|
</svg>
|
||||||
@@ -37,22 +37,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 space-y-2 text-sm text-gray-600">
|
<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>Name:</strong> <%= @api_key.name %></p>
|
||||||
<p><strong>Application:</strong> <%= @api_key.application.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>
|
<p><strong>Expires:</strong> <%= @api_key.expires_at ? @api_key.expires_at.strftime("%b %d, %Y %H:%M") : "Never" %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 rounded-md bg-gray-50 p-4">
|
<div class="mt-6 rounded-md bg-gray-50 dark:bg-gray-700 p-4">
|
||||||
<p class="text-sm font-medium text-gray-700 mb-2">Usage example:</p>
|
<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 overflow-x-auto">curl -H "Authorization: Bearer <%= @plaintext_token %>" \
|
<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" \
|
-H "X-Forwarded-Host: your-app.example.com" \
|
||||||
<%= request.base_url %>/api/verify</pre>
|
<%= request.base_url %>/api/verify</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<%= link_to "Done", api_keys_path,
|
<%= 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" %>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<div class="mb-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 %>
|
Welcome, <%= @user.email_address %>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-2 text-gray-600">
|
<p class="mt-2 text-gray-600 dark:text-gray-400">
|
||||||
<% if @user.admin? %>
|
<% if @user.admin? %>
|
||||||
Administrator
|
Administrator
|
||||||
<% else %>
|
<% else %>
|
||||||
@@ -13,34 +13,34 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<!-- Active Sessions Card -->
|
<!-- 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="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<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>
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<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
|
Active Sessions
|
||||||
</dt>
|
</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 %>
|
<%= @user.sessions.active.count %>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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" %>
|
<%= link_to "View all sessions", profile_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if @user.totp_enabled? %>
|
<% if @user.totp_enabled? %>
|
||||||
<!-- 2FA Status Card -->
|
<!-- 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="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<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
|
Two-Factor Authentication
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="text-lg font-semibold text-green-600">
|
<dd class="text-lg font-semibold text-green-600">
|
||||||
@@ -60,13 +60,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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" %>
|
<%= link_to "Manage 2FA", profile_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<!-- 2FA Disabled Card -->
|
<!-- 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="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<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
|
Two-Factor Authentication
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="text-lg font-semibold text-yellow-600">
|
<dd class="text-lg font-semibold text-yellow-600">
|
||||||
@@ -86,34 +86,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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" %>
|
<%= link_to "Enable 2FA", profile_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- API Keys Card -->
|
<!-- API Keys 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="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<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="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>
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<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">
|
||||||
API Keys
|
API Keys
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="text-lg font-semibold text-gray-900">
|
<dd class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
<%= @user.api_keys.active.count %>
|
<%= @user.api_keys.active.count %>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 API Keys", api_keys_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
<%= link_to "Manage API Keys", api_keys_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,39 +121,35 @@
|
|||||||
|
|
||||||
<!-- Your Applications Section -->
|
<!-- Your Applications Section -->
|
||||||
<div class="mt-8">
|
<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? %>
|
<% if @applications.any? %>
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<% @applications.each do |app| %>
|
<% @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="p-6">
|
||||||
<div class="flex items-start gap-3 mb-4">
|
<div class="flex items-start gap-3 mb-4">
|
||||||
<% if app.icon.attached? %>
|
<% 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 %>
|
<% else %>
|
||||||
<div class="h-12 w-12 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0">
|
<%= render "shared/app_monogram", name: app.name, class: "h-12 w-12 rounded-lg 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>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-start justify-between">
|
<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 %>
|
<%= app.name %>
|
||||||
</h3>
|
</h3>
|
||||||
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium shrink-0
|
<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? %>
|
<% 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 %>
|
<% else %>
|
||||||
bg-green-100 text-green-800
|
bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200
|
||||||
<% end %>">
|
<% end %>">
|
||||||
<%= app.app_type.humanize %>
|
<%= app.app_type.humanize %>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<% if app.description.present? %>
|
<% 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 %>
|
<%= app.description %>
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -165,30 +161,40 @@
|
|||||||
<%= link_to "Open Application", app.landing_url,
|
<%= link_to "Open Application", app.landing_url,
|
||||||
target: "_blank",
|
target: "_blank",
|
||||||
rel: "noopener noreferrer",
|
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 %>
|
<% 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
|
No landing URL configured
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if app.user_has_active_session?(@user) %>
|
<% if app.user_has_active_session?(@user) %>
|
||||||
<%= button_to "Require Re-Auth", logout_from_app_active_sessions_path(application_id: app.id), method: :delete,
|
<%= 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 hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 transition",
|
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?" } } %>
|
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 %>
|
<% 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="bg-gray-50 rounded-lg border border-gray-200 p-8 text-center">
|
<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">
|
<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>
|
<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>
|
</svg>
|
||||||
<h3 class="mt-4 text-lg font-medium text-gray-900">No applications available</h3>
|
<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">
|
<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.
|
You don't have access to any applications yet. Contact your administrator if you think this is an error.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,21 +203,21 @@
|
|||||||
|
|
||||||
<% if @user.admin? %>
|
<% if @user.admin? %>
|
||||||
<div class="mt-8">
|
<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">
|
<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 %>
|
<%= 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 mb-2">Manage Users</h3>
|
<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">View, edit, and invite users</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400">View, edit, and invite users</p>
|
||||||
<% end %>
|
<% 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 %>
|
<%= 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 mb-2">Manage Applications</h3>
|
<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">Register and configure applications</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400">Register and configure applications</p>
|
||||||
<% end %>
|
<% 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 %>
|
<%= 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 mb-2">Manage Groups</h3>
|
<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">Create and organize user groups</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400">Create and organize user groups</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,15 +4,15 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<h1 class="font-bold text-4xl">Welcome to Clinch!</h1>
|
<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| %>
|
<%= form_with url: invitation_path(params[:token]), method: :put, class: "contents" do |form| %>
|
||||||
<div class="my-5">
|
<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>
|
||||||
|
|
||||||
<div class="my-5">
|
<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>
|
||||||
|
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
You've been invited to join Clinch!
|
You've been invited to join Clinch!
|
||||||
|
|
||||||
To set up your account and create your password, please visit:
|
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.
|
If you didn't expect this invitation, you can safely ignore this email.
|
||||||
@@ -9,6 +9,15 @@
|
|||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
<%= csp_meta_tag %>
|
<%= csp_meta_tag %>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(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 %>
|
<%= yield :head %>
|
||||||
|
|
||||||
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
|
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
|
||||||
@@ -23,15 +32,15 @@
|
|||||||
<%= javascript_importmap_tags %>
|
<%= javascript_importmap_tags %>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body class="dark:bg-gray-900 dark:text-gray-100">
|
||||||
<% if authenticated? %>
|
<% if authenticated? %>
|
||||||
<div data-controller="mobile-sidebar">
|
<div data-controller="mobile-sidebar">
|
||||||
<%= render "shared/sidebar" %>
|
<%= render "shared/sidebar" %>
|
||||||
<div class="lg:pl-64">
|
<div class="lg:pl-64">
|
||||||
<!-- Mobile menu button -->
|
<!-- 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"
|
<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"
|
id="mobile-menu-button"
|
||||||
data-action="click->mobile-sidebar#openSidebar">
|
data-action="click->mobile-sidebar#openSidebar">
|
||||||
<span class="sr-only">Open sidebar</span>
|
<span class="sr-only">Open sidebar</span>
|
||||||
@@ -51,6 +60,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<!-- Public layout (signup/signin) -->
|
<!-- 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">
|
<main class="container mx-auto mt-28 px-5">
|
||||||
<%= render "shared/flash" %>
|
<%= render "shared/flash" %>
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
<div class="mx-auto max-w-md">
|
<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">
|
<div class="mb-8 text-center">
|
||||||
<% if @application.icon.attached? %>
|
<% 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 %>
|
<% 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">
|
<div class="mx-auto mb-4">
|
||||||
<svg class="h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<%= render "shared/app_monogram", name: @application.name, class: "h-20 w-20 rounded-xl shadow-sm" %>
|
||||||
<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>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<h2 class="text-2xl font-bold text-gray-900">Authorize Application</h2>
|
<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">
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<strong><%= @application.name %></strong> is requesting access to your account.
|
<strong><%= @application.name %></strong> is requesting access to your account.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-6">
|
<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">
|
<ul class="space-y-2">
|
||||||
<% if @scopes.include?("openid") %>
|
<% if @scopes.include?("openid") %>
|
||||||
<li class="flex items-start">
|
<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">
|
<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"/>
|
<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>
|
</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>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if @scopes.include?("email") %>
|
<% 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">
|
<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"/>
|
<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>
|
</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>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if @scopes.include?("profile") %>
|
<% 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">
|
<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"/>
|
<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>
|
</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>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if @scopes.include?("groups") %>
|
<% 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">
|
<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"/>
|
<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>
|
</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>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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">
|
<div class="flex">
|
||||||
<svg class="h-5 w-5 text-blue-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
<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"/>
|
<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>
|
</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>You'll be redirected to:</p>
|
||||||
<p class="mt-1 font-mono text-xs break-all"><%= @redirect_uri %></p>
|
<p class="mt-1 font-mono text-xs break-all"><%= @redirect_uri %></p>
|
||||||
</div>
|
</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_with url: "/oauth/authorize/consent", method: :post, class: "space-y-3", data: { turbo: false }, local: true do |form| %>
|
||||||
<%= form.submit "Authorize",
|
<%= 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",
|
<%= button_tag "Deny",
|
||||||
type: :submit,
|
type: :submit,
|
||||||
name: :deny,
|
name: :deny,
|
||||||
value: "1",
|
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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
|
|
||||||
<%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %>
|
<%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %>
|
||||||
<div class="my-5">
|
<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>
|
||||||
|
|
||||||
<div class="my-5">
|
<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>
|
||||||
|
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<%= form_with url: passwords_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
<%= form_with url: passwords_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
||||||
<div class="my-5">
|
<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>
|
||||||
|
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<p>
|
<p>
|
||||||
You can reset your password on
|
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>
|
</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
You can reset your password on
|
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 class="space-y-8" data-controller="modal">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Account Security</h1>
|
<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">Manage your account settings, active sessions, and connected applications.</p>
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Manage your account settings, active sessions, and connected applications.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Account Information -->
|
<!-- 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">
|
<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">
|
<div class="mt-5 space-y-6">
|
||||||
<%= form_with model: @user, url: profile_path, method: :patch, class: "space-y-6" do |form| %>
|
<%= form_with model: @user, url: profile_path, method: :patch, class: "space-y-6" do |form| %>
|
||||||
<% if @user.errors.any? %>
|
<% if @user.errors.any? %>
|
||||||
<div class="rounded-md bg-red-50 p-4">
|
<div class="rounded-md bg-red-50 dark:bg-red-900/30 p-4">
|
||||||
<h3 class="text-sm font-medium text-red-800">
|
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">
|
||||||
<%= pluralize(@user.errors.count, "error") %> prohibited this from being saved:
|
<%= pluralize(@user.errors.count, "error") %> prohibited this from being saved:
|
||||||
</h3>
|
</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| %>
|
<% @user.errors.each do |error| %>
|
||||||
<li><%= error.full_message %></li>
|
<li><%= error.full_message %></li>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -24,24 +24,24 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div>
|
<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,
|
<%= form.email_field :email_address,
|
||||||
required: true,
|
required: true,
|
||||||
autocomplete: "email",
|
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>
|
||||||
|
|
||||||
<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,
|
<%= form.password_field :current_password,
|
||||||
autocomplete: "current-password",
|
autocomplete: "current-password",
|
||||||
placeholder: "Required to change email",
|
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" %>
|
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">Enter your current password to confirm this change</p>
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Enter your current password to confirm this change</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,38 +49,38 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Change Password -->
|
<!-- 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">
|
<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">
|
<div class="mt-5">
|
||||||
<%= form_with model: @user, url: profile_path, method: :patch, class: "space-y-6" do |form| %>
|
<%= form_with model: @user, url: profile_path, method: :patch, class: "space-y-6" do |form| %>
|
||||||
<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,
|
<%= form.password_field :current_password,
|
||||||
autocomplete: "current-password",
|
autocomplete: "current-password",
|
||||||
placeholder: "Enter 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>
|
||||||
|
|
||||||
<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,
|
<%= form.password_field :password,
|
||||||
autocomplete: "new-password",
|
autocomplete: "new-password",
|
||||||
placeholder: "Enter 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" %>
|
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">Must be at least 8 characters</p>
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Must be at least 8 characters</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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,
|
<%= form.password_field :password_confirmation,
|
||||||
autocomplete: "new-password",
|
autocomplete: "new-password",
|
||||||
placeholder: "Confirm 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>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,15 +88,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Two-Factor Authentication -->
|
<!-- 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">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Two-Factor Authentication</h3>
|
<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">
|
<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>
|
<p>Add an extra layer of security to your account by enabling two-factor authentication.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
<% if @user.totp_enabled? %>
|
<% 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">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
@@ -104,11 +104,11 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3 flex-1">
|
<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
|
Two-factor authentication is enabled
|
||||||
</p>
|
</p>
|
||||||
<% if @user.totp_required? %>
|
<% 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">
|
<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" />
|
<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>
|
</svg>
|
||||||
@@ -119,12 +119,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% if @user.totp_required? %>
|
<% 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">
|
<div class="flex">
|
||||||
<svg class="h-5 w-5 text-blue-400 mr-2 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
<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" />
|
<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>
|
</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.
|
Your administrator requires two-factor authentication. You cannot disable it.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
<button type="button"
|
<button type="button"
|
||||||
data-action="click->modal#show"
|
data-action="click->modal#show"
|
||||||
data-modal-id="view-backup-codes-modal"
|
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
|
View Backup Codes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,19 +142,19 @@
|
|||||||
<button type="button"
|
<button type="button"
|
||||||
data-action="click->modal#show"
|
data-action="click->modal#show"
|
||||||
data-modal-id="disable-2fa-modal"
|
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
|
Disable 2FA
|
||||||
</button>
|
</button>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
data-action="click->modal#show"
|
data-action="click->modal#show"
|
||||||
data-modal-id="view-backup-codes-modal"
|
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
|
View Backup Codes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% else %>
|
<% 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
|
Enable 2FA
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -166,17 +166,17 @@
|
|||||||
<div id="disable-2fa-modal"
|
<div id="disable-2fa-modal"
|
||||||
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape"
|
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">
|
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="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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
<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">
|
<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>
|
</div>
|
||||||
<%= form_with url: totp_path, method: :delete, class: "mt-4" do |form| %>
|
<%= form_with url: totp_path, method: :delete, class: "mt-4" do |form| %>
|
||||||
<div>
|
<div>
|
||||||
@@ -184,14 +184,14 @@
|
|||||||
placeholder: "Enter your password",
|
placeholder: "Enter your password",
|
||||||
autocomplete: "current-password",
|
autocomplete: "current-password",
|
||||||
required: true,
|
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>
|
||||||
<div class="mt-4 flex gap-3">
|
<div class="mt-4 flex gap-3">
|
||||||
<%= form.submit "Disable 2FA",
|
<%= 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"
|
<button type="button"
|
||||||
data-action="click->modal#hide"
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,18 +205,18 @@
|
|||||||
<div id="view-backup-codes-modal"
|
<div id="view-backup-codes-modal"
|
||||||
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape"
|
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">
|
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>
|
<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">
|
<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>
|
||||||
<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">
|
<div class="flex">
|
||||||
<svg class="h-5 w-5 text-yellow-400 mr-2 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
<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" />
|
<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>
|
</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.
|
<strong>Important:</strong> Save the new codes immediately after generation. You won't be able to see them again without regenerating.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,14 +227,14 @@
|
|||||||
placeholder: "Enter your password",
|
placeholder: "Enter your password",
|
||||||
autocomplete: "current-password",
|
autocomplete: "current-password",
|
||||||
required: true,
|
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>
|
||||||
<div class="mt-4 flex gap-3">
|
<div class="mt-4 flex gap-3">
|
||||||
<%= form.submit "Generate New Codes",
|
<%= 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"
|
<button type="button"
|
||||||
data-action="click->modal#hide"
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,10 +244,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Passkeys (WebAuthn) -->
|
<!-- 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">
|
<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>
|
<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">
|
<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>
|
<p>Use your fingerprint, face recognition, or security key to sign in without passwords.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -255,20 +255,20 @@
|
|||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
<div id="add-passkey-form" class="space-y-4">
|
<div id="add-passkey-form" class="space-y-4">
|
||||||
<div>
|
<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"
|
<input type="text"
|
||||||
id="passkey-nickname"
|
id="passkey-nickname"
|
||||||
data-webauthn-target="nickname"
|
data-webauthn-target="nickname"
|
||||||
placeholder="e.g., MacBook Touch ID, iPhone Face ID"
|
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">
|
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">Give this passkey a memorable name so you can identify it later.</p>
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
data-action="click->webauthn#register"
|
data-action="click->webauthn#register"
|
||||||
data-webauthn-target="submitButton"
|
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">
|
<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>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -284,11 +284,11 @@
|
|||||||
|
|
||||||
<!-- Existing Passkeys List -->
|
<!-- Existing Passkeys List -->
|
||||||
<div class="mt-8">
|
<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? %>
|
<% if @user.webauthn_credentials.exists? %>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<% @user.webauthn_credentials.order(created_at: :desc).each do |credential| %>
|
<% @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 items-center space-x-3">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<% if credential.platform_authenticator? %>
|
<% if credential.platform_authenticator? %>
|
||||||
@@ -304,10 +304,10 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<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 %>
|
<%= credential.nickname %>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-500">
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
<%= credential.authenticator_type.humanize %> •
|
<%= credential.authenticator_type.humanize %> •
|
||||||
Last used <%= credential.last_used_ago %>
|
Last used <%= credential.last_used_ago %>
|
||||||
<% if credential.backed_up? %>
|
<% if credential.backed_up? %>
|
||||||
@@ -318,7 +318,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<% if credential.created_recently? %>
|
<% 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
|
New
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -338,7 +338,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</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">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
@@ -346,7 +346,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3">
|
<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.
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -354,11 +354,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="text-center py-8">
|
<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>
|
<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>
|
</svg>
|
||||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No passkeys</h3>
|
<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">Get started by adding your first passkey for passwordless sign-in.</p>
|
<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>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</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" %>
|
||||||
8
app/views/security_mailer/totp_disabled.html.erb
Normal file
8
app/views/security_mailer/totp_disabled.html.erb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Two-factor authentication was just <strong>disabled</strong> on your
|
||||||
|
Clinch account (<strong><%= @user.email_address %></strong>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
6
app/views/security_mailer/totp_disabled.text.erb
Normal file
6
app/views/security_mailer/totp_disabled.text.erb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
Two-factor authentication was just disabled on your Clinch account
|
||||||
|
(<%= @user.email_address %>).
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<div class="mx-auto md:w-2/3 w-full" data-controller="webauthn login-form" data-webauthn-check-url-value="/webauthn/check">
|
<div class="mx-auto max-w-md w-full" data-controller="webauthn login-form" data-webauthn-check-url-value="/webauthn/check">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="font-bold text-4xl">Sign in to Clinch</h1>
|
<h1 class="font-bold text-4xl text-center">Sign in to Clinch</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
<%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
||||||
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
|
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %>
|
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700 dark:text-gray-300" %>
|
||||||
<%= form.email_field :email_address,
|
<%= form.email_field :email_address,
|
||||||
required: true,
|
required: true,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
@@ -14,17 +14,17 @@
|
|||||||
placeholder: "your@email.com",
|
placeholder: "your@email.com",
|
||||||
value: @login_hint || params[:email_address],
|
value: @login_hint || params[:email_address],
|
||||||
data: { action: "blur->webauthn#checkWebAuthnSupport change->webauthn#checkWebAuthnSupport" },
|
data: { action: "blur->webauthn#checkWebAuthnSupport change->webauthn#checkWebAuthnSupport" },
|
||||||
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
class: "block shadow-sm rounded-md border border-gray-400 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>
|
||||||
|
|
||||||
<!-- WebAuthn section - initially hidden -->
|
<!-- WebAuthn section - initially hidden -->
|
||||||
<div id="webauthn-section" data-login-form-target="webauthnSection" class="my-5 hidden">
|
<div id="webauthn-section" data-login-form-target="webauthnSection" class="my-5 hidden">
|
||||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
|
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-4 dark:bg-green-900/30 dark:border-green-700">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
<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>
|
</svg>
|
||||||
<p class="text-sm text-green-800">
|
<p class="text-sm text-green-800 dark:text-green-200">
|
||||||
<strong>Passkey detected!</strong> You can sign in without a password.
|
<strong>Passkey detected!</strong> You can sign in without a password.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,18 +38,24 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Continue with Passkey
|
Continue with Passkey
|
||||||
</button>
|
</button>
|
||||||
|
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600" style="display: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Password section - shown by default, hidden if WebAuthn is required -->
|
<!-- Password section - shown by default, hidden if WebAuthn is required -->
|
||||||
<div id="password-section" data-login-form-target="passwordSection">
|
<div id="password-section" data-login-form-target="passwordSection">
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= form.label :password, class: "block font-medium text-sm text-gray-700" %>
|
<%= form.label :password, class: "block font-medium text-sm text-gray-700 dark:text-gray-300" %>
|
||||||
<%= form.password_field :password,
|
<%= form.password_field :password,
|
||||||
required: true,
|
required: true,
|
||||||
autocomplete: "current-password",
|
autocomplete: "current-password",
|
||||||
placeholder: "Enter your password",
|
placeholder: "Enter your password",
|
||||||
maxlength: 72,
|
maxlength: 72,
|
||||||
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
class: "block shadow-sm rounded-md border border-gray-400 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 flex items-center">
|
||||||
|
<%= form.check_box :remember_me, { class: "rounded border-gray-400 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800" }, "1", "0" %>
|
||||||
|
<%= form.label :remember_me, "Remember me for 30 days", class: "ml-2 text-sm text-gray-600 dark:text-gray-400" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
@@ -58,14 +64,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 text-sm text-gray-600 text-center">
|
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400 text-center">
|
||||||
<%= link_to "Forgot your password?", new_password_path, class: "text-blue-600 hover:text-blue-500 underline" %>
|
<%= link_to "Forgot your password?", new_password_path, class: "text-blue-600 hover:text-blue-500 underline" %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- Loading overlay -->
|
<!-- Loading overlay -->
|
||||||
<div id="loading-overlay" data-login-form-target="loadingOverlay" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
|
<div id="loading-overlay" data-login-form-target="loadingOverlay"
|
||||||
<div class="bg-white rounded-lg p-6 flex items-center">
|
data-action="click->login-form#hideLoading"
|
||||||
|
class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50 cursor-pointer">
|
||||||
|
<div class="bg-white rounded-lg p-6 flex items-center dark:bg-gray-900">
|
||||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24">
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<div class="mx-auto max-w-md">
|
<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 py-8 px-6 shadow rounded-lg sm:px-10 dark:bg-gray-900">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h2 class="text-2xl font-bold text-gray-900">Two-Factor Authentication</h2>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Two-Factor Authentication</h2>
|
||||||
<p class="mt-2 text-sm text-gray-600">
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
Enter the 6-digit code from your authenticator app to complete sign in.
|
Enter the 6-digit code from your authenticator app to complete sign in.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
} do |form| %>
|
} do |form| %>
|
||||||
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
|
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
|
||||||
<div>
|
<div>
|
||||||
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700" %>
|
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
<%= text_field_tag :code,
|
<%= text_field_tag :code,
|
||||||
nil,
|
nil,
|
||||||
placeholder: "000000",
|
placeholder: "000000",
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
required: true,
|
required: true,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
autocomplete: "off",
|
autocomplete: "off",
|
||||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-center text-2xl tracking-widest font-mono sm:text-sm" %>
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-center text-2xl tracking-widest font-mono sm:text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
|
||||||
<p class="mt-2 text-xs text-gray-500">
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
Enter your 6-digit authenticator code or an 8-character backup code
|
Enter your 6-digit authenticator code or an 8-character backup code
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,25 +30,50 @@
|
|||||||
<div>
|
<div>
|
||||||
<%= form.submit "Verify",
|
<%= form.submit "Verify",
|
||||||
data: { form_submit_protection_target: "submit" },
|
data: { form_submit_protection_target: "submit" },
|
||||||
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 focus:ring-blue-500 dark:focus:ring-offset-gray-900" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @user_has_webauthn %>
|
||||||
|
<div data-controller="webauthn" data-webauthn-check-url-value="/webauthn/check">
|
||||||
|
<input type="hidden" name="webauthn_email" value="<%= @pending_email %>">
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="relative my-4">
|
||||||
|
<div class="absolute inset-0 flex items-center">
|
||||||
|
<div class="w-full border-t border-gray-300 dark:border-gray-600"></div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center text-sm">
|
||||||
|
<span class="px-2 bg-white text-gray-500 dark:bg-gray-900 dark:text-gray-400">Or</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
data-action="click->webauthn#authenticate"
|
||||||
|
class="w-full rounded-md px-3.5 py-2.5 bg-green-600 hover:bg-green-500 text-white font-medium cursor-pointer flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 mr-2" 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" />
|
||||||
|
</svg>
|
||||||
|
Use Passkey Instead
|
||||||
|
</button>
|
||||||
|
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-0 flex items-center">
|
<div class="absolute inset-0 flex items-center">
|
||||||
<div class="w-full border-t border-gray-300"></div>
|
<div class="w-full border-t border-gray-300 dark:border-gray-600"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex justify-center text-sm">
|
<div class="relative flex justify-center text-sm">
|
||||||
<span class="px-2 bg-white text-gray-500">Need help?</span>
|
<span class="px-2 bg-white text-gray-500 dark:bg-gray-900 dark:text-gray-400">Need help?</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 text-center">
|
<div class="mt-4 text-center">
|
||||||
<p class="text-sm text-gray-600">
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Lost access to your authenticator?
|
Lost access to your authenticator?
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
Contact an administrator for assistance.
|
Contact an administrator for assistance.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
18
app/views/shared/_app_monogram.html.erb
Normal file
18
app/views/shared/_app_monogram.html.erb
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<%# Renders a deterministic monogram SVG for an Application that has no icon.
|
||||||
|
Locals:
|
||||||
|
name - the application name (required)
|
||||||
|
class - css classes for the <svg> element (e.g. "h-12 w-12 rounded-lg")
|
||||||
|
%>
|
||||||
|
<% initials = monogram_initials(name) %>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"
|
||||||
|
class="<%= local_assigns[:class] || "h-12 w-12 rounded-lg" %>"
|
||||||
|
role="img" aria-label="<%= name %> icon">
|
||||||
|
<rect width="40" height="40" fill="<%= monogram_color(name) %>" />
|
||||||
|
<text x="50%" y="52%" dominant-baseline="middle" text-anchor="middle"
|
||||||
|
font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif"
|
||||||
|
font-weight="600" fill="#ffffff"
|
||||||
|
font-size="<%= initials.length == 1 ? 22 : 17 %>"
|
||||||
|
letter-spacing="-0.5">
|
||||||
|
<%= initials %>
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
@@ -8,34 +8,34 @@
|
|||||||
# Map flash types to styling
|
# Map flash types to styling
|
||||||
case type.to_s
|
case type.to_s
|
||||||
when 'notice'
|
when 'notice'
|
||||||
bg_class = 'bg-green-50'
|
bg_class = 'bg-green-50 dark:bg-green-900/30'
|
||||||
text_class = 'text-green-800'
|
text_class = 'text-green-800 dark:text-green-200'
|
||||||
icon_class = 'text-green-400'
|
icon_class = 'text-green-400 dark:text-green-300'
|
||||||
icon_path = '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'
|
icon_path = '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'
|
||||||
auto_dismiss = true
|
auto_dismiss = true
|
||||||
when 'alert', 'error'
|
when 'alert', 'error'
|
||||||
bg_class = 'bg-red-50'
|
bg_class = 'bg-red-50 dark:bg-red-900/30'
|
||||||
text_class = 'text-red-800'
|
text_class = 'text-red-800 dark:text-red-200'
|
||||||
icon_class = 'text-red-400'
|
icon_class = 'text-red-400 dark:text-red-300'
|
||||||
icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z'
|
icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z'
|
||||||
auto_dismiss = false
|
auto_dismiss = false
|
||||||
when 'warning'
|
when 'warning'
|
||||||
bg_class = 'bg-yellow-50'
|
bg_class = 'bg-yellow-50 dark:bg-yellow-900/30'
|
||||||
text_class = 'text-yellow-800'
|
text_class = 'text-yellow-800 dark:text-yellow-200'
|
||||||
icon_class = 'text-yellow-400'
|
icon_class = 'text-yellow-400 dark:text-yellow-300'
|
||||||
icon_path = 'M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
|
icon_path = 'M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
|
||||||
auto_dismiss = false
|
auto_dismiss = false
|
||||||
when 'info'
|
when 'info'
|
||||||
bg_class = 'bg-blue-50'
|
bg_class = 'bg-blue-50 dark:bg-blue-900/30'
|
||||||
text_class = 'text-blue-800'
|
text_class = 'text-blue-800 dark:text-blue-200'
|
||||||
icon_class = 'text-blue-400'
|
icon_class = 'text-blue-400 dark:text-blue-300'
|
||||||
icon_path = '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'
|
icon_path = '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'
|
||||||
auto_dismiss = true
|
auto_dismiss = true
|
||||||
else
|
else
|
||||||
# Default styling for unknown types
|
# Default styling for unknown types
|
||||||
bg_class = 'bg-gray-50'
|
bg_class = 'bg-gray-50 dark:bg-gray-800'
|
||||||
text_class = 'text-gray-800'
|
text_class = 'text-gray-800 dark:text-gray-200'
|
||||||
icon_class = 'text-gray-400'
|
icon_class = 'text-gray-400 dark:text-gray-500'
|
||||||
icon_path = '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'
|
icon_path = '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'
|
||||||
auto_dismiss = false
|
auto_dismiss = false
|
||||||
end
|
end
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
<div class="-mx-1.5 -my-1.5">
|
<div class="-mx-1.5 -my-1.5">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
data-action="click->flash#dismiss"
|
data-action="click->flash#dismiss"
|
||||||
class="inline-flex rounded-md <%= bg_class %> p-1.5 <%= icon_class %> hover:bg-opacity-70 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-<%= bg_class.gsub('bg-', '') %>"
|
class="inline-flex rounded-md <%= bg_class %> p-1.5 <%= icon_class %> hover:bg-opacity-70 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||||
aria-label="Dismiss">
|
aria-label="Dismiss">
|
||||||
<span class="sr-only">Dismiss</span>
|
<span class="sr-only">Dismiss</span>
|
||||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
|||||||
@@ -3,19 +3,19 @@
|
|||||||
|
|
||||||
<% form_object = form.respond_to?(:object) ? form.object : (object || form) %>
|
<% form_object = form.respond_to?(:object) ? form.object : (object || form) %>
|
||||||
<% if form_object&.errors&.any? %>
|
<% if form_object&.errors&.any? %>
|
||||||
<div class="rounded-md bg-red-50 p-4 mb-6 border border-red-200" role="alert" aria-labelledby="form-errors-title" data-form-errors-target="container">
|
<div class="rounded-md bg-red-50 dark:bg-red-900/30 p-4 mb-6 border border-red-200 dark:border-red-700" role="alert" aria-labelledby="form-errors-title" data-form-errors-target="container">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg class="h-5 w-5 text-red-400 dark:text-red-300" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3 flex-1">
|
<div class="ml-3 flex-1">
|
||||||
<h3 id="form-errors-title" class="text-sm font-medium text-red-800">
|
<h3 id="form-errors-title" class="text-sm font-medium text-red-800 dark:text-red-200">
|
||||||
<%= pluralize(form_object.errors.count, "error") %> prohibited this <%= form_object.class.name.downcase.gsub(/^admin::/, '') %> from being saved:
|
<%= pluralize(form_object.errors.count, "error") %> prohibited this <%= form_object.class.name.downcase.gsub(/^admin::/, '') %> from being saved:
|
||||||
</h3>
|
</h3>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<ul class="list-disc space-y-1 pl-5 text-sm text-red-700">
|
<ul class="list-disc space-y-1 pl-5 text-sm text-red-700 dark:text-red-300">
|
||||||
<% form_object.errors.full_messages.each do |message| %>
|
<% form_object.errors.full_messages.each do |message| %>
|
||||||
<li><%= message %></li>
|
<li><%= message %></li>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-auto pl-3">
|
<div class="ml-auto pl-3">
|
||||||
<div class="-mx-1.5 -my-1.5">
|
<div class="-mx-1.5 -my-1.5">
|
||||||
<button type="button" data-action="click->form-errors#dismiss" class="inline-flex rounded-md bg-red-50 p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50" aria-label="Dismiss">
|
<button type="button" data-action="click->form-errors#dismiss" class="inline-flex rounded-md bg-red-50 dark:bg-red-900/30 p-1.5 text-red-500 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/50 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50 dark:focus:ring-offset-gray-900" aria-label="Dismiss">
|
||||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -6,16 +6,16 @@
|
|||||||
|
|
||||||
<!-- Desktop sidebar -->
|
<!-- Desktop sidebar -->
|
||||||
<div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-64 lg:flex-col">
|
<div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-64 lg:flex-col">
|
||||||
<div class="flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4">
|
<div class="flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-6 pb-4">
|
||||||
<!-- Branding and User Info -->
|
<!-- Branding and User Info -->
|
||||||
<div class="flex flex-col pt-5 pb-4 border-b border-gray-200">
|
<div class="flex flex-col pt-5 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Clinch</h1>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Clinch</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<p class="text-sm text-gray-600 truncate"><%= user.email_address %></p>
|
<p class="text-sm text-gray-600 dark:text-gray-400 truncate"><%= user.email_address %></p>
|
||||||
<% if user.admin? %>
|
<% 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-800 mt-1">
|
<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-800 dark:text-blue-200 mt-1">
|
||||||
Administrator
|
Administrator
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<ul role="list" class="-mx-2 space-y-1">
|
<ul role="list" class="-mx-2 space-y-1">
|
||||||
<!-- Dashboard -->
|
<!-- Dashboard -->
|
||||||
<li>
|
<li>
|
||||||
<%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %>
|
<%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/' ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }" do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<% if user.admin? %>
|
<% if user.admin? %>
|
||||||
<!-- Admin: Users -->
|
<!-- Admin: Users -->
|
||||||
<li>
|
<li>
|
||||||
<%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/users') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %>
|
<%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/users') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }" do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
|
|
||||||
<!-- Admin: Applications -->
|
<!-- Admin: Applications -->
|
||||||
<li>
|
<li>
|
||||||
<%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/applications') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %>
|
<%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/applications') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }" do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -59,18 +59,28 @@
|
|||||||
|
|
||||||
<!-- Admin: Groups -->
|
<!-- Admin: Groups -->
|
||||||
<li>
|
<li>
|
||||||
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %>
|
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }" do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
Groups
|
Groups
|
||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<!-- Admin: Access check -->
|
||||||
|
<li>
|
||||||
|
<%= link_to admin_access_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/access') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }" do %>
|
||||||
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Access check
|
||||||
|
<% end %>
|
||||||
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- Profile -->
|
<!-- Profile -->
|
||||||
<li>
|
<li>
|
||||||
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %>
|
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }" do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -80,7 +90,7 @@
|
|||||||
|
|
||||||
<!-- Sessions -->
|
<!-- Sessions -->
|
||||||
<li>
|
<li>
|
||||||
<%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/active_sessions' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %>
|
<%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/active_sessions' ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }" do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -88,9 +98,25 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<!-- Dark Mode Toggle -->
|
||||||
|
<li data-controller="dark-mode">
|
||||||
|
<button type="button" data-action="click->dark-mode#toggle" class="group flex w-full gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||||
|
<!-- Moon icon (shown in light mode) -->
|
||||||
|
<svg data-dark-mode-target="icon" data-mode="light" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="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>
|
||||||
|
<!-- Sun icon (shown in dark mode) -->
|
||||||
|
<svg data-dark-mode-target="icon" data-mode="dark" class="hidden h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 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>
|
||||||
|
<span data-dark-mode-target="icon" data-mode="light">Dark Mode</span>
|
||||||
|
<span data-dark-mode-target="icon" data-mode="dark" class="hidden">Light Mode</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
<!-- Sign Out -->
|
<!-- Sign Out -->
|
||||||
<li>
|
<li>
|
||||||
<%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
|
<%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20" do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -99,6 +125,10 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li class="mt-auto pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<%= render "shared/version_info" %>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,16 +154,16 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex grow flex-col gap-y-5 overflow-y-auto bg-white px-6 pb-2">
|
<div class="flex grow flex-col gap-y-5 overflow-y-auto bg-white dark:bg-gray-900 px-6 pb-2">
|
||||||
<!-- Branding and User Info -->
|
<!-- Branding and User Info -->
|
||||||
<div class="flex flex-col pt-5 pb-4 border-b border-gray-200">
|
<div class="flex flex-col pt-5 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Clinch</h1>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Clinch</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<p class="text-sm text-gray-600 truncate"><%= user.email_address %></p>
|
<p class="text-sm text-gray-600 dark:text-gray-400 truncate"><%= user.email_address %></p>
|
||||||
<% if user.admin? %>
|
<% 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-800 mt-1">
|
<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-800 dark:text-blue-200 mt-1">
|
||||||
Administrator
|
Administrator
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -144,7 +174,7 @@
|
|||||||
<!-- Same nav items as desktop -->
|
<!-- Same nav items as desktop -->
|
||||||
<ul role="list" class="-mx-2 space-y-1">
|
<ul role="list" class="-mx-2 space-y-1">
|
||||||
<li>
|
<li>
|
||||||
<%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
<%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/' ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -153,7 +183,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<% if user.admin? %>
|
<% if user.admin? %>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/users') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
<%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/users') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -161,7 +191,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/applications') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
<%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/applications') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -169,16 +199,24 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
Groups
|
Groups
|
||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<%= link_to admin_access_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/access') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Access check
|
||||||
|
<% end %>
|
||||||
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -186,15 +224,30 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/active_sessions' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
<%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/active_sessions' ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
|
||||||
</svg>
|
</svg>
|
||||||
Sessions
|
Sessions
|
||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<!-- Dark Mode Toggle (mobile) -->
|
||||||
|
<li data-controller="dark-mode">
|
||||||
|
<button type="button" data-action="click->dark-mode#toggle" class="group flex w-full gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||||
|
<svg data-dark-mode-target="icon" data-mode="light" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="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-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 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>
|
||||||
|
<span data-dark-mode-target="icon" data-mode="light">Dark Mode</span>
|
||||||
|
<span data-dark-mode-target="icon" data-mode="dark" class="hidden">Light Mode</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
|
<%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20" do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -202,6 +255,10 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<div class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<%= render "shared/version_info" %>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
4
app/views/shared/_version_info.html.erb
Normal file
4
app/views/shared/_version_info.html.erb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="px-2 text-xs text-gray-500 dark:text-gray-500 space-y-0.5">
|
||||||
|
<div>Clinch <%= Clinch::VERSION %></div>
|
||||||
|
<div>Rails <%= Rails.version %> · Ruby <%= RUBY_VERSION %></div>
|
||||||
|
</div>
|
||||||
@@ -1,42 +1,42 @@
|
|||||||
<div class="max-w-2xl mx-auto" data-controller="backup-codes" data-backup-codes-codes-value="<%= @backup_codes.to_json %>">
|
<div class="max-w-2xl mx-auto" data-controller="backup-codes" data-backup-codes-codes-value="<%= @backup_codes.to_json %>">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Backup Codes</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Backup Codes</h1>
|
||||||
<p class="mt-2 text-sm text-gray-600">
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
Save these backup codes in a safe place. Each code can only be used once.
|
Save these backup codes in a safe place. Each code can only be used once.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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="px-4 py-5 sm:p-6">
|
||||||
<div class="rounded-md bg-yellow-50 p-4 mb-6">
|
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/30 p-4 mb-6">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<svg class="h-5 w-5 text-yellow-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
<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" />
|
<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>
|
</svg>
|
||||||
<div class="text-sm text-yellow-800">
|
<div class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
<p class="font-medium">Save these codes now!</p>
|
<p class="font-medium">Save these codes now!</p>
|
||||||
<p class="mt-1">Store them somewhere safe. You won't be able to see them again without re-entering your password.</p>
|
<p class="mt-1">Store them somewhere safe. You won't be able to see them again without re-entering your password.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4 p-6 bg-gray-50 rounded-lg font-mono">
|
<div class="grid grid-cols-2 gap-4 p-6 bg-gray-50 dark:bg-gray-700 rounded-lg font-mono">
|
||||||
<% @backup_codes.each do |code| %>
|
<% @backup_codes.each do |code| %>
|
||||||
<div class="text-center text-lg tracking-wider py-2 px-4 bg-white rounded border border-gray-200">
|
<div class="text-center text-lg tracking-wider py-2 px-4 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 dark:text-gray-100">
|
||||||
<%= code %>
|
<%= code %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex gap-3">
|
<div class="mt-6 flex gap-3">
|
||||||
<button data-action="click->backup-codes#download" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 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">
|
<button data-action="click->backup-codes#download" class="inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 py-2 px-4 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-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
</svg>
|
</svg>
|
||||||
Download Codes
|
Download Codes
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button data-action="click->backup-codes#print" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 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">
|
<button data-action="click->backup-codes#print" class="inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 py-2 px-4 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-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -47,13 +47,12 @@
|
|||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<% if @auto_signin_pending %>
|
<% if @auto_signin_pending %>
|
||||||
<%= button_to "Continue to Sign In", complete_totp_setup_path, method: :post,
|
<%= button_to "Continue to Sign In", complete_totp_setup_path, method: :post,
|
||||||
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" %>
|
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" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= link_to "Done", profile_path,
|
<%= link_to "Done", profile_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" %>
|
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" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Enable Two-Factor Authentication</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Enable Two-Factor Authentication</h1>
|
||||||
<p class="mt-2 text-sm text-gray-600">
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
Scan the QR code below with your authenticator app, then enter the verification code to confirm.
|
Scan the QR code below with your authenticator app, then enter the verification code to confirm.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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="px-4 py-5 sm:p-6">
|
||||||
<!-- Step 1: Scan QR Code -->
|
<!-- Step 1: Scan QR Code -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Step 1: Scan QR Code</h3>
|
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">Step 1: Scan QR Code</h3>
|
||||||
<div class="flex justify-center p-6 bg-gray-50 rounded-lg">
|
<div class="flex justify-center p-6 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
<%= @qr_code.as_svg(
|
<%= @qr_code.as_svg(
|
||||||
module_size: 4,
|
module_size: 4,
|
||||||
color: "000",
|
color: "000",
|
||||||
@@ -19,26 +19,24 @@
|
|||||||
standalone: true
|
standalone: true
|
||||||
).html_safe %>
|
).html_safe %>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-4 text-sm text-gray-600 text-center">
|
<p class="mt-4 text-sm text-gray-600 dark:text-gray-400 text-center">
|
||||||
Use an authenticator app like Google Authenticator, Authy, or 1Password to scan this code.
|
Use an authenticator app like Google Authenticator, Authy, or 1Password to scan this code.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Manual Entry Option -->
|
<!-- Manual Entry Option -->
|
||||||
<div class="mb-8 p-4 bg-blue-50 rounded-lg">
|
<div class="mb-8 p-4 bg-blue-50 dark:bg-blue-900/30 rounded-lg">
|
||||||
<p class="text-sm font-medium text-blue-900 mb-2">Can't scan the QR code?</p>
|
<p class="text-sm font-medium text-blue-900 dark:text-blue-200 mb-2">Can't scan the QR code?</p>
|
||||||
<p class="text-sm text-blue-800">Enter this key manually in your authenticator app:</p>
|
<p class="text-sm text-blue-800 dark:text-blue-300">Enter this key manually in your authenticator app:</p>
|
||||||
<code class="mt-2 block p-2 bg-white rounded text-sm font-mono break-all"><%= @totp_secret %></code>
|
<code class="mt-2 block p-2 bg-white dark:bg-gray-700 dark:text-gray-200 rounded text-sm font-mono break-all"><%= @totp_secret %></code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 2: Verify -->
|
<!-- Step 2: Verify -->
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Step 2: Verify</h3>
|
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">Step 2: Verify</h3>
|
||||||
<%= form_with url: totp_path, method: :post, class: "space-y-4" do |form| %>
|
<%= form_with url: totp_path, method: :post, class: "space-y-4" do |form| %>
|
||||||
<%= hidden_field_tag :totp_secret, @totp_secret %>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700" %>
|
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
<%= text_field_tag :code,
|
<%= text_field_tag :code,
|
||||||
nil,
|
nil,
|
||||||
placeholder: "000000",
|
placeholder: "000000",
|
||||||
@@ -46,27 +44,27 @@
|
|||||||
required: true,
|
required: true,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
autocomplete: "off",
|
autocomplete: "off",
|
||||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-center text-2xl tracking-widest 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 text-center text-2xl tracking-widest font-mono" %>
|
||||||
<p class="mt-1 text-sm text-gray-500">Enter the 6-digit code from your authenticator app</p>
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Enter the 6-digit code from your authenticator app</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<%= form.submit "Verify and Enable 2FA",
|
<%= form.submit "Verify and Enable 2FA",
|
||||||
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" %>
|
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" %>
|
||||||
<%= link_to "Cancel", profile_path,
|
<%= link_to "Cancel", profile_path,
|
||||||
class: "inline-flex justify-center rounded-md border border-gray-300 bg-white py-2 px-4 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 py-2 px-4 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" %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 p-4 bg-yellow-50 rounded-lg">
|
<div class="mt-6 p-4 bg-yellow-50 dark:bg-yellow-900/30 rounded-lg">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<svg class="h-5 w-5 text-yellow-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
<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" />
|
<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>
|
</svg>
|
||||||
<div class="text-sm text-yellow-800">
|
<div class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
<p class="font-medium">Important: Save your backup codes</p>
|
<p class="font-medium">Important: Save your backup codes</p>
|
||||||
<p class="mt-1">After verifying, you'll be shown backup codes. Save these in a safe place - they can be used to access your account if you lose your device.</p>
|
<p class="mt-1">After verifying, you'll be shown backup codes. Save these in a safe place - they can be used to access your account if you lose your device.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Regenerate Backup Codes</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Regenerate Backup Codes</h1>
|
||||||
<p class="mt-2 text-sm text-gray-600">
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
This will invalidate all existing backup codes and generate new ones.
|
This will invalidate all existing backup codes and generate new ones.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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="px-4 py-5 sm:p-6">
|
||||||
<div class="rounded-md bg-yellow-50 p-4 mb-6">
|
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/30 p-4 mb-6">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<svg class="h-5 w-5 text-yellow-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
<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" />
|
<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>
|
</svg>
|
||||||
<div class="text-sm text-yellow-800">
|
<div class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
<p class="font-medium">Important Security Notice</p>
|
<p class="font-medium">Important Security Notice</p>
|
||||||
<p class="mt-1">All your current backup codes will become invalid after this action. Make sure you're ready to save the new codes.</p>
|
<p class="mt-1">All your current backup codes will become invalid after this action. Make sure you're ready to save the new codes.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,22 +22,22 @@
|
|||||||
|
|
||||||
<%= form_with(url: create_new_backup_codes_totp_path, method: :post, class: "space-y-6") do |form| %>
|
<%= form_with(url: create_new_backup_codes_totp_path, method: :post, class: "space-y-6") do |form| %>
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :password, "Enter your password to confirm", class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :password, "Enter your password to confirm", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<%= form.password_field :password, required: true,
|
<%= form.password_field :password, required: true,
|
||||||
class: "block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm" %>
|
class: "block w-full appearance-none rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 placeholder-gray-400 dark:placeholder-gray-500 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm" %>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-gray-500">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
This is required to verify your identity before regenerating backup codes.
|
This is required to verify your identity before regenerating backup codes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<%= form.submit "Generate New Backup Codes",
|
<%= form.submit "Generate New Backup Codes",
|
||||||
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" %>
|
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" %>
|
||||||
|
|
||||||
<%= link_to "Cancel", profile_path,
|
<%= link_to "Cancel", profile_path,
|
||||||
class: "inline-flex justify-center rounded-md border border-gray-300 bg-white py-2 px-4 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 py-2 px-4 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" %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
16
app/views/totp_mailer/enabled.html.erb
Normal file
16
app/views/totp_mailer/enabled.html.erb
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Two-factor authentication was just enabled on the Clinch account for
|
||||||
|
<strong><%= @user.email_address %></strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you did this, you can ignore this email.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you did <strong>not</strong> do this, your account may have been
|
||||||
|
accessed by someone else. Reset your password immediately and contact
|
||||||
|
your administrator.
|
||||||
|
</p>
|
||||||
9
app/views/totp_mailer/enabled.text.erb
Normal file
9
app/views/totp_mailer/enabled.text.erb
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
Two-factor authentication was just enabled on the Clinch account for
|
||||||
|
<%= @user.email_address %>.
|
||||||
|
|
||||||
|
If you did this, you can ignore this email.
|
||||||
|
|
||||||
|
If you did NOT do this, your account may have been accessed by someone
|
||||||
|
else. Reset your password immediately and contact your administrator.
|
||||||
@@ -1,41 +1,41 @@
|
|||||||
<div class="mx-auto md:w-2/3 w-full">
|
<div class="mx-auto md:w-2/3 w-full">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="font-bold text-4xl">Welcome to Clinch</h1>
|
<h1 class="font-bold text-4xl">Welcome to Clinch</h1>
|
||||||
<p class="mt-2 text-gray-600">Create your admin account to get started</p>
|
<p class="mt-2 text-gray-600 dark:text-gray-400">Create your admin account to get started</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_with model: @user, url: signup_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
<%= form_with model: @user, url: signup_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
||||||
<%= render "shared/form_errors", form: form %>
|
<%= render "shared/form_errors", form: form %>
|
||||||
|
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= form.label :email_address, class: "block font-medium text-sm text-gray-700" %>
|
<%= form.label :email_address, class: "block font-medium text-sm text-gray-700 dark:text-gray-300" %>
|
||||||
<%= form.email_field :email_address,
|
<%= form.email_field :email_address,
|
||||||
required: true,
|
required: true,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
autocomplete: "email",
|
autocomplete: "email",
|
||||||
placeholder: "admin@example.com",
|
placeholder: "admin@example.com",
|
||||||
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
class: "block shadow-sm rounded-md border border-gray-400 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>
|
||||||
|
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= form.label :password, class: "block font-medium text-sm text-gray-700" %>
|
<%= form.label :password, class: "block font-medium text-sm text-gray-700 dark:text-gray-300" %>
|
||||||
<%= form.password_field :password,
|
<%= form.password_field :password,
|
||||||
required: true,
|
required: true,
|
||||||
autocomplete: "new-password",
|
autocomplete: "new-password",
|
||||||
placeholder: "Enter a strong password",
|
placeholder: "Enter a strong password",
|
||||||
maxlength: 72,
|
maxlength: 72,
|
||||||
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
|
||||||
<p class="mt-1 text-sm text-gray-500">Must be at least 8 characters</p>
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Must be at least 8 characters</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= form.label :password_confirmation, "Confirm Password", class: "block font-medium text-sm text-gray-700" %>
|
<%= form.label :password_confirmation, "Confirm Password", class: "block font-medium text-sm text-gray-700 dark:text-gray-300" %>
|
||||||
<%= form.password_field :password_confirmation,
|
<%= form.password_field :password_confirmation,
|
||||||
required: true,
|
required: true,
|
||||||
autocomplete: "new-password",
|
autocomplete: "new-password",
|
||||||
placeholder: "Re-enter your password",
|
placeholder: "Re-enter your password",
|
||||||
maxlength: 72,
|
maxlength: 72,
|
||||||
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
class: "block shadow-sm rounded-md border border-gray-400 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>
|
||||||
|
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
@@ -43,8 +43,8 @@
|
|||||||
class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %>
|
class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
|
<div class="mt-4 p-4 bg-blue-50 rounded-lg dark:bg-blue-900/30">
|
||||||
<p class="text-sm text-blue-900">
|
<p class="text-sm text-blue-900 dark:text-blue-200">
|
||||||
<strong>Note:</strong> This is a first-run setup. You're creating the initial administrator account.
|
<strong>Note:</strong> This is a first-run setup. You're creating the initial administrator account.
|
||||||
After this, you'll be able to invite other users from the admin dashboard.
|
After this, you'll be able to invite other users from the admin dashboard.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -38,10 +38,6 @@ env:
|
|||||||
secret:
|
secret:
|
||||||
- RAILS_MASTER_KEY
|
- RAILS_MASTER_KEY
|
||||||
clear:
|
clear:
|
||||||
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
|
|
||||||
# When you start using multiple servers, you should split out job processing to a dedicated machine.
|
|
||||||
SOLID_QUEUE_IN_PUMA: true
|
|
||||||
|
|
||||||
# Set number of processes dedicated to Solid Queue (default: 1)
|
# Set number of processes dedicated to Solid Queue (default: 1)
|
||||||
# JOB_CONCURRENCY: 3
|
# JOB_CONCURRENCY: 3
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user