Compare commits
34 Commits
2026.01
...
c7d9df48b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7d9df48b5 | ||
|
|
3d98261a51 | ||
|
|
43958f50ce | ||
|
|
d8d8000b92 | ||
|
|
6844c5fab3 | ||
|
|
5505f99287 | ||
|
|
1b691ad341 | ||
|
|
f65df76d99 | ||
|
|
c5898bd9a4 | ||
|
|
9dbde8ea31 | ||
|
|
191a7b5fb3 | ||
|
|
7a9348c1f1 | ||
|
|
225d8ae5ca | ||
|
|
65c19fa732 | ||
|
|
fd8785a43d | ||
|
|
444ae6291c | ||
|
|
233fb723d5 | ||
|
|
cc6d4fcc65 | ||
|
|
5268f10eb3 | ||
|
|
5c5662eaab | ||
|
|
27d77ebf47 | ||
|
|
ba08158c85 | ||
|
|
a6480b0860 | ||
|
|
75cc223329 | ||
|
|
46ae65f4d2 | ||
|
|
95d0d844e9 | ||
|
|
524a7719c3 | ||
|
|
8110d547dd | ||
|
|
25e1043312 | ||
|
|
074a734c0c | ||
|
|
4a48012a82 | ||
|
|
e631f606e7 | ||
|
|
f4a697ae9b | ||
|
|
16e34ffaf0 |
@@ -1 +1 @@
|
|||||||
3.4.8
|
4.0.1
|
||||||
|
|||||||
@@ -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.1
|
||||||
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.2"
|
||||||
# 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
|
||||||
|
|||||||
144
Gemfile.lock
144
Gemfile.lock
@@ -3,29 +3,29 @@ GEM
|
|||||||
specs:
|
specs:
|
||||||
action_text-trix (2.1.16)
|
action_text-trix (2.1.16)
|
||||||
railties
|
railties
|
||||||
actioncable (8.1.1)
|
actioncable (8.1.2)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
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.2)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.2)
|
||||||
activejob (= 8.1.1)
|
activejob (= 8.1.2)
|
||||||
activerecord (= 8.1.1)
|
activerecord (= 8.1.2)
|
||||||
activestorage (= 8.1.1)
|
activestorage (= 8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
actionmailer (8.1.1)
|
actionmailer (8.1.2)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.2)
|
||||||
actionview (= 8.1.1)
|
actionview (= 8.1.2)
|
||||||
activejob (= 8.1.1)
|
activejob (= 8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
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.2)
|
||||||
actionview (= 8.1.1)
|
actionview (= 8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
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.2)
|
||||||
action_text-trix (~> 2.1.15)
|
action_text-trix (~> 2.1.15)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.2)
|
||||||
activerecord (= 8.1.1)
|
activerecord (= 8.1.2)
|
||||||
activestorage (= 8.1.1)
|
activestorage (= 8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (8.1.1)
|
actionview (8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
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.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (8.1.1)
|
activemodel (8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
activerecord (8.1.1)
|
activerecord (8.1.2)
|
||||||
activemodel (= 8.1.1)
|
activemodel (= 8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activestorage (8.1.1)
|
activestorage (8.1.2)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.2)
|
||||||
activejob (= 8.1.1)
|
activejob (= 8.1.2)
|
||||||
activerecord (= 8.1.1)
|
activerecord (= 8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (8.1.1)
|
activesupport (8.1.2)
|
||||||
base64
|
base64
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||||
@@ -120,7 +120,7 @@ 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.2)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.4.0)
|
et-orbi (1.4.0)
|
||||||
tzinfo
|
tzinfo
|
||||||
@@ -146,14 +146,15 @@ GEM
|
|||||||
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.17.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.0)
|
||||||
jwt (3.1.2)
|
jwt (3.1.2)
|
||||||
base64
|
base64
|
||||||
kamal (2.10.1)
|
kamal (2.10.1)
|
||||||
@@ -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.3)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
@@ -207,19 +208,19 @@ GEM
|
|||||||
net-protocol
|
net-protocol
|
||||||
net-ssh (7.3.0)
|
net-ssh (7.3.0)
|
||||||
nio4r (2.7.5)
|
nio4r (2.7.5)
|
||||||
nokogiri (1.19.0-aarch64-linux-gnu)
|
nokogiri (1.19.1-aarch64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.0-aarch64-linux-musl)
|
nokogiri (1.19.1-aarch64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.0-arm-linux-gnu)
|
nokogiri (1.19.1-arm-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.0-arm-linux-musl)
|
nokogiri (1.19.1-arm-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.0-arm64-darwin)
|
nokogiri (1.19.1-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.0-x86_64-linux-gnu)
|
nokogiri (1.19.1-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.0-x86_64-linux-musl)
|
nokogiri (1.19.1-x86_64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
openssl (4.0.0)
|
openssl (4.0.0)
|
||||||
openssl-signature_algorithm (1.3.0)
|
openssl-signature_algorithm (1.3.0)
|
||||||
@@ -245,7 +246,7 @@ GEM
|
|||||||
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.5)
|
||||||
rack-session (2.1.1)
|
rack-session (2.1.1)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
rack (>= 3.0.0)
|
rack (>= 3.0.0)
|
||||||
@@ -253,30 +254,30 @@ GEM
|
|||||||
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.2)
|
||||||
actioncable (= 8.1.1)
|
actioncable (= 8.1.2)
|
||||||
actionmailbox (= 8.1.1)
|
actionmailbox (= 8.1.2)
|
||||||
actionmailer (= 8.1.1)
|
actionmailer (= 8.1.2)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.2)
|
||||||
actiontext (= 8.1.1)
|
actiontext (= 8.1.2)
|
||||||
actionview (= 8.1.1)
|
actionview (= 8.1.2)
|
||||||
activejob (= 8.1.1)
|
activejob (= 8.1.2)
|
||||||
activemodel (= 8.1.1)
|
activemodel (= 8.1.2)
|
||||||
activerecord (= 8.1.1)
|
activerecord (= 8.1.2)
|
||||||
activestorage (= 8.1.1)
|
activestorage (= 8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.1.1)
|
railties (= 8.1.2)
|
||||||
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.2)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
irb (~> 1.13)
|
irb (~> 1.13)
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
@@ -285,7 +286,7 @@ GEM
|
|||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.1)
|
rake (13.3.1)
|
||||||
rdoc (7.0.3)
|
rdoc (7.2.0)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
tsort
|
tsort
|
||||||
@@ -396,7 +397,7 @@ GEM
|
|||||||
tailwindcss-ruby (4.1.18-arm64-darwin)
|
tailwindcss-ruby (4.1.18-arm64-darwin)
|
||||||
tailwindcss-ruby (4.1.18-x86_64-linux-gnu)
|
tailwindcss-ruby (4.1.18-x86_64-linux-gnu)
|
||||||
tailwindcss-ruby (4.1.18-x86_64-linux-musl)
|
tailwindcss-ruby (4.1.18-x86_64-linux-musl)
|
||||||
thor (1.4.0)
|
thor (1.5.0)
|
||||||
thruster (0.1.17)
|
thruster (0.1.17)
|
||||||
thruster (0.1.17-aarch64-linux)
|
thruster (0.1.17-aarch64-linux)
|
||||||
thruster (0.1.17-arm64-darwin)
|
thruster (0.1.17-arm64-darwin)
|
||||||
@@ -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.2)
|
||||||
rotp (~> 6.3)
|
rotp (~> 6.3)
|
||||||
rqrcode (~> 3.1)
|
rqrcode (~> 3.1)
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
|
|||||||
41
README.md
41
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
|
||||||
@@ -75,6 +81,7 @@ Apps that speak OIDC use the OIDC flow.
|
|||||||
Apps that only need "who is it?", or you want available from the internet behind authentication (MeTube, Jellyfin) use ForwardAuth.
|
Apps that only need "who is it?", or you want available from the internet behind authentication (MeTube, Jellyfin) use ForwardAuth.
|
||||||
|
|
||||||
#### OpenID Connect (OIDC)
|
#### OpenID Connect (OIDC)
|
||||||
|
|
||||||
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
|
||||||
@@ -128,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)
|
||||||
@@ -284,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)
|
||||||
|
|
||||||
@@ -698,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
|
||||||
@@ -758,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,23 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/forms";
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
.dark input:where([type="text"], [type="email"], [type="password"], [type="number"], [type="url"], [type="tel"], [type="search"]),
|
||||||
|
.dark textarea,
|
||||||
|
.dark select {
|
||||||
|
background-color: var(--color-gray-800);
|
||||||
|
border-color: var(--color-gray-600);
|
||||||
|
color: var(--color-gray-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark input::placeholder,
|
||||||
|
.dark textarea::placeholder {
|
||||||
|
color: var(--color-gray-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark input:where([type="checkbox"], [type="radio"]) {
|
||||||
|
background-color: var(--color-gray-700);
|
||||||
|
border-color: var(--color-gray-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class ActiveSessionsController < ApplicationController
|
|||||||
Rails.logger.info "ActiveSessionsController: Logged out from #{application.name} - revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens"
|
Rails.logger.info "ActiveSessionsController: Logged out from #{application.name} - revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens"
|
||||||
|
|
||||||
# Keep the consent intact - this is the key difference from revoke_consent
|
# Keep the consent intact - this is the key difference from revoke_consent
|
||||||
redirect_to root_path, notice: "Successfully logged out of #{application.name}."
|
redirect_to root_path, notice: "Revoked access tokens for #{application.name}. Re-authentication will be required on next use."
|
||||||
end
|
end
|
||||||
|
|
||||||
def revoke_all_consents
|
def revoke_all_consents
|
||||||
|
|||||||
@@ -104,7 +104,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
|
:icon, :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
|
||||||
|
|||||||
@@ -1,69 +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)
|
bearer_result = authenticate_bearer_token
|
||||||
|
return bearer_result if bearer_result
|
||||||
|
|
||||||
# Check for one-time forward auth token first (to handle race condition)
|
|
||||||
session_id = check_forward_auth_token
|
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")
|
||||||
@@ -71,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
|
||||||
@@ -79,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
|
||||||
@@ -88,8 +74,10 @@ module Api
|
|||||||
case key
|
case key
|
||||||
when :user, :email, :name
|
when :user, :email, :name
|
||||||
[header_name, user.email_address]
|
[header_name, user.email_address]
|
||||||
|
when :username
|
||||||
|
[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
|
||||||
@@ -97,102 +85,127 @@ 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
|
||||||
|
auth_header = request.headers["Authorization"]
|
||||||
|
return nil unless auth_header&.start_with?("Bearer ")
|
||||||
|
|
||||||
|
token = auth_header.delete_prefix("Bearer ").strip
|
||||||
|
return render_bearer_error("Missing token") if token.blank?
|
||||||
|
|
||||||
|
api_key = ApiKey.find_by_token(token)
|
||||||
|
return render_bearer_error("Invalid or expired API key") unless api_key&.active?
|
||||||
|
|
||||||
|
user = api_key.user
|
||||||
|
return render_bearer_error("User account is not active") unless user.active?
|
||||||
|
|
||||||
|
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
||||||
|
app = api_key.application
|
||||||
|
|
||||||
|
if forwarded_host.present? && !app.matches_domain?(forwarded_host)
|
||||||
|
return render_bearer_error("API key not valid for this domain")
|
||||||
|
end
|
||||||
|
|
||||||
|
unless app.active?
|
||||||
|
return render_bearer_error("Application is inactive")
|
||||||
|
end
|
||||||
|
|
||||||
|
api_key.touch_last_used!
|
||||||
|
|
||||||
|
headers = app.headers_for_user(user)
|
||||||
|
headers.each { |key, value| response.headers[key] = value }
|
||||||
|
|
||||||
|
Rails.logger.info "ForwardAuth: API key '#{api_key.name}' authenticated user #{user.email_address} for #{forwarded_host}"
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_bearer_error(message)
|
||||||
|
render json: { error: message }, status: :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
def check_forward_auth_token
|
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 unless token.present?
|
||||||
|
|
||||||
# Try to get session ID from cache
|
|
||||||
session_id = Rails.cache.read("forward_auth_token:#{token}")
|
session_id = Rails.cache.read("forward_auth_token:#{token}")
|
||||||
return nil unless session_id
|
return nil unless session_id
|
||||||
|
|
||||||
# Verify the session exists and is valid
|
|
||||||
session = Session.find_by(id: session_id)
|
session = Session.find_by(id: 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}")
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -201,19 +214,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
|
||||||
@@ -222,32 +230,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
|
||||||
|
|||||||
51
app/controllers/api_keys_controller.rb
Normal file
51
app/controllers/api_keys_controller.rb
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
class ApiKeysController < ApplicationController
|
||||||
|
before_action :set_api_key, only: :destroy
|
||||||
|
|
||||||
|
def index
|
||||||
|
@api_keys = Current.session.user.api_keys.includes(:application).order(created_at: :desc)
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@api_key = ApiKey.new
|
||||||
|
@applications = forward_auth_apps_for_user
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@api_key = Current.session.user.api_keys.build(api_key_params)
|
||||||
|
|
||||||
|
if @api_key.save
|
||||||
|
flash[:api_key_token] = @api_key.plaintext_token
|
||||||
|
redirect_to api_key_path(@api_key)
|
||||||
|
else
|
||||||
|
@applications = forward_auth_apps_for_user
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
@api_key = Current.session.user.api_keys.find(params[:id])
|
||||||
|
@plaintext_token = flash[:api_key_token]
|
||||||
|
|
||||||
|
redirect_to api_keys_path unless @plaintext_token
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@api_key.revoke!
|
||||||
|
redirect_to api_keys_path, notice: "API key revoked."
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_api_key
|
||||||
|
@api_key = Current.session.user.api_keys.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def api_key_params
|
||||||
|
params.require(:api_key).permit(:name, :application_id, :expires_at)
|
||||||
|
end
|
||||||
|
|
||||||
|
def forward_auth_apps_for_user
|
||||||
|
user = Current.session.user
|
||||||
|
Application.forward_auth.active.select { |app| app.user_allowed?(user) }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,4 +9,31 @@ class ApplicationController < ActionController::Base
|
|||||||
|
|
||||||
# CSRF protection
|
# CSRF protection
|
||||||
protect_from_forgery with: :exception
|
protect_from_forgery with: :exception
|
||||||
|
|
||||||
|
helper_method :remove_query_param
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Remove a query parameter from a URL using proper URI parsing
|
||||||
|
# More robust than regex - handles URL encoding, edge cases, etc.
|
||||||
|
#
|
||||||
|
# @param url [String] The URL to modify
|
||||||
|
# @param param_name [String] The query parameter name to remove
|
||||||
|
# @return [String] The URL with the parameter removed
|
||||||
|
#
|
||||||
|
# @example
|
||||||
|
# remove_query_param("https://example.com?foo=bar&baz=qux", "foo")
|
||||||
|
# # => "https://example.com?baz=qux"
|
||||||
|
def remove_query_param(url, param_name)
|
||||||
|
uri = URI.parse(url)
|
||||||
|
return url unless uri.query
|
||||||
|
|
||||||
|
params = Rack::Utils.parse_query(uri.query)
|
||||||
|
params.delete(param_name)
|
||||||
|
|
||||||
|
uri.query = params.any? ? Rack::Utils.build_query(params) : nil
|
||||||
|
uri.to_s
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
url
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ module Authentication
|
|||||||
end
|
end
|
||||||
|
|
||||||
def after_authentication_url
|
def after_authentication_url
|
||||||
session[:return_to_after_authenticating]
|
|
||||||
session.delete(:return_to_after_authenticating) || root_url
|
session.delete(:return_to_after_authenticating) || root_url
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -52,12 +51,24 @@ module Authentication
|
|||||||
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
||||||
domain = extract_root_domain(request.host)
|
domain = extract_root_domain(request.host)
|
||||||
|
|
||||||
cookie_options = {
|
# Set cookie options based on environment
|
||||||
|
# Production: Use SameSite=None to allow cross-site cookies (needed for OIDC conformance testing)
|
||||||
|
# Development: Use SameSite=Lax since HTTPS might not be available
|
||||||
|
cookie_options = if Rails.env.production?
|
||||||
|
{
|
||||||
|
value: session.id,
|
||||||
|
httponly: true,
|
||||||
|
same_site: :none, # Allow cross-site cookies for OIDC testing
|
||||||
|
secure: true # Required for SameSite=None
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
value: session.id,
|
value: session.id,
|
||||||
httponly: true,
|
httponly: true,
|
||||||
same_site: :lax,
|
same_site: :lax,
|
||||||
secure: Rails.env.production?
|
secure: false
|
||||||
}
|
}
|
||||||
|
end
|
||||||
|
|
||||||
# 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?
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
class OidcController < ApplicationController
|
class OidcController < ApplicationController
|
||||||
# Discovery and JWKS endpoints are public
|
# Discovery and JWKS endpoints are public
|
||||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout]
|
# authorize is also unauthenticated to handle prompt=none and prompt=login specially
|
||||||
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout]
|
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout, :authorize]
|
||||||
|
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout, :authorize, :consent]
|
||||||
|
|
||||||
# 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: -> {
|
||||||
@@ -43,7 +44,9 @@ class OidcController < ApplicationController
|
|||||||
],
|
],
|
||||||
code_challenge_methods_supported: ["plain", "S256"],
|
code_challenge_methods_supported: ["plain", "S256"],
|
||||||
backchannel_logout_supported: true,
|
backchannel_logout_supported: true,
|
||||||
backchannel_logout_session_supported: true
|
backchannel_logout_session_supported: true,
|
||||||
|
request_parameter_supported: false,
|
||||||
|
claims_parameter_supported: true
|
||||||
}
|
}
|
||||||
|
|
||||||
render json: config
|
render json: config
|
||||||
@@ -119,6 +122,18 @@ class OidcController < ApplicationController
|
|||||||
# per OAuth2 RFC 6749 Section 4.1.2.1
|
# per OAuth2 RFC 6749 Section 4.1.2.1
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
# Reject request objects (JWT-encoded authorization parameters)
|
||||||
|
# Per OIDC Core §3.1.2.6: If request parameter is present and not supported,
|
||||||
|
# return request_not_supported error
|
||||||
|
if params[:request].present? || params[:request_uri].present?
|
||||||
|
Rails.logger.error "OAuth: Request object not supported"
|
||||||
|
error_uri = "#{redirect_uri}?error=request_not_supported"
|
||||||
|
error_uri += "&error_description=#{CGI.escape("Request objects are not supported")}"
|
||||||
|
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||||
|
redirect_to error_uri, allow_other_host: true
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# Validate response_type (now we can safely redirect with error)
|
# Validate response_type (now we can safely redirect with error)
|
||||||
unless response_type == "code"
|
unless response_type == "code"
|
||||||
Rails.logger.error "OAuth: Invalid response_type: #{response_type}"
|
Rails.logger.error "OAuth: Invalid response_type: #{response_type}"
|
||||||
@@ -151,6 +166,35 @@ class OidcController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parse claims parameter (JSON string) for OIDC claims request
|
||||||
|
# Per OIDC Core §5.5: The claims parameter is a JSON object that requests
|
||||||
|
# specific claims to be returned in the id_token and/or userinfo
|
||||||
|
claims_parameter = params[:claims]
|
||||||
|
parsed_claims = parse_claims_parameter(claims_parameter) if claims_parameter.present?
|
||||||
|
|
||||||
|
# Validate claims parameter format if present
|
||||||
|
if claims_parameter.present? && parsed_claims.nil?
|
||||||
|
Rails.logger.error "OAuth: Invalid claims parameter format"
|
||||||
|
error_uri = "#{redirect_uri}?error=invalid_request"
|
||||||
|
error_uri += "&error_description=#{CGI.escape("Invalid claims parameter: must be valid JSON")}"
|
||||||
|
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||||
|
redirect_to error_uri, allow_other_host: true
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate that requested claims are covered by granted scopes
|
||||||
|
if parsed_claims.present?
|
||||||
|
validation_result = validate_claims_against_scopes(parsed_claims, requested_scopes)
|
||||||
|
unless validation_result[:valid]
|
||||||
|
Rails.logger.error "OAuth: Claims parameter requests claims not covered by scopes: #{validation_result[:errors]}"
|
||||||
|
error_uri = "#{redirect_uri}?error=invalid_scope"
|
||||||
|
error_uri += "&error_description=#{CGI.escape("Claims parameter requests claims not covered by granted scopes")}"
|
||||||
|
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||||
|
redirect_to error_uri, allow_other_host: true
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Check if application is active (now we can safely redirect with error)
|
# Check if application is active (now we can safely redirect with error)
|
||||||
unless @application.active?
|
unless @application.active?
|
||||||
Rails.logger.error "OAuth: Application is not active: #{@application.name}"
|
Rails.logger.error "OAuth: Application is not active: #{@application.name}"
|
||||||
@@ -162,7 +206,17 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Check if user is authenticated
|
# Check if user is authenticated
|
||||||
unless authenticated?
|
unless authenticated?
|
||||||
# Store OAuth parameters in session and redirect to sign in
|
# Handle prompt=none - no UI allowed, return error immediately
|
||||||
|
# Per OIDC Core spec §3.1.2.6: If prompt=none and user not authenticated,
|
||||||
|
# return login_required error without showing any UI
|
||||||
|
if params[:prompt] == "none"
|
||||||
|
error_uri = "#{redirect_uri}?error=login_required"
|
||||||
|
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||||
|
redirect_to error_uri, allow_other_host: true
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Normal flow: store OAuth parameters and redirect to sign in
|
||||||
session[:oauth_params] = {
|
session[:oauth_params] = {
|
||||||
client_id: client_id,
|
client_id: client_id,
|
||||||
redirect_uri: redirect_uri,
|
redirect_uri: redirect_uri,
|
||||||
@@ -170,12 +224,62 @@ class OidcController < ApplicationController
|
|||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
scope: scope,
|
scope: scope,
|
||||||
code_challenge: code_challenge,
|
code_challenge: code_challenge,
|
||||||
code_challenge_method: code_challenge_method
|
code_challenge_method: code_challenge_method,
|
||||||
|
claims_requests: parsed_claims&.to_json
|
||||||
}
|
}
|
||||||
|
# Store the current URL (with all OAuth params) for redirect after authentication
|
||||||
|
session[:return_to_after_authenticating] = request.url
|
||||||
redirect_to signin_path, alert: "Please sign in to continue"
|
redirect_to signin_path, alert: "Please sign in to continue"
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Handle prompt=login - force re-authentication
|
||||||
|
# Per OIDC Core spec §3.1.2.1: If prompt=login, the Authorization Server MUST prompt
|
||||||
|
# the End-User for reauthentication, even if the End-User is currently authenticated
|
||||||
|
if params[:prompt] == "login"
|
||||||
|
# Destroy current session to force re-authentication
|
||||||
|
# This creates a fresh authentication event with a new auth_time
|
||||||
|
Current.session&.destroy!
|
||||||
|
|
||||||
|
# Clear the session cookie so the user is truly logged out
|
||||||
|
cookies.delete(:session_id)
|
||||||
|
|
||||||
|
# Store the current URL (which contains all OAuth params) for redirect after login
|
||||||
|
# Remove prompt=login to prevent infinite re-auth loop
|
||||||
|
return_url = remove_query_param(request.url, "prompt")
|
||||||
|
session[:return_to_after_authenticating] = return_url
|
||||||
|
|
||||||
|
redirect_to signin_path, alert: "Please sign in to continue"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle max_age - require re-authentication if session is too old
|
||||||
|
# Per OIDC Core spec §3.1.2.1: If max_age is provided and the auth time is older,
|
||||||
|
# the Authorization Server MUST prompt for reauthentication
|
||||||
|
if params[:max_age].present?
|
||||||
|
max_age_seconds = params[:max_age].to_i
|
||||||
|
# Calculate session age
|
||||||
|
session_age_seconds = Time.current.to_i - Current.session.created_at.to_i
|
||||||
|
|
||||||
|
if session_age_seconds >= max_age_seconds
|
||||||
|
# Session is too old - require re-authentication
|
||||||
|
# Store the return URL in Rails session, then destroy the Session record
|
||||||
|
|
||||||
|
# Store return URL before destroying anything
|
||||||
|
# Remove max_age from return URL to prevent infinite re-auth loop
|
||||||
|
return_url = remove_query_param(request.url, "max_age")
|
||||||
|
session[:return_to_after_authenticating] = return_url
|
||||||
|
|
||||||
|
# Destroy the Session record and clear its cookie
|
||||||
|
Current.session&.destroy!
|
||||||
|
cookies.delete(:session_id)
|
||||||
|
Current.session = nil
|
||||||
|
|
||||||
|
redirect_to signin_path, alert: "Please sign in to continue"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Get the authenticated user
|
# Get the authenticated user
|
||||||
user = Current.session.user
|
user = Current.session.user
|
||||||
|
|
||||||
@@ -187,9 +291,41 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
requested_scopes = scope.split(" ")
|
requested_scopes = scope.split(" ")
|
||||||
|
|
||||||
|
# Check if application is configured to skip consent
|
||||||
|
# If so, automatically create consent and proceed without showing consent screen
|
||||||
|
if @application.skip_consent?
|
||||||
|
# Create or update consent record automatically for trusted applications
|
||||||
|
consent = OidcUserConsent.find_or_initialize_by(user: user, application: @application)
|
||||||
|
consent.scopes_granted = requested_scopes.join(" ")
|
||||||
|
consent.claims_requests = parsed_claims || {}
|
||||||
|
consent.granted_at = Time.current
|
||||||
|
consent.save!
|
||||||
|
|
||||||
|
# Generate authorization code directly
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: user,
|
||||||
|
redirect_uri: redirect_uri,
|
||||||
|
scope: scope,
|
||||||
|
nonce: nonce,
|
||||||
|
code_challenge: code_challenge,
|
||||||
|
code_challenge_method: code_challenge_method,
|
||||||
|
claims_requests: parsed_claims || {},
|
||||||
|
auth_time: Current.session.created_at.to_i,
|
||||||
|
acr: Current.session.acr,
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redirect back to client with authorization code (plaintext)
|
||||||
|
redirect_uri = "#{redirect_uri}?code=#{auth_code.plaintext_code}"
|
||||||
|
redirect_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||||
|
redirect_to redirect_uri, allow_other_host: true
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# Check if user has already granted consent for these scopes
|
# Check if user has already granted consent for these scopes
|
||||||
existing_consent = user.has_oidc_consent?(@application, requested_scopes)
|
existing_consent = user.has_oidc_consent?(@application, requested_scopes)
|
||||||
if existing_consent
|
if existing_consent && claims_match_consent?(parsed_claims, existing_consent)
|
||||||
# User has already consented, generate authorization code directly
|
# User has already consented, generate authorization code directly
|
||||||
auth_code = OidcAuthorizationCode.create!(
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
application: @application,
|
application: @application,
|
||||||
@@ -199,6 +335,7 @@ class OidcController < ApplicationController
|
|||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
code_challenge: code_challenge,
|
code_challenge: code_challenge,
|
||||||
code_challenge_method: code_challenge_method,
|
code_challenge_method: code_challenge_method,
|
||||||
|
claims_requests: parsed_claims || {},
|
||||||
auth_time: Current.session.created_at.to_i,
|
auth_time: Current.session.created_at.to_i,
|
||||||
acr: Current.session.acr,
|
acr: Current.session.acr,
|
||||||
expires_at: 10.minutes.from_now
|
expires_at: 10.minutes.from_now
|
||||||
@@ -219,7 +356,8 @@ class OidcController < ApplicationController
|
|||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
scope: scope,
|
scope: scope,
|
||||||
code_challenge: code_challenge,
|
code_challenge: code_challenge,
|
||||||
code_challenge_method: code_challenge_method
|
code_challenge_method: code_challenge_method,
|
||||||
|
claims_requests: parsed_claims&.to_json
|
||||||
}
|
}
|
||||||
|
|
||||||
# Render consent page with dynamic CSP for OAuth redirect
|
# Render consent page with dynamic CSP for OAuth redirect
|
||||||
@@ -284,8 +422,15 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Record user consent
|
# Record user consent
|
||||||
requested_scopes = oauth_params["scope"].split(" ")
|
requested_scopes = oauth_params["scope"].split(" ")
|
||||||
|
parsed_claims = begin
|
||||||
|
JSON.parse(oauth_params["claims_requests"])
|
||||||
|
rescue
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
|
consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
|
||||||
consent.scopes_granted = requested_scopes.join(" ")
|
consent.scopes_granted = requested_scopes.join(" ")
|
||||||
|
consent.claims_requests = parsed_claims
|
||||||
consent.granted_at = Time.current
|
consent.granted_at = Time.current
|
||||||
consent.save!
|
consent.save!
|
||||||
|
|
||||||
@@ -298,6 +443,7 @@ class OidcController < ApplicationController
|
|||||||
nonce: oauth_params["nonce"],
|
nonce: oauth_params["nonce"],
|
||||||
code_challenge: oauth_params["code_challenge"],
|
code_challenge: oauth_params["code_challenge"],
|
||||||
code_challenge_method: oauth_params["code_challenge_method"],
|
code_challenge_method: oauth_params["code_challenge_method"],
|
||||||
|
claims_requests: parsed_claims,
|
||||||
auth_time: Current.session.created_at.to_i,
|
auth_time: Current.session.created_at.to_i,
|
||||||
acr: Current.session.acr,
|
acr: Current.session.acr,
|
||||||
expires_at: 10.minutes.from_now
|
expires_at: 10.minutes.from_now
|
||||||
@@ -315,6 +461,16 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# POST /oauth/token
|
# POST /oauth/token
|
||||||
def token
|
def token
|
||||||
|
# Reject claims parameter - per OIDC security, claims parameter is only valid
|
||||||
|
# in authorization requests, not at the token endpoint
|
||||||
|
if params[:claims].present?
|
||||||
|
render json: {
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "claims parameter is not allowed at the token endpoint"
|
||||||
|
}, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
grant_type = params[:grant_type]
|
grant_type = params[:grant_type]
|
||||||
|
|
||||||
case grant_type
|
case grant_type
|
||||||
@@ -457,6 +613,7 @@ class OidcController < ApplicationController
|
|||||||
# Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr
|
# Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr
|
||||||
# auth_time and acr come from the authorization code (captured at /authorize time)
|
# auth_time and acr come from the authorization code (captured at /authorize time)
|
||||||
# scopes determine which claims are included (per OIDC Core spec)
|
# scopes determine which claims are included (per OIDC Core spec)
|
||||||
|
# claims_requests parameter filters which claims are included
|
||||||
id_token = OidcJwtService.generate_id_token(
|
id_token = OidcJwtService.generate_id_token(
|
||||||
user,
|
user,
|
||||||
application,
|
application,
|
||||||
@@ -465,9 +622,14 @@ class OidcController < ApplicationController
|
|||||||
access_token: access_token_record.plaintext_token,
|
access_token: access_token_record.plaintext_token,
|
||||||
auth_time: auth_code.auth_time,
|
auth_time: auth_code.auth_time,
|
||||||
acr: auth_code.acr,
|
acr: auth_code.acr,
|
||||||
scopes: auth_code.scope
|
scopes: auth_code.scope,
|
||||||
|
claims_requests: auth_code.parsed_claims_requests
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# RFC6749-5.1: Token endpoint MUST return Cache-Control: no-store
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
|
||||||
# Return tokens
|
# Return tokens
|
||||||
render json: {
|
render json: {
|
||||||
access_token: access_token_record.plaintext_token, # Opaque token
|
access_token: access_token_record.plaintext_token, # Opaque token
|
||||||
@@ -587,6 +749,7 @@ class OidcController < ApplicationController
|
|||||||
# Generate new ID token (JWT with pairwise SID, at_hash, auth_time, acr; no nonce for refresh grants)
|
# Generate new ID token (JWT with pairwise SID, at_hash, auth_time, acr; no nonce for refresh grants)
|
||||||
# auth_time and acr come from the original refresh token (carried over from initial auth)
|
# auth_time and acr come from the original refresh token (carried over from initial auth)
|
||||||
# scopes determine which claims are included (per OIDC Core spec)
|
# scopes determine which claims are included (per OIDC Core spec)
|
||||||
|
# claims_requests parameter filters which claims are included (from original consent)
|
||||||
id_token = OidcJwtService.generate_id_token(
|
id_token = OidcJwtService.generate_id_token(
|
||||||
user,
|
user,
|
||||||
application,
|
application,
|
||||||
@@ -594,9 +757,14 @@ class OidcController < ApplicationController
|
|||||||
access_token: new_access_token.plaintext_token,
|
access_token: new_access_token.plaintext_token,
|
||||||
auth_time: refresh_token_record.auth_time,
|
auth_time: refresh_token_record.auth_time,
|
||||||
acr: refresh_token_record.acr,
|
acr: refresh_token_record.acr,
|
||||||
scopes: refresh_token_record.scope
|
scopes: refresh_token_record.scope,
|
||||||
|
claims_requests: consent.parsed_claims_requests
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# RFC6749-5.1: Token endpoint MUST return Cache-Control: no-store
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
|
||||||
# Return new tokens
|
# Return new tokens
|
||||||
render json: {
|
render json: {
|
||||||
access_token: new_access_token.plaintext_token, # Opaque token
|
access_token: new_access_token.plaintext_token, # Opaque token
|
||||||
@@ -654,34 +822,46 @@ class OidcController < ApplicationController
|
|||||||
# Parse scopes from access token (space-separated string)
|
# Parse scopes from access token (space-separated string)
|
||||||
requested_scopes = access_token.scope.to_s.split
|
requested_scopes = access_token.scope.to_s.split
|
||||||
|
|
||||||
|
# Get claims_requests from consent (if available) for UserInfo context
|
||||||
|
userinfo_claims = consent&.parsed_claims_requests&.dig("userinfo") || {}
|
||||||
|
|
||||||
# Return user claims (filter by scope per OIDC Core spec)
|
# Return user claims (filter by scope per OIDC Core spec)
|
||||||
# Required claims (always included)
|
# Required claims (always included - cannot be filtered by claims parameter)
|
||||||
claims = {
|
claims = {
|
||||||
sub: subject
|
sub: subject
|
||||||
}
|
}
|
||||||
|
|
||||||
# Email claims (only if 'email' scope requested)
|
# Email claims (only if 'email' scope requested AND requested in claims parameter)
|
||||||
if requested_scopes.include?("email")
|
if requested_scopes.include?("email")
|
||||||
|
if should_include_claim_for_userinfo?("email", userinfo_claims)
|
||||||
claims[:email] = user.email_address
|
claims[:email] = user.email_address
|
||||||
|
end
|
||||||
|
if should_include_claim_for_userinfo?("email_verified", userinfo_claims)
|
||||||
claims[:email_verified] = true
|
claims[:email_verified] = true
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Profile claims (only if 'profile' scope requested)
|
# Profile claims (only if 'profile' scope requested)
|
||||||
# Per OIDC Core spec section 5.4, include available profile claims
|
# Per OIDC Core spec section 5.4, include available profile claims
|
||||||
# Only include claims we have data for - omit unknown claims rather than returning null
|
# Only include claims we have data for - omit unknown claims rather than returning null
|
||||||
if requested_scopes.include?("profile")
|
if requested_scopes.include?("profile")
|
||||||
# Use username if available, otherwise email as preferred_username
|
if should_include_claim_for_userinfo?("preferred_username", userinfo_claims)
|
||||||
claims[:preferred_username] = user.username.presence || user.email_address
|
claims[:preferred_username] = user.username.presence || user.email_address
|
||||||
# Name: use stored name or fall back to email
|
end
|
||||||
|
if should_include_claim_for_userinfo?("name", userinfo_claims)
|
||||||
claims[:name] = user.name.presence || user.email_address
|
claims[:name] = user.name.presence || user.email_address
|
||||||
# Time the user's information was last updated
|
end
|
||||||
|
if should_include_claim_for_userinfo?("updated_at", userinfo_claims)
|
||||||
claims[:updated_at] = user.updated_at.to_i
|
claims[:updated_at] = user.updated_at.to_i
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Groups claim (only if 'groups' scope requested)
|
# Groups claim (only if 'groups' scope requested AND requested in claims parameter)
|
||||||
if requested_scopes.include?("groups") && user.groups.any?
|
if requested_scopes.include?("groups") && user.groups.any?
|
||||||
|
if should_include_claim_for_userinfo?("groups", userinfo_claims)
|
||||||
claims[:groups] = user.groups.pluck(:name)
|
claims[:groups] = user.groups.pluck(:name)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Merge custom claims from groups
|
# Merge custom claims from groups
|
||||||
user.groups.each do |group|
|
user.groups.each do |group|
|
||||||
@@ -695,6 +875,16 @@ class OidcController < ApplicationController
|
|||||||
application = access_token.application
|
application = access_token.application
|
||||||
claims.merge!(application.custom_claims_for_user(user))
|
claims.merge!(application.custom_claims_for_user(user))
|
||||||
|
|
||||||
|
# Filter custom claims based on claims parameter
|
||||||
|
# If claims parameter is present, only include requested custom claims
|
||||||
|
if userinfo_claims.any?
|
||||||
|
claims = filter_custom_claims_for_userinfo(claims, userinfo_claims)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Security: Don't cache user data responses
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
|
||||||
render json: claims
|
render json: claims
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -839,12 +1029,12 @@ class OidcController < ApplicationController
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate code verifier format (base64url-encoded, 43-128 characters)
|
# Validate code verifier format (per RFC 7636: [A-Za-z0-9\-._~], 43-128 characters)
|
||||||
unless code_verifier.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
unless code_verifier.match?(/\A[A-Za-z0-9.\-_~]{43,128}\z/)
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: "invalid_request",
|
error: "invalid_request",
|
||||||
error_description: "Invalid code_verifier format. Must be 43-128 characters of base64url encoding",
|
error_description: "Invalid code_verifier format. Must be 43-128 characters [A-Z/a-z/0-9/-/./_/~]",
|
||||||
status: :bad_request
|
status: :bad_request
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -960,4 +1150,133 @@ class OidcController < ApplicationController
|
|||||||
# Log error but don't block logout
|
# Log error but don't block logout
|
||||||
Rails.logger.error "OidcController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
|
Rails.logger.error "OidcController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parse claims parameter JSON string
|
||||||
|
# Per OIDC Core §5.5: The claims parameter is a JSON object containing
|
||||||
|
# id_token and/or userinfo keys, each mapping to claim requests
|
||||||
|
def parse_claims_parameter(claims_string)
|
||||||
|
return {} if claims_string.blank?
|
||||||
|
|
||||||
|
parsed = JSON.parse(claims_string)
|
||||||
|
return nil unless parsed.is_a?(Hash)
|
||||||
|
|
||||||
|
# Validate structure: can have id_token, userinfo, or both
|
||||||
|
valid_keys = parsed.keys & ["id_token", "userinfo"]
|
||||||
|
return nil if valid_keys.empty?
|
||||||
|
|
||||||
|
# Validate each claim request has proper structure
|
||||||
|
valid_keys.each do |key|
|
||||||
|
next unless parsed[key].is_a?(Hash)
|
||||||
|
|
||||||
|
parsed[key].each do |_claim_name, claim_spec|
|
||||||
|
# Claim spec can be null (requested), true (essential), or a hash with specific keys
|
||||||
|
next if claim_spec.nil? || claim_spec == true || claim_spec == false
|
||||||
|
next if claim_spec.is_a?(Hash) && claim_spec.keys.all? { |k| ["essential", "value", "values"].include?(k) }
|
||||||
|
|
||||||
|
# Invalid claim specification
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
parsed
|
||||||
|
rescue JSON::ParserError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate that requested claims are covered by granted scopes
|
||||||
|
# Per OIDC Core §5.5: Claims can only be requested if the corresponding scope is granted
|
||||||
|
def validate_claims_against_scopes(parsed_claims, granted_scopes)
|
||||||
|
granted = Array(granted_scopes).map(&:to_s)
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Standard claim-to-scope mapping
|
||||||
|
claim_scope_mapping = {
|
||||||
|
"email" => "email",
|
||||||
|
"email_verified" => "email",
|
||||||
|
"preferred_username" => "profile",
|
||||||
|
"name" => "profile",
|
||||||
|
"updated_at" => "profile",
|
||||||
|
"groups" => "groups"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check both id_token and userinfo claims
|
||||||
|
["id_token", "userinfo"].each do |context|
|
||||||
|
next unless parsed_claims[context]&.is_a?(Hash)
|
||||||
|
|
||||||
|
parsed_claims[context].each do |claim_name, _claim_spec|
|
||||||
|
# Skip custom claims (not in standard mapping)
|
||||||
|
# Custom claims are allowed since they're configured in the IdP
|
||||||
|
next unless claim_scope_mapping.key?(claim_name)
|
||||||
|
|
||||||
|
required_scope = claim_scope_mapping[claim_name]
|
||||||
|
unless granted.include?(required_scope)
|
||||||
|
errors << "#{claim_name} requires #{required_scope} scope"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if errors.any?
|
||||||
|
{valid: false, errors: errors}
|
||||||
|
else
|
||||||
|
{valid: true}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if claims match existing consent
|
||||||
|
# For MVP: treat any claims request as requiring new consent if consent has no claims stored
|
||||||
|
def claims_match_consent?(parsed_claims, consent)
|
||||||
|
return true if parsed_claims.nil? || parsed_claims.empty?
|
||||||
|
|
||||||
|
# If consent has no claims stored, this is a new claims request
|
||||||
|
# Require fresh consent
|
||||||
|
return false if consent.parsed_claims_requests.empty?
|
||||||
|
|
||||||
|
# If both have claims, they must match exactly
|
||||||
|
consent.parsed_claims_requests == parsed_claims
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if a claim should be included in UserInfo response
|
||||||
|
# Returns true if no claims filtering or claim is explicitly requested
|
||||||
|
def should_include_claim_for_userinfo?(claim_name, userinfo_claims)
|
||||||
|
return true if userinfo_claims.empty?
|
||||||
|
userinfo_claims.key?(claim_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filter custom claims for UserInfo endpoint
|
||||||
|
# Removes claims not explicitly requested
|
||||||
|
# Applies value/values filtering if specified
|
||||||
|
def filter_custom_claims_for_userinfo(claims, userinfo_claims)
|
||||||
|
# Get all claim names that are NOT standard OIDC claims
|
||||||
|
standard_claims = %w[sub email email_verified name preferred_username updated_at groups]
|
||||||
|
custom_claim_names = claims.keys.map(&:to_s) - standard_claims
|
||||||
|
|
||||||
|
filtered = claims.dup
|
||||||
|
|
||||||
|
custom_claim_names.each do |claim_name|
|
||||||
|
claim_sym = claim_name.to_sym
|
||||||
|
|
||||||
|
unless userinfo_claims.key?(claim_name) || userinfo_claims.key?(claim_sym)
|
||||||
|
filtered.delete(claim_sym)
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# Apply value/values filtering if specified
|
||||||
|
claim_spec = userinfo_claims[claim_name] || userinfo_claims[claim_sym]
|
||||||
|
next unless claim_spec.is_a?(Hash)
|
||||||
|
|
||||||
|
current_value = filtered[claim_sym]
|
||||||
|
|
||||||
|
# Check value constraint
|
||||||
|
if claim_spec["value"].present?
|
||||||
|
filtered.delete(claim_sym) unless current_value == claim_spec["value"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check values constraint (array of allowed values)
|
||||||
|
if claim_spec["values"].is_a?(Array)
|
||||||
|
filtered.delete(claim_sym) unless claim_spec["values"].include?(current_value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
filtered
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ class SessionsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Extract login_hint from the return URL for pre-filling the email field (OIDC spec)
|
||||||
|
@login_hint = nil
|
||||||
|
if session[:return_to_after_authenticating].present?
|
||||||
|
begin
|
||||||
|
uri = URI.parse(session[:return_to_after_authenticating])
|
||||||
|
if uri.query.present?
|
||||||
|
query_params = Rack::Utils.parse_query(uri.query)
|
||||||
|
@login_hint = query_params["login_hint"]
|
||||||
|
end
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
# Ignore parsing errors
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
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 }
|
||||||
@@ -73,7 +87,10 @@ class SessionsController < ApplicationController
|
|||||||
|
|
||||||
# Sign in successful (password only)
|
# Sign in successful (password only)
|
||||||
start_new_session_for user, acr: "1"
|
start_new_session_for user, acr: "1"
|
||||||
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
|
||||||
|
# Use status: :see_other to ensure browser makes a GET request
|
||||||
|
# This prevents Turbo from converting it to a TURBO_STREAM request
|
||||||
|
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true, status: :see_other
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify_totp
|
def verify_totp
|
||||||
@@ -130,6 +147,10 @@ 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
|
||||||
|
|
||||||
# Just render the form
|
# Just render the form
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,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
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ module ApplicationHelper
|
|||||||
|
|
||||||
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
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
15
app/javascript/controllers/clipboard_controller.js
Normal file
15
app/javascript/controllers/clipboard_controller.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["source", "label"]
|
||||||
|
|
||||||
|
async copy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(this.sourceTarget.value)
|
||||||
|
this.labelTarget.textContent = "Copied!"
|
||||||
|
setTimeout(() => { this.labelTarget.textContent = "Copy" }, 2000)
|
||||||
|
} catch {
|
||||||
|
this.sourceTarget.select()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/javascript/controllers/dark_mode_controller.js
Normal file
27
app/javascript/controllers/dark_mode_controller.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["icon"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.updateIcon()
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
document.documentElement.classList.toggle("dark")
|
||||||
|
const isDark = document.documentElement.classList.contains("dark")
|
||||||
|
localStorage.setItem("theme", isDark ? "dark" : "light")
|
||||||
|
this.updateIcon()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateIcon() {
|
||||||
|
const isDark = document.documentElement.classList.contains("dark")
|
||||||
|
this.iconTargets.forEach(icon => {
|
||||||
|
if (icon.dataset.mode === "dark") {
|
||||||
|
icon.classList.toggle("hidden", !isDark)
|
||||||
|
} else {
|
||||||
|
icon.classList.toggle("hidden", isDark)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,10 +49,9 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-trigger passkey authentication if required
|
// 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");
|
||||||
}
|
}
|
||||||
@@ -289,6 +288,10 @@ 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() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +314,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.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
app/lib/duration_parser.rb
Normal file
45
app/lib/duration_parser.rb
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
class DurationParser
|
||||||
|
UNITS = {
|
||||||
|
"s" => 1, # seconds
|
||||||
|
"m" => 60, # minutes
|
||||||
|
"h" => 3600, # hours
|
||||||
|
"d" => 86400, # days
|
||||||
|
"w" => 604800, # weeks
|
||||||
|
"M" => 2592000, # months (30 days)
|
||||||
|
"y" => 31536000 # years (365 days)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse a duration string into seconds
|
||||||
|
# Accepts formats: "1h", "30m", "1d", "1M" (month), "3600" (plain number)
|
||||||
|
# Returns integer seconds or nil if invalid
|
||||||
|
# Case-sensitive: 1s, 1m, 1h, 1d, 1w, 1M (month), 1y
|
||||||
|
def self.parse(input)
|
||||||
|
# Handle integers directly
|
||||||
|
return input if input.is_a?(Integer)
|
||||||
|
|
||||||
|
# Convert to string and strip whitespace
|
||||||
|
str = input.to_s.strip
|
||||||
|
|
||||||
|
# Return nil for blank input
|
||||||
|
return nil if str.blank?
|
||||||
|
|
||||||
|
# Try to parse as plain number (already in seconds)
|
||||||
|
if str.match?(/^\d+$/)
|
||||||
|
return str.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
# Try to parse with unit (e.g., "1h", "30m", "1M")
|
||||||
|
# Allow optional space between number and unit
|
||||||
|
# Case-sensitive to avoid confusion (1m = minute, 1M = month)
|
||||||
|
match = str.match(/^(\d+)\s*([smhdwMy])$/)
|
||||||
|
return nil unless match
|
||||||
|
|
||||||
|
number = match[1].to_i
|
||||||
|
unit = match[2]
|
||||||
|
|
||||||
|
multiplier = UNITS[unit]
|
||||||
|
return nil unless multiplier
|
||||||
|
|
||||||
|
number * multiplier
|
||||||
|
end
|
||||||
|
end
|
||||||
66
app/models/api_key.rb
Normal file
66
app/models/api_key.rb
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
class ApiKey < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :application
|
||||||
|
|
||||||
|
before_validation :generate_token, on: :create
|
||||||
|
|
||||||
|
validates :name, presence: true
|
||||||
|
validates :token_hmac, presence: true, uniqueness: true
|
||||||
|
validate :application_must_be_forward_auth
|
||||||
|
validate :user_must_have_access
|
||||||
|
|
||||||
|
scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||||
|
scope :revoked, -> { where.not(revoked_at: nil) }
|
||||||
|
|
||||||
|
attr_accessor :plaintext_token
|
||||||
|
|
||||||
|
def self.find_by_token(plaintext_token)
|
||||||
|
return nil if plaintext_token.blank?
|
||||||
|
|
||||||
|
token_hmac = compute_token_hmac(plaintext_token)
|
||||||
|
find_by(token_hmac: token_hmac)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.compute_token_hmac(plaintext_token)
|
||||||
|
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
def expired?
|
||||||
|
expires_at.present? && expires_at <= Time.current
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoked?
|
||||||
|
revoked_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def active?
|
||||||
|
!expired? && !revoked?
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoke!
|
||||||
|
update!(revoked_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
def touch_last_used!
|
||||||
|
update_column(:last_used_at, Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def generate_token
|
||||||
|
self.plaintext_token ||= "clk_#{SecureRandom.urlsafe_base64(48)}"
|
||||||
|
self.token_hmac ||= self.class.compute_token_hmac(plaintext_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
def application_must_be_forward_auth
|
||||||
|
if application && !application.forward_auth?
|
||||||
|
errors.add(:application, "must be a forward auth application")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def user_must_have_access
|
||||||
|
if user && application && !application.user_allowed?(user)
|
||||||
|
errors.add(:user, "does not have access to this application")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,6 +5,25 @@ class Application < ApplicationRecord
|
|||||||
# When true, no client_secret will be generated (public client)
|
# When true, no client_secret will be generated (public client)
|
||||||
attr_accessor :is_public_client
|
attr_accessor :is_public_client
|
||||||
|
|
||||||
|
# Virtual setters for TTL fields - accept human-friendly durations
|
||||||
|
# e.g., "1h", "30m", "1d", or plain numbers "3600"
|
||||||
|
def access_token_ttl=(value)
|
||||||
|
parsed = DurationParser.parse(value)
|
||||||
|
super(parsed)
|
||||||
|
end
|
||||||
|
|
||||||
|
def refresh_token_ttl=(value)
|
||||||
|
parsed = DurationParser.parse(value)
|
||||||
|
super(parsed)
|
||||||
|
end
|
||||||
|
|
||||||
|
def id_token_ttl=(value)
|
||||||
|
parsed = DurationParser.parse(value)
|
||||||
|
super(parsed)
|
||||||
|
end
|
||||||
|
|
||||||
|
after_commit :bust_forward_auth_cache, if: :forward_auth?
|
||||||
|
|
||||||
has_one_attached :icon
|
has_one_attached :icon
|
||||||
|
|
||||||
# Fix SVG content type after attachment
|
# Fix SVG content type after attachment
|
||||||
@@ -17,6 +36,7 @@ class Application < ApplicationRecord
|
|||||||
has_many :oidc_access_tokens, dependent: :destroy
|
has_many :oidc_access_tokens, dependent: :destroy
|
||||||
has_many :oidc_refresh_tokens, dependent: :destroy
|
has_many :oidc_refresh_tokens, dependent: :destroy
|
||||||
has_many :oidc_user_consents, dependent: :destroy
|
has_many :oidc_user_consents, dependent: :destroy
|
||||||
|
has_many :api_keys, dependent: :destroy
|
||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
validates :slug, presence: true, uniqueness: {case_sensitive: false},
|
validates :slug, presence: true, uniqueness: {case_sensitive: false},
|
||||||
@@ -39,7 +59,7 @@ class Application < ApplicationRecord
|
|||||||
|
|
||||||
# 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
|
||||||
validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000}, if: :oidc? # 1 day - 90 days
|
validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 7776000}, if: :oidc? # 5 min - 90 days
|
||||||
validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
||||||
|
|
||||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||||
@@ -59,6 +79,7 @@ class Application < ApplicationRecord
|
|||||||
user: "X-Remote-User",
|
user: "X-Remote-User",
|
||||||
email: "X-Remote-Email",
|
email: "X-Remote-Email",
|
||||||
name: "X-Remote-Name",
|
name: "X-Remote-Name",
|
||||||
|
username: "X-Remote-Username",
|
||||||
groups: "X-Remote-Groups",
|
groups: "X-Remote-Groups",
|
||||||
admin: "X-Remote-Admin"
|
admin: "X-Remote-Admin"
|
||||||
}.freeze
|
}.freeze
|
||||||
@@ -178,8 +199,10 @@ class Application < ApplicationRecord
|
|||||||
headers[header_name] = user.email_address
|
headers[header_name] = user.email_address
|
||||||
when :name
|
when :name
|
||||||
headers[header_name] = user.name.presence || user.email_address
|
headers[header_name] = user.name.presence || user.email_address
|
||||||
|
when :username
|
||||||
|
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
|
||||||
@@ -247,6 +270,10 @@ class Application < ApplicationRecord
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def bust_forward_auth_cache
|
||||||
|
Rails.application.config.forward_auth_cache&.delete("fa_apps")
|
||||||
|
end
|
||||||
|
|
||||||
def fix_icon_content_type
|
def fix_icon_content_type
|
||||||
return unless icon.attached?
|
return unless icon.attached?
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
code_challenge.present?
|
code_challenge.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parse claims_requests JSON field
|
||||||
|
def parsed_claims_requests
|
||||||
|
return {} if claims_requests.blank?
|
||||||
|
claims_requests.is_a?(Hash) ? claims_requests : {}
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def generate_code
|
def generate_code
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ class OidcUserConsent < ApplicationRecord
|
|||||||
find_by(sid: sid)
|
find_by(sid: sid)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parse claims_requests JSON field
|
||||||
|
def parsed_claims_requests
|
||||||
|
return {} if claims_requests.blank?
|
||||||
|
claims_requests.is_a?(Hash) ? claims_requests : {}
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_granted_at
|
def set_granted_at
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class User < ApplicationRecord
|
|||||||
has_many :application_user_claims, dependent: :destroy
|
has_many :application_user_claims, dependent: :destroy
|
||||||
has_many :oidc_user_consents, dependent: :destroy
|
has_many :oidc_user_consents, dependent: :destroy
|
||||||
has_many :webauthn_credentials, dependent: :destroy
|
has_many :webauthn_credentials, dependent: :destroy
|
||||||
|
has_many :api_keys, dependent: :destroy
|
||||||
|
|
||||||
# Token generation for passwordless flows
|
# Token generation for passwordless flows
|
||||||
generates_token_for :invitation_login, expires_in: 24.hours do
|
generates_token_for :invitation_login, expires_in: 24.hours do
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ class OidcJwtService
|
|||||||
|
|
||||||
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")
|
def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil, scopes: "openid", claims_requests: {})
|
||||||
now = Time.current.to_i
|
now = Time.current.to_i
|
||||||
# Use application's configured ID token TTL (defaults to 1 hour)
|
# Use application's configured ID token TTL (defaults to 1 hour)
|
||||||
ttl = application.id_token_expiry_seconds
|
ttl = application.id_token_expiry_seconds
|
||||||
@@ -14,6 +14,9 @@ class OidcJwtService
|
|||||||
# Parse scopes (space-separated string)
|
# Parse scopes (space-separated string)
|
||||||
requested_scopes = scopes.to_s.split
|
requested_scopes = scopes.to_s.split
|
||||||
|
|
||||||
|
# Parse claims_requests parameter for id_token context
|
||||||
|
id_token_claims = claims_requests["id_token"] || {}
|
||||||
|
|
||||||
# Required claims (always included per OIDC Core spec)
|
# Required claims (always included per OIDC Core spec)
|
||||||
payload = {
|
payload = {
|
||||||
iss: issuer_url,
|
iss: issuer_url,
|
||||||
@@ -23,10 +26,28 @@ class OidcJwtService
|
|||||||
iat: now
|
iat: now
|
||||||
}
|
}
|
||||||
|
|
||||||
# NOTE: Email and profile claims are NOT included in the ID token for authorization code flow
|
# Email claims (only if 'email' scope requested AND either no claims filter OR email requested)
|
||||||
# Per OIDC Core spec §5.4, these claims should only be returned via the UserInfo endpoint
|
if requested_scopes.include?("email")
|
||||||
# For implicit flow (response_type=id_token), claims would be included here, but we only
|
if should_include_claim?("email", id_token_claims)
|
||||||
# support authorization code flow, so these claims are omitted from the ID token.
|
payload[:email] = user.email_address
|
||||||
|
end
|
||||||
|
if should_include_claim?("email_verified", id_token_claims)
|
||||||
|
payload[:email_verified] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Profile claims (only if 'profile' scope requested)
|
||||||
|
if requested_scopes.include?("profile")
|
||||||
|
if should_include_claim?("preferred_username", id_token_claims)
|
||||||
|
payload[:preferred_username] = user.username.presence || user.email_address
|
||||||
|
end
|
||||||
|
if should_include_claim?("name", id_token_claims)
|
||||||
|
payload[:name] = user.name.presence || user.email_address
|
||||||
|
end
|
||||||
|
if should_include_claim?("updated_at", id_token_claims)
|
||||||
|
payload[:updated_at] = user.updated_at.to_i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Add nonce if provided (OIDC requires this for implicit flow)
|
# Add nonce if provided (OIDC requires this for implicit flow)
|
||||||
payload[:nonce] = nonce if nonce.present?
|
payload[:nonce] = nonce if nonce.present?
|
||||||
@@ -49,10 +70,12 @@ class OidcJwtService
|
|||||||
payload[:at_hash] = at_hash
|
payload[:at_hash] = at_hash
|
||||||
end
|
end
|
||||||
|
|
||||||
# Groups claims (only if 'groups' scope requested)
|
# Groups claims (only if 'groups' scope requested AND requested in claims parameter)
|
||||||
if requested_scopes.include?("groups") && user.groups.any?
|
if requested_scopes.include?("groups") && user.groups.any?
|
||||||
|
if should_include_claim?("groups", id_token_claims)
|
||||||
payload[:groups] = user.groups.pluck(:name)
|
payload[:groups] = user.groups.pluck(:name)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# 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)
|
||||||
@@ -66,6 +89,12 @@ class OidcJwtService
|
|||||||
# 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))
|
||||||
|
|
||||||
|
# Filter custom claims based on claims parameter
|
||||||
|
# If claims parameter is present, only include requested custom claims
|
||||||
|
if id_token_claims.any?
|
||||||
|
payload = filter_custom_claims(payload, id_token_claims)
|
||||||
|
end
|
||||||
|
|
||||||
JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
|
JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -178,5 +207,69 @@ class OidcJwtService
|
|||||||
def key_id
|
def key_id
|
||||||
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
|
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check if a claim should be included based on claims parameter
|
||||||
|
# Returns true if:
|
||||||
|
# - No claims parameter specified (include all scope-based claims)
|
||||||
|
# - Claim is explicitly requested (even with null spec or essential: true)
|
||||||
|
def should_include_claim?(claim_name, id_token_claims)
|
||||||
|
# No claims parameter = include all scope-based claims
|
||||||
|
return true if id_token_claims.empty?
|
||||||
|
|
||||||
|
# Check if claim is requested
|
||||||
|
return false unless id_token_claims.key?(claim_name)
|
||||||
|
|
||||||
|
# Claim specification can be:
|
||||||
|
# - null (requested)
|
||||||
|
# - true (essential, requested)
|
||||||
|
# - false (not requested)
|
||||||
|
# - Hash with essential/value/values
|
||||||
|
|
||||||
|
claim_spec = id_token_claims[claim_name]
|
||||||
|
return true if claim_spec.nil? || claim_spec == true
|
||||||
|
return false if claim_spec == false
|
||||||
|
|
||||||
|
# If it's a hash, the claim is requested (filtering happens later)
|
||||||
|
true if claim_spec.is_a?(Hash)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filter custom claims based on claims parameter
|
||||||
|
# Removes claims not explicitly requested
|
||||||
|
# Applies value/values filtering if specified
|
||||||
|
def filter_custom_claims(payload, id_token_claims)
|
||||||
|
# Get all claim names that are NOT standard OIDC claims
|
||||||
|
standard_claims = %w[iss sub aud exp iat nbf jti nonce azp at_hash auth_time acr email email_verified name preferred_username updated_at groups]
|
||||||
|
custom_claim_names = payload.keys.map(&:to_s) - standard_claims
|
||||||
|
|
||||||
|
filtered = payload.dup
|
||||||
|
|
||||||
|
custom_claim_names.each do |claim_name|
|
||||||
|
claim_sym = claim_name.to_sym
|
||||||
|
|
||||||
|
# If claim is not requested, remove it
|
||||||
|
unless id_token_claims.key?(claim_name) || id_token_claims.key?(claim_sym)
|
||||||
|
filtered.delete(claim_sym)
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# Apply value/values filtering if specified
|
||||||
|
claim_spec = id_token_claims[claim_name] || id_token_claims[claim_sym]
|
||||||
|
next unless claim_spec.is_a?(Hash)
|
||||||
|
|
||||||
|
current_value = filtered[claim_sym]
|
||||||
|
|
||||||
|
# Check value constraint
|
||||||
|
if claim_spec["value"].present?
|
||||||
|
filtered.delete(claim_sym) unless current_value == claim_spec["value"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check values constraint (array of allowed values)
|
||||||
|
if claim_spec["values"].is_a?(Array)
|
||||||
|
filtered.delete(claim_sym) unless claim_spec["values"].include?(current_value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
filtered
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<%= form.label :description, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
|
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<%= form.label :icon, "Application Icon", class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :icon, "Application Icon", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
<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>
|
||||||
@@ -32,8 +51,8 @@
|
|||||||
<%# Only show icon if we can successfully get its URL (blob is persisted) %>
|
<%# Only show icon if we can successfully get its URL (blob is persisted) %>
|
||||||
<% if application.icon.blob&.persisted? && application.icon.blob.key.present? %>
|
<% if application.icon.blob&.persisted? && application.icon.blob.key.present? %>
|
||||||
<div class="mt-2 mb-3 flex items-center gap-4">
|
<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" %>
|
<%= image_tag application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700", alt: "Current icon" %>
|
||||||
<div class="text-sm text-gray-600">
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
<p class="font-medium">Current icon</p>
|
<p class="font-medium">Current icon</p>
|
||||||
<p class="text-xs"><%= number_to_human_size(application.icon.blob.byte_size) %></p>
|
<p class="text-xs"><%= number_to_human_size(application.icon.blob.byte_size) %></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,7 +61,7 @@
|
|||||||
<% rescue ArgumentError => e %>
|
<% rescue ArgumentError => e %>
|
||||||
<%# Handle case where icon attachment exists but can't generate signed_id %>
|
<%# 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") %>
|
<% if e.message.include?("Cannot get a signed_id for a new record") %>
|
||||||
<div class="mt-2 mb-3 text-sm text-gray-600">
|
<div class="mt-2 mb-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<p class="font-medium">Icon uploaded</p>
|
<p class="font-medium">Icon uploaded</p>
|
||||||
<p class="text-xs">File will be processed shortly</p>
|
<p class="text-xs">File will be processed shortly</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,17 +73,17 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="mt-2" data-controller="file-drop image-paste">
|
<div class="mt-2" data-controller="file-drop image-paste">
|
||||||
<div class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-blue-400 transition-colors"
|
<div class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 dark:border-gray-600 border-dashed rounded-md hover:border-blue-400 transition-colors"
|
||||||
data-file-drop-target="dropzone"
|
data-file-drop-target="dropzone"
|
||||||
data-image-paste-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"
|
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste"
|
||||||
tabindex="0">
|
tabindex="0">
|
||||||
<div class="space-y-1 text-center">
|
<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">
|
<svg class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||||
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
<div class="flex text-sm text-gray-600">
|
<div class="flex text-sm text-gray-600 dark:text-gray-400">
|
||||||
<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">
|
<label for="<%= form.field_id(:icon) %>" class="relative cursor-pointer bg-white dark:bg-gray-800 rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 dark:focus-within:ring-offset-gray-900 focus-within:ring-blue-500">
|
||||||
<span>Upload a file</span>
|
<span>Upload a file</span>
|
||||||
<%= form.file_field :icon,
|
<%= form.file_field :icon,
|
||||||
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
|
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
|
||||||
@@ -77,18 +96,18 @@
|
|||||||
</label>
|
</label>
|
||||||
<p class="pl-1">or drag and drop</p>
|
<p class="pl-1">or drag and drop</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500">PNG, JPG, GIF, or SVG up to 2MB</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">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>
|
<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>
|
</div>
|
||||||
<div data-file-drop-target="preview" class="mt-3 hidden">
|
<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">
|
<div class="flex items-center gap-3 p-3 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-12 w-12 rounded object-cover" alt="Preview">
|
<img data-file-drop-target="previewImage" class="h-12 w-12 rounded object-cover" alt="Preview">
|
||||||
<div class="flex-1 min-w-0">
|
<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-sm font-medium text-gray-900 dark:text-gray-100" data-file-drop-target="filename"></p>
|
||||||
<p class="text-xs text-gray-500" data-file-drop-target="filesize"></p>
|
<p class="text-xs text-gray-500 dark:text-gray-400" data-file-drop-target="filesize"></p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" data-action="click->file-drop#clear" class="text-gray-400 hover:text-gray-600">
|
<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-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
<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" />
|
<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>
|
</svg>
|
||||||
@@ -99,44 +118,32 @@
|
|||||||
</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,129 +151,207 @@
|
|||||||
<% 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 -->
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">OAuth2 Flow</h4>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Clinch uses the <code class="bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono">authorization_code</code> flow with <code class="bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono">response_type=code</code> (the modern, secure standard).
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Deprecated flows like Implicit (<code class="bg-white dark:bg-gray-800 px-1 rounded text-xs font-mono">id_token</code>, <code class="bg-white dark:bg-gray-800 px-1 rounded text-xs font-mono">token</code>) are not supported for security reasons.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-blue-200 dark:border-blue-700 pt-3">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">Client Authentication</h4>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Clinch supports both <code class="bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono">client_secret_basic</code> (HTTP Basic Auth) and <code class="bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono">client_secret_post</code> (POST parameters) authentication methods.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- PKCE Requirement (only for confidential clients) -->
|
<!-- 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 -->
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= form.check_box :skip_consent, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :skip_consent, "Skip Consent Screen", class: "ml-2 block text-sm font-medium text-gray-900 dark:text-gray-100" %>
|
||||||
|
</div>
|
||||||
|
<p class="ml-6 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Automatically grant consent for all users. Useful for first-party or trusted applications.
|
||||||
|
<br><span class="text-xs text-amber-600">Only enable for applications you fully trust. Consent is still recorded in the database.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div>
|
<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 (seconds)", 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.number_field :access_token_ttl, value: application.access_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
<%= form.text_field :access_token_ttl,
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
value: application.access_token_ttl || "1h",
|
||||||
Range: 5 min - 24 hours
|
placeholder: "e.g., 1h, 30m, 3600",
|
||||||
<br>Default: 1 hour (3600s)
|
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" %>
|
||||||
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human || "1 hour" %></span>
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Range: 5m - 24h
|
||||||
|
<br>Default: 1h
|
||||||
|
<% if application.access_token_ttl.present? %>
|
||||||
|
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human %> (<%= application.access_token_ttl %>s)</span>
|
||||||
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :refresh_token_ttl, "Refresh Token TTL (seconds)", 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.number_field :refresh_token_ttl, value: application.refresh_token_ttl || 2592000, min: 86400, max: 7776000, step: 86400, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
<%= form.text_field :refresh_token_ttl,
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
value: application.refresh_token_ttl || "30d",
|
||||||
Range: 1 day - 90 days
|
placeholder: "e.g., 30d, 1M, 2592000",
|
||||||
<br>Default: 30 days (2592000s)
|
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" %>
|
||||||
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human || "30 days" %></span>
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Range: 5m - 90d
|
||||||
|
<br>Default: 30d
|
||||||
|
<% if application.refresh_token_ttl.present? %>
|
||||||
|
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human %> (<%= application.refresh_token_ttl %>s)</span>
|
||||||
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :id_token_ttl, "ID Token TTL (seconds)", 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.number_field :id_token_ttl, value: application.id_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
<%= form.text_field :id_token_ttl,
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
value: application.id_token_ttl || "1h",
|
||||||
Range: 5 min - 24 hours
|
placeholder: "e.g., 1h, 30m, 3600",
|
||||||
<br>Default: 1 hour (3600s)
|
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" %>
|
||||||
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human || "1 hour" %></span>
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Range: 5m - 24h
|
||||||
|
<br>Default: 1h
|
||||||
|
<% if application.id_token_ttl.present? %>
|
||||||
|
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human %> (<%= application.id_token_ttl %>s)</span>
|
||||||
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details class="mt-3">
|
<details class="mt-3">
|
||||||
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types</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-2 text-sm text-gray-600">
|
<div class="mt-2 ml-4 space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">Token Types:</p>
|
||||||
<p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p>
|
<p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p>
|
||||||
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Longer lifetime = better UX (less re-logins).</p>
|
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Each refresh issues a new refresh token (token rotation).</p>
|
||||||
<p><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p>
|
<p><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p>
|
||||||
<p class="text-xs italic mt-2">💡 Tip: Banking apps use 5-15 min access tokens. Internal tools use 1-4 hours.</p>
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||||
|
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">How Session Length Works:</p>
|
||||||
|
<p><strong>Refresh Token TTL = Maximum Inactivity Period</strong></p>
|
||||||
|
<p class="ml-3">Because refresh tokens are automatically rotated (new token = new expiry), active users can stay logged in indefinitely. The TTL controls how long they can be <em>inactive</em> before requiring re-authentication.</p>
|
||||||
|
|
||||||
|
<p class="mt-2"><strong>Example:</strong> Refresh TTL = 30 days</p>
|
||||||
|
<ul class="ml-6 list-disc space-y-1 text-xs">
|
||||||
|
<li>User logs in on Day 0, uses app daily → stays logged in forever (tokens keep rotating)</li>
|
||||||
|
<li>User logs in on Day 0, stops using app → must re-login after 30 days of inactivity</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||||
|
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">Forcing Re-Authentication:</p>
|
||||||
|
<p class="ml-3 text-xs">Because of token rotation, there's no way to force periodic re-authentication using TTL settings alone. Active users can stay logged in indefinitely by refreshing tokens before they expire.</p>
|
||||||
|
|
||||||
|
<p class="mt-2 ml-3 text-xs"><strong>To enforce absolute session limits:</strong> Clients can include the <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">max_age</code> parameter in their authorization requests to require re-authentication after a specific time, regardless of token rotation.</p>
|
||||||
|
|
||||||
|
<p class="mt-2 ml-3 text-xs"><strong>Example:</strong> A banking app might set <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">max_age=900</code> (15 minutes) in the authorization request to force re-authentication every 15 minutes, even if refresh tokens are still valid.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||||
|
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">Common Configurations:</p>
|
||||||
|
<ul class="ml-3 space-y-1 text-xs">
|
||||||
|
<li><strong>Banking/High Security:</strong> Access TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">5m</code>, Refresh TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">5m</code> → Re-auth every 5 minutes</li>
|
||||||
|
<li><strong>Corporate Tools:</strong> Access TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">1h</code>, Refresh TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">8h</code> → Re-auth after 8 hours inactive</li>
|
||||||
|
<li><strong>Personal Apps:</strong> Access TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">1h</code>, Refresh TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">30d</code> → Re-auth after 30 days inactive</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</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", "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-Groups, X-Remote-Admin</p>
|
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Username, X-Remote-Groups, X-Remote-Admin</p>
|
||||||
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||||
<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">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">username</code> - User's login username (only sent if set)</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">groups</code> - Comma-separated list of group names (e.g., "admin,developers")</p>
|
||||||
<p class="mt-2 italic">Example: <code class="bg-gray-100 px-1 rounded">{"user": "Remote-User", "groups": "Remote-Groups"}</code></p>
|
<p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">admin</code> - "true" or "false" indicating admin status</p>
|
||||||
|
<p class="mt-2 italic">Example: <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">{"user": "Remote-User", "groups": "Remote-Groups", "username": "Remote-Username"}</code></p>
|
||||||
<p class="italic">Need custom user fields? Add them to user's custom_claims for OIDC tokens</p>
|
<p class="italic">Need custom user fields? Add them to user's custom_claims for OIDC tokens</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -275,31 +360,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 %>
|
||||||
|
|
||||||
|
|||||||
@@ -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,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">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" %>
|
||||||
@@ -11,29 +11,29 @@
|
|||||||
<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">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">
|
||||||
<% @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" %>
|
<%= image_tag application.icon, class: "h-10 w-10 rounded-lg object-cover border border-gray-200 dark:border-gray-700 flex-shrink-0", alt: "#{application.name} icon" %>
|
||||||
<% 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">
|
<div class="h-10 w-10 rounded-lg bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 flex items-center justify-center flex-shrink-0">
|
||||||
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" 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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,29 +41,29 @@
|
|||||||
<%= 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? %>
|
<% if application.allowed_groups.empty? %>
|
||||||
<span class="text-gray-400">All users</span>
|
<span class="text-gray-400 dark:text-gray-500">All users</span>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= application.allowed_groups.count %>
|
<%= application.allowed_groups.count %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -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,27 +1,27 @@
|
|||||||
<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 %>
|
||||||
@@ -32,21 +32,21 @@
|
|||||||
<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" %>
|
<%= image_tag @application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0", alt: "#{@application.name} icon" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="h-16 w-16 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0">
|
<div class="h-16 w-16 rounded-lg bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 flex items-center justify-center shrink-0">
|
||||||
<svg class="h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-8 w-8 text-gray-400 dark:text-gray-500" 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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</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 +54,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,59 +99,59 @@
|
|||||||
|
|
||||||
<!-- 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>
|
||||||
@@ -159,33 +159,33 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
<% 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,24 +198,24 @@
|
|||||||
|
|
||||||
<!-- 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-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 %>
|
||||||
</dd>
|
</dd>
|
||||||
@@ -226,29 +226,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-blue-50 dark:bg-blue-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-blue-700 dark:text-blue-300">
|
||||||
No groups assigned - all active users can access this application.
|
No groups assigned - all active users can access this application.
|
||||||
</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 %>
|
||||||
|
|||||||
@@ -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,51 @@
|
|||||||
<%= 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" %>
|
<%= 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 rounded-md p-3">
|
<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>
|
||||||
|
|
||||||
<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 +55,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,13 @@
|
|||||||
<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>
|
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @group.name %></h1>
|
||||||
<% 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 +15,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 +41,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 +79,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,49 @@
|
|||||||
<%= 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 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) %>
|
<%= form.check_box :admin, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500", disabled: (user == Current.session.user) %>
|
||||||
<%= form.label :admin, "Administrator", class: "ml-2 block text-sm text-gray-900" %>
|
<%= form.label :admin, "Administrator", class: "ml-2 block text-sm text-gray-900 dark:text-gray-100" %>
|
||||||
<% if user == Current.session.user %>
|
<% if user == Current.session.user %>
|
||||||
<span class="ml-2 text-xs text-gray-500">(Cannot change your own admin status)</span>
|
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">(Cannot change your own admin status)</span>
|
||||||
<% 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 +57,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 +83,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 %>
|
<%= user.email_address %>
|
||||||
</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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
71
app/views/api_keys/index.html.erb
Normal file
71
app/views/api_keys/index.html.erb
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div class="mb-8 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">API Keys</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Bearer tokens for server-to-server access to forward auth applications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<%= link_to "New API Key", new_api_key_path,
|
||||||
|
class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @api_keys.any? %>
|
||||||
|
<div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Name</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Application</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Created</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Used</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Expires</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<% @api_keys.each do |key| %>
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100"><%= key.name %></td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"><%= key.application.name %></td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"><%= key.created_at.strftime("%b %d, %Y") %></td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"><%= key.last_used_at ? time_ago_in_words(key.last_used_at) + " ago" : "Never" %></td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"><%= key.expires_at ? key.expires_at.strftime("%b %d, %Y") : "Never" %></td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<% if key.revoked? %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-200">Revoked</span>
|
||||||
|
<% elsif key.expired? %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900/50 text-yellow-800 dark:text-yellow-200">Expired</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200">Active</span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<% if key.active? %>
|
||||||
|
<%= button_to "Revoke", api_key_path(key), method: :delete,
|
||||||
|
class: "text-red-600 hover:text-red-900",
|
||||||
|
form: { data: { turbo_confirm: "Revoke this API key? This cannot be undone." } } %>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-8 text-center">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-gray-100">No API keys</h3>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Create an API key to authenticate server-to-server requests.
|
||||||
|
</p>
|
||||||
|
<div class="mt-6">
|
||||||
|
<%= link_to "Create API Key", new_api_key_path,
|
||||||
|
class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
55
app/views/api_keys/new.html.erb
Normal file
55
app/views/api_keys/new.html.erb
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<div class="max-w-lg mx-auto">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">New API Key</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Create a bearer token for server-to-server access to a forward auth application.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<%= form_with(model: @api_key, class: "space-y-6") do |f| %>
|
||||||
|
<% if @api_key.errors.any? %>
|
||||||
|
<div class="rounded-md bg-red-50 dark:bg-red-900/30 p-4">
|
||||||
|
<div class="text-sm text-red-700 dark:text-red-300">
|
||||||
|
<ul class="list-disc pl-5 space-y-1">
|
||||||
|
<% @api_key.errors.full_messages.each do |msg| %>
|
||||||
|
<li><%= msg %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
|
<%= f.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||||
|
placeholder: "e.g., Video Player WebDAV" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :application_id, "Application", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
|
<% if @applications.any? %>
|
||||||
|
<%= f.collection_select :application_id, @applications, :id, :name,
|
||||||
|
{ prompt: "Select an application" },
|
||||||
|
{ class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||||
|
<% else %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No forward auth applications available.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :expires_at, "Expiration (optional)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
|
<%= f.datetime_local_field :expires_at, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave blank for no expiration.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<%= link_to "Cancel", api_keys_path, class: "text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-500 dark:hover:text-gray-400" %>
|
||||||
|
<%= f.submit "Create API Key",
|
||||||
|
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
59
app/views/api_keys/show.html.erb
Normal file
59
app/views/api_keys/show.html.erb
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<div class="max-w-2xl mx-auto" data-controller="clipboard">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">API Key Created</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Copy your API key now. You won't be able to see it again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/30 p-4 mb-6">
|
||||||
|
<div class="flex">
|
||||||
|
<svg class="h-5 w-5 text-yellow-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<div class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
|
<p class="font-medium">Save this key now!</p>
|
||||||
|
<p class="mt-1">This is the only time you'll see the full API key. Store it securely.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">API Key</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="text" readonly value="<%= @plaintext_token %>"
|
||||||
|
data-clipboard-target="source"
|
||||||
|
class="flex-1 rounded-md border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 dark:text-gray-100 font-mono text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500" />
|
||||||
|
<button data-action="click->clipboard#copy"
|
||||||
|
data-clipboard-target="button"
|
||||||
|
class="inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 py-2 px-3 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
|
||||||
|
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||||
|
</svg>
|
||||||
|
<span data-clipboard-target="label">Copy</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<p><strong>Name:</strong> <%= @api_key.name %></p>
|
||||||
|
<p><strong>Application:</strong> <%= @api_key.application.name %></p>
|
||||||
|
<p><strong>Expires:</strong> <%= @api_key.expires_at ? @api_key.expires_at.strftime("%b %d, %Y %H:%M") : "Never" %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 rounded-md bg-gray-50 dark:bg-gray-700 p-4">
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Usage example:</p>
|
||||||
|
<pre class="text-xs text-gray-600 dark:text-gray-200 overflow-x-auto">curl -H "Authorization: Bearer <%= @plaintext_token %>" \
|
||||||
|
-H "X-Forwarded-Host: your-app.example.com" \
|
||||||
|
<%= request.base_url %>/api/verify</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<%= link_to "Done", api_keys_path,
|
||||||
|
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<div class="mb-8">
|
<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,48 +86,74 @@
|
|||||||
</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 -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
API Keys
|
||||||
|
</dt>
|
||||||
|
<dd class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
<%= @user.api_keys.active.count %>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700 px-5 py-3">
|
||||||
|
<%= link_to "Manage API Keys", api_keys_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 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" %>
|
<%= image_tag app.icon, class: "h-12 w-12 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0", alt: "#{app.name} icon" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="h-12 w-12 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0">
|
<div class="h-12 w-12 rounded-lg bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-700 flex items-center justify-center shrink-0">
|
||||||
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" 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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</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 %>
|
||||||
@@ -139,17 +165,27 @@
|
|||||||
<%= 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 "Logout", 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 log you out of #{app.name}. You can sign back in without re-authorizing. 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 %>
|
||||||
|
|
||||||
|
<% 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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,12 +193,12 @@
|
|||||||
<% 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>
|
||||||
@@ -171,21 +207,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">
|
||||||
|
|||||||
@@ -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" %>
|
<%= image_tag @application.icon, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 dark:border-gray-700 shadow-sm mb-4", alt: "#{@application.name} icon" %>
|
||||||
<% 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 h-20 w-20 rounded-xl bg-gray-100 dark:bg-gray-700 border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center mb-4">
|
||||||
<svg class="h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-10 w-10 text-gray-400 dark:text-gray-500" 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" />
|
<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>
|
</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,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>
|
||||||
|
|||||||
@@ -6,25 +6,25 @@
|
|||||||
<%= 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,
|
||||||
autocomplete: "username",
|
autocomplete: "username",
|
||||||
placeholder: "your@email.com",
|
placeholder: "your@email.com",
|
||||||
value: 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,19 @@
|
|||||||
</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>
|
||||||
|
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
@@ -58,14 +59,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>
|
||||||
|
|||||||
@@ -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,7 +59,7 @@
|
|||||||
|
|
||||||
<!-- 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>
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
|
|
||||||
<!-- 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 +80,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 +88,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>
|
||||||
@@ -124,16 +140,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 +160,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 +169,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 +177,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,7 +185,7 @@
|
|||||||
<% 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>
|
||||||
@@ -178,7 +194,7 @@
|
|||||||
</li>
|
</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 +202,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>
|
||||||
|
|||||||
@@ -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,26 @@
|
|||||||
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 %>
|
<%= 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 +46,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ Rails.application.configure do
|
|||||||
|
|
||||||
# Use Solid Queue for background jobs
|
# Use Solid Queue for background jobs
|
||||||
config.active_job.queue_adapter = :solid_queue
|
config.active_job.queue_adapter = :solid_queue
|
||||||
|
config.solid_queue.connects_to = {database: {writing: :queue}}
|
||||||
|
|
||||||
# Ignore bad email addresses and do not raise email delivery errors.
|
# Ignore bad email addresses and do not raise email delivery errors.
|
||||||
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
||||||
|
|||||||
2
config/initializers/forward_auth_cache.rb
Normal file
2
config/initializers/forward_auth_cache.rb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Rails.application.config.forward_auth_cache =
|
||||||
|
ActiveSupport::Cache::MemoryStore.new(size: 8.megabytes)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Clinch
|
module Clinch
|
||||||
VERSION = "0.8.4"
|
VERSION = "0.9.0"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Rails.application.routes.draw do
|
|||||||
# OIDC (OpenID Connect) routes
|
# OIDC (OpenID Connect) routes
|
||||||
get "/.well-known/openid-configuration", to: "oidc#discovery"
|
get "/.well-known/openid-configuration", to: "oidc#discovery"
|
||||||
get "/.well-known/jwks.json", to: "oidc#jwks"
|
get "/.well-known/jwks.json", to: "oidc#jwks"
|
||||||
get "/oauth/authorize", to: "oidc#authorize"
|
match "/oauth/authorize", to: "oidc#authorize", via: [:get, :post]
|
||||||
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
|
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
|
||||||
post "/oauth/token", to: "oidc#token"
|
post "/oauth/token", to: "oidc#token"
|
||||||
post "/oauth/revoke", to: "oidc#revoke"
|
post "/oauth/revoke", to: "oidc#revoke"
|
||||||
@@ -40,6 +40,8 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Authenticated routes
|
# Authenticated routes
|
||||||
|
resources :api_keys, only: [:index, :new, :create, :show, :destroy]
|
||||||
|
|
||||||
root "dashboard#index"
|
root "dashboard#index"
|
||||||
resource :profile, only: [:show, :update] do
|
resource :profile, only: [:show, :update] do
|
||||||
member do
|
member do
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddSkipConsentToApplications < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :applications, :skip_consent, :boolean, default: false, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddClaimsRequestsToOidcUserConsents < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :oidc_user_consents, :claims_requests, :json, default: {}, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddClaimsRequestsToOidcAuthorizationCodes < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :oidc_authorization_codes, :claims_requests, :json, default: {}, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
20
db/migrate/20260305000001_create_api_keys.rb
Normal file
20
db/migrate/20260305000001_create_api_keys.rb
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
class CreateApiKeys < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :api_keys do |t|
|
||||||
|
t.references :user, null: false, foreign_key: true
|
||||||
|
t.references :application, null: false, foreign_key: true
|
||||||
|
t.string :name, null: false
|
||||||
|
t.string :token_hmac, null: false
|
||||||
|
t.datetime :expires_at
|
||||||
|
t.datetime :last_used_at
|
||||||
|
t.datetime :revoked_at
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :api_keys, :token_hmac, unique: true
|
||||||
|
add_index :api_keys, [:user_id, :application_id]
|
||||||
|
add_index :api_keys, :expires_at
|
||||||
|
add_index :api_keys, :revoked_at
|
||||||
|
end
|
||||||
|
end
|
||||||
25
db/schema.rb
generated
25
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do
|
ActiveRecord::Schema[8.1].define(version: 2026_03_05_000001) do
|
||||||
create_table "active_storage_attachments", force: :cascade do |t|
|
create_table "active_storage_attachments", force: :cascade do |t|
|
||||||
t.bigint "blob_id", null: false
|
t.bigint "blob_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -39,6 +39,24 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do
|
|||||||
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "api_keys", force: :cascade do |t|
|
||||||
|
t.integer "application_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "expires_at"
|
||||||
|
t.datetime "last_used_at"
|
||||||
|
t.string "name", null: false
|
||||||
|
t.datetime "revoked_at"
|
||||||
|
t.string "token_hmac", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.integer "user_id", null: false
|
||||||
|
t.index ["application_id"], name: "index_api_keys_on_application_id"
|
||||||
|
t.index ["expires_at"], name: "index_api_keys_on_expires_at"
|
||||||
|
t.index ["revoked_at"], name: "index_api_keys_on_revoked_at"
|
||||||
|
t.index ["token_hmac"], name: "index_api_keys_on_token_hmac", unique: true
|
||||||
|
t.index ["user_id", "application_id"], name: "index_api_keys_on_user_id_and_application_id"
|
||||||
|
t.index ["user_id"], name: "index_api_keys_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "application_groups", force: :cascade do |t|
|
create_table "application_groups", force: :cascade do |t|
|
||||||
t.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -78,6 +96,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do
|
|||||||
t.text "redirect_uris"
|
t.text "redirect_uris"
|
||||||
t.integer "refresh_token_ttl", default: 2592000
|
t.integer "refresh_token_ttl", default: 2592000
|
||||||
t.boolean "require_pkce", default: true, null: false
|
t.boolean "require_pkce", default: true, null: false
|
||||||
|
t.boolean "skip_consent", default: false, null: false
|
||||||
t.string "slug", null: false
|
t.string "slug", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["active"], name: "index_applications_on_active"
|
t.index ["active"], name: "index_applications_on_active"
|
||||||
@@ -116,6 +135,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do
|
|||||||
t.string "acr"
|
t.string "acr"
|
||||||
t.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
t.integer "auth_time"
|
t.integer "auth_time"
|
||||||
|
t.json "claims_requests", default: {}, null: false
|
||||||
t.string "code_challenge"
|
t.string "code_challenge"
|
||||||
t.string "code_challenge_method"
|
t.string "code_challenge_method"
|
||||||
t.string "code_hmac", null: false
|
t.string "code_hmac", null: false
|
||||||
@@ -160,6 +180,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do
|
|||||||
|
|
||||||
create_table "oidc_user_consents", force: :cascade do |t|
|
create_table "oidc_user_consents", force: :cascade do |t|
|
||||||
t.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
|
t.json "claims_requests", default: {}, null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "granted_at", null: false
|
t.datetime "granted_at", null: false
|
||||||
t.text "scopes_granted", null: false
|
t.text "scopes_granted", null: false
|
||||||
@@ -246,6 +267,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do
|
|||||||
|
|
||||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
|
add_foreign_key "api_keys", "applications"
|
||||||
|
add_foreign_key "api_keys", "users"
|
||||||
add_foreign_key "application_groups", "applications"
|
add_foreign_key "application_groups", "applications"
|
||||||
add_foreign_key "application_groups", "groups"
|
add_foreign_key "application_groups", "groups"
|
||||||
add_foreign_key "application_user_claims", "applications", on_delete: :cascade
|
add_foreign_key "application_user_claims", "applications", on_delete: :cascade
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ This checklist ensures Clinch meets security, quality, and documentation standar
|
|||||||
- [x] Authorization code flow with PKCE support
|
- [x] Authorization code flow with PKCE support
|
||||||
- [x] Refresh token rotation
|
- [x] Refresh token rotation
|
||||||
- [x] Token family tracking (detects replay attacks)
|
- [x] Token family tracking (detects replay attacks)
|
||||||
- [x] All tokens HMAC-SHA256 hashed in database
|
- [x] All tokens and authorization codes HMAC-SHA256 hashed in database
|
||||||
|
- [x] TOTP secrets AES-256-GCM encrypted at rest (Rails credentials)
|
||||||
- [x] Configurable token expiry (access, refresh, ID)
|
- [x] Configurable token expiry (access, refresh, ID)
|
||||||
- [x] One-time use authorization codes
|
- [x] One-time use authorization codes
|
||||||
- [x] Pairwise subject identifiers (privacy)
|
- [x] Pairwise subject identifiers (privacy)
|
||||||
@@ -130,8 +131,7 @@ This checklist ensures Clinch meets security, quality, and documentation standar
|
|||||||
|
|
||||||
## Code Quality
|
## Code Quality
|
||||||
|
|
||||||
- [x] **RuboCop** - Code style and linting
|
- [x] **StandardRB** - Code style and linting
|
||||||
- Configuration: Rails Omakase
|
|
||||||
- CI: Runs on every PR and push to main
|
- CI: Runs on every PR and push to main
|
||||||
|
|
||||||
- [x] **Documentation** - Comprehensive README
|
- [x] **Documentation** - Comprehensive README
|
||||||
@@ -158,7 +158,7 @@ This checklist ensures Clinch meets security, quality, and documentation standar
|
|||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
- [ ] Review N+1 queries
|
- [ ] Review N+1 queries
|
||||||
- [ ] Add database indexes where needed
|
- [x] Add database indexes where needed
|
||||||
- [ ] Test with realistic data volumes
|
- [ ] Test with realistic data volumes
|
||||||
- [ ] Review token cleanup job performance
|
- [ ] Review token cleanup job performance
|
||||||
|
|
||||||
|
|||||||
44
package-lock.json
generated
Normal file
44
package-lock.json
generated
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "clinch",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "clinch",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/forms": "^0.5.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/forms": {
|
||||||
|
"version": "0.5.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz",
|
||||||
|
"integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mini-svg-data-uri": "^1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mini-svg-data-uri": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mini-svg-data-uri": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tailwindcss": {
|
||||||
|
"version": "4.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||||
|
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "clinch",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "> [!NOTE] > This software is experimental. If you'd like to try it out, find bugs, security flaws and improvements, please do.",
|
||||||
|
"main": "index.js",
|
||||||
|
"directories": {
|
||||||
|
"doc": "docs",
|
||||||
|
"lib": "lib",
|
||||||
|
"test": "test"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "ssh://git@git.booko.info:2222/dkam/clinch.git"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/forms": "^0.5.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
148
test/controllers/api/forward_auth_bearer_test.rb
Normal file
148
test/controllers/api/forward_auth_bearer_test.rb
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
module Api
|
||||||
|
class ForwardAuthBearerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@user = users(:bob)
|
||||||
|
@app = Application.create!(
|
||||||
|
name: "WebDAV App",
|
||||||
|
slug: "webdav-app",
|
||||||
|
app_type: "forward_auth",
|
||||||
|
domain_pattern: "webdav.example.com",
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
@api_key = @user.api_keys.create!(name: "Test Key", application: @app)
|
||||||
|
@token = @api_key.plaintext_token
|
||||||
|
end
|
||||||
|
|
||||||
|
test "valid bearer token returns 200 with user headers" do
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"Authorization" => "Bearer #{@token}",
|
||||||
|
"X-Forwarded-Host" => "webdav.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :ok
|
||||||
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
|
assert_equal @user.email_address, response.headers["x-remote-email"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "valid bearer token updates last_used_at" do
|
||||||
|
assert_nil @api_key.last_used_at
|
||||||
|
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"Authorization" => "Bearer #{@token}",
|
||||||
|
"X-Forwarded-Host" => "webdav.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :ok
|
||||||
|
assert @api_key.reload.last_used_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "expired bearer token returns 401 JSON" do
|
||||||
|
@api_key.update_column(:expires_at, 1.hour.ago)
|
||||||
|
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"Authorization" => "Bearer #{@token}",
|
||||||
|
"X-Forwarded-Host" => "webdav.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
assert_equal "Invalid or expired API key", json["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "revoked bearer token returns 401 JSON" do
|
||||||
|
@api_key.revoke!
|
||||||
|
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"Authorization" => "Bearer #{@token}",
|
||||||
|
"X-Forwarded-Host" => "webdav.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
assert_equal "Invalid or expired API key", json["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "invalid bearer token returns 401 JSON" do
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"Authorization" => "Bearer clk_totally_bogus_token",
|
||||||
|
"X-Forwarded-Host" => "webdav.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
assert_equal "Invalid or expired API key", json["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "bearer token for wrong domain returns 401 JSON" do
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"Authorization" => "Bearer #{@token}",
|
||||||
|
"X-Forwarded-Host" => "other.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
assert_equal "API key not valid for this domain", json["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "bearer token for inactive user returns 401 JSON" do
|
||||||
|
@user.update!(status: :disabled)
|
||||||
|
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"Authorization" => "Bearer #{@token}",
|
||||||
|
"X-Forwarded-Host" => "webdav.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
assert_equal "User account is not active", json["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "bearer token for inactive application returns 401 JSON" do
|
||||||
|
@app.update!(active: false)
|
||||||
|
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"Authorization" => "Bearer #{@token}",
|
||||||
|
"X-Forwarded-Host" => "webdav.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
assert_equal "Application is inactive", json["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "no bearer token falls through to cookie auth" do
|
||||||
|
# No auth header, no session -> should redirect (cookie flow)
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "webdav.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
assert_match %r{/signin}, response.location
|
||||||
|
end
|
||||||
|
|
||||||
|
test "bearer token does not redirect on failure" do
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"Authorization" => "Bearer clk_bad",
|
||||||
|
"X-Forwarded-Host" => "webdav.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
assert_equal "application/json", response.media_type
|
||||||
|
# Should NOT be a redirect
|
||||||
|
assert_nil response.headers["Location"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cookie auth still works when no bearer token present" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "webdav.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :ok
|
||||||
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -279,7 +279,7 @@ module Api
|
|||||||
rd: evil_url # Ensure the rd parameter is preserved in login
|
rd: evil_url # Ensure the rd parameter is preserved in login
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 303
|
||||||
# Should NOT redirect to evil URL after successful authentication
|
# Should NOT redirect to evil URL after successful authentication
|
||||||
refute_match evil_url, response.location, "Should not redirect to evil URL after authentication"
|
refute_match evil_url, response.location, "Should not redirect to evil URL after authentication"
|
||||||
# Should redirect to the legitimate URL (not the evil one)
|
# Should redirect to the legitimate URL (not the evil one)
|
||||||
@@ -516,6 +516,188 @@ module Api
|
|||||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Rate Limiting Tests
|
||||||
|
test "should return 429 when rate limit exceeded" do
|
||||||
|
cache = Rails.application.config.forward_auth_cache
|
||||||
|
cache.write("fa_fail:127.0.0.1", 50, expires_in: 1.minute)
|
||||||
|
|
||||||
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
|
||||||
|
assert_response 429
|
||||||
|
assert_equal "60", response.headers["Retry-After"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should allow requests below rate limit" do
|
||||||
|
cache = Rails.application.config.forward_auth_cache
|
||||||
|
cache.write("fa_fail:127.0.0.1", 49, expires_in: 1.minute)
|
||||||
|
|
||||||
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
|
||||||
|
assert_response 302 # unauthorized redirect, but not rate limited
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should track failed attempts and eventually rate limit" do
|
||||||
|
cache = Rails.application.config.forward_auth_cache
|
||||||
|
|
||||||
|
# Make 50 failed requests (no session = unauthorized)
|
||||||
|
50.times do
|
||||||
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
end
|
||||||
|
|
||||||
|
# The 51st should be rate limited
|
||||||
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
assert_response 429
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should not track successful requests as failures" do
|
||||||
|
cache = Rails.application.config.forward_auth_cache
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
assert_response 200
|
||||||
|
|
||||||
|
count = cache.read("fa_fail:127.0.0.1")
|
||||||
|
assert_nil count, "Successful requests should not increment failure counter"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Caching Tests
|
||||||
|
test "should debounce last_activity_at updates" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
session = Session.last
|
||||||
|
|
||||||
|
# First request should update last_activity_at
|
||||||
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
assert_response 200
|
||||||
|
first_activity = session.reload.last_activity_at
|
||||||
|
|
||||||
|
# Second request within 1 minute should NOT update
|
||||||
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
assert_response 200
|
||||||
|
assert_equal first_activity, session.reload.last_activity_at
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should bust app cache when forward auth application is saved" do
|
||||||
|
cache = Rails.application.config.forward_auth_cache
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
# Prime the cache
|
||||||
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
assert_response 200
|
||||||
|
assert cache.read("fa_apps"), "Cache should be populated after request"
|
||||||
|
|
||||||
|
# Update the application
|
||||||
|
@rule.update!(name: "Updated App")
|
||||||
|
|
||||||
|
assert_nil cache.read("fa_apps"), "Cache should be busted after application update"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should bust app cache when application group membership changes" do
|
||||||
|
cache = Rails.application.config.forward_auth_cache
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
# Prime the cache
|
||||||
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
assert_response 200
|
||||||
|
assert cache.read("fa_apps"), "Cache should be populated after request"
|
||||||
|
|
||||||
|
# Add a group to the application
|
||||||
|
@rule.allowed_groups << @group
|
||||||
|
|
||||||
|
assert_nil cache.read("fa_apps"), "Cache should be busted after adding group to application"
|
||||||
|
|
||||||
|
# Prime cache again
|
||||||
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
|
||||||
|
# Remove the group
|
||||||
|
@rule.application_groups.destroy_all
|
||||||
|
|
||||||
|
assert_nil cache.read("fa_apps"), "Cache should be busted after removing group from application"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should persist first failure in rate limit cache" do
|
||||||
|
cache = Rails.application.config.forward_auth_cache
|
||||||
|
|
||||||
|
assert_nil cache.read("fa_fail:127.0.0.1"), "Counter should not exist before any failures"
|
||||||
|
|
||||||
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
assert_response 302
|
||||||
|
|
||||||
|
count = cache.read("fa_fail:127.0.0.1")
|
||||||
|
assert_equal 1, count, "First failure should write counter with value 1"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should count bearer token failures toward rate limit" do
|
||||||
|
cache = Rails.application.config.forward_auth_cache
|
||||||
|
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "test.example.com",
|
||||||
|
"Authorization" => "Bearer invalid_token"
|
||||||
|
}
|
||||||
|
assert_response 401
|
||||||
|
|
||||||
|
count = cache.read("fa_fail:127.0.0.1")
|
||||||
|
assert_equal 1, count, "Bearer token failure should increment rate limit counter"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should rate limit bearer token requests after too many failures" do
|
||||||
|
cache = Rails.application.config.forward_auth_cache
|
||||||
|
cache.write("fa_fail:127.0.0.1", 50, expires_in: 1.minute)
|
||||||
|
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "test.example.com",
|
||||||
|
"Authorization" => "Bearer invalid_token"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response 429
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should reject rd parameter for deactivated application" do
|
||||||
|
# Prime cache by triggering a lookup
|
||||||
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
assert_response 302
|
||||||
|
|
||||||
|
# Deactivate the app (this busts the cache via after_commit)
|
||||||
|
@rule.update!(active: false)
|
||||||
|
|
||||||
|
# Unauthenticated request with rd pointing to the now-inactive domain
|
||||||
|
get "/api/verify", headers: {"X-Forwarded-Host" => "other.example.com"},
|
||||||
|
params: {rd: "https://test.example.com/dashboard"}
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
# The rd URL should be rejected since the app is inactive
|
||||||
|
refute_match "test.example.com/dashboard", response.location
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should update last_activity_at after debounce window expires" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
session = Session.last
|
||||||
|
|
||||||
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
assert_response 200
|
||||||
|
first_activity = session.reload.last_activity_at
|
||||||
|
|
||||||
|
# Travel past the 1-minute debounce window
|
||||||
|
travel 61.seconds do
|
||||||
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
assert_response 200
|
||||||
|
assert_not_equal first_activity, session.reload.last_activity_at,
|
||||||
|
"last_activity_at should update after debounce window expires"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should not reset failure counter on successful request" do
|
||||||
|
cache = Rails.application.config.forward_auth_cache
|
||||||
|
# Simulate 30 prior failures
|
||||||
|
cache.write("fa_fail:127.0.0.1", 30, expires_in: 1.minute)
|
||||||
|
|
||||||
|
sign_in_as(@user)
|
||||||
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
assert_response 200
|
||||||
|
|
||||||
|
count = cache.read("fa_fail:127.0.0.1")
|
||||||
|
assert_equal 30, count, "Successful request should not reset or decrement failure counter"
|
||||||
|
end
|
||||||
|
|
||||||
# Performance and Load Tests
|
# Performance and Load Tests
|
||||||
test "should handle requests efficiently under load" do
|
test "should handle requests efficiently under load" do
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|||||||
394
test/controllers/oidc_claims_security_test.rb
Normal file
394
test/controllers/oidc_claims_security_test.rb
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcClaimsSecurityTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@user = User.create!(email_address: "claims_security_test@example.com", password: "password123")
|
||||||
|
@application = Application.create!(
|
||||||
|
name: "Claims Security Test App",
|
||||||
|
slug: "claims-security-test-app",
|
||||||
|
app_type: "oidc",
|
||||||
|
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||||
|
active: true,
|
||||||
|
require_pkce: false
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the plain text client secret for testing
|
||||||
|
@application.generate_new_client_secret!
|
||||||
|
@plain_client_secret = @application.client_secret
|
||||||
|
@application.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
# Delete in correct order to avoid foreign key constraints
|
||||||
|
OidcRefreshToken.where(application: @application).delete_all
|
||||||
|
OidcAccessToken.where(application: @application).delete_all
|
||||||
|
OidcAuthorizationCode.where(application: @application).delete_all
|
||||||
|
OidcUserConsent.where(application: @application).delete_all
|
||||||
|
@user.destroy
|
||||||
|
@application.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# CLAIMS PARAMETER ESCALATION ATTACKS
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "rejects claims parameter during authorization code exchange" do
|
||||||
|
# Create consent with minimal scopes (no profile, email, or admin access)
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# ATTEMPT: Inject claims parameter during token exchange (ATTACK!)
|
||||||
|
# The client is trying to request 'admin' claim that they never got consent for
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.plaintext_code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
claims: '{"id_token":{"admin":{"essential":true}}}' # ← ATTACK!
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
# SHOULD: Reject the claims parameter - it's only allowed in authorization requests
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_request", error["error"], "Should reject claims parameter at token endpoint"
|
||||||
|
assert_match(/claims.*not allowed|unsupported parameter/i, error["error_description"], "Error should mention claims parameter not allowed")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects claims parameter during authorization code exchange with profile escalation" do
|
||||||
|
# Create consent with ONLY openid scope (no profile scope)
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# ATTEMPT: Try to get profile claims via claims parameter without profile scope
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.plaintext_code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
claims: '{"id_token":{"name":null,"email":{"essential":true}}}'
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
# SHOULD: Reject the claims parameter
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_request", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects claims parameter during refresh token grant" do
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
|
||||||
|
# ATTEMPT: Inject claims parameter during refresh (ATTACK!)
|
||||||
|
# Trying to escalate to admin claims during refresh
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token,
|
||||||
|
claims: '{"id_token":{"admin":true,"role":{"essential":true}}}' # ← ATTACK!
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
# SHOULD: Reject the claims parameter
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_request", error["error"], "Should reject claims parameter at refresh token endpoint"
|
||||||
|
assert_match(/claims.*not allowed|unsupported parameter/i, error["error_description"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects claims parameter during refresh token grant with custom claims escalation" do
|
||||||
|
# Setup: User has a custom claim at user level
|
||||||
|
@user.update!(custom_claims: {"role" => "user"})
|
||||||
|
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
|
||||||
|
# ATTEMPT: Try to escalate role to admin via claims parameter
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token,
|
||||||
|
claims: '{"id_token":{"role":{"value":"admin"}}}' # ← ATTACK! Trying to override role value
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
# SHOULD: Reject the claims parameter
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_request", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allows token exchange without claims parameter" do
|
||||||
|
# Create consent
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid profile",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normal token exchange WITHOUT claims parameter should work fine
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.plaintext_code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
response_body = JSON.parse(response.body)
|
||||||
|
assert response_body.key?("access_token")
|
||||||
|
assert response_body.key?("id_token")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allows refresh without claims parameter" do
|
||||||
|
# Create consent for this application
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid profile",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-refresh-456"
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
|
||||||
|
# Normal refresh WITHOUT claims parameter should work fine
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
response_body = JSON.parse(response.body)
|
||||||
|
assert response_body.key?("access_token")
|
||||||
|
assert response_body.key?("id_token")
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# CLAIMS PARAMETER IS AUTHORIZATION-ONLY
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "claims parameter is only valid in authorization request per OIDC spec" do
|
||||||
|
# Per OIDC Core spec section 18.2.1, claims parameter usage location is "Authorization Request"
|
||||||
|
# This test verifies that claims parameter cannot be used at token endpoint
|
||||||
|
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test various attempts to inject claims parameter
|
||||||
|
malicious_claims = [
|
||||||
|
'{"id_token":{"admin":true}}',
|
||||||
|
'{"id_token":{"email":{"essential":true}}}',
|
||||||
|
'{"userinfo":{"groups":{"values":["admin"]}}}',
|
||||||
|
'{"id_token":{"custom_claim":"custom_value"}}',
|
||||||
|
"invalid-json"
|
||||||
|
]
|
||||||
|
|
||||||
|
malicious_claims.each do |claims_value|
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.plaintext_code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
claims: claims_value
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
# All should be rejected
|
||||||
|
assert_response :bad_request, "Claims parameter '#{claims_value}' should be rejected"
|
||||||
|
error = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_request", error["error"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# VERIFY CONSENT-BASED ACCESS IS ENFORCED
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "token endpoint respects scopes granted during authorization" do
|
||||||
|
# Create consent with ONLY openid scope (no email, profile, etc.)
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exchange code for tokens
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.plaintext_code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
response_body = JSON.parse(response.body)
|
||||||
|
id_token = response_body["id_token"]
|
||||||
|
|
||||||
|
# Decode ID token to check claims
|
||||||
|
decoded = JWT.decode(id_token, nil, false).first
|
||||||
|
|
||||||
|
# Should only have required claims, not email/profile
|
||||||
|
assert_includes decoded.keys, "iss"
|
||||||
|
assert_includes decoded.keys, "sub"
|
||||||
|
assert_includes decoded.keys, "aud"
|
||||||
|
assert_includes decoded.keys, "exp"
|
||||||
|
assert_includes decoded.keys, "iat"
|
||||||
|
|
||||||
|
# Should NOT have claims that weren't consented to
|
||||||
|
refute_includes decoded.keys, "email", "Should not include email without email scope"
|
||||||
|
refute_includes decoded.keys, "email_verified", "Should not include email_verified without email scope"
|
||||||
|
refute_includes decoded.keys, "name", "Should not include name without profile scope"
|
||||||
|
refute_includes decoded.keys, "preferred_username", "Should not include preferred_username without profile scope"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refresh token preserves original scopes granted during authorization" do
|
||||||
|
# Create consent with specific scopes
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid email",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-refresh-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid email"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid email"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
|
||||||
|
# Refresh the token
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
response_body = JSON.parse(response.body)
|
||||||
|
id_token = response_body["id_token"]
|
||||||
|
|
||||||
|
# Decode ID token to verify scopes are preserved
|
||||||
|
decoded = JWT.decode(id_token, nil, false).first
|
||||||
|
|
||||||
|
# Should have email claims (from original consent)
|
||||||
|
assert_includes decoded.keys, "email", "Should preserve email scope from original consent"
|
||||||
|
assert_includes decoded.keys, "email_verified", "Should preserve email_verified scope from original consent"
|
||||||
|
|
||||||
|
# Should NOT have profile claims (not in original consent)
|
||||||
|
refute_includes decoded.keys, "name", "Should not add profile claims that weren't consented to"
|
||||||
|
refute_includes decoded.keys, "preferred_username", "Should not add preferred_username that wasn't consented to"
|
||||||
|
end
|
||||||
|
end
|
||||||
236
test/controllers/oidc_prompt_login_test.rb
Normal file
236
test/controllers/oidc_prompt_login_test.rb
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcPromptLoginTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@user = users(:alice)
|
||||||
|
@application = applications(:kavita_app)
|
||||||
|
@client_secret = SecureRandom.urlsafe_base64(48)
|
||||||
|
@application.client_secret = @client_secret
|
||||||
|
@application.save!
|
||||||
|
|
||||||
|
# Pre-authorize the application so we skip consent screen
|
||||||
|
consent = OidcUserConsent.find_or_initialize_by(
|
||||||
|
user: @user,
|
||||||
|
application: @application
|
||||||
|
)
|
||||||
|
consent.scopes_granted ||= "openid profile email"
|
||||||
|
consent.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
teardown do
|
||||||
|
# Clean up
|
||||||
|
OidcAccessToken.where(user: @user, application: @application).destroy_all
|
||||||
|
OidcAuthorizationCode.where(user: @user, application: @application).destroy_all
|
||||||
|
end
|
||||||
|
|
||||||
|
test "max_age requires re-authentication when session is too old" do
|
||||||
|
# Sign in to create a session
|
||||||
|
post "/signin", params: {
|
||||||
|
email_address: @user.email_address,
|
||||||
|
password: "password"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
follow_redirect!
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
# Get first auth_time
|
||||||
|
get "/oauth/authorize", params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid",
|
||||||
|
state: "first-state",
|
||||||
|
nonce: "first-nonce"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
first_redirect_url = response.location
|
||||||
|
first_code = Rack::Utils.parse_query(URI(first_redirect_url).query)["code"]
|
||||||
|
|
||||||
|
# Exchange for tokens and extract auth_time
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: first_code,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
first_tokens = JSON.parse(response.body)
|
||||||
|
first_id_token = OidcJwtService.decode_id_token(first_tokens["id_token"])
|
||||||
|
first_auth_time = first_id_token[0]["auth_time"]
|
||||||
|
|
||||||
|
# Wait a bit (simulate time passing - in real scenario this would be actual seconds)
|
||||||
|
# Then request with max_age=0 (means session must be brand new)
|
||||||
|
get "/oauth/authorize", params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid",
|
||||||
|
state: "second-state",
|
||||||
|
nonce: "second-nonce",
|
||||||
|
max_age: "0" # Requires session to be 0 seconds old (i.e., brand new)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should redirect to sign in because session is too old
|
||||||
|
assert_response :redirect
|
||||||
|
assert_redirected_to(/signin/)
|
||||||
|
|
||||||
|
# Sign in again
|
||||||
|
post "/signin", params: {
|
||||||
|
email_address: @user.email_address,
|
||||||
|
password: "password"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
follow_redirect!
|
||||||
|
|
||||||
|
# Should receive authorization code
|
||||||
|
assert_response :redirect
|
||||||
|
second_redirect_url = response.location
|
||||||
|
second_code = Rack::Utils.parse_query(URI(second_redirect_url).query)["code"]
|
||||||
|
|
||||||
|
assert second_code.present?, "Should receive authorization code after re-authentication"
|
||||||
|
|
||||||
|
# Exchange second authorization code for tokens
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: second_code,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
second_tokens = JSON.parse(response.body)
|
||||||
|
second_id_token = OidcJwtService.decode_id_token(second_tokens["id_token"])
|
||||||
|
second_auth_time = second_id_token[0]["auth_time"]
|
||||||
|
|
||||||
|
# The second auth_time should be >= the first (re-authentication occurred)
|
||||||
|
# Note: May be equal if both occur in the same second (test timing edge case)
|
||||||
|
assert second_auth_time >= first_auth_time,
|
||||||
|
"max_age=0 should result in a re-authentication. " \
|
||||||
|
"First: #{first_auth_time}, Second: #{second_auth_time}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "prompt=none returns login_required error when not authenticated" do
|
||||||
|
# Don't sign in - user is not authenticated
|
||||||
|
|
||||||
|
# Request authorization with prompt=none
|
||||||
|
get "/oauth/authorize", params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid",
|
||||||
|
state: "test-state",
|
||||||
|
prompt: "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should redirect with error=login_required (NOT to sign-in page)
|
||||||
|
assert_response :redirect
|
||||||
|
redirect_url = response.location
|
||||||
|
|
||||||
|
# Parse the redirect URL
|
||||||
|
uri = URI.parse(redirect_url)
|
||||||
|
query_params = uri.query ? Rack::Utils.parse_query(uri.query) : {}
|
||||||
|
|
||||||
|
assert_equal "login_required", query_params["error"],
|
||||||
|
"Should return login_required error for prompt=none when not authenticated"
|
||||||
|
assert_equal "test-state", query_params["state"],
|
||||||
|
"Should return state parameter"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "prompt=login forces re-authentication with new auth_time" do
|
||||||
|
# First authentication
|
||||||
|
post "/signin", params: {
|
||||||
|
email_address: @user.email_address,
|
||||||
|
password: "password"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
follow_redirect!
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
# Get first authorization code
|
||||||
|
get "/oauth/authorize", params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid",
|
||||||
|
state: "first-state",
|
||||||
|
nonce: "first-nonce"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
first_redirect_url = response.location
|
||||||
|
first_code = Rack::Utils.parse_query(URI(first_redirect_url).query)["code"]
|
||||||
|
|
||||||
|
# Exchange for tokens and extract auth_time from ID token
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: first_code,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
first_tokens = JSON.parse(response.body)
|
||||||
|
first_id_token = OidcJwtService.decode_id_token(first_tokens["id_token"])
|
||||||
|
first_auth_time = first_id_token[0]["auth_time"]
|
||||||
|
|
||||||
|
# Now request authorization again with prompt=login
|
||||||
|
get "/oauth/authorize", params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid",
|
||||||
|
state: "second-state",
|
||||||
|
nonce: "second-nonce",
|
||||||
|
prompt: "login"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should redirect to sign in
|
||||||
|
assert_response :redirect
|
||||||
|
assert_redirected_to(/signin/)
|
||||||
|
|
||||||
|
# Sign in again (simulating user re-authentication)
|
||||||
|
post "/signin", params: {
|
||||||
|
email_address: @user.email_address,
|
||||||
|
password: "password"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
# Follow redirect to after_authentication_url (which is /oauth/authorize without prompt=login)
|
||||||
|
follow_redirect!
|
||||||
|
|
||||||
|
# Should receive authorization code redirect
|
||||||
|
assert_response :redirect
|
||||||
|
second_redirect_url = response.location
|
||||||
|
second_code = Rack::Utils.parse_query(URI(second_redirect_url).query)["code"]
|
||||||
|
|
||||||
|
assert second_code.present?, "Should receive authorization code after re-authentication"
|
||||||
|
|
||||||
|
# Exchange second authorization code for tokens
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: second_code,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
second_tokens = JSON.parse(response.body)
|
||||||
|
second_id_token = OidcJwtService.decode_id_token(second_tokens["id_token"])
|
||||||
|
second_auth_time = second_id_token[0]["auth_time"]
|
||||||
|
|
||||||
|
# The second auth_time should be >= the first (re-authentication occurred)
|
||||||
|
# Note: May be equal if both occur in the same second (test timing edge case)
|
||||||
|
assert second_auth_time >= first_auth_time,
|
||||||
|
"prompt=login should result in a later auth_time. " \
|
||||||
|
"First: #{first_auth_time}, Second: #{second_auth_time}"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -31,7 +31,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
|
|||||||
# Step 3: Sign in
|
# Step 3: Sign in
|
||||||
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 303
|
||||||
redirect_uri = URI.parse(response.location)
|
redirect_uri = URI.parse(response.location)
|
||||||
assert_equal "https", redirect_uri.scheme
|
assert_equal "https", redirect_uri.scheme
|
||||||
assert_equal "app.example.com", redirect_uri.host
|
assert_equal "app.example.com", redirect_uri.host
|
||||||
@@ -64,7 +64,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Sign in once
|
# Sign in once
|
||||||
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
assert_response 302
|
assert_response 303
|
||||||
assert_redirected_to "/"
|
assert_redirected_to "/"
|
||||||
|
|
||||||
# Test access to different applications
|
# Test access to different applications
|
||||||
@@ -101,7 +101,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
assert_response 302
|
assert_response 303
|
||||||
|
|
||||||
# Should have access (in allowed group)
|
# Should have access (in allowed group)
|
||||||
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
|
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
|
||||||
@@ -139,7 +139,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
assert_response 302
|
assert_response 303
|
||||||
|
|
||||||
# Should have access (bypass mode)
|
# Should have access (bypass mode)
|
||||||
get "/api/verify", headers: {"X-Forwarded-Host" => "public.example.com"}
|
get "/api/verify", headers: {"X-Forwarded-Host" => "public.example.com"}
|
||||||
@@ -255,7 +255,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Sign in once
|
# Sign in once
|
||||||
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
assert_response 302
|
assert_response 303
|
||||||
|
|
||||||
# Test access to each application
|
# Test access to each application
|
||||||
apps.each do |app|
|
apps.each do |app|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Step 2: Sign in
|
# Step 2: Sign in
|
||||||
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
assert_response 302
|
assert_response 303
|
||||||
# Signin now redirects back with fa_token parameter
|
# Signin now redirects back with fa_token parameter
|
||||||
assert_match(/\?fa_token=/, response.location)
|
assert_match(/\?fa_token=/, response.location)
|
||||||
assert cookies[:session_id]
|
assert cookies[:session_id]
|
||||||
@@ -130,7 +130,9 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
# Rails normalizes header keys to lowercase
|
# Rails normalizes header keys to lowercase
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
assert response.headers.key?("x-remote-groups")
|
assert response.headers.key?("x-remote-groups")
|
||||||
assert_equal "Group Two,Group One", response.headers["x-remote-groups"]
|
groups_in_header = response.headers["x-remote-groups"].split(",").sort
|
||||||
|
expected_groups = @user.groups.reload.map(&:name).sort
|
||||||
|
assert_equal expected_groups, groups_in_header
|
||||||
|
|
||||||
# Test custom headers
|
# Test custom headers
|
||||||
get "/api/verify", headers: {"X-Forwarded-Host" => "custom.example.com"}
|
get "/api/verify", headers: {"X-Forwarded-Host" => "custom.example.com"}
|
||||||
@@ -138,7 +140,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
# Custom headers are also normalized to lowercase
|
# Custom headers are also normalized to lowercase
|
||||||
assert_equal @user.email_address, response.headers["x-webauth-user"]
|
assert_equal @user.email_address, response.headers["x-webauth-user"]
|
||||||
assert response.headers.key?("x-webauth-roles")
|
assert response.headers.key?("x-webauth-roles")
|
||||||
assert_equal "Group Two,Group One", response.headers["x-webauth-roles"]
|
assert_equal expected_groups, response.headers["x-webauth-roles"].split(",").sort
|
||||||
|
|
||||||
# Test no headers
|
# Test no headers
|
||||||
get "/api/verify", headers: {"X-Forwarded-Host" => "noheaders.example.com"}
|
get "/api/verify", headers: {"X-Forwarded-Host" => "noheaders.example.com"}
|
||||||
|
|||||||
136
test/lib/duration_parser_test.rb
Normal file
136
test/lib/duration_parser_test.rb
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class DurationParserTest < ActiveSupport::TestCase
|
||||||
|
# Valid formats
|
||||||
|
test "parses seconds" do
|
||||||
|
assert_equal 1, DurationParser.parse("1s")
|
||||||
|
assert_equal 30, DurationParser.parse("30s")
|
||||||
|
assert_equal 3600, DurationParser.parse("3600s")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses minutes" do
|
||||||
|
assert_equal 60, DurationParser.parse("1m")
|
||||||
|
assert_equal 300, DurationParser.parse("5m")
|
||||||
|
assert_equal 1800, DurationParser.parse("30m")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses hours" do
|
||||||
|
assert_equal 3600, DurationParser.parse("1h")
|
||||||
|
assert_equal 7200, DurationParser.parse("2h")
|
||||||
|
assert_equal 86400, DurationParser.parse("24h")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses days" do
|
||||||
|
assert_equal 86400, DurationParser.parse("1d")
|
||||||
|
assert_equal 172800, DurationParser.parse("2d")
|
||||||
|
assert_equal 2592000, DurationParser.parse("30d")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses weeks" do
|
||||||
|
assert_equal 604800, DurationParser.parse("1w")
|
||||||
|
assert_equal 1209600, DurationParser.parse("2w")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses months (30 days)" do
|
||||||
|
assert_equal 2592000, DurationParser.parse("1M")
|
||||||
|
assert_equal 5184000, DurationParser.parse("2M")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses years (365 days)" do
|
||||||
|
assert_equal 31536000, DurationParser.parse("1y")
|
||||||
|
assert_equal 63072000, DurationParser.parse("2y")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Plain numbers
|
||||||
|
test "parses plain integer as seconds" do
|
||||||
|
assert_equal 3600, DurationParser.parse(3600)
|
||||||
|
assert_equal 300, DurationParser.parse(300)
|
||||||
|
assert_equal 0, DurationParser.parse(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses plain numeric string as seconds" do
|
||||||
|
assert_equal 3600, DurationParser.parse("3600")
|
||||||
|
assert_equal 300, DurationParser.parse("300")
|
||||||
|
assert_equal 0, DurationParser.parse("0")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Whitespace handling
|
||||||
|
test "handles leading and trailing whitespace" do
|
||||||
|
assert_equal 3600, DurationParser.parse(" 1h ")
|
||||||
|
assert_equal 300, DurationParser.parse(" 5m ")
|
||||||
|
assert_equal 86400, DurationParser.parse("\t1d\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles space between number and unit" do
|
||||||
|
assert_equal 3600, DurationParser.parse("1 h")
|
||||||
|
assert_equal 300, DurationParser.parse("5 m")
|
||||||
|
assert_equal 86400, DurationParser.parse("1 d")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Case sensitivity - only lowercase units work (except M for months)
|
||||||
|
test "lowercase units work" do
|
||||||
|
assert_equal 1, DurationParser.parse("1s")
|
||||||
|
assert_equal 60, DurationParser.parse("1m") # minute (lowercase)
|
||||||
|
assert_equal 3600, DurationParser.parse("1h")
|
||||||
|
assert_equal 86400, DurationParser.parse("1d")
|
||||||
|
assert_equal 604800, DurationParser.parse("1w")
|
||||||
|
assert_equal 31536000, DurationParser.parse("1y")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uppercase M for months works" do
|
||||||
|
assert_equal 2592000, DurationParser.parse("1M") # month (uppercase)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil for wrong case" do
|
||||||
|
assert_nil DurationParser.parse("1S") # Should be 1s
|
||||||
|
assert_nil DurationParser.parse("1H") # Should be 1h
|
||||||
|
assert_nil DurationParser.parse("1D") # Should be 1d
|
||||||
|
assert_nil DurationParser.parse("1W") # Should be 1w
|
||||||
|
assert_nil DurationParser.parse("1Y") # Should be 1y
|
||||||
|
end
|
||||||
|
|
||||||
|
# Edge cases
|
||||||
|
test "handles zero duration" do
|
||||||
|
assert_equal 0, DurationParser.parse("0s")
|
||||||
|
assert_equal 0, DurationParser.parse("0m")
|
||||||
|
assert_equal 0, DurationParser.parse("0h")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles large numbers" do
|
||||||
|
assert_equal 86400000, DurationParser.parse("1000d")
|
||||||
|
assert_equal 360000, DurationParser.parse("100h")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Invalid formats - should return nil (not raise)
|
||||||
|
test "returns nil for invalid format" do
|
||||||
|
assert_nil DurationParser.parse("invalid")
|
||||||
|
assert_nil DurationParser.parse("1x")
|
||||||
|
assert_nil DurationParser.parse("abc")
|
||||||
|
assert_nil DurationParser.parse("1.5h") # No decimals
|
||||||
|
assert_nil DurationParser.parse("-1h") # No negatives
|
||||||
|
assert_nil DurationParser.parse("h1") # Wrong order
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil for blank input" do
|
||||||
|
assert_nil DurationParser.parse("")
|
||||||
|
assert_nil DurationParser.parse(nil)
|
||||||
|
assert_nil DurationParser.parse(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil for multiple units" do
|
||||||
|
assert_nil DurationParser.parse("1h30m") # Keep it simple, don't support this
|
||||||
|
assert_nil DurationParser.parse("1d2h")
|
||||||
|
end
|
||||||
|
|
||||||
|
# String coercion
|
||||||
|
test "handles string input" do
|
||||||
|
assert_equal 3600, DurationParser.parse("1h")
|
||||||
|
assert_equal 3600, DurationParser.parse(:"1h") # Symbol
|
||||||
|
end
|
||||||
|
|
||||||
|
# Boundary validation (not parser's job, but good to know)
|
||||||
|
test "parses values outside typical TTL ranges without error" do
|
||||||
|
assert_equal 1, DurationParser.parse("1s") # Below min access_token_ttl
|
||||||
|
assert_equal 315360000, DurationParser.parse("10y") # Above max refresh_token_ttl
|
||||||
|
end
|
||||||
|
end
|
||||||
94
test/models/api_key_test.rb
Normal file
94
test/models/api_key_test.rb
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ApiKeyTest < ActiveSupport::TestCase
|
||||||
|
setup do
|
||||||
|
@user = users(:bob)
|
||||||
|
@app = Application.create!(
|
||||||
|
name: "WebDAV",
|
||||||
|
slug: "webdav",
|
||||||
|
app_type: "forward_auth",
|
||||||
|
domain_pattern: "webdav.example.com",
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "generates clk_ prefixed token on create" do
|
||||||
|
key = @user.api_keys.create!(name: "Test Key", application: @app)
|
||||||
|
assert key.plaintext_token.start_with?("clk_")
|
||||||
|
assert key.token_hmac.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "find_by_token looks up via HMAC" do
|
||||||
|
key = @user.api_keys.create!(name: "Test Key", application: @app)
|
||||||
|
found = ApiKey.find_by_token(key.plaintext_token)
|
||||||
|
assert_equal key.id, found.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "find_by_token returns nil for invalid token" do
|
||||||
|
assert_nil ApiKey.find_by_token("clk_bogus")
|
||||||
|
assert_nil ApiKey.find_by_token("")
|
||||||
|
assert_nil ApiKey.find_by_token(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "active scope excludes revoked and expired keys" do
|
||||||
|
active_key = @user.api_keys.create!(name: "Active", application: @app)
|
||||||
|
revoked_key = @user.api_keys.create!(name: "Revoked", application: @app)
|
||||||
|
revoked_key.revoke!
|
||||||
|
expired_key = @user.api_keys.create!(name: "Expired", application: @app, expires_at: 1.day.ago)
|
||||||
|
|
||||||
|
active_keys = @user.api_keys.active
|
||||||
|
assert_includes active_keys, active_key
|
||||||
|
assert_not_includes active_keys, revoked_key
|
||||||
|
assert_not_includes active_keys, expired_key
|
||||||
|
end
|
||||||
|
|
||||||
|
test "active? expired? revoked? methods" do
|
||||||
|
key = @user.api_keys.create!(name: "Test", application: @app)
|
||||||
|
assert key.active?
|
||||||
|
assert_not key.expired?
|
||||||
|
assert_not key.revoked?
|
||||||
|
|
||||||
|
key.revoke!
|
||||||
|
assert_not key.active?
|
||||||
|
assert key.revoked?
|
||||||
|
|
||||||
|
key2 = @user.api_keys.create!(name: "Expiring", application: @app, expires_at: 1.hour.ago)
|
||||||
|
assert_not key2.active?
|
||||||
|
assert key2.expired?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "nil expires_at means never expires" do
|
||||||
|
key = @user.api_keys.create!(name: "No Expiry", application: @app, expires_at: nil)
|
||||||
|
assert_not key.expired?
|
||||||
|
assert key.active?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "touch_last_used! updates timestamp" do
|
||||||
|
key = @user.api_keys.create!(name: "Test", application: @app)
|
||||||
|
assert_nil key.last_used_at
|
||||||
|
key.touch_last_used!
|
||||||
|
assert key.reload.last_used_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates application must be forward_auth" do
|
||||||
|
oidc_app = applications(:kavita_app)
|
||||||
|
key = @user.api_keys.build(name: "Bad", application: oidc_app)
|
||||||
|
assert_not key.valid?
|
||||||
|
assert_includes key.errors[:application], "must be a forward auth application"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates user must have access to application" do
|
||||||
|
group = groups(:admin_group)
|
||||||
|
@app.allowed_groups << group
|
||||||
|
# @user (bob) is not in admin_group
|
||||||
|
key = @user.api_keys.build(name: "No Access", application: @app)
|
||||||
|
assert_not key.valid?
|
||||||
|
assert_includes key.errors[:user], "does not have access to this application"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates name presence" do
|
||||||
|
key = @user.api_keys.build(name: "", application: @app)
|
||||||
|
assert_not key.valid?
|
||||||
|
assert_includes key.errors[:name], "can't be blank"
|
||||||
|
end
|
||||||
|
end
|
||||||
109
test/models/application_duration_parser_test.rb
Normal file
109
test/models/application_duration_parser_test.rb
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ApplicationDurationParserTest < ActiveSupport::TestCase
|
||||||
|
test "access_token_ttl accepts human-friendly durations" do
|
||||||
|
app = Application.new(access_token_ttl: "1h")
|
||||||
|
assert_equal 3600, app.access_token_ttl
|
||||||
|
|
||||||
|
app.access_token_ttl = "30m"
|
||||||
|
assert_equal 1800, app.access_token_ttl
|
||||||
|
|
||||||
|
app.access_token_ttl = "5m"
|
||||||
|
assert_equal 300, app.access_token_ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refresh_token_ttl accepts human-friendly durations" do
|
||||||
|
app = Application.new(refresh_token_ttl: "30d")
|
||||||
|
assert_equal 2592000, app.refresh_token_ttl
|
||||||
|
|
||||||
|
app.refresh_token_ttl = "1M"
|
||||||
|
assert_equal 2592000, app.refresh_token_ttl
|
||||||
|
|
||||||
|
app.refresh_token_ttl = "7d"
|
||||||
|
assert_equal 604800, app.refresh_token_ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
test "id_token_ttl accepts human-friendly durations" do
|
||||||
|
app = Application.new(id_token_ttl: "1h")
|
||||||
|
assert_equal 3600, app.id_token_ttl
|
||||||
|
|
||||||
|
app.id_token_ttl = "2h"
|
||||||
|
assert_equal 7200, app.id_token_ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
test "TTL fields still accept plain numbers" do
|
||||||
|
app = Application.new(
|
||||||
|
access_token_ttl: 3600,
|
||||||
|
refresh_token_ttl: 2592000,
|
||||||
|
id_token_ttl: 3600
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal 3600, app.access_token_ttl
|
||||||
|
assert_equal 2592000, app.refresh_token_ttl
|
||||||
|
assert_equal 3600, app.id_token_ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
test "TTL fields accept plain number strings" do
|
||||||
|
app = Application.new(
|
||||||
|
access_token_ttl: "3600",
|
||||||
|
refresh_token_ttl: "2592000",
|
||||||
|
id_token_ttl: "3600"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal 3600, app.access_token_ttl
|
||||||
|
assert_equal 2592000, app.refresh_token_ttl
|
||||||
|
assert_equal 3600, app.id_token_ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
test "invalid TTL values are set to nil" do
|
||||||
|
app = Application.new(
|
||||||
|
access_token_ttl: "invalid",
|
||||||
|
refresh_token_ttl: "bad",
|
||||||
|
id_token_ttl: "nope"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_nil app.access_token_ttl
|
||||||
|
assert_nil app.refresh_token_ttl
|
||||||
|
assert_nil app.id_token_ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validation still works with parsed values" do
|
||||||
|
app = Application.new(
|
||||||
|
name: "Test",
|
||||||
|
slug: "test",
|
||||||
|
app_type: "oidc",
|
||||||
|
redirect_uris: "https://example.com/callback"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Too short (below 5 minutes)
|
||||||
|
app.access_token_ttl = "1m"
|
||||||
|
assert_not app.valid?
|
||||||
|
assert_includes app.errors[:access_token_ttl], "must be greater than or equal to 300"
|
||||||
|
|
||||||
|
# Too long (above 24 hours for access token)
|
||||||
|
app.access_token_ttl = "2d"
|
||||||
|
assert_not app.valid?
|
||||||
|
assert_includes app.errors[:access_token_ttl], "must be less than or equal to 86400"
|
||||||
|
|
||||||
|
# Just right
|
||||||
|
app.access_token_ttl = "1h"
|
||||||
|
app.valid? # Revalidate
|
||||||
|
assert app.errors[:access_token_ttl].blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can create OIDC app with human-friendly TTL values" do
|
||||||
|
app = Application.create!(
|
||||||
|
name: "Test App",
|
||||||
|
slug: "test-app",
|
||||||
|
app_type: "oidc",
|
||||||
|
redirect_uris: "https://example.com/callback",
|
||||||
|
access_token_ttl: "1h",
|
||||||
|
refresh_token_ttl: "30d",
|
||||||
|
id_token_ttl: "2h"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal 3600, app.access_token_ttl
|
||||||
|
assert_equal 2592000, app.refresh_token_ttl
|
||||||
|
assert_equal 7200, app.id_token_ttl
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -32,5 +32,10 @@ module ActiveSupport
|
|||||||
fixtures :all
|
fixtures :all
|
||||||
|
|
||||||
# Add more helper methods to be used by all tests here...
|
# Add more helper methods to be used by all tests here...
|
||||||
|
|
||||||
|
# Clear in-memory forward auth cache before each test to prevent cross-test pollution
|
||||||
|
setup do
|
||||||
|
Rails.application.config.forward_auth_cache&.clear
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user