31 Commits

Author SHA1 Message Date
Dan Milne
c7d9df48b5 Remove auto-trigger of passkey authentication on page load
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Safari's WebAuthn dialog can become undismissable when invoked without
a user gesture. Always require the user to click "Continue with Passkey"
instead of auto-triggering navigator.credentials.get().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 00:38:48 +11:00
Dan Milne
3d98261a51 Add dark mode with toggle and localStorage persistence
Uses Tailwind v4 class-based dark mode with a Stimulus controller for
toggling. Respects prefers-color-scheme as default, prevents FOUC with
an inline script, and persists the user's choice in localStorage. All
views updated with dark: variants for backgrounds, text, borders,
badges, buttons, and form inputs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 00:37:58 +11:00
Dan Milne
43958f50ce Add @tailwindcss/forms plugin and improve application form UX
Install @tailwindcss/forms to fix missing padding on form inputs across
the app. Move the Application Type selector earlier in the new application
form (after slug, before description) so it gates type-specific fields
sooner. On the edit page, replace the confusing disabled dropdown with a
read-only badge since the type can't be changed after creation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 00:36:37 +11:00
Dan Milne
d8d8000b92 Add tests for forward auth cache gaps: invalidation, rate limiting, and debounce
- Test ApplicationGroup cache busting on add and remove
- Test first failure persists in rate limit cache (increment fallback)
- Test bearer token failures count toward rate limit
- Test rd parameter rejected for deactivated applications
- Test last_activity_at updates after debounce window expires
- Test successful requests don't reset failure counter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:59:18 +11:00
Dan Milne
6844c5fab3 Clean up forward auth caching: remove duplication, fix rate limiting, and plug cache gaps
- Remove duplicated app_allows_user_cached?/headers_for_user_cached methods; call model methods directly
- Fix sliding-window rate limit bug by using increment instead of write (avoids TTL reset)
- Use cached app lookup in validate_redirect_url instead of hitting DB on every unauthorized request
- Add cache busting to ApplicationGroup so group assignment changes invalidate the cache
- Eager-load user groups (includes(user: :groups)) to eliminate N+1 queries
- Replace pluck(:name) with map(&:name) to use already-loaded associations
- Remove hardcoded fallback domain, dead methods, and unnecessary comments
- Fix test indentation and make group-order assertions deterministic

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:54:19 +11:00
Dan Milne
5505f99287 Add rate limiting and in-memory caching for forward auth endpoint
Rate limit failed attempts (50/min per IP) with 429 + Retry-After.
Cache forward auth applications in a dedicated MemoryStore (8MB LRU)
to avoid loading all apps from SQLite on every request. Debounce
last_activity_at writes to at most once per minute per session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:15:54 +11:00
Dan Milne
1b691ad341 Bump Rails from 8.1.1 to 8.1.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:11:13 +11:00
Dan Milne
f65df76d99 Show user-friendly error when passkey authentication fails
Add error target to login page so WebAuthn errors are visible instead
of only appearing in the console. Use a helpful fallback message that
suggests a browser extension may be interfering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:11:43 +11:00
Dan Milne
c5898bd9a4 Add passkey option on TOTP page and auto-trigger passkey for TOTP users
When a user has both passkeys and TOTP configured, auto-trigger the
passkey flow on login to save them from the password→TOTP path. Also
add a "Use Passkey Instead" button on the TOTP verification page as
an escape hatch for users who end up there.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:09:01 +11:00
Dan Milne
9dbde8ea31 Fix README: don't claim OIDC certification, just conformance
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:39:10 +11:00
Dan Milne
191a7b5fb3 Update README: add API keys docs, VoidAuth, highlight conformance
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
- Document API keys / bearer tokens for forward auth
- Add VoidAuth to alternatives list
- Move OIDC conformance certification and test counts to top
- Update Ruby requirement to 4.0+, test count to 450

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:36:12 +11:00
Dan Milne
7a9348c1f1 Add voidauth to the list of alternatives
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-03-05 22:30:08 +11:00
Dan Milne
225d8ae5ca Update the README
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-03-05 22:27:24 +11:00
Dan Milne
65c19fa732 Upgrade to Ruby 4.0.1, bump version to 0.9.0
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Replace CGI.parse (removed in Ruby 4.0) with Rack::Utils.parse_query
in application controller, sessions controller, and OIDC tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:52:11 +11:00
Dan Milne
fd8785a43d Add API keys / bearer tokens for forward auth
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Enables server-to-server authentication for forward auth applications
(e.g., video players accessing WebDAV) where browser cookies aren't
available. API keys use clk_ prefixed tokens stored as HMAC hashes.

Bearer token auth is checked before cookie auth in /api/verify.
Invalid tokens return 401 JSON (no redirect). Requests without
bearer tokens fall through to existing cookie flow unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:45:40 +11:00
Dan Milne
444ae6291c Add missing files, fix formatting
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-05 23:34:11 +11:00
Dan Milne
233fb723d5 More accurate language around passing the OpenID Conformance tests
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-05 23:32:34 +11:00
Dan Milne
cc6d4fcc65 Add test files, update checklist
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-05 23:28:55 +11:00
Dan Milne
5268f10eb3 Don't allow claim escalation
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-05 16:40:11 +11:00
Dan Milne
5c5662eaab Expose 'username' via forward auth headers 2026-01-05 15:12:24 +11:00
Dan Milne
27d77ebf47 Expose 'username' via forward auth headers 2026-01-05 15:12:02 +11:00
Dan Milne
ba08158c85 Bug fix for background jobs 2026-01-05 14:43:06 +11:00
Dan Milne
a6480b0860 Verion Bump
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-05 13:08:22 +11:00
Dan Milne
75cc223329 303 is the correct response 2026-01-05 13:05:24 +11:00
Dan Milne
46ae65f4d2 Move the 'remove_query_param' to the application controller 2026-01-05 13:03:03 +11:00
Dan Milne
95d0d844e9 Add a method to remove parameters from urls, so we can redirect without risk of infinite redirect. Fix a bunch of redirects to login afer being foced to log out. Add missing migrations 2026-01-05 13:01:32 +11:00
Dan Milne
524a7719c3 Merge branch 'main' into feature/claims 2026-01-05 12:11:53 +11:00
Dan Milne
8110d547dd Fix bug with session deletion when logout forced and we have a redirect to follow 2026-01-05 12:11:52 +11:00
Dan Milne
25e1043312 Add skip-consent, correctly use 303, rather than 302, actually rename per app 'logout' to 'require re-auth'. Add helper methods for token lifetime - allowing 10d for 10days for example. 2026-01-05 12:03:01 +11:00
Dan Milne
074a734c0c Accidentally added skip-consent to this branch 2026-01-05 12:01:04 +11:00
Dan Milne
4a48012a82 Add claims support 2026-01-05 12:00:29 +11:00
85 changed files with 3420 additions and 933 deletions

View File

@@ -1 +1 @@
3.4.8
4.0.1

View File

@@ -8,7 +8,7 @@
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.4.8
ARG RUBY_VERSION=4.0.1
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch

View File

@@ -1,7 +1,7 @@
source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.1"
gem "rails", "~> 8.1.2"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use sqlite3 as the database for Active Record

View File

@@ -3,29 +3,29 @@ GEM
specs:
action_text-trix (2.1.16)
railties
actioncable (8.1.1)
actionpack (= 8.1.1)
activesupport (= 8.1.1)
actioncable (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.1)
actionpack (= 8.1.1)
activejob (= 8.1.1)
activerecord (= 8.1.1)
activestorage (= 8.1.1)
activesupport (= 8.1.1)
actionmailbox (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
mail (>= 2.8.0)
actionmailer (8.1.1)
actionpack (= 8.1.1)
actionview (= 8.1.1)
activejob (= 8.1.1)
activesupport (= 8.1.1)
actionmailer (8.1.2)
actionpack (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activesupport (= 8.1.2)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.1)
actionview (= 8.1.1)
activesupport (= 8.1.1)
actionpack (8.1.2)
actionview (= 8.1.2)
activesupport (= 8.1.2)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -33,36 +33,36 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.1)
actiontext (8.1.2)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.1)
activerecord (= 8.1.1)
activestorage (= 8.1.1)
activesupport (= 8.1.1)
actionpack (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.1)
activesupport (= 8.1.1)
actionview (8.1.2)
activesupport (= 8.1.2)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.1.1)
activesupport (= 8.1.1)
activejob (8.1.2)
activesupport (= 8.1.2)
globalid (>= 0.3.6)
activemodel (8.1.1)
activesupport (= 8.1.1)
activerecord (8.1.1)
activemodel (= 8.1.1)
activesupport (= 8.1.1)
activemodel (8.1.2)
activesupport (= 8.1.2)
activerecord (8.1.2)
activemodel (= 8.1.2)
activesupport (= 8.1.2)
timeout (>= 0.4.0)
activestorage (8.1.1)
actionpack (= 8.1.1)
activejob (= 8.1.1)
activerecord (= 8.1.1)
activesupport (= 8.1.1)
activestorage (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activesupport (= 8.1.2)
marcel (~> 1.0)
activesupport (8.1.1)
activesupport (8.1.2)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -120,7 +120,7 @@ GEM
dotenv (3.2.0)
drb (2.2.3)
ed25519 (1.4.0)
erb (6.0.1)
erb (6.0.2)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
@@ -146,14 +146,15 @@ GEM
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.2)
irb (1.16.0)
irb (1.17.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.18.0)
json (2.19.0)
jwt (3.1.2)
base64
kamal (2.10.1)
@@ -192,7 +193,7 @@ GEM
mini_mime (1.1.5)
minitest (5.27.0)
msgpack (1.8.0)
net-imap (0.6.2)
net-imap (0.6.3)
date
net-protocol
net-pop (0.1.2)
@@ -207,19 +208,19 @@ GEM
net-protocol
net-ssh (7.3.0)
nio4r (2.7.5)
nokogiri (1.19.0-aarch64-linux-gnu)
nokogiri (1.19.1-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.0-aarch64-linux-musl)
nokogiri (1.19.1-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.0-arm-linux-gnu)
nokogiri (1.19.1-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.0-arm-linux-musl)
nokogiri (1.19.1-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.19.0-arm64-darwin)
nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-gnu)
nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-musl)
nokogiri (1.19.1-x86_64-linux-musl)
racc (~> 1.4)
openssl (4.0.0)
openssl-signature_algorithm (1.3.0)
@@ -245,7 +246,7 @@ GEM
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.4)
rack (3.2.5)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
@@ -253,30 +254,30 @@ GEM
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.1.1)
actioncable (= 8.1.1)
actionmailbox (= 8.1.1)
actionmailer (= 8.1.1)
actionpack (= 8.1.1)
actiontext (= 8.1.1)
actionview (= 8.1.1)
activejob (= 8.1.1)
activemodel (= 8.1.1)
activerecord (= 8.1.1)
activestorage (= 8.1.1)
activesupport (= 8.1.1)
rails (8.1.2)
actioncable (= 8.1.2)
actionmailbox (= 8.1.2)
actionmailer (= 8.1.2)
actionpack (= 8.1.2)
actiontext (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activemodel (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
bundler (>= 1.15.0)
railties (= 8.1.1)
railties (= 8.1.2)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.1)
actionpack (= 8.1.1)
activesupport (= 8.1.1)
railties (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -285,7 +286,7 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rdoc (7.0.3)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
tsort
@@ -396,7 +397,7 @@ GEM
tailwindcss-ruby (4.1.18-arm64-darwin)
tailwindcss-ruby (4.1.18-x86_64-linux-gnu)
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-aarch64-linux)
thruster (0.1.17-arm64-darwin)
@@ -437,7 +438,7 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.4)
zeitwerk (2.7.5)
PLATFORMS
aarch64-linux
@@ -446,6 +447,7 @@ PLATFORMS
arm-linux-gnu
arm-linux-musl
arm64-darwin-24
arm64-darwin-25
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
@@ -467,7 +469,7 @@ DEPENDENCIES
propshaft
public_suffix (~> 7.0)
puma (>= 5.0)
rails (~> 8.1.1)
rails (~> 8.1.2)
rotp (~> 6.3)
rqrcode (~> 3.1)
selenium-webdriver

View File

@@ -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 sits in a sweet spot between two excellent open-source identity solutions:
Clinch sits in a sweet spot among several excellent open-source identity solutions:
**[Authelia](https://www.authelia.com)** is a fantastic choice for those who prefer external user management through LDAP and enjoy comprehensive YAML-based configuration. It's lightweight, secure, and works beautifully with reverse proxies.
**[VoidAuth](https://voidauth.app/)** is an open-source SSO provider with a similar feature set to Clinch — OIDC, ForwardAuth, passkeys, user management, and easy Docker deployment. If you're evaluating self-hosted auth solutions, it's well worth a look.
**[Authentik](https://goauthentik.io)** is an enterprise-grade powerhouse offering extensive protocol support (OAuth2, SAML, LDAP, RADIUS), advanced policy engines, and distributed "outpost" architecture for complex deployments.
**Clinch** offers a middle ground with built-in user management, a modern web interface, and focused SSO capabilities (OIDC + ForwardAuth). It's perfect for users who want self-hosted simplicity without external dependencies or enterprise complexity.
- **[Passes the OpenID Connect Conformance Tests](https://www.certification.openid.net/plan-detail.html?plan=FbQNTJuYVzrzs&public=true)** — verified against the official OIDC test suite
- **450+ tests, 1800+ assertions** — comprehensive test coverage across integration, model, controller, and security tests
- **Single Docker container** — SQLite, job queue, and cache all in one process
---
## Screenshots
@@ -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.
#### OpenID Connect (OIDC)
Standard OAuth2/OIDC provider with endpoints:
- `/.well-known/openid-configuration` - Discovery endpoint
- `/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.
#### API Keys (Bearer Tokens)
For server-to-server access to ForwardAuth-protected services (e.g., a video player accessing WebDAV, rclone syncing files), Clinch supports API keys that work as bearer tokens — no browser or cookies needed.
- **Token format:** `clk_<base64>` prefix for easy identification
- **Storage:** HMAC-SHA256 hashed (plaintext shown once at creation, never stored)
- **Scope:** Each key is tied to one ForwardAuth application and one user
- **Expiration:** Optional — set a date or leave blank for no expiry
- **Auth flow:** `Authorization: Bearer clk_...` header checked before cookie auth
- **Failure response:** 401 JSON `{"error": "..."}` (no redirect)
**Creating an API key:**
1. Go to **Dashboard → Manage API Keys** (or `/api_keys`)
2. Click **New API Key**, select a ForwardAuth application, and give it a name
3. Copy the `clk_...` token — it's shown only once
**Usage:**
```bash
curl -H "Authorization: Bearer clk_..." \
-H "X-Forwarded-Host: webdav.example.com" \
https://auth.example.com/api/verify
# Returns 200 with X-Remote-User headers on success
```
API keys respect the same access controls as browser sessions — the user must have access to the application, the application must be active, and the user's account must be active.
### SMTP Integration
Send emails for:
- Invitation links (one-time token, 7-day expiry)
@@ -284,7 +317,7 @@ This is transparent to end users and requires no configuration.
## Setup & Installation
### Requirements
- Ruby 3.3+
- Ruby 4.0+
- SQLite 3.8+
- SMTP server (for sending emails)
@@ -698,7 +731,7 @@ user.revoke_all_consents!
### Running Tests
Clinch has comprehensive test coverage with 341 tests covering integration, models, controllers, services, and system tests.
Clinch has comprehensive test coverage with 450 tests covering integration, models, controllers, services, and system tests.
```bash
# Run all tests
@@ -758,7 +791,7 @@ All security scans run automatically on every pull request and push to main via
**Current Status:**
- ✅ All security scans passing
-341 tests, 1349 assertions, 0 failures
-450 tests, 1818 assertions, 0 failures
- ✅ No known dependency vulnerabilities
- ✅ Phases 1-4 security hardening complete (18+ vulnerabilities fixed)
- 🟡 3 outstanding security issues (all MEDIUM/LOW priority)

View File

@@ -1 +1,23 @@
@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);
}
}

View File

@@ -71,7 +71,7 @@ class ActiveSessionsController < ApplicationController
Rails.logger.info "ActiveSessionsController: Logged out from #{application.name} - revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens"
# Keep the consent intact - this is the key difference from revoke_consent
redirect_to root_path, notice: "Successfully logged out of #{application.name}."
redirect_to root_path, notice: "Revoked access tokens for #{application.name}. Re-authentication will be required on next use."
end
def revoke_all_consents

View File

@@ -104,7 +104,7 @@ module Admin
permitted = params.require(:application).permit(
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
:icon, :backchannel_logout_uri, :is_public_client, :require_pkce
:icon, :backchannel_logout_uri, :is_public_client, :require_pkce, :skip_consent
)
# Handle headers_config - it comes as a JSON string from the text area

View File

@@ -1,69 +1,58 @@
module Api
class ForwardAuthController < ApplicationController
# ForwardAuth endpoints need session storage for return URL
allow_unauthenticated_access
skip_before_action :verify_authenticity_token
# No rate limiting on forward_auth endpoint - proxy middleware hits this frequently
before_action :check_forward_auth_rate_limit
after_action :track_failed_forward_auth_attempt
# GET /api/verify
# This endpoint is called by reverse proxies (Traefik, Caddy, nginx)
# to verify if a user is authenticated and authorized to access a domain
# Called by reverse proxies (Traefik, Caddy, nginx) to verify authentication and authorization.
def verify
# Note: app_slug parameter is no longer used - we match domains directly with Application (forward_auth type)
bearer_result = authenticate_bearer_token
return bearer_result if bearer_result
# Check for one-time forward auth token first (to handle race condition)
session_id = check_forward_auth_token
# If no token found, try to get session from cookie
session_id ||= extract_session_id
unless session_id
# No session cookie or token - user is not authenticated
return render_unauthorized("No session cookie")
end
# Find the session with user association (eager loading for performance)
session = Session.includes(:user).find_by(id: session_id)
session = Session.includes(user: :groups).find_by(id: session_id)
unless session
# Invalid session
return render_unauthorized("Invalid session")
end
# Check if session is expired
if session.expired?
session.destroy
return render_unauthorized("Session expired")
end
# Update last activity (skip validations for performance)
# Debounce last_activity_at updates (at most once per minute)
if session.last_activity_at.nil? || session.last_activity_at < 1.minute.ago
session.update_column(:last_activity_at, Time.current)
end
# Get the user (already loaded via includes(:user))
user = session.user
unless user.active?
return render_unauthorized("User account is not active")
end
# Check for forward auth application authorization
# Get the forwarded host for domain matching
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
app = nil
if forwarded_host.present?
# Load all forward auth applications (including inactive ones) for security checks
# Preload groups to avoid N+1 queries in user_allowed? checks
apps = Application.forward_auth.includes(:allowed_groups)
apps = cached_forward_auth_apps
# Find matching forward auth application for this domain
app = apps.find { |a| a.matches_domain?(forwarded_host) }
if app
# Check if application is active
unless app.active?
Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - application is inactive"
return render_forbidden("No authentication rule configured for this domain")
end
# Check if user is allowed by this application
unless app.user_allowed?(user)
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by app #{app.domain_pattern}"
return render_forbidden("You do not have permission to access this domain")
@@ -71,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)})"
else
# No application found - DENY by default (fail-closed security)
Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - no authentication rule configured"
return render_forbidden("No authentication rule configured for this domain")
end
@@ -79,8 +67,6 @@ module Api
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
end
# User is authenticated and authorized
# Return 200 with user information headers using app-specific configuration
headers = if app
app.headers_for_user(user)
else
@@ -88,8 +74,10 @@ module Api
case key
when :user, :email, :name
[header_name, user.email_address]
when :username
[header_name, user.username] if user.username.present?
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
[header_name, user.admin? ? "true" : "false"]
end
@@ -97,102 +85,127 @@ module Api
end
headers.each { |key, value| response.headers[key] = value }
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}" if headers.any?
# Log what headers we're sending (helpful for debugging)
if headers.any?
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}"
else
Rails.logger.debug "ForwardAuth: No headers sent (access only)"
end
# Return 200 OK with no body
head :ok
end
private
def fa_cache
Rails.application.config.forward_auth_cache
end
def cached_forward_auth_apps
fa_cache.fetch("fa_apps", expires_in: 5.minutes) do
Application.forward_auth.includes(:allowed_groups).to_a
end
end
RATE_LIMIT_MAX_FAILURES = 50
RATE_LIMIT_WINDOW = 1.minute
def check_forward_auth_rate_limit
count = fa_cache.read("fa_fail:#{request.remote_ip}")
return unless count && count >= RATE_LIMIT_MAX_FAILURES
response.headers["Retry-After"] = "60"
head :too_many_requests
end
def track_failed_forward_auth_attempt
return unless response.status.in?([401, 403, 302])
return if response.status == 302 && !response.headers["X-Auth-Reason"]
cache_key = "fa_fail:#{request.remote_ip}"
# Use increment to avoid resetting TTL on each failure (fixed window)
unless fa_cache.increment(cache_key)
fa_cache.write(cache_key, 1, expires_in: RATE_LIMIT_WINDOW)
end
end
def authenticate_bearer_token
auth_header = request.headers["Authorization"]
return nil unless auth_header&.start_with?("Bearer ")
token = auth_header.delete_prefix("Bearer ").strip
return render_bearer_error("Missing token") if token.blank?
api_key = ApiKey.find_by_token(token)
return render_bearer_error("Invalid or expired API key") unless api_key&.active?
user = api_key.user
return render_bearer_error("User account is not active") unless user.active?
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
app = api_key.application
if forwarded_host.present? && !app.matches_domain?(forwarded_host)
return render_bearer_error("API key not valid for this domain")
end
unless app.active?
return render_bearer_error("Application is inactive")
end
api_key.touch_last_used!
headers = app.headers_for_user(user)
headers.each { |key, value| response.headers[key] = value }
Rails.logger.info "ForwardAuth: API key '#{api_key.name}' authenticated user #{user.email_address} for #{forwarded_host}"
head :ok
end
def render_bearer_error(message)
render json: { error: message }, status: :unauthorized
end
def check_forward_auth_token
# Check for one-time token in query parameters (for race condition handling)
token = params[:fa_token]
return nil unless token.present?
# Try to get session ID from cache
session_id = Rails.cache.read("forward_auth_token:#{token}")
return nil unless session_id
# Verify the session exists and is valid
session = Session.find_by(id: session_id)
return nil unless session && !session.expired?
# Delete the token immediately (one-time use)
Rails.cache.delete("forward_auth_token:#{token}")
session_id
end
def extract_session_id
# Extract session ID from cookie
# Rails uses signed cookies by default
cookies.signed[:session_id]
end
def extract_app_from_headers
# This method is deprecated since we now use Application (forward_auth type) domain matching
# Keeping it for backward compatibility but it's no longer used
nil
end
def render_unauthorized(reason = nil)
Rails.logger.info "ForwardAuth: Unauthorized - #{reason}"
# Set auth reason header for debugging (like Authelia)
response.headers["X-Auth-Reason"] = reason if reason.present?
# Get the redirect URL from query params or construct default
redirect_url = validate_redirect_url(params[:rd])
base_url = determine_base_url(redirect_url)
# Set the original URL that user was trying to access
# This will be used after authentication
original_host = request.headers["X-Forwarded-Host"]
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
# Debug logging to see what headers we're getting
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers["Host"]}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers["X-Forwarded-Uri"]}, X-Forwarded-Path=#{request.headers["X-Forwarded-Path"]}"
original_url = if original_host
# Use the forwarded host and URI (original behavior)
"https://#{original_host}#{original_uri}"
else
# Fallback: use the validated redirect URL or default
redirect_url || "https://clinch.aapamilne.com"
redirect_url || base_url
end
# Debug: log what we're redirecting to after login
Rails.logger.info "ForwardAuth: Will redirect to after login: #{original_url}"
session[:return_to_after_authenticating] = original_url
# Build login URL with redirect parameters like Authelia
login_params = {
rd: original_url,
rm: request.method
}
login_params = { rd: original_url, rm: request.method }
login_url = "#{base_url}/signin?#{login_params.to_query}"
# Return 302 Found directly to login page (matching Authelia)
# This is the same as Authelia's StatusFound response
Rails.logger.info "Setting 302 redirect to: #{login_url}"
redirect_to login_url, allow_other_host: true, status: :found
end
def render_forbidden(reason = nil)
Rails.logger.info "ForwardAuth: Forbidden - #{reason}"
# Set auth reason header for debugging (like Authelia)
response.headers["X-Auth-Reason"] = reason if reason.present?
# Return 403 Forbidden
head :forbidden
end
@@ -201,19 +214,14 @@ module Api
begin
uri = URI.parse(url)
# Only allow HTTP/HTTPS schemes
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
# Only allow HTTPS in production
return nil unless Rails.env.development? || uri.scheme == "https"
redirect_domain = uri.host.downcase
return nil unless redirect_domain.present?
# Check against our ForwardAuth applications
matching_app = Application.forward_auth.active.find do |app|
app.matches_domain?(redirect_domain)
matching_app = cached_forward_auth_apps.find do |app|
app.active? && app.matches_domain?(redirect_domain)
end
matching_app ? url : nil
@@ -222,32 +230,19 @@ module Api
end
end
def domain_has_forward_auth_rule?(domain)
return false if domain.blank?
Application.forward_auth.active.any? do |app|
app.matches_domain?(domain.downcase)
end
end
def determine_base_url(redirect_url)
# If we have a valid redirect URL, use it
return redirect_url if redirect_url.present?
# Try CLINCH_HOST environment variable first
if ENV["CLINCH_HOST"].present?
host = ENV["CLINCH_HOST"]
# Ensure URL has https:// protocol
host.match?(/^https?:\/\//) ? host : "https://#{host}"
else
# Fallback to the request host
request_host = request.host || request.headers["X-Forwarded-Host"]
if request_host.present?
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
"https://#{request_host}"
else
# No host information available - raise exception to force proper configuration
raise StandardError, "ForwardAuth: CLINCH_HOST environment variable not set and no request host available. Please configure CLINCH_HOST properly."
raise StandardError, "ForwardAuth: CLINCH_HOST environment variable not set and no request host available."
end
end
end

View 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

View File

@@ -9,4 +9,31 @@ class ApplicationController < ActionController::Base
# CSRF protection
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

View File

@@ -40,7 +40,6 @@ module Authentication
end
def after_authentication_url
session[:return_to_after_authenticating]
session.delete(:return_to_after_authenticating) || root_url
end

View File

@@ -45,7 +45,8 @@ class OidcController < ApplicationController
code_challenge_methods_supported: ["plain", "S256"],
backchannel_logout_supported: true,
backchannel_logout_session_supported: true,
request_parameter_supported: false
request_parameter_supported: false,
claims_parameter_supported: true
}
render json: config
@@ -165,6 +166,35 @@ class OidcController < ApplicationController
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)
unless @application.active?
Rails.logger.error "OAuth: Application is not active: #{@application.name}"
@@ -194,7 +224,8 @@ class OidcController < ApplicationController
nonce: nonce,
scope: scope,
code_challenge: code_challenge,
code_challenge_method: code_challenge_method
code_challenge_method: code_challenge_method,
claims_requests: parsed_claims&.to_json
}
# Store the current URL (with all OAuth params) for redirect after authentication
session[:return_to_after_authenticating] = request.url
@@ -215,9 +246,7 @@ class OidcController < ApplicationController
# Store the current URL (which contains all OAuth params) for redirect after login
# Remove prompt=login to prevent infinite re-auth loop
return_url = request.url.sub(/&prompt=login(?=&|$)|\?prompt=login&?/, '\1')
# Fix any resulting URL issues (like ?& or & at end)
return_url = return_url.gsub("?&", "?").gsub(/[?&]$/, "")
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"
@@ -232,15 +261,19 @@ class OidcController < ApplicationController
# Calculate session age
session_age_seconds = Time.current.to_i - Current.session.created_at.to_i
if session_age_seconds > max_age_seconds
if session_age_seconds >= max_age_seconds
# Session is too old - require re-authentication
# Store return URL in session (creates new session cookie)
# Store the return URL in Rails session, then destroy the Session record
# Destroy session and clear cookie to force fresh login
# 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)
session[:return_to_after_authenticating] = request.url
Current.session = nil
redirect_to signin_path, alert: "Please sign in to continue"
return
@@ -258,9 +291,41 @@ class OidcController < ApplicationController
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
existing_consent = user.has_oidc_consent?(@application, requested_scopes)
if existing_consent
if existing_consent && claims_match_consent?(parsed_claims, existing_consent)
# User has already consented, generate authorization code directly
auth_code = OidcAuthorizationCode.create!(
application: @application,
@@ -270,6 +335,7 @@ class OidcController < ApplicationController
nonce: nonce,
code_challenge: code_challenge,
code_challenge_method: code_challenge_method,
claims_requests: parsed_claims || {},
auth_time: Current.session.created_at.to_i,
acr: Current.session.acr,
expires_at: 10.minutes.from_now
@@ -290,7 +356,8 @@ class OidcController < ApplicationController
nonce: nonce,
scope: scope,
code_challenge: code_challenge,
code_challenge_method: code_challenge_method
code_challenge_method: code_challenge_method,
claims_requests: parsed_claims&.to_json
}
# Render consent page with dynamic CSP for OAuth redirect
@@ -355,8 +422,15 @@ class OidcController < ApplicationController
# Record user consent
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.scopes_granted = requested_scopes.join(" ")
consent.claims_requests = parsed_claims
consent.granted_at = Time.current
consent.save!
@@ -369,6 +443,7 @@ class OidcController < ApplicationController
nonce: oauth_params["nonce"],
code_challenge: oauth_params["code_challenge"],
code_challenge_method: oauth_params["code_challenge_method"],
claims_requests: parsed_claims,
auth_time: Current.session.created_at.to_i,
acr: Current.session.acr,
expires_at: 10.minutes.from_now
@@ -386,6 +461,16 @@ class OidcController < ApplicationController
# POST /oauth/token
def token
# Reject claims parameter - per OIDC security, claims parameter is only valid
# in authorization requests, not at the token endpoint
if params[:claims].present?
render json: {
error: "invalid_request",
error_description: "claims parameter is not allowed at the token endpoint"
}, status: :bad_request
return
end
grant_type = params[:grant_type]
case grant_type
@@ -528,6 +613,7 @@ class OidcController < ApplicationController
# Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr
# auth_time and acr come from the authorization code (captured at /authorize time)
# scopes determine which claims are included (per OIDC Core spec)
# claims_requests parameter filters which claims are included
id_token = OidcJwtService.generate_id_token(
user,
application,
@@ -536,7 +622,8 @@ class OidcController < ApplicationController
access_token: access_token_record.plaintext_token,
auth_time: auth_code.auth_time,
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
@@ -662,6 +749,7 @@ class OidcController < ApplicationController
# Generate new ID token (JWT with pairwise SID, at_hash, auth_time, acr; no nonce for refresh grants)
# auth_time and acr come from the original refresh token (carried over from initial auth)
# scopes determine which claims are included (per OIDC Core spec)
# claims_requests parameter filters which claims are included (from original consent)
id_token = OidcJwtService.generate_id_token(
user,
application,
@@ -669,7 +757,8 @@ class OidcController < ApplicationController
access_token: new_access_token.plaintext_token,
auth_time: refresh_token_record.auth_time,
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
@@ -733,34 +822,46 @@ class OidcController < ApplicationController
# Parse scopes from access token (space-separated string)
requested_scopes = access_token.scope.to_s.split
# Get claims_requests from consent (if available) for UserInfo context
userinfo_claims = consent&.parsed_claims_requests&.dig("userinfo") || {}
# Return user claims (filter by scope per OIDC Core spec)
# Required claims (always included)
# Required claims (always included - cannot be filtered by claims parameter)
claims = {
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 should_include_claim_for_userinfo?("email", userinfo_claims)
claims[:email] = user.email_address
end
if should_include_claim_for_userinfo?("email_verified", userinfo_claims)
claims[:email_verified] = true
end
end
# Profile claims (only if 'profile' scope requested)
# Per OIDC Core spec section 5.4, include available profile claims
# Only include claims we have data for - omit unknown claims rather than returning null
if requested_scopes.include?("profile")
# 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
# 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
# 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
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 should_include_claim_for_userinfo?("groups", userinfo_claims)
claims[:groups] = user.groups.pluck(:name)
end
end
# Merge custom claims from groups
user.groups.each do |group|
@@ -774,6 +875,12 @@ class OidcController < ApplicationController
application = access_token.application
claims.merge!(application.custom_claims_for_user(user))
# Filter custom claims based on claims parameter
# If claims parameter is present, only include requested custom claims
if userinfo_claims.any?
claims = filter_custom_claims_for_userinfo(claims, userinfo_claims)
end
# Security: Don't cache user data responses
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
@@ -923,7 +1030,7 @@ class OidcController < ApplicationController
end
# 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 {
valid: false,
error: "invalid_request",
@@ -1043,4 +1150,133 @@ class OidcController < ApplicationController
# Log error but don't block logout
Rails.logger.error "OidcController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
end
# Parse claims parameter JSON string
# Per OIDC Core §5.5: The claims parameter is a JSON object containing
# id_token and/or userinfo keys, each mapping to claim requests
def parse_claims_parameter(claims_string)
return {} if claims_string.blank?
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

View File

@@ -20,8 +20,8 @@ class SessionsController < ApplicationController
begin
uri = URI.parse(session[:return_to_after_authenticating])
if uri.query.present?
query_params = CGI.parse(uri.query)
@login_hint = query_params["login_hint"]&.first
query_params = Rack::Utils.parse_query(uri.query)
@login_hint = query_params["login_hint"]
end
rescue URI::InvalidURIError
# Ignore parsing errors
@@ -147,6 +147,10 @@ class SessionsController < ApplicationController
nil
end
# Pass data to the view for passkey option
@user_has_webauthn = user&.can_authenticate_with_webauthn?
@pending_email = user&.email_address
# Just render the form
end

View File

@@ -148,7 +148,8 @@ class WebauthnController < ApplicationController
# Only return minimal necessary info - no user_id or preferred_method
render json: {
has_webauthn: user.can_authenticate_with_webauthn?,
requires_webauthn: user.require_webauthn?
requires_webauthn: user.require_webauthn?,
has_totp: user.totp_enabled?
}
end

View File

@@ -22,11 +22,11 @@ module ApplicationHelper
def border_class_for(type)
case type.to_s
when "notice" then "border-green-200"
when "alert", "error" then "border-red-200"
when "warning" then "border-yellow-200"
when "info" then "border-blue-200"
else "border-gray-200"
when "notice" then "border-green-200 dark:border-green-700"
when "alert", "error" then "border-red-200 dark:border-red-700"
when "warning" then "border-yellow-200 dark:border-yellow-700"
when "info" then "border-blue-200 dark:border-blue-700"
else "border-gray-200 dark:border-gray-700"
end
end
end

View 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()
}
}
}

View 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)
}
})
}
}

View File

@@ -49,10 +49,9 @@ export default class extends Controller {
}
});
// Auto-trigger passkey authentication if required
if (data.requires_webauthn) {
setTimeout(() => this.authenticate(), 100);
}
// Don't auto-trigger navigator.credentials.get() — Safari's WebAuthn
// dialog can become undismissable when invoked without a user gesture.
// Always let the user click "Continue with Passkey" instead.
} else {
console.debug("No WebAuthn credentials found for this email");
}
@@ -289,6 +288,10 @@ export default class extends Controller {
if (!emailInput) {
emailInput = document.querySelector('input[name="user[email_address]"]');
}
// Fallback to hidden webauthn_email field (e.g., on TOTP verification page)
if (!emailInput) {
emailInput = document.querySelector('input[name="webauthn_email"]');
}
return emailInput ? emailInput.value.trim() : "";
}
@@ -311,7 +314,7 @@ export default class extends Controller {
return "This authenticator has already been registered.";
}
// Fallback to error message
return error.message || "An unexpected error occurred";
// Fallback to a user-friendly message
return "Passkey authentication failed. A browser extension may be interfering — try using your password instead.";
}
}

View 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
View 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

View File

@@ -5,6 +5,25 @@ class Application < ApplicationRecord
# When true, no client_secret will be generated (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
# Fix SVG content type after attachment
@@ -17,6 +36,7 @@ class Application < ApplicationRecord
has_many :oidc_access_tokens, dependent: :destroy
has_many :oidc_refresh_tokens, dependent: :destroy
has_many :oidc_user_consents, dependent: :destroy
has_many :api_keys, dependent: :destroy
validates :name, presence: true
validates :slug, presence: true, uniqueness: {case_sensitive: false},
@@ -39,7 +59,7 @@ class Application < ApplicationRecord
# Token TTL validations (for OIDC apps)
validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000}, if: :oidc? # 1 day - 90 days
validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 7776000}, if: :oidc? # 5 min - 90 days
validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
normalizes :slug, with: ->(slug) { slug.strip.downcase }
@@ -59,6 +79,7 @@ class Application < ApplicationRecord
user: "X-Remote-User",
email: "X-Remote-Email",
name: "X-Remote-Name",
username: "X-Remote-Username",
groups: "X-Remote-Groups",
admin: "X-Remote-Admin"
}.freeze
@@ -178,8 +199,10 @@ class Application < ApplicationRecord
headers[header_name] = user.email_address
when :name
headers[header_name] = user.name.presence || user.email_address
when :username
headers[header_name] = user.username if user.username.present?
when :groups
headers[header_name] = user.groups.pluck(:name).join(",") if user.groups.any?
headers[header_name] = user.groups.map(&:name).join(",") if user.groups.any?
when :admin
headers[header_name] = user.admin? ? "true" : "false"
end
@@ -247,6 +270,10 @@ class Application < ApplicationRecord
private
def bust_forward_auth_cache
Rails.application.config.forward_auth_cache&.delete("fa_apps")
end
def fix_icon_content_type
return unless icon.attached?

View File

@@ -3,4 +3,12 @@ class ApplicationGroup < ApplicationRecord
belongs_to :group
validates :application_id, uniqueness: {scope: :group_id}
after_commit :bust_forward_auth_cache
private
def bust_forward_auth_cache
Rails.application.config.forward_auth_cache&.delete("fa_apps")
end
end

View File

@@ -44,6 +44,12 @@ class OidcAuthorizationCode < ApplicationRecord
code_challenge.present?
end
# Parse claims_requests JSON field
def parsed_claims_requests
return {} if claims_requests.blank?
claims_requests.is_a?(Hash) ? claims_requests : {}
end
private
def generate_code

View File

@@ -50,6 +50,12 @@ class OidcUserConsent < ApplicationRecord
find_by(sid: sid)
end
# Parse claims_requests JSON field
def parsed_claims_requests
return {} if claims_requests.blank?
claims_requests.is_a?(Hash) ? claims_requests : {}
end
private
def set_granted_at

View File

@@ -9,6 +9,7 @@ class User < ApplicationRecord
has_many :application_user_claims, dependent: :destroy
has_many :oidc_user_consents, dependent: :destroy
has_many :webauthn_credentials, dependent: :destroy
has_many :api_keys, dependent: :destroy
# Token generation for passwordless flows
generates_token_for :invitation_login, expires_in: 24.hours do

View File

@@ -3,7 +3,7 @@ class OidcJwtService
class << self
# Generate an ID token (JWT) for the user
def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil, 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
# Use application's configured ID token TTL (defaults to 1 hour)
ttl = application.id_token_expiry_seconds
@@ -14,6 +14,9 @@ class OidcJwtService
# Parse scopes (space-separated string)
requested_scopes = scopes.to_s.split
# Parse claims_requests parameter for id_token context
id_token_claims = claims_requests["id_token"] || {}
# Required claims (always included per OIDC Core spec)
payload = {
iss: issuer_url,
@@ -23,10 +26,28 @@ class OidcJwtService
iat: now
}
# NOTE: Email and profile claims are NOT included in the ID token for authorization code flow
# Per OIDC Core spec §5.4, these claims should only be returned via the UserInfo endpoint
# For implicit flow (response_type=id_token), claims would be included here, but we only
# support authorization code flow, so these claims are omitted from the ID token.
# Email claims (only if 'email' scope requested AND either no claims filter OR email requested)
if requested_scopes.include?("email")
if should_include_claim?("email", id_token_claims)
payload[:email] = user.email_address
end
if should_include_claim?("email_verified", id_token_claims)
payload[:email_verified] = true
end
end
# Profile claims (only if 'profile' scope requested)
if requested_scopes.include?("profile")
if should_include_claim?("preferred_username", id_token_claims)
payload[:preferred_username] = user.username.presence || user.email_address
end
if should_include_claim?("name", id_token_claims)
payload[:name] = user.name.presence || user.email_address
end
if should_include_claim?("updated_at", id_token_claims)
payload[:updated_at] = user.updated_at.to_i
end
end
# Add nonce if provided (OIDC requires this for implicit flow)
payload[:nonce] = nonce if nonce.present?
@@ -49,10 +70,12 @@ class OidcJwtService
payload[:at_hash] = at_hash
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 should_include_claim?("groups", id_token_claims)
payload[:groups] = user.groups.pluck(:name)
end
end
# Merge custom claims from groups (arrays are combined, not overwritten)
# Note: Custom claims from groups are always merged (not scope-dependent)
@@ -66,6 +89,12 @@ class OidcJwtService
# Merge app-specific custom claims (highest priority, arrays are combined)
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"})
end
@@ -178,5 +207,69 @@ class OidcJwtService
def key_id
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
end
# Check if a claim should be included based on claims parameter
# Returns true if:
# - No claims parameter specified (include all scope-based claims)
# - Claim is explicitly requested (even with null spec or essential: true)
def should_include_claim?(claim_name, id_token_claims)
# No claims parameter = include all scope-based claims
return true if id_token_claims.empty?
# Check if claim is requested
return false unless id_token_claims.key?(claim_name)
# Claim specification can be:
# - null (requested)
# - true (essential, requested)
# - false (not requested)
# - Hash with essential/value/values
claim_spec = id_token_claims[claim_name]
return true if claim_spec.nil? || claim_spec == true
return false if claim_spec == false
# If it's a hash, the claim is requested (filtering happens later)
true if claim_spec.is_a?(Hash)
end
# Filter custom claims based on claims parameter
# Removes claims not explicitly requested
# Applies value/values filtering if specified
def filter_custom_claims(payload, id_token_claims)
# Get all claim names that are NOT standard OIDC claims
standard_claims = %w[iss sub aud exp iat nbf jti nonce azp at_hash auth_time acr email email_verified name preferred_username updated_at groups]
custom_claim_names = payload.keys.map(&:to_s) - standard_claims
filtered = payload.dup
custom_claim_names.each do |claim_name|
claim_sym = claim_name.to_sym
# If claim is not requested, remove it
unless id_token_claims.key?(claim_name) || id_token_claims.key?(claim_sym)
filtered.delete(claim_sym)
next
end
# Apply value/values filtering if specified
claim_spec = id_token_claims[claim_name] || id_token_claims[claim_sym]
next unless claim_spec.is_a?(Hash)
current_value = filtered[claim_sym]
# Check value constraint
if claim_spec["value"].present?
filtered.delete(claim_sym) unless current_value == claim_spec["value"]
end
# Check values constraint (array of allowed values)
if claim_spec["values"].is_a?(Array)
filtered.delete(claim_sym) unless claim_spec["values"].include?(current_value)
end
end
filtered
end
end
end

View File

@@ -1,50 +1,50 @@
<div class="space-y-8">
<div>
<h1 class="text-3xl font-bold text-gray-900">Sessions</h1>
<p class="mt-2 text-sm text-gray-600">Manage your active sessions and connected applications.</p>
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Sessions</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Manage your active sessions and connected applications.</p>
</div>
<!-- Connected Applications -->
<div class="bg-white shadow sm:rounded-lg">
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">Connected Applications</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Connected Applications</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500 dark:text-gray-400">
<p>These applications have access to your account. You can revoke access at any time.</p>
</div>
<div class="mt-5">
<% if @connected_applications.any? %>
<ul role="list" class="divide-y divide-gray-200">
<ul role="list" class="divide-y divide-gray-200 dark:divide-gray-700">
<% @connected_applications.each do |consent| %>
<li class="py-4">
<div class="flex items-center justify-between">
<div class="flex flex-col">
<p class="text-sm font-medium text-gray-900">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
<%= consent.application.name %>
</p>
<p class="mt-1 text-sm text-gray-500">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Access to: <%= consent.formatted_scopes %>
</p>
<p class="mt-1 text-xs text-gray-400">
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
Authorized <%= time_ago_in_words(consent.granted_at) %> ago
</p>
</div>
<%= button_to "Revoke Access", revoke_consent_active_sessions_path(application_id: consent.application.id), method: :delete,
class: "inline-flex items-center rounded-md border border-red-300 bg-white px-3 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2",
class: "inline-flex items-center rounded-md border border-red-300 bg-white dark:bg-gray-700 dark:ring-gray-600 dark:text-gray-200 px-3 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900",
form: { data: { turbo_confirm: "Are you sure you want to revoke access to #{consent.application.name}? You'll need to re-authorize this application to use it again." } } %>
</div>
</li>
<% end %>
</ul>
<% else %>
<p class="text-sm text-gray-500">No connected applications.</p>
<p class="text-sm text-gray-500 dark:text-gray-400">No connected applications.</p>
<% end %>
<% if @connected_applications.any? %>
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<div class="flex justify-end">
<div class="inline-block">
<%= button_to "Revoke All App Access", revoke_all_consents_active_sessions_path, method: :delete,
class: "inline-flex items-center rounded-md border border-red-300 bg-white px-3 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 whitespace-nowrap",
class: "inline-flex items-center rounded-md border border-red-300 bg-white dark:bg-gray-700 dark:ring-gray-600 dark:text-gray-200 px-3 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap",
form: { data: { turbo_confirm: "This will revoke access from all connected applications. You'll need to re-authorize each application to use them again. Are you sure?" } } %>
</div>
</div>
@@ -55,37 +55,37 @@
</div>
<!-- Active Sessions -->
<div class="bg-white shadow sm:rounded-lg">
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">Active Sessions</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Active Sessions</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500 dark:text-gray-400">
<p>These devices are currently signed in to your account. Revoke any sessions that you don't recognize.</p>
</div>
<div class="mt-5">
<% if @active_sessions.any? %>
<ul role="list" class="divide-y divide-gray-200">
<ul role="list" class="divide-y divide-gray-200 dark:divide-gray-700">
<% @active_sessions.each do |session| %>
<li class="py-4">
<div class="flex items-center justify-between">
<div class="flex flex-col">
<p class="text-sm font-medium text-gray-900">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
<%= session.device_name || "Unknown Device" %>
<% if session.id == Current.session.id %>
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:text-green-200">
This device
</span>
<% end %>
</p>
<p class="mt-1 text-sm text-gray-500">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
<%= session.ip_address %>
</p>
<p class="mt-1 text-xs text-gray-400">
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
Last active <%= time_ago_in_words(session.last_activity_at || session.updated_at) %> ago
</p>
</div>
<% if session.id != Current.session.id %>
<%= button_to "Revoke", session_path(session), method: :delete,
class: "inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
class: "inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 dark:text-gray-200 px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900",
form: { data: { turbo_confirm: "Are you sure you want to revoke this session?" } } %>
<% end %>
</div>
@@ -93,15 +93,15 @@
<% end %>
</ul>
<% else %>
<p class="text-sm text-gray-500">No other active sessions.</p>
<p class="text-sm text-gray-500 dark:text-gray-400">No other active sessions.</p>
<% end %>
<% if @active_sessions.count > 1 %>
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<div class="flex justify-end">
<div class="inline-block">
<%= button_to "Sign Out Everywhere Else", session_path(Current.session), method: :delete,
class: "inline-flex items-center rounded-md border border-orange-300 bg-white px-3 py-2 text-sm font-medium text-orange-700 shadow-sm hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 whitespace-nowrap",
class: "inline-flex items-center rounded-md border border-orange-300 bg-white dark:bg-gray-700 dark:ring-gray-600 dark:text-gray-200 px-3 py-2 text-sm font-medium text-orange-700 shadow-sm hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap",
form: { data: { turbo_confirm: "This will sign you out from all other devices except this one. Are you sure?" } } %>
</div>
</div>

View File

@@ -2,24 +2,43 @@
<%= render "shared/form_errors", form: form %>
<div>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "My Application" %>
<%= form.label :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "My Application" %>
</div>
<div>
<%= form.label :slug, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :slug, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "my-app" %>
<p class="mt-1 text-sm text-gray-500">Lowercase letters, numbers, and hyphens only. Used in URLs and API calls.</p>
<%= form.label :slug, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_field :slug, required: true, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "my-app" %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Lowercase letters, numbers, and hyphens only. Used in URLs and API calls.</p>
</div>
<div>
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %>
<% if application.persisted? %>
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Application Type</span>
<div class="mt-1 flex items-center gap-2">
<span class="inline-flex items-center rounded-md bg-blue-50 dark:bg-blue-900/30 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300 ring-1 ring-inset ring-blue-600/20">
<%= application.oidc? ? "OpenID Connect (OIDC)" : "Forward Auth (Reverse Proxy)" %>
</span>
</div>
<%= form.hidden_field :app_type %>
<select class="hidden" data-application-form-target="appTypeSelect"><option value="<%= application.app_type %>" selected></option></select>
<% else %>
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, {
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
data: { action: "change->application-form#updateFieldVisibility", application_form_target: "appTypeSelect" }
} %>
<% end %>
</div>
<div>
<%= 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 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">
<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>
@@ -32,8 +51,8 @@
<%# Only show icon if we can successfully get its URL (blob is persisted) %>
<% if application.icon.blob&.persisted? && application.icon.blob.key.present? %>
<div class="mt-2 mb-3 flex items-center gap-4">
<%= image_tag application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200", alt: "Current icon" %>
<div class="text-sm text-gray-600">
<%= 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 dark:text-gray-400">
<p class="font-medium">Current icon</p>
<p class="text-xs"><%= number_to_human_size(application.icon.blob.byte_size) %></p>
</div>
@@ -42,7 +61,7 @@
<% rescue ArgumentError => e %>
<%# Handle case where icon attachment exists but can't generate signed_id %>
<% if e.message.include?("Cannot get a signed_id for a new record") %>
<div class="mt-2 mb-3 text-sm text-gray-600">
<div class="mt-2 mb-3 text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium">Icon uploaded</p>
<p class="text-xs">File will be processed shortly</p>
</div>
@@ -54,17 +73,17 @@
<% end %>
<div class="mt-2" data-controller="file-drop image-paste">
<div class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-blue-400 transition-colors"
<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-image-paste-target="dropzone"
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste"
tabindex="0">
<div class="space-y-1 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<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" />
</svg>
<div class="flex text-sm text-gray-600">
<label for="<%= form.field_id(:icon) %>" class="relative cursor-pointer bg-white rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-blue-500">
<div class="flex text-sm text-gray-600 dark:text-gray-400">
<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>
<%= form.file_field :icon,
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
@@ -77,18 +96,18 @@
</label>
<p class="pl-1">or drag and drop</p>
</div>
<p class="text-xs text-gray-500">PNG, JPG, GIF, or SVG up to 2MB</p>
<p class="text-xs text-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>
</div>
</div>
<div data-file-drop-target="preview" class="mt-3 hidden">
<div class="flex items-center gap-3 p-3 bg-blue-50 rounded-md border border-blue-200">
<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">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900" data-file-drop-target="filename"></p>
<p class="text-xs text-gray-500" data-file-drop-target="filesize"></p>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100" data-file-drop-target="filename"></p>
<p class="text-xs text-gray-500 dark:text-gray-400" data-file-drop-target="filesize"></p>
</div>
<button type="button" data-action="click->file-drop#clear" class="text-gray-400 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">
<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>
@@ -99,44 +118,32 @@
</div>
<div>
<%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700" %>
<%= form.url_field :landing_url, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %>
<p class="mt-1 text-sm text-gray-500">The main URL users will visit to access this application. This will be shown as a link on their dashboard.</p>
</div>
<div>
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %>
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, {
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
disabled: application.persisted?,
data: { action: "change->application-form#updateFieldVisibility", application_form_target: "appTypeSelect" }
} %>
<% if application.persisted? %>
<p class="mt-1 text-sm text-gray-500">Application type cannot be changed after creation.</p>
<% end %>
<%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.url_field :landing_url, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">The main URL users will visit to access this application. This will be shown as a link on their dashboard.</p>
</div>
<!-- OIDC-specific fields -->
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.oidc? || !application.persisted? %>" data-application-form-target="oidcFields">
<h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3>
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 dark:border-gray-700 pt-6 <%= 'hidden' unless application.oidc? || !application.persisted? %>" data-application-form-target="oidcFields">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">OIDC Configuration</h3>
<!-- Client Type Selection (only for new applications) -->
<% unless application.persisted? %>
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
<h4 class="text-sm font-semibold text-gray-900 mb-3">Client Type</h4>
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-gray-800">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Client Type</h4>
<div class="space-y-3">
<div class="flex items-start">
<%= form.radio_button :is_public_client, "false", checked: !application.is_public_client, class: "mt-1 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500", data: { action: "change->application-form#updatePkceVisibility" } %>
<%= form.radio_button :is_public_client, "false", checked: !application.is_public_client, class: "mt-1 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500", data: { action: "change->application-form#updatePkceVisibility" } %>
<div class="ml-3">
<label for="application_is_public_client_false" class="block text-sm font-medium text-gray-900">Confidential Client (Recommended)</label>
<p class="text-sm text-gray-500">Backend server app that can securely store a client secret. Examples: traditional web apps, server-to-server APIs.</p>
<label for="application_is_public_client_false" class="block text-sm font-medium text-gray-900 dark:text-gray-100">Confidential Client (Recommended)</label>
<p class="text-sm text-gray-500 dark:text-gray-400">Backend server app that can securely store a client secret. Examples: traditional web apps, server-to-server APIs.</p>
</div>
</div>
<div class="flex items-start">
<%= form.radio_button :is_public_client, "true", checked: application.is_public_client, class: "mt-1 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500", data: { action: "change->application-form#updatePkceVisibility" } %>
<%= form.radio_button :is_public_client, "true", checked: application.is_public_client, class: "mt-1 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500", data: { action: "change->application-form#updatePkceVisibility" } %>
<div class="ml-3">
<label for="application_is_public_client_true" class="block text-sm font-medium text-gray-900">Public Client</label>
<p class="text-sm text-gray-500">Frontend-only app that cannot store secrets securely. Examples: SPAs (React/Vue), mobile apps, CLI tools. <strong class="text-amber-600">PKCE is required.</strong></p>
<label for="application_is_public_client_true" class="block text-sm font-medium text-gray-900 dark:text-gray-100">Public Client</label>
<p class="text-sm text-gray-500 dark:text-gray-400">Frontend-only app that cannot store secrets securely. Examples: SPAs (React/Vue), mobile apps, CLI tools. <strong class="text-amber-600">PKCE is required.</strong></p>
</div>
</div>
</div>
@@ -144,129 +151,207 @@
<% else %>
<!-- Show client type for existing applications (read-only) -->
<div class="flex items-center gap-2 text-sm">
<span class="font-medium text-gray-700">Client Type:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">Client Type:</span>
<% if application.public_client? %>
<span class="inline-flex items-center rounded-md bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700 ring-1 ring-inset ring-amber-600/20">Public Client (PKCE Required)</span>
<span class="inline-flex items-center rounded-md bg-amber-50 dark:bg-amber-900/30 px-2 py-1 text-xs font-medium text-amber-700 dark:text-amber-300 ring-1 ring-inset ring-amber-600/20">Public Client (PKCE Required)</span>
<% else %>
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">Confidential Client</span>
<span class="inline-flex items-center rounded-md bg-green-50 dark:bg-green-900/30 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300 ring-1 ring-inset ring-green-600/20">Confidential Client</span>
<% end %>
</div>
<% end %>
<!-- OAuth2/OIDC Flow Information -->
<div class="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg p-4 space-y-3">
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">OAuth2 Flow</h4>
<p class="text-sm text-gray-700 dark:text-gray-300">
Clinch uses the <code class="bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono">authorization_code</code> flow with <code class="bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono">response_type=code</code> (the modern, secure standard).
</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Deprecated flows like Implicit (<code class="bg-white dark:bg-gray-800 px-1 rounded text-xs font-mono">id_token</code>, <code class="bg-white dark:bg-gray-800 px-1 rounded text-xs font-mono">token</code>) are not supported for security reasons.
</p>
</div>
<div class="border-t border-blue-200 dark:border-blue-700 pt-3">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">Client Authentication</h4>
<p class="text-sm text-gray-700 dark:text-gray-300">
Clinch supports both <code class="bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono">client_secret_basic</code> (HTTP Basic Auth) and <code class="bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono">client_secret_post</code> (POST parameters) authentication methods.
</p>
</div>
</div>
<!-- PKCE Requirement (only for confidential clients) -->
<div id="pkce-options" data-application-form-target="pkceOptions" class="<%= 'hidden' if application.persisted? && application.public_client? %>">
<div class="flex items-center">
<%= form.check_box :require_pkce, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
<%= form.label :require_pkce, "Require PKCE (Proof Key for Code Exchange)", class: "ml-2 block text-sm font-medium text-gray-900" %>
<%= form.check_box :require_pkce, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= form.label :require_pkce, "Require PKCE (Proof Key for Code Exchange)", class: "ml-2 block text-sm font-medium text-gray-900 dark:text-gray-100" %>
</div>
<p class="ml-6 text-sm text-gray-500">
<p class="ml-6 text-sm text-gray-500 dark:text-gray-400">
Recommended for enhanced security (OAuth 2.1 best practice).
<br><span class="text-xs text-gray-400">Note: Public clients always require PKCE regardless of this setting.</span>
<br><span class="text-xs text-gray-400 dark:text-gray-500">Note: Public clients always require PKCE regardless of this setting.</span>
</p>
</div>
<!-- Skip Consent -->
<div class="flex items-center">
<%= form.check_box :skip_consent, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= form.label :skip_consent, "Skip Consent Screen", class: "ml-2 block text-sm font-medium text-gray-900 dark:text-gray-100" %>
</div>
<p class="ml-6 text-sm text-gray-500 dark:text-gray-400">
Automatically grant consent for all users. Useful for first-party or trusted applications.
<br><span class="text-xs text-amber-600">Only enable for applications you fully trust. Consent is still recorded in the database.</span>
</p>
<div>
<%= form.label :redirect_uris, "Redirect URIs", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
<p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p>
<%= form.label :redirect_uris, "Redirect URIs", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">One URI per line. These are the allowed callback URLs for your application.</p>
</div>
<div>
<%= form.label :backchannel_logout_uri, "Backchannel Logout URI (Optional)", class: "block text-sm font-medium text-gray-700" %>
<%= form.url_field :backchannel_logout_uri, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://app.example.com/oidc/backchannel-logout" %>
<p class="mt-1 text-sm text-gray-500">
<%= form.label :backchannel_logout_uri, "Backchannel Logout URI (Optional)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.url_field :backchannel_logout_uri, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://app.example.com/oidc/backchannel-logout" %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
If the application supports OpenID Connect Backchannel Logout, enter the logout endpoint URL.
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
Leave blank if the application doesn't support backchannel logout.
</p>
</div>
<div class="border-t border-gray-200 pt-4 mt-4">
<h4 class="text-sm font-semibold text-gray-900 mb-3">Token Expiration Settings</h4>
<p class="text-sm text-gray-500 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p>
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Token Expiration Settings</h4>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<%= form.label :access_token_ttl, "Access Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
<%= form.number_field :access_token_ttl, value: application.access_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-xs text-gray-500">
Range: 5 min - 24 hours
<br>Default: 1 hour (3600s)
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human || "1 hour" %></span>
<%= form.label :access_token_ttl, "Access Token TTL", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_field :access_token_ttl,
value: application.access_token_ttl || "1h",
placeholder: "e.g., 1h, 30m, 3600",
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Range: 5m - 24h
<br>Default: 1h
<% if application.access_token_ttl.present? %>
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human %> (<%= application.access_token_ttl %>s)</span>
<% end %>
</p>
</div>
<div>
<%= form.label :refresh_token_ttl, "Refresh Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
<%= form.number_field :refresh_token_ttl, value: application.refresh_token_ttl || 2592000, min: 86400, max: 7776000, step: 86400, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-xs text-gray-500">
Range: 1 day - 90 days
<br>Default: 30 days (2592000s)
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human || "30 days" %></span>
<%= form.label :refresh_token_ttl, "Refresh Token TTL", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_field :refresh_token_ttl,
value: application.refresh_token_ttl || "30d",
placeholder: "e.g., 30d, 1M, 2592000",
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Range: 5m - 90d
<br>Default: 30d
<% if application.refresh_token_ttl.present? %>
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human %> (<%= application.refresh_token_ttl %>s)</span>
<% end %>
</p>
</div>
<div>
<%= form.label :id_token_ttl, "ID Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
<%= form.number_field :id_token_ttl, value: application.id_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-xs text-gray-500">
Range: 5 min - 24 hours
<br>Default: 1 hour (3600s)
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human || "1 hour" %></span>
<%= form.label :id_token_ttl, "ID Token TTL", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_field :id_token_ttl,
value: application.id_token_ttl || "1h",
placeholder: "e.g., 1h, 30m, 3600",
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Range: 5m - 24h
<br>Default: 1h
<% if application.id_token_ttl.present? %>
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human %> (<%= application.id_token_ttl %>s)</span>
<% end %>
</p>
</div>
</div>
<details class="mt-3">
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types</summary>
<div class="mt-2 ml-4 space-y-2 text-sm text-gray-600">
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types & Session Length</summary>
<div class="mt-2 ml-4 space-y-3 text-sm text-gray-600 dark:text-gray-400">
<div>
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">Token Types:</p>
<p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p>
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Longer lifetime = better UX (less re-logins).</p>
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Each refresh issues a new refresh token (token rotation).</p>
<p><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p>
<p class="text-xs italic mt-2">💡 Tip: Banking apps use 5-15 min access tokens. Internal tools use 1-4 hours.</p>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-2">
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">How Session Length Works:</p>
<p><strong>Refresh Token TTL = Maximum Inactivity Period</strong></p>
<p class="ml-3">Because refresh tokens are automatically rotated (new token = new expiry), active users can stay logged in indefinitely. The TTL controls how long they can be <em>inactive</em> before requiring re-authentication.</p>
<p class="mt-2"><strong>Example:</strong> Refresh TTL = 30 days</p>
<ul class="ml-6 list-disc space-y-1 text-xs">
<li>User logs in on Day 0, uses app daily → stays logged in forever (tokens keep rotating)</li>
<li>User logs in on Day 0, stops using app → must re-login after 30 days of inactivity</li>
</ul>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-2">
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">Forcing Re-Authentication:</p>
<p class="ml-3 text-xs">Because of token rotation, there's no way to force periodic re-authentication using TTL settings alone. Active users can stay logged in indefinitely by refreshing tokens before they expire.</p>
<p class="mt-2 ml-3 text-xs"><strong>To enforce absolute session limits:</strong> Clients can include the <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">max_age</code> parameter in their authorization requests to require re-authentication after a specific time, regardless of token rotation.</p>
<p class="mt-2 ml-3 text-xs"><strong>Example:</strong> A banking app might set <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">max_age=900</code> (15 minutes) in the authorization request to force re-authentication every 15 minutes, even if refresh tokens are still valid.</p>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-2">
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">Common Configurations:</p>
<ul class="ml-3 space-y-1 text-xs">
<li><strong>Banking/High Security:</strong> Access TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">5m</code>, Refresh TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">5m</code> → Re-auth every 5 minutes</li>
<li><strong>Corporate Tools:</strong> Access TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">1h</code>, Refresh TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">8h</code> → Re-auth after 8 hours inactive</li>
<li><strong>Personal Apps:</strong> Access TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">1h</code>, Refresh TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">30d</code> → Re-auth after 30 days inactive</li>
</ul>
</div>
</div>
</details>
</div>
</div>
<!-- Forward Auth-specific fields -->
<div id="forward-auth-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.forward_auth? %>" data-application-form-target="forwardAuthFields">
<h3 class="text-base font-semibold text-gray-900">Forward Auth Configuration</h3>
<div id="forward-auth-fields" class="space-y-6 border-t border-gray-200 dark:border-gray-700 pt-6 <%= 'hidden' unless application.forward_auth? %>" data-application-form-target="forwardAuthFields">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Forward Auth Configuration</h3>
<div>
<%= form.label :domain_pattern, "Domain Pattern", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :domain_pattern, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "*.example.com or app.example.com" %>
<p class="mt-1 text-sm text-gray-500">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
<%= form.label :domain_pattern, "Domain Pattern", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_field :domain_pattern, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "*.example.com or app.example.com" %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
</div>
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
<%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %>
<%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_area :headers_config, value: (application.headers_config.present? && application.headers_config.any? ? JSON.pretty_generate(application.headers_config) : ""), rows: 10,
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
placeholder: '{"user": "Remote-User", "groups": "Remote-Groups"}',
data: {
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea"
} %>
<div class="mt-2 text-sm text-gray-600 space-y-1">
<div class="mt-2 text-sm text-gray-600 dark:text-gray-400 space-y-1">
<div class="flex items-center justify-between">
<p class="font-medium">Optional: Customize header names sent to your application.</p>
<div class="flex items-center gap-2">
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"user": "Remote-User", "groups": "Remote-Groups", "email": "Remote-Email", "name": "Remote-Name", "admin": "Remote-Admin"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 px-2 py-1 rounded">Format JSON</button>
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"user": "Remote-User", "groups": "Remote-Groups", "email": "Remote-Email", "name": "Remote-Name", "username": "Remote-Username", "admin": "Remote-Admin"}' class="text-xs bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-1 rounded">Insert Example</button>
</div>
</div>
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p>
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Username, X-Remote-Groups, X-Remote-Admin</p>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
<details class="mt-2">
<summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary>
<div class="mt-2 ml-4 space-y-1 text-xs">
<p><code class="bg-gray-100 px-1 rounded">user</code> - User's email address</p>
<p><code class="bg-gray-100 px-1 rounded">email</code> - User's email address</p>
<p><code class="bg-gray-100 px-1 rounded">name</code> - User's display name (falls back to email if not set)</p>
<p><code class="bg-gray-100 px-1 rounded">groups</code> - Comma-separated list of group names (e.g., "admin,developers")</p>
<p><code class="bg-gray-100 px-1 rounded">admin</code> - "true" or "false" indicating admin status</p>
<p class="mt-2 italic">Example: <code class="bg-gray-100 px-1 rounded">{"user": "Remote-User", "groups": "Remote-Groups"}</code></p>
<p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">user</code> - User's email address</p>
<p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">email</code> - User's email address</p>
<p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">name</code> - User's display name (falls back to email if not set)</p>
<p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">username</code> - User's login username (only sent if set)</p>
<p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">groups</code> - Comma-separated list of group names (e.g., "admin,developers")</p>
<p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">admin</code> - "true" or "false" indicating admin status</p>
<p class="mt-2 italic">Example: <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">{"user": "Remote-User", "groups": "Remote-Groups", "username": "Remote-Username"}</code></p>
<p class="italic">Need custom user fields? Add them to user's custom_claims for OIDC tokens</p>
</div>
</details>
@@ -275,31 +360,30 @@
</div>
<div>
<%= form.label :group_ids, "Allowed Groups (Optional)", class: "block text-sm font-medium text-gray-700" %>
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto border border-gray-200 rounded-md p-3">
<%= form.label :group_ids, "Allowed Groups (Optional)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
<% if @available_groups.any? %>
<% @available_groups.each do |group| %>
<div class="flex items-center">
<%= check_box_tag "application[group_ids][]", group.id, application.allowed_groups.include?(group), class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
<%= label_tag "application_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900" %>
<span class="ml-2 text-xs text-gray-500">(<%= pluralize(group.users.count, "member") %>)</span>
<%= check_box_tag "application[group_ids][]", group.id, application.allowed_groups.include?(group), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= label_tag "application_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">(<%= pluralize(group.users.count, "member") %>)</span>
</div>
<% end %>
<% else %>
<p class="text-sm text-gray-500">No groups available. Create groups first to restrict access.</p>
<p class="text-sm text-gray-500 dark:text-gray-400">No groups available. Create groups first to restrict access.</p>
<% end %>
</div>
<p class="mt-1 text-sm text-gray-500">If no groups are selected, all active users can access this application.</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">If no groups are selected, all active users can access this application.</p>
</div>
<div class="flex items-center">
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900 dark:text-gray-100" %>
</div>
<div class="flex gap-3">
<%= form.submit application.persisted? ? "Update Application" : "Create Application", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
<%= link_to "Cancel", admin_applications_path, class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
<%= link_to "Cancel", admin_applications_path, class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
</div>
<% end %>

View File

@@ -1,5 +1,5 @@
<div class="max-w-3xl">
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit Application</h1>
<p class="text-sm text-gray-600 mb-6">Editing: <%= @application.name %></p>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-6">Edit Application</h1>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">Editing: <%= @application.name %></p>
<%= render "form", application: @application %>
</div>

View File

@@ -1,7 +1,7 @@
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-gray-900">Applications</h1>
<p class="mt-2 text-sm text-gray-700">Manage OIDC Clients.</p>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Applications</h1>
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">Manage OIDC Clients.</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<%= link_to "New Application", new_admin_application_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
@@ -11,29 +11,29 @@
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table class="min-w-full divide-y divide-gray-300">
<table class="min-w-full divide-y divide-gray-300 dark:divide-gray-600">
<thead>
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Application</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Slug</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Groups</th>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-100 sm:pl-0">Application</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Slug</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Type</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Status</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Groups</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<% @applications.each do |application| %>
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-0">
<div class="flex items-center gap-3">
<% if application.icon.attached? %>
<%= image_tag application.icon, class: "h-10 w-10 rounded-lg object-cover border border-gray-200 flex-shrink-0", alt: "#{application.name} icon" %>
<%= 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 %>
<div class="h-10 w-10 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center flex-shrink-0">
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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 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" />
</svg>
</div>
@@ -41,29 +41,29 @@
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<code class="text-xs bg-gray-100 px-2 py-1 rounded"><%= application.slug %></code>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<code class="text-xs bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-2 py-1 rounded"><%= application.slug %></code>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<% case application.app_type %>
<% when "oidc" %>
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
<span class="inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/50 px-2 py-1 text-xs font-medium text-purple-700 dark:text-purple-300">OIDC</span>
<% when "forward_auth" %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Forward Auth</span>
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300">Forward Auth</span>
<% when "saml" %>
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
<span class="inline-flex items-center rounded-full bg-orange-100 dark:bg-orange-900/50 px-2 py-1 text-xs font-medium text-orange-700 dark:text-orange-300">SAML</span>
<% end %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<% if application.active? %>
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Active</span>
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">Active</span>
<% else %>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Inactive</span>
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-300">Inactive</span>
<% end %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<% 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 %>
<%= application.allowed_groups.count %>
<% end %>

View File

@@ -1,4 +1,4 @@
<div class="max-w-3xl">
<h1 class="text-2xl font-semibold text-gray-900 mb-6">New Application</h1>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-6">New Application</h1>
<%= render "form", application: @application %>
</div>

View File

@@ -1,27 +1,27 @@
<div class="mb-6">
<% if flash[:client_id] %>
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
<h4 class="text-sm font-medium text-yellow-800 mb-2">🔐 OIDC Client Credentials</h4>
<div class="bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-700 rounded-md p-4 mb-6">
<h4 class="text-sm font-medium text-yellow-800 dark:text-yellow-200 mb-2">🔐 OIDC Client Credentials</h4>
<% if flash[:public_client] %>
<p class="text-xs text-yellow-700 mb-3">This is a public client. Copy the client ID below.</p>
<p class="text-xs text-yellow-700 dark:text-yellow-300 mb-3">This is a public client. Copy the client ID below.</p>
<% else %>
<p class="text-xs text-yellow-700 mb-3">Copy these credentials now. The client secret will not be shown again.</p>
<p class="text-xs text-yellow-700 dark:text-yellow-300 mb-3">Copy these credentials now. The client secret will not be shown again.</p>
<% end %>
<div class="space-y-2">
<div>
<span class="text-xs font-medium text-yellow-700">Client ID:</span>
<span class="text-xs font-medium text-yellow-700 dark:text-yellow-300">Client ID:</span>
</div>
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_id] %></code>
<code class="block bg-yellow-100 dark:bg-yellow-900/50 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_id] %></code>
<% if flash[:client_secret] %>
<div class="mt-3">
<span class="text-xs font-medium text-yellow-700">Client Secret:</span>
<span class="text-xs font-medium text-yellow-700 dark:text-yellow-300">Client Secret:</span>
</div>
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_secret] %></code>
<code class="block bg-yellow-100 dark:bg-yellow-900/50 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_secret] %></code>
<% elsif flash[:public_client] %>
<div class="mt-3">
<span class="text-xs font-medium text-yellow-700">Client Secret:</span>
<span class="text-xs font-medium text-yellow-700 dark:text-yellow-300">Client Secret:</span>
</div>
<div class="bg-yellow-100 px-3 py-2 rounded text-xs text-yellow-600">
<div class="bg-yellow-100 dark:bg-yellow-900/50 px-3 py-2 rounded text-xs text-yellow-600 dark:text-yellow-400">
Public clients do not have a client secret. PKCE is required.
</div>
<% end %>
@@ -32,21 +32,21 @@
<div class="sm:flex sm:items-start sm:justify-between">
<div class="flex items-start gap-4">
<% if @application.icon.attached? %>
<%= image_tag @application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 shrink-0", alt: "#{@application.name} icon" %>
<%= 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 %>
<div class="h-16 w-16 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0">
<svg class="h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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 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" />
</svg>
</div>
<% end %>
<div>
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
<p class="mt-1 text-sm text-gray-500"><%= @application.description %></p>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @application.name %></h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @application.description %></p>
</div>
</div>
<div class="mt-4 sm:mt-0 flex gap-3">
<%= link_to "Edit", edit_admin_application_path(@application), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
<%= link_to "Edit", edit_admin_application_path(@application), class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
<%= button_to "Delete", admin_application_path(@application), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500" %>
</div>
</div>
@@ -54,42 +54,42 @@
<div class="space-y-6">
<!-- Basic Information -->
<div class="bg-white shadow sm:rounded-lg">
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Basic Information</h3>
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">Basic Information</h3>
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500">Slug</dt>
<dd class="mt-1 text-sm text-gray-900"><code class="bg-gray-100 px-2 py-1 rounded"><%= @application.slug %></code></dd>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Slug</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100"><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-2 py-1 rounded"><%= @application.slug %></code></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Type</dt>
<dd class="mt-1 text-sm text-gray-900">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Type</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<% case @application.app_type %>
<% when "oidc" %>
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
<span class="inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/50 px-2 py-1 text-xs font-medium text-purple-700 dark:text-purple-300">OIDC</span>
<% when "forward_auth" %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Forward Auth</span>
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300">Forward Auth</span>
<% end %>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Status</dt>
<dd class="mt-1 text-sm text-gray-900">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Status</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<% if @application.active? %>
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Active</span>
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">Active</span>
<% else %>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Inactive</span>
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-300">Inactive</span>
<% end %>
</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-sm font-medium text-gray-500">Landing URL</dt>
<dd class="mt-1 text-sm text-gray-900">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Landing URL</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<% if @application.landing_url.present? %>
<%= link_to @application.landing_url, @application.landing_url, target: "_blank", rel: "noopener noreferrer", class: "text-blue-600 hover:text-blue-800 underline" %>
<% else %>
<span class="text-gray-400 italic">Not configured</span>
<span class="text-gray-400 dark:text-gray-500 italic">Not configured</span>
<% end %>
</dd>
</div>
@@ -99,59 +99,59 @@
<!-- OIDC Configuration (only for OIDC apps) -->
<% if @application.oidc? %>
<div class="bg-white shadow sm:rounded-lg">
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold leading-6 text-gray-900">OIDC Configuration</h3>
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100">OIDC Configuration</h3>
<%= button_to "Regenerate Credentials", regenerate_credentials_admin_application_path(@application), method: :post, data: { turbo_confirm: "This will invalidate the current credentials. Continue?" }, class: "text-sm text-red-600 hover:text-red-900" %>
</div>
<dl class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<dt class="text-sm font-medium text-gray-500">Client Type</dt>
<dd class="mt-1 text-sm text-gray-900">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Client Type</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<% if @application.public_client? %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Public</span>
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300">Public</span>
<% else %>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Confidential</span>
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-300">Confidential</span>
<% end %>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">PKCE</dt>
<dd class="mt-1 text-sm text-gray-900">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">PKCE</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<% if @application.requires_pkce? %>
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Required</span>
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">Required</span>
<% else %>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Optional</span>
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-300">Optional</span>
<% end %>
</dd>
</div>
</div>
<% unless flash[:client_id] %>
<div>
<dt class="text-sm font-medium text-gray-500">Client ID</dt>
<dd class="mt-1 text-sm text-gray-900">
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.client_id %></code>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Client ID</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<code class="block bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.client_id %></code>
</dd>
</div>
<% if @application.confidential_client? %>
<div>
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
<dd class="mt-1 text-sm text-gray-900">
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500 italic">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Client Secret</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<div class="bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded text-xs text-gray-500 dark:text-gray-400 italic">
🔒 Client secret is stored securely and cannot be displayed
</div>
<p class="mt-2 text-xs text-gray-500">
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
To get a new client secret, use the "Regenerate Credentials" button above.
</p>
</dd>
</div>
<% else %>
<div>
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
<dd class="mt-1 text-sm text-gray-900">
<div class="bg-blue-50 px-3 py-2 rounded text-xs text-blue-600">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Client Secret</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<div class="bg-blue-50 dark:bg-blue-900/30 px-3 py-2 rounded text-xs text-blue-600 dark:text-blue-400">
Public clients do not use a client secret. PKCE is required for authorization.
</div>
</dd>
@@ -159,33 +159,33 @@
<% end %>
<% end %>
<div>
<dt class="text-sm font-medium text-gray-500">Redirect URIs</dt>
<dd class="mt-1 text-sm text-gray-900">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Redirect URIs</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<% if @application.redirect_uris.present? %>
<% @application.parsed_redirect_uris.each do |uri| %>
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all mb-2"><%= uri %></code>
<code class="block bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-3 py-2 rounded font-mono text-xs break-all mb-2"><%= uri %></code>
<% end %>
<% else %>
<span class="text-gray-400">No redirect URIs configured</span>
<span class="text-gray-400 dark:text-gray-500">No redirect URIs configured</span>
<% end %>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Backchannel Logout URI
<% if @application.supports_backchannel_logout? %>
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">Enabled</span>
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Enabled</span>
<% end %>
</dt>
<dd class="mt-1 text-sm text-gray-900">
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<% if @application.backchannel_logout_uri.present? %>
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.backchannel_logout_uri %></code>
<p class="mt-2 text-xs text-gray-500">
<code class="block bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.backchannel_logout_uri %></code>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
</p>
<% else %>
<span class="text-gray-400 italic">Not configured</span>
<p class="mt-1 text-xs text-gray-500">
<span class="text-gray-400 dark:text-gray-500 italic">Not configured</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Backchannel logout is optional. Configure it if the application supports OpenID Connect Backchannel Logout.
</p>
<% end %>
@@ -198,24 +198,24 @@
<!-- Forward Auth Configuration (only for Forward Auth apps) -->
<% if @application.forward_auth? %>
<div class="bg-white shadow sm:rounded-lg">
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Forward Auth Configuration</h3>
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">Forward Auth Configuration</h3>
<dl class="space-y-4">
<div>
<dt class="text-sm font-medium text-gray-500">Domain Pattern</dt>
<dd class="mt-1 text-sm text-gray-900">
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs"><%= @application.domain_pattern %></code>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Domain Pattern</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<code class="block bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-3 py-2 rounded font-mono text-xs"><%= @application.domain_pattern %></code>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Headers Configuration</dt>
<dd class="mt-1 text-sm text-gray-900">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Headers Configuration</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<% if @application.headers_config.present? && @application.headers_config.any? %>
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs whitespace-pre-wrap"><%= JSON.pretty_generate(@application.headers_config) %></code>
<code class="block bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-3 py-2 rounded font-mono text-xs whitespace-pre-wrap"><%= JSON.pretty_generate(@application.headers_config) %></code>
<% else %>
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500">
Using default headers: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin
<div class="bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded text-xs text-gray-500 dark:text-gray-400">
Using default headers: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Username, X-Remote-Groups, X-Remote-Admin
</div>
<% end %>
</dd>
@@ -226,29 +226,29 @@
<% end %>
<!-- Group Access Control -->
<div class="bg-white shadow sm:rounded-lg">
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Access Control</h3>
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">Access Control</h3>
<div>
<dt class="text-sm font-medium text-gray-500 mb-2">Allowed Groups</dt>
<dd class="mt-1 text-sm text-gray-900">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Allowed Groups</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<% if @allowed_groups.empty? %>
<div class="rounded-md bg-blue-50 p-4">
<div class="rounded-md bg-blue-50 dark:bg-blue-900/30 p-4">
<div class="flex">
<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.
</p>
</div>
</div>
</div>
<% else %>
<ul class="divide-y divide-gray-200 border border-gray-200 rounded-md">
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
<% @allowed_groups.each do |group| %>
<li class="px-4 py-3 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900"><%= group.name %></p>
<p class="text-xs text-gray-500"><%= pluralize(group.users.count, "member") %></p>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= group.name %></p>
<p class="text-xs text-gray-500 dark:text-gray-400"><%= pluralize(group.users.count, "member") %></p>
</div>
</li>
<% end %>

View File

@@ -1,28 +1,28 @@
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Admin Dashboard</h1>
<p class="mt-2 text-gray-600">System overview and quick actions</p>
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Admin Dashboard</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">System overview and quick actions</p>
</div>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<!-- Users Card -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
Total Users
</dt>
<dd class="flex items-baseline">
<div class="text-2xl font-semibold text-gray-900">
<div class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
<%= @user_count %>
</div>
<div class="ml-2 text-sm text-gray-600">
<div class="ml-2 text-sm text-gray-600 dark:text-gray-400">
(<%= @active_user_count %> active)
</div>
</dd>
@@ -30,30 +30,30 @@
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="bg-gray-50 dark:bg-gray-700 px-5 py-3">
<%= link_to "Manage users", admin_users_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
</div>
</div>
<!-- Applications Card -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h4a1 1 0 010 2H6v10a1 1 0 001 1h10a1 1 0 001-1v-3a1 1 0 112 0v3a3 3 0 01-3 3H7a3 3 0 01-3-3V6a1 1 0 011-1zm9 1a1 1 0 10-2 0v3a1 1 0 102 0V6zm-4 8a1 1 0 100 2h.01a1 1 0 100-2H9zm4 0a1 1 0 100 2h.01a1 1 0 100-2H13z"></path>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
Applications
</dt>
<dd class="flex items-baseline">
<div class="text-2xl font-semibold text-gray-900">
<div class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
<%= @application_count %>
</div>
<div class="ml-2 text-sm text-gray-600">
<div class="ml-2 text-sm text-gray-600 dark:text-gray-400">
(<%= @active_application_count %> active)
</div>
</dd>
@@ -61,33 +61,33 @@
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="bg-gray-50 dark:bg-gray-700 px-5 py-3">
<%= link_to "Manage applications", admin_applications_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
</div>
</div>
<!-- Groups Card -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
Groups
</dt>
<dd class="text-2xl font-semibold text-gray-900">
<dd class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
<%= @group_count %>
</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="bg-gray-50 dark:bg-gray-700 px-5 py-3">
<%= link_to "Manage groups", admin_groups_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
</div>
</div>
@@ -95,26 +95,26 @@
<!-- Recent Users -->
<div class="mt-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Recent Users</h2>
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
<ul class="divide-y divide-gray-200">
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Recent Users</h2>
<div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
<% @recent_users.each do |user| %>
<li class="px-6 py-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900"><%= user.email_address %></p>
<p class="text-xs text-gray-500">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= user.email_address %></p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Created <%= time_ago_in_words(user.created_at) %> ago
</p>
</div>
<div class="flex gap-2">
<% if user.admin? %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Admin</span>
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
<% end %>
<% if user.totp_enabled? %>
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">2FA</span>
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">2FA</span>
<% end %>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700"><%= user.status.titleize %></span>
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-300"><%= user.status.titleize %></span>
</div>
</div>
</li>
@@ -125,21 +125,21 @@
<!-- Quick Actions -->
<div class="mt-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Quick Actions</h2>
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Quick Actions</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<%= link_to new_admin_user_path, class: "block p-6 bg-white rounded-lg border border-gray-200 shadow-sm hover:bg-gray-50 hover:shadow-md transition" do %>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Create User</h3>
<p class="text-sm text-gray-600">Add a new user to the system</p>
<%= link_to new_admin_user_path, class: "block p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 hover:shadow-md transition" do %>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Create User</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Add a new user to the system</p>
<% end %>
<%= link_to new_admin_application_path, class: "block p-6 bg-white rounded-lg border border-gray-200 shadow-sm hover:bg-gray-50 hover:shadow-md transition" do %>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Register Application</h3>
<p class="text-sm text-gray-600">Add a new OIDC or ForwardAuth app</p>
<%= link_to new_admin_application_path, class: "block p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 hover:shadow-md transition" do %>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Register Application</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Add a new OIDC or ForwardAuth app</p>
<% end %>
<%= link_to new_admin_group_path, class: "block p-6 bg-white rounded-lg border border-gray-200 shadow-sm hover:bg-gray-50 hover:shadow-md transition" do %>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Create Group</h3>
<p class="text-sm text-gray-600">Organize users into a new group</p>
<%= link_to new_admin_group_path, class: "block p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 hover:shadow-md transition" do %>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Create Group</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Organize users into a new group</p>
<% end %>
</div>
</div>

View File

@@ -2,51 +2,51 @@
<%= render "shared/form_errors", form: form %>
<div>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "developers" %>
<p class="mt-1 text-sm text-gray-500">Group names are automatically normalized to lowercase.</p>
<%= form.label :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "developers" %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Group names are automatically normalized to lowercase.</p>
</div>
<div>
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this group" %>
<%= form.label :description, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this group" %>
</div>
<div>
<%= form.label :user_ids, "Group Members", class: "block text-sm font-medium text-gray-700" %>
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 rounded-md p-3">
<%= form.label :user_ids, "Group Members", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
<% if @available_users.any? %>
<% @available_users.each do |user| %>
<div class="flex items-center">
<%= check_box_tag "group[user_ids][]", user.id, group.users.include?(user), class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
<%= label_tag "group_user_ids_#{user.id}", user.email_address, class: "ml-2 text-sm text-gray-900" %>
<%= check_box_tag "group[user_ids][]", user.id, group.users.include?(user), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= label_tag "group_user_ids_#{user.id}", user.email_address, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
<% if user.admin? %>
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">Admin</span>
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
<% end %>
</div>
<% end %>
<% else %>
<p class="text-sm text-gray-500">No users available.</p>
<p class="text-sm text-gray-500 dark:text-gray-400">No users available.</p>
<% end %>
</div>
<p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Select which users should be members of this group.</p>
</div>
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8,
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
placeholder: '{"roles": ["admin", "editor"]}',
data: {
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea"
} %>
<div class="mt-2 text-sm text-gray-600 space-y-1">
<div class="mt-2 text-sm text-gray-600 dark:text-gray-400 space-y-1">
<div class="flex items-center justify-between">
<p>Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.</p>
<div class="flex items-center gap-2">
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"roles": ["admin", "editor"], "permissions": ["read", "write"], "team": "backend"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 px-2 py-1 rounded">Format JSON</button>
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"roles": ["admin", "editor"], "permissions": ["read", "write"], "team": "backend"}' class="text-xs bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-1 rounded">Insert Example</button>
</div>
</div>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
@@ -55,6 +55,6 @@
<div class="flex gap-3">
<%= form.submit group.persisted? ? "Update Group" : "Create Group", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
<%= link_to "Cancel", admin_groups_path, class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
<%= link_to "Cancel", admin_groups_path, class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
</div>
<% end %>

View File

@@ -1,5 +1,5 @@
<div class="max-w-2xl">
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit Group</h1>
<p class="text-sm text-gray-600 mb-6">Editing: <%= @group.name %></p>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-6">Edit Group</h1>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">Editing: <%= @group.name %></p>
<%= render "form", group: @group %>
</div>

View File

@@ -1,7 +1,7 @@
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-gray-900">Groups</h1>
<p class="mt-2 text-sm text-gray-700">Organize users into groups for application access control.</p>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Groups</h1>
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">Organize users into groups for application access control.</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<%= link_to "New Group", new_admin_group_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
@@ -11,31 +11,31 @@
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table class="min-w-full divide-y divide-gray-300">
<table class="min-w-full divide-y divide-gray-300 dark:divide-gray-600">
<thead>
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Name</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Description</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Members</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Applications</th>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-100 sm:pl-0">Name</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Description</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Members</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Applications</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<% @groups.each do |group| %>
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-0">
<%= link_to group.name, admin_group_path(group), class: "text-blue-600 hover:text-blue-900" %>
</td>
<td class="px-3 py-4 text-sm text-gray-500">
<%= truncate(group.description, length: 80) || content_tag(:span, "No description", class: "text-gray-400") %>
<td class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<%= truncate(group.description, length: 80) || content_tag(:span, "No description", class: "text-gray-400 dark:text-gray-500") %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<%= pluralize(group.users.count, "member") %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<%= pluralize(group.applications.count, "app") %>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">

View File

@@ -1,4 +1,4 @@
<div class="max-w-2xl">
<h1 class="text-2xl font-semibold text-gray-900 mb-6">New Group</h1>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-6">New Group</h1>
<%= render "form", group: @group %>
</div>

View File

@@ -1,13 +1,13 @@
<div class="mb-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-gray-900"><%= @group.name %></h1>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @group.name %></h1>
<% if @group.description.present? %>
<p class="mt-1 text-sm text-gray-500"><%= @group.description %></p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @group.description %></p>
<% end %>
</div>
<div class="mt-4 sm:mt-0 flex gap-3">
<%= link_to "Edit", edit_admin_group_path(@group), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
<%= link_to "Edit", edit_admin_group_path(@group), class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
<%= button_to "Delete", admin_group_path(@group), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500" %>
</div>
</div>
@@ -15,25 +15,25 @@
<div class="space-y-6">
<!-- Members -->
<div class="bg-white shadow sm:rounded-lg">
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
Members (<%= @members.count %>)
</h3>
<% if @members.any? %>
<ul class="divide-y divide-gray-200 border border-gray-200 rounded-md">
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
<% @members.each do |user| %>
<li class="px-4 py-3 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900"><%= user.email_address %></p>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= user.email_address %></p>
<div class="flex gap-2 mt-1">
<% if user.admin? %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">Admin</span>
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
<% end %>
<% if user.totp_enabled? %>
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">2FA</span>
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">2FA</span>
<% end %>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700"><%= user.status.titleize %></span>
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300"><%= user.status.titleize %></span>
</div>
</div>
<%= link_to "View", admin_user_path(user), class: "text-blue-600 hover:text-blue-900 text-sm" %>
@@ -41,36 +41,36 @@
<% end %>
</ul>
<% else %>
<div class="rounded-md bg-gray-50 p-4">
<p class="text-sm text-gray-500">No members in this group yet.</p>
<div class="rounded-md bg-gray-50 dark:bg-gray-700 p-4">
<p class="text-sm text-gray-500 dark:text-gray-400">No members in this group yet.</p>
</div>
<% end %>
</div>
</div>
<!-- Applications -->
<div class="bg-white shadow sm:rounded-lg">
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
Assigned Applications (<%= @applications.count %>)
</h3>
<% if @applications.any? %>
<ul class="divide-y divide-gray-200 border border-gray-200 rounded-md">
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
<% @applications.each do |app| %>
<li class="px-4 py-3 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900"><%= app.name %></p>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= app.name %></p>
<div class="flex gap-2 mt-1">
<% case app.app_type %>
<% when "oidc" %>
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700">OIDC</span>
<span class="inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/50 px-2 py-0.5 text-xs font-medium text-purple-700 dark:text-purple-300">OIDC</span>
<% when "trusted_header" %>
<span class="inline-flex items-center rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-medium text-indigo-700">ForwardAuth</span>
<span class="inline-flex items-center rounded-full bg-indigo-100 dark:bg-indigo-900/50 px-2 py-0.5 text-xs font-medium text-indigo-700 dark:text-indigo-300">ForwardAuth</span>
<% end %>
<% if app.active? %>
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">Active</span>
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Active</span>
<% else %>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700">Inactive</span>
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">Inactive</span>
<% end %>
</div>
</div>
@@ -79,8 +79,8 @@
<% end %>
</ul>
<% else %>
<div class="rounded-md bg-gray-50 p-4">
<p class="text-sm text-gray-500">This group is not assigned to any applications.</p>
<div class="rounded-md bg-gray-50 dark:bg-gray-700 p-4">
<p class="text-sm text-gray-500 dark:text-gray-400">This group is not assigned to any applications.</p>
</div>
<% end %>
</div>

View File

@@ -3,29 +3,29 @@
<!-- OIDC Apps: Custom Claims -->
<% if oidc_apps.any? %>
<div class="mt-12 border-t pt-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">OIDC App-Specific Claims</h2>
<p class="text-sm text-gray-600 mb-6">
<div class="mt-12 border-t dark:border-gray-700 pt-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">OIDC App-Specific Claims</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Configure custom claims that apply only to specific OIDC applications. These override both group and user global claims and are included in ID tokens.
</p>
<div class="space-y-6">
<% oidc_apps.each do |app| %>
<% app_claim = user.application_user_claims.find_by(application: app) %>
<details class="border rounded-lg" <%= "open" if app_claim&.custom_claims&.any? %>>
<summary class="cursor-pointer bg-gray-50 px-4 py-3 hover:bg-gray-100 rounded-t-lg flex items-center justify-between">
<details class="border dark:border-gray-700 rounded-lg" <%= "open" if app_claim&.custom_claims&.any? %>>
<summary class="cursor-pointer bg-gray-50 dark:bg-gray-800 px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-t-lg flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="font-medium text-gray-900"><%= app.name %></span>
<span class="text-xs px-2 py-1 rounded-full bg-blue-100 text-blue-700">
<span class="font-medium text-gray-900 dark:text-gray-100"><%= app.name %></span>
<span class="text-xs px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300">
OIDC
</span>
<% if app_claim&.custom_claims&.any? %>
<span class="text-xs px-2 py-1 rounded-full bg-amber-100 text-amber-700">
<span class="text-xs px-2 py-1 rounded-full bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300">
<%= app_claim.custom_claims.keys.count %> claim(s)
</span>
<% end %>
</div>
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-5 w-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
@@ -35,22 +35,22 @@
<%= hidden_field_tag :application_id, app.id %>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Custom Claims (JSON)</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Custom Claims (JSON)</label>
<%= text_area_tag :custom_claims,
(app_claim&.custom_claims.present? ? JSON.pretty_generate(app_claim.custom_claims) : ""),
rows: 8,
class: "w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
class: "w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
placeholder: '{"kavita_groups": ["admin"], "library_access": "all"}',
data: {
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea"
} %>
<div class="mt-2 space-y-1">
<p class="text-xs text-gray-600">
<p class="text-xs text-gray-600 dark:text-gray-400">
Example for <%= app.name %>: Add claims that this app specifically needs to read.
</p>
<p class="text-xs text-amber-600">
<strong>Note:</strong> Do not use reserved claim names (<code class="bg-amber-50 px-1 rounded">groups</code>, <code class="bg-amber-50 px-1 rounded">email</code>, <code class="bg-amber-50 px-1 rounded">name</code>, etc.). Use app-specific names like <code class="bg-amber-50 px-1 rounded">kavita_groups</code> instead.
<strong>Note:</strong> Do not use reserved claim names (<code class="bg-amber-50 dark:bg-amber-900/30 px-1 rounded">groups</code>, <code class="bg-amber-50 dark:bg-amber-900/30 px-1 rounded">email</code>, <code class="bg-amber-50 dark:bg-amber-900/30 px-1 rounded">name</code>, etc.). Use app-specific names like <code class="bg-amber-50 dark:bg-amber-900/30 px-1 rounded">kavita_groups</code> instead.
</p>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
</div>
@@ -66,27 +66,27 @@
delete_application_claims_admin_user_path(user, application_id: app.id),
method: :delete,
data: { turbo_confirm: "Remove app-specific claims for #{app.name}?" },
class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
<% end %>
</div>
<% end %>
<!-- Preview merged claims -->
<div class="mt-4 border-t pt-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Preview: Final ID Token Claims for <%= app.name %></h4>
<div class="bg-gray-50 rounded-lg p-3">
<pre class="text-xs font-mono text-gray-800 overflow-x-auto"><%= JSON.pretty_generate(preview_user_claims(user, app)) %></pre>
<div class="mt-4 border-t dark:border-gray-700 pt-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Preview: Final ID Token Claims for <%= app.name %></h4>
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<pre class="text-xs font-mono text-gray-800 dark:text-gray-200 overflow-x-auto"><%= JSON.pretty_generate(preview_user_claims(user, app)) %></pre>
</div>
<details class="mt-2">
<summary class="cursor-pointer text-xs text-gray-600 hover:text-gray-900">Show claim sources</summary>
<summary class="cursor-pointer text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">Show claim sources</summary>
<div class="mt-2 space-y-1">
<% claim_sources(user, app).each do |source| %>
<div class="flex gap-2 items-start text-xs">
<span class="px-2 py-1 rounded <%= source[:type] == :group ? 'bg-blue-100 text-blue-700' : (source[:type] == :user ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700') %>">
<span class="px-2 py-1 rounded <%= source[:type] == :group ? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300' : (source[:type] == :user ? 'bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300' : 'bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300') %>">
<%= source[:name] %>
</span>
<code class="text-gray-700"><%= source[:claims].to_json %></code>
<code class="text-gray-700 dark:text-gray-300"><%= source[:claims].to_json %></code>
</div>
<% end %>
</div>
@@ -101,32 +101,32 @@
<!-- ForwardAuth Apps: Headers Preview -->
<% if forward_auth_apps.any? %>
<div class="mt-12 border-t pt-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">ForwardAuth Headers Preview</h2>
<p class="text-sm text-gray-600 mb-6">
<div class="mt-12 border-t dark:border-gray-700 pt-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">ForwardAuth Headers Preview</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
ForwardAuth applications receive HTTP headers (not OIDC tokens). Headers are based on user's email, name, groups, and admin status.
</p>
<div class="space-y-6">
<% forward_auth_apps.each do |app| %>
<details class="border rounded-lg">
<summary class="cursor-pointer bg-gray-50 px-4 py-3 hover:bg-gray-100 rounded-t-lg flex items-center justify-between">
<details class="border dark:border-gray-700 rounded-lg">
<summary class="cursor-pointer bg-gray-50 dark:bg-gray-800 px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-t-lg flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="font-medium text-gray-900"><%= app.name %></span>
<span class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700">
<span class="font-medium text-gray-900 dark:text-gray-100"><%= app.name %></span>
<span class="text-xs px-2 py-1 rounded-full bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300">
FORWARD AUTH
</span>
<span class="text-xs text-gray-500">
<span class="text-xs text-gray-500 dark:text-gray-400">
<%= app.domain_pattern %>
</span>
</div>
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-5 w-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="p-4 space-y-4">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div class="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg p-3">
<div class="flex items-start">
<svg class="h-5 w-5 text-blue-400 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
@@ -135,33 +135,33 @@
</div>
<div>
<h4 class="text-sm font-medium text-gray-700 mb-2">Headers Sent to <%= app.name %></h4>
<div class="bg-gray-50 rounded-lg p-3 border">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Headers Sent to <%= app.name %></h4>
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 border dark:border-gray-700">
<% headers = app.headers_for_user(user) %>
<% if headers.any? %>
<dl class="space-y-2 text-xs font-mono">
<% headers.each do |header_name, value| %>
<div class="flex">
<dt class="text-blue-600 font-semibold w-48"><%= header_name %>:</dt>
<dd class="text-gray-800 flex-1"><%= value %></dd>
<dt class="text-blue-600 dark:text-blue-400 font-semibold w-48"><%= header_name %>:</dt>
<dd class="text-gray-800 dark:text-gray-200 flex-1"><%= value %></dd>
</div>
<% end %>
</dl>
<% else %>
<p class="text-xs text-gray-500 italic">All headers disabled for this application.</p>
<p class="text-xs text-gray-500 dark:text-gray-400 italic">All headers disabled for this application.</p>
<% end %>
</div>
<p class="mt-2 text-xs text-gray-500">
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
These headers are configured in the application settings and sent by your reverse proxy (Caddy/Traefik) to the upstream application.
</p>
</div>
<% if user.groups.any? %>
<div>
<h4 class="text-sm font-medium text-gray-700 mb-2">User's Groups</h4>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">User's Groups</h4>
<div class="flex flex-wrap gap-2">
<% user.groups.each do |group| %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-200">
<%= group.name %>
</span>
<% end %>
@@ -176,10 +176,10 @@
<% end %>
<% if oidc_apps.empty? && forward_auth_apps.empty? %>
<div class="mt-12 border-t pt-8">
<div class="text-center py-12 bg-gray-50 rounded-lg">
<p class="text-gray-500">No active applications found.</p>
<p class="text-sm text-gray-400 mt-1">Create applications in the Admin panel first.</p>
<div class="mt-12 border-t dark:border-gray-700 pt-8">
<div class="text-center py-12 bg-gray-50 dark:bg-gray-800 rounded-lg">
<p class="text-gray-500 dark:text-gray-400">No active applications found.</p>
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Create applications in the Admin panel first.</p>
</div>
</div>
<% end %>

View File

@@ -2,49 +2,49 @@
<%= render "shared/form_errors", form: form %>
<div>
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %>
<%= form.email_field :email_address, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %>
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.email_field :email_address, required: true, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %>
</div>
<div>
<%= form.label :username, "Username (Optional)", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :username, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "jsmith" %>
<p class="mt-1 text-sm text-gray-500">Optional: Short username/handle for login. Can only contain letters, numbers, underscores, and hyphens.</p>
<%= form.label :username, "Username (Optional)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_field :username, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "jsmith" %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Optional: Short username/handle for login. Can only contain letters, numbers, underscores, and hyphens.</p>
</div>
<div>
<%= form.label :name, "Display Name (Optional)", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "John Smith" %>
<p class="mt-1 text-sm text-gray-500">Optional: Full name shown in applications. Defaults to email address if not set.</p>
<%= form.label :name, "Display Name (Optional)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "John Smith" %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Optional: Full name shown in applications. Defaults to email address if not set.</p>
</div>
<div>
<%= form.label :password, class: "block text-sm font-medium text-gray-700" %>
<%= form.password_field :password, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: user.persisted? ? "Leave blank to keep current password" : "Enter password" %>
<%= form.label :password, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.password_field :password, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: user.persisted? ? "Leave blank to keep current password" : "Enter password" %>
<% if user.persisted? %>
<p class="mt-1 text-sm text-gray-500">Leave blank to keep the current password</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Leave blank to keep the current password</p>
<% else %>
<p class="mt-1 text-sm text-gray-500">Leave blank to generate a random password</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Leave blank to generate a random password</p>
<% end %>
</div>
<div>
<%= form.label :status, class: "block text-sm font-medium text-gray-700" %>
<%= form.select :status, User.statuses.keys.map { |s| [s.titleize, s] }, {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<%= form.label :status, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.select :status, User.statuses.keys.map { |s| [s.titleize, s] }, {}, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
<div class="flex items-center">
<%= form.check_box :admin, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500", disabled: (user == Current.session.user) %>
<%= form.label :admin, "Administrator", class: "ml-2 block text-sm text-gray-900" %>
<%= 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 dark:text-gray-100" %>
<% 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 %>
</div>
<div>
<div class="flex items-center">
<%= form.check_box :totp_required, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
<%= form.label :totp_required, "Require Two-Factor Authentication", class: "ml-2 block text-sm text-gray-900" %>
<%= form.check_box :totp_required, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= form.label :totp_required, "Require Two-Factor Authentication", class: "ml-2 block text-sm text-gray-900 dark:text-gray-100" %>
<% if user.totp_required? && !user.totp_enabled? %>
<span class="ml-2 text-xs text-amber-600">(User has not set up 2FA yet)</span>
<% end %>
@@ -57,24 +57,24 @@
Warning: This user will be prompted to set up 2FA on their next login.
</p>
<% end %>
<p class="mt-1 text-sm text-gray-500">When enabled, this user must use two-factor authentication to sign in.</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">When enabled, this user must use two-factor authentication to sign in.</p>
</div>
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_area :custom_claims, value: (user.custom_claims.present? ? JSON.pretty_generate(user.custom_claims) : ""), rows: 8,
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
placeholder: '{"department": "engineering", "level": "senior"}',
data: {
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea"
} %>
<div class="mt-2 text-sm text-gray-600 space-y-1">
<div class="mt-2 text-sm text-gray-600 dark:text-gray-400 space-y-1">
<div class="flex items-center justify-between">
<p>Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p>
<div class="flex items-center gap-2">
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"department": "engineering", "level": "senior", "location": "remote"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 px-2 py-1 rounded">Format JSON</button>
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"department": "engineering", "level": "senior", "location": "remote"}' class="text-xs bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-1 rounded">Insert Example</button>
</div>
</div>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
@@ -83,6 +83,6 @@
<div class="flex gap-3">
<%= form.submit user.persisted? ? "Update User" : "Create User", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
<%= link_to "Cancel", admin_users_path, class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
<%= link_to "Cancel", admin_users_path, class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
</div>
<% end %>

View File

@@ -1,6 +1,6 @@
<div class="max-w-4xl">
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit User</h1>
<p class="text-sm text-gray-600 mb-6">Editing: <%= @user.email_address %></p>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-6">Edit User</h1>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">Editing: <%= @user.email_address %></p>
<div class="max-w-2xl">
<%= render "form", user: @user %>

View File

@@ -1,7 +1,7 @@
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-gray-900">Users</h1>
<p class="mt-2 text-sm text-gray-700">A list of all users in the system.</p>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Users</h1>
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">A list of all users in the system.</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<%= link_to "New User", new_admin_user_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
@@ -9,7 +9,7 @@
</div>
<% unless smtp_configured? %>
<div class="mt-6 rounded-md bg-yellow-50 p-4">
<div class="mt-6 rounded-md bg-yellow-50 dark:bg-yellow-900/30 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
@@ -17,10 +17,10 @@
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Email delivery not configured
</h3>
<div class="mt-2 text-sm text-yellow-700">
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<p>
<% if Rails.env.development? %>
Emails are being delivered using <span class="font-mono"><%= email_delivery_method %></span> and will open in your browser.
@@ -44,63 +44,63 @@
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table class="min-w-full divide-y divide-gray-300">
<table class="min-w-full divide-y divide-gray-300 dark:divide-gray-600">
<thead>
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Email</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Role</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">2FA</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Groups</th>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-100 sm:pl-0">Email</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Status</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Role</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">2FA</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Groups</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<% @users.each do |user| %>
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
<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 %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<% if user.status.present? %>
<% case user.status.to_sym %>
<% when :active %>
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Active</span>
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">Active</span>
<% when :disabled %>
<span class="inline-flex items-center rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-700">Disabled</span>
<span class="inline-flex items-center rounded-full bg-red-100 dark:bg-red-900/50 px-2 py-1 text-xs font-medium text-red-700 dark:text-red-300">Disabled</span>
<% when :pending_invitation %>
<span class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-700">Pending</span>
<span class="inline-flex items-center rounded-full bg-yellow-100 dark:bg-yellow-900/50 px-2 py-1 text-xs font-medium text-yellow-700 dark:text-yellow-300">Pending</span>
<% end %>
<% else %>
<span class="text-gray-400">-</span>
<span class="text-gray-400 dark:text-gray-500">-</span>
<% end %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<% if user.admin? %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Admin</span>
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
<% else %>
<span class="text-gray-500">User</span>
<span class="text-gray-500 dark:text-gray-400">User</span>
<% end %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-2">
<% if user.totp_enabled? %>
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="2FA Enabled">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<% else %>
<svg class="h-5 w-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="2FA Not Enabled">
<svg class="h-5 w-5 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="2FA Not Enabled">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<% end %>
<% if user.totp_required? %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700" title="2FA Required by Admin">Required</span>
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300" title="2FA Required by Admin">Required</span>
<% end %>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<%= user.groups.count %>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">

View File

@@ -1,4 +1,4 @@
<div class="max-w-2xl">
<h1 class="text-2xl font-semibold text-gray-900 mb-6">New User</h1>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-6">New User</h1>
<%= render "form", user: @user %>
</div>

View 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>

View 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>

View 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>

View File

@@ -1,8 +1,8 @@
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">
Welcome, <%= @user.email_address %>
</h1>
<p class="mt-2 text-gray-600">
<p class="mt-2 text-gray-600 dark:text-gray-400">
<% if @user.admin? %>
Administrator
<% else %>
@@ -13,34 +13,34 @@
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<!-- Active Sessions Card -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
Active Sessions
</dt>
<dd class="text-lg font-semibold text-gray-900">
<dd class="text-lg font-semibold text-gray-900 dark:text-gray-100">
<%= @user.sessions.active.count %>
</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="bg-gray-50 dark:bg-gray-700 px-5 py-3">
<%= link_to "View all sessions", profile_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
</div>
</div>
<% if @user.totp_enabled? %>
<!-- 2FA Status Card -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
@@ -50,7 +50,7 @@
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
Two-Factor Authentication
</dt>
<dd class="text-lg font-semibold text-green-600">
@@ -60,13 +60,13 @@
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="bg-gray-50 dark:bg-gray-700 px-5 py-3">
<%= link_to "Manage 2FA", profile_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
</div>
</div>
<% else %>
<!-- 2FA Disabled Card -->
<div class="bg-white overflow-hidden shadow rounded-lg border-2 border-yellow-200">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg border-2 border-yellow-200">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
@@ -76,7 +76,7 @@
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
Two-Factor Authentication
</dt>
<dd class="text-lg font-semibold text-yellow-600">
@@ -86,48 +86,74 @@
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="bg-gray-50 dark:bg-gray-700 px-5 py-3">
<%= link_to "Enable 2FA", profile_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
</div>
</div>
<% end %>
<!-- API Keys Card -->
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
API Keys
</dt>
<dd class="text-lg font-semibold text-gray-900 dark:text-gray-100">
<%= @user.api_keys.active.count %>
</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700 px-5 py-3">
<%= link_to "Manage API Keys", api_keys_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
</div>
</div>
</div>
<!-- Your Applications Section -->
<div class="mt-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Your Applications</h2>
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Your Applications</h2>
<% if @applications.any? %>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<% @applications.each do |app| %>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition">
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition">
<div class="p-6">
<div class="flex items-start gap-3 mb-4">
<% if app.icon.attached? %>
<%= image_tag app.icon, class: "h-12 w-12 rounded-lg object-cover border border-gray-200 shrink-0", alt: "#{app.name} icon" %>
<%= 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 %>
<div class="h-12 w-12 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0">
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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 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" />
</svg>
</div>
<% end %>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between">
<h3 class="text-lg font-semibold text-gray-900 truncate">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 truncate">
<%= app.name %>
</h3>
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium shrink-0
<% if app.oidc? %>
bg-blue-100 text-blue-800
bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-200
<% else %>
bg-green-100 text-green-800
bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200
<% end %>">
<%= app.app_type.humanize %>
</span>
</div>
<% if app.description.present? %>
<p class="text-sm text-gray-600 mt-1 line-clamp-2">
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
<%= app.description %>
</p>
<% end %>
@@ -139,17 +165,27 @@
<%= link_to "Open Application", app.landing_url,
target: "_blank",
rel: "noopener noreferrer",
class: "w-full flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition" %>
class: "w-full flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 focus:ring-blue-500 transition" %>
<% else %>
<div class="text-sm text-gray-500 italic">
<div class="text-sm text-gray-500 dark:text-gray-400 italic">
No landing URL configured
</div>
<% end %>
<% if app.user_has_active_session?(@user) %>
<%= button_to "Logout", logout_from_app_active_sessions_path(application_id: app.id), method: :delete,
class: "w-full flex justify-center items-center px-4 py-2 border border-orange-300 text-sm font-medium rounded-md text-orange-700 bg-white hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 transition",
form: { data: { turbo_confirm: "This will log you out of #{app.name}. You can sign back in without re-authorizing. Continue?" } } %>
<%= button_to "Require Re-Auth", logout_from_app_active_sessions_path(application_id: app.id), method: :delete,
class: "w-full flex justify-center items-center px-4 py-2 border border-orange-300 text-sm font-medium rounded-md text-orange-700 bg-white dark:bg-gray-700 dark:ring-gray-600 dark:text-gray-200 hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 focus:ring-orange-500 transition",
form: { data: { turbo_confirm: "This will revoke #{app.name}'s access tokens. The next time #{app.name} needs to authenticate, you'll sign in again (no re-authorization needed). Continue?" } } %>
<% end %>
<% if @user.admin? %>
<div class="flex gap-2 mt-1">
<%= link_to "View", admin_application_path(app),
class: "text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-blue-600 transition" %>
<span class="text-gray-300 dark:text-gray-600">|</span>
<%= link_to "Edit", edit_admin_application_path(app),
class: "text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-blue-600 transition" %>
</div>
<% end %>
</div>
</div>
@@ -157,12 +193,12 @@
<% end %>
</div>
<% else %>
<div class="bg-gray-50 rounded-lg border border-gray-200 p-8 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-8 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No applications available</h3>
<p class="mt-2 text-sm text-gray-500">
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-gray-100">No applications available</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
You don't have access to any applications yet. Contact your administrator if you think this is an error.
</p>
</div>
@@ -171,21 +207,21 @@
<% if @user.admin? %>
<div class="mt-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Admin Quick Actions</h2>
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Admin Quick Actions</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<%= link_to admin_users_path, class: "block p-6 bg-white rounded-lg border border-gray-200 shadow-sm hover:bg-gray-50 hover:shadow-md transition" do %>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Manage Users</h3>
<p class="text-sm text-gray-600">View, edit, and invite users</p>
<%= link_to admin_users_path, class: "block p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 hover:shadow-md transition" do %>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Manage Users</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">View, edit, and invite users</p>
<% end %>
<%= link_to admin_applications_path, class: "block p-6 bg-white rounded-lg border border-gray-200 shadow-sm hover:bg-gray-50 hover:shadow-md transition" do %>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Manage Applications</h3>
<p class="text-sm text-gray-600">Register and configure applications</p>
<%= link_to admin_applications_path, class: "block p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 hover:shadow-md transition" do %>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Manage Applications</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Register and configure applications</p>
<% end %>
<%= link_to admin_groups_path, class: "block p-6 bg-white rounded-lg border border-gray-200 shadow-sm hover:bg-gray-50 hover:shadow-md transition" do %>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Manage Groups</h3>
<p class="text-sm text-gray-600">Create and organize user groups</p>
<%= link_to admin_groups_path, class: "block p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 hover:shadow-md transition" do %>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Manage Groups</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Create and organize user groups</p>
<% end %>
</div>
</div>

View File

@@ -4,15 +4,15 @@
<% end %>
<h1 class="font-bold text-4xl">Welcome to Clinch!</h1>
<p class="mt-2 text-gray-600">You've been invited to join Clinch. Please create your password to complete your account setup.</p>
<p class="mt-2 text-gray-600 dark:text-gray-400">You've been invited to join Clinch. Please create your password to complete your account setup.</p>
<%= form_with url: invitation_path(params[:token]), method: :put, class: "contents" do |form| %>
<div class="my-5">
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
</div>
<div class="my-5">
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
</div>
<div class="inline">

View File

@@ -9,6 +9,15 @@
<%= csrf_meta_tags %>
<%= 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 %>
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
@@ -23,15 +32,15 @@
<%= javascript_importmap_tags %>
</head>
<body>
<body class="dark:bg-gray-900 dark:text-gray-100">
<% if authenticated? %>
<div data-controller="mobile-sidebar">
<%= render "shared/sidebar" %>
<div class="lg:pl-64">
<!-- Mobile menu button -->
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden">
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden">
<button type="button"
class="-m-2.5 p-2.5 text-gray-700"
class="-m-2.5 p-2.5 text-gray-700 dark:text-gray-300"
id="mobile-menu-button"
data-action="click->mobile-sidebar#openSidebar">
<span class="sr-only">Open sidebar</span>
@@ -51,6 +60,16 @@
</div>
<% else %>
<!-- Public layout (signup/signin) -->
<div class="absolute top-4 right-4" data-controller="dark-mode">
<button type="button" data-action="click->dark-mode#toggle" class="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800">
<svg data-dark-mode-target="icon" data-mode="light" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg data-dark-mode-target="icon" data-mode="dark" class="hidden h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
</div>
<main class="container mx-auto mt-28 px-5">
<%= render "shared/flash" %>
<%= yield %>

View File

@@ -1,30 +1,30 @@
<div class="mx-auto max-w-md">
<div class="bg-white py-8 px-6 shadow rounded-lg sm:px-10">
<div class="bg-white dark:bg-gray-800 py-8 px-6 shadow rounded-lg sm:px-10">
<div class="mb-8 text-center">
<% if @application.icon.attached? %>
<%= image_tag @application.icon, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 shadow-sm mb-4", alt: "#{@application.name} icon" %>
<%= 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 %>
<div class="mx-auto h-20 w-20 rounded-xl bg-gray-100 border-2 border-gray-200 flex items-center justify-center mb-4">
<svg class="h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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 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" />
</svg>
</div>
<% end %>
<h2 class="text-2xl font-bold text-gray-900">Authorize Application</h2>
<p class="mt-2 text-sm text-gray-600">
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Authorize Application</h2>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
<strong><%= @application.name %></strong> is requesting access to your account.
</p>
</div>
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-900 mb-3">This application will be able to:</h3>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">This application will be able to:</h3>
<ul class="space-y-2">
<% if @scopes.include?("openid") %>
<li class="flex items-start">
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<span class="text-sm text-gray-700">Verify your identity</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Verify your identity</span>
</li>
<% end %>
<% if @scopes.include?("email") %>
@@ -32,7 +32,7 @@
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<span class="text-sm text-gray-700">Access your email address (<%= Current.session.user.email_address %>)</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Access your email address (<%= Current.session.user.email_address %>)</span>
</li>
<% end %>
<% if @scopes.include?("profile") %>
@@ -40,7 +40,7 @@
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<span class="text-sm text-gray-700">Access your profile information</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Access your profile information</span>
</li>
<% end %>
<% if @scopes.include?("groups") %>
@@ -48,18 +48,18 @@
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<span class="text-sm text-gray-700">Access your group memberships</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Access your group memberships</span>
</li>
<% end %>
</ul>
</div>
<div class="rounded-md bg-blue-50 p-4 mb-6">
<div class="rounded-md bg-blue-50 dark:bg-blue-900/30 p-4 mb-6">
<div class="flex">
<svg class="h-5 w-5 text-blue-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
<div class="text-sm text-blue-700">
<div class="text-sm text-blue-700 dark:text-blue-300">
<p>You'll be redirected to:</p>
<p class="mt-1 font-mono text-xs break-all"><%= @redirect_uri %></p>
</div>
@@ -68,13 +68,13 @@
<%= form_with url: "/oauth/authorize/consent", method: :post, class: "space-y-3", data: { turbo: false }, local: true do |form| %>
<%= form.submit "Authorize",
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 focus:ring-blue-500" %>
<%= button_tag "Deny",
type: :submit,
name: :deny,
value: "1",
class: "w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
class: "w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 focus:ring-blue-500" %>
<% end %>
</div>
</div>

View File

@@ -7,11 +7,11 @@
<%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %>
<div class="my-5">
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
</div>
<div class="my-5">
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
</div>
<div class="inline">

View File

@@ -7,7 +7,7 @@
<%= form_with url: passwords_path, class: "contents", data: { controller: "form-errors" } do |form| %>
<div class="my-5">
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
</div>
<div class="inline">

View File

@@ -1,21 +1,21 @@
<div class="space-y-8" data-controller="modal">
<div>
<h1 class="text-3xl font-bold text-gray-900">Account Security</h1>
<p class="mt-2 text-sm text-gray-600">Manage your account settings, active sessions, and connected applications.</p>
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Account Security</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Manage your account settings, active sessions, and connected applications.</p>
</div>
<!-- Account Information -->
<div class="bg-white shadow sm:rounded-lg">
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">Account Information</h3>
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Account Information</h3>
<div class="mt-5 space-y-6">
<%= form_with model: @user, url: profile_path, method: :patch, class: "space-y-6" do |form| %>
<% if @user.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<h3 class="text-sm font-medium text-red-800">
<div class="rounded-md bg-red-50 dark:bg-red-900/30 p-4">
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">
<%= pluralize(@user.errors.count, "error") %> prohibited this from being saved:
</h3>
<ul class="mt-2 list-disc list-inside text-sm text-red-700">
<ul class="mt-2 list-disc list-inside text-sm text-red-700 dark:text-red-300">
<% @user.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
@@ -24,24 +24,24 @@
<% end %>
<div>
<%= form.label :email_address, "Email Address", class: "block text-sm font-medium text-gray-700" %>
<%= form.label :email_address, "Email Address", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.email_field :email_address,
required: true,
autocomplete: "email",
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
<div>
<%= form.label :current_password, "Current Password", class: "block text-sm font-medium text-gray-700" %>
<%= form.label :current_password, "Current Password", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.password_field :current_password,
autocomplete: "current-password",
placeholder: "Required to change email",
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-sm text-gray-500">Enter your current password to confirm this change</p>
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Enter your current password to confirm this change</p>
</div>
<div>
<%= form.submit "Update Email", class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
<%= form.submit "Update Email", class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
</div>
<% end %>
</div>
@@ -49,38 +49,38 @@
</div>
<!-- Change Password -->
<div class="bg-white shadow sm:rounded-lg">
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">Change Password</h3>
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Change Password</h3>
<div class="mt-5">
<%= form_with model: @user, url: profile_path, method: :patch, class: "space-y-6" do |form| %>
<div>
<%= form.label :current_password, "Current Password", class: "block text-sm font-medium text-gray-700" %>
<%= form.label :current_password, "Current Password", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.password_field :current_password,
autocomplete: "current-password",
placeholder: "Enter current password",
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
<div>
<%= form.label :password, "New Password", class: "block text-sm font-medium text-gray-700" %>
<%= form.label :password, "New Password", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.password_field :password,
autocomplete: "new-password",
placeholder: "Enter new password",
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-sm text-gray-500">Must be at least 8 characters</p>
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Must be at least 8 characters</p>
</div>
<div>
<%= form.label :password_confirmation, "Confirm New Password", class: "block text-sm font-medium text-gray-700" %>
<%= form.label :password_confirmation, "Confirm New Password", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.password_field :password_confirmation,
autocomplete: "new-password",
placeholder: "Confirm new password",
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
<div>
<%= form.submit "Update Password", class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
<%= form.submit "Update Password", class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
</div>
<% end %>
</div>
@@ -88,15 +88,15 @@
</div>
<!-- Two-Factor Authentication -->
<div class="bg-white shadow sm:rounded-lg">
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">Two-Factor Authentication</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Two-Factor Authentication</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500 dark:text-gray-400">
<p>Add an extra layer of security to your account by enabling two-factor authentication.</p>
</div>
<div class="mt-5">
<% if @user.totp_enabled? %>
<div class="rounded-md bg-green-50 p-4">
<div class="rounded-md bg-green-50 dark:bg-green-900/30 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
@@ -104,11 +104,11 @@
</svg>
</div>
<div class="ml-3 flex-1">
<p class="text-sm font-medium text-green-800">
<p class="text-sm font-medium text-green-800 dark:text-green-200">
Two-factor authentication is enabled
</p>
<% if @user.totp_required? %>
<p class="mt-1 text-sm text-green-700">
<p class="mt-1 text-sm text-green-700 dark:text-green-300">
<svg class="inline h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg>
@@ -119,12 +119,12 @@
</div>
</div>
<% if @user.totp_required? %>
<div class="mt-4 rounded-md bg-blue-50 p-4">
<div class="mt-4 rounded-md bg-blue-50 dark:bg-blue-900/30 p-4">
<div class="flex">
<svg class="h-5 w-5 text-blue-400 mr-2 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<p class="text-sm text-blue-800">
<p class="text-sm text-blue-800 dark:text-blue-200">
Your administrator requires two-factor authentication. You cannot disable it.
</p>
</div>
@@ -133,7 +133,7 @@
<button type="button"
data-action="click->modal#show"
data-modal-id="view-backup-codes-modal"
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
class="inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
View Backup Codes
</button>
</div>
@@ -142,19 +142,19 @@
<button type="button"
data-action="click->modal#show"
data-modal-id="disable-2fa-modal"
class="inline-flex items-center rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
class="inline-flex items-center rounded-md border border-red-300 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
Disable 2FA
</button>
<button type="button"
data-action="click->modal#show"
data-modal-id="view-backup-codes-modal"
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
class="inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
View Backup Codes
</button>
</div>
<% end %>
<% else %>
<%= link_to new_totp_path, class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" do %>
<%= link_to new_totp_path, class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" do %>
Enable 2FA
<% end %>
<% end %>
@@ -166,17 +166,17 @@
<div id="disable-2fa-modal"
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape"
class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
<div class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
<div class="bg-white dark:bg-gray-800 rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/50 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
<h3 class="text-lg font-medium leading-6 text-gray-900">Disable Two-Factor Authentication</h3>
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Disable Two-Factor Authentication</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">Enter your password to disable 2FA. This will make your account less secure.</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Enter your password to disable 2FA. This will make your account less secure.</p>
</div>
<%= form_with url: totp_path, method: :delete, class: "mt-4" do |form| %>
<div>
@@ -184,14 +184,14 @@
placeholder: "Enter your password",
autocomplete: "current-password",
required: true,
class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-red-500 focus:ring-red-500 sm:text-sm" %>
class: "block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-red-500 focus:ring-red-500 sm:text-sm" %>
</div>
<div class="mt-4 flex gap-3">
<%= form.submit "Disable 2FA",
class: "inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2" %>
class: "inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
<button type="button"
data-action="click->modal#hide"
class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
class="inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
Cancel
</button>
</div>
@@ -205,18 +205,18 @@
<div id="view-backup-codes-modal"
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape"
class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
<div class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
<div class="bg-white dark:bg-gray-800 rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
<div>
<h3 class="text-lg font-medium leading-6 text-gray-900">Generate New Backup Codes</h3>
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Generate New Backup Codes</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">Due to security improvements, you need to generate new backup codes. Your old codes have been invalidated.</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Due to security improvements, you need to generate new backup codes. Your old codes have been invalidated.</p>
</div>
<div class="mt-3 p-3 bg-yellow-50 rounded-md">
<div class="mt-3 p-3 bg-yellow-50 dark:bg-yellow-900/30 rounded-md">
<div class="flex">
<svg class="h-5 w-5 text-yellow-400 mr-2 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>
<p class="text-sm text-yellow-800">
<p class="text-sm text-yellow-800 dark:text-yellow-200">
<strong>Important:</strong> Save the new codes immediately after generation. You won't be able to see them again without regenerating.
</p>
</div>
@@ -227,14 +227,14 @@
placeholder: "Enter your password",
autocomplete: "current-password",
required: true,
class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
class: "block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
<div class="mt-4 flex gap-3">
<%= form.submit "Generate New Codes",
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
<button type="button"
data-action="click->modal#hide"
class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
class="inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
Cancel
</button>
</div>
@@ -244,10 +244,10 @@
</div>
<!-- Passkeys (WebAuthn) -->
<div class="bg-white shadow sm:rounded-lg">
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6" data-controller="webauthn" data-webauthn-challenge-url-value="/webauthn/challenge" data-webauthn-create-url-value="/webauthn/create">
<h3 class="text-lg font-medium leading-6 text-gray-900">Passkeys</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Passkeys</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500 dark:text-gray-400">
<p>Use your fingerprint, face recognition, or security key to sign in without passwords.</p>
</div>
@@ -255,20 +255,20 @@
<div class="mt-5">
<div id="add-passkey-form" class="space-y-4">
<div>
<label for="passkey-nickname" class="block text-sm font-medium text-gray-700">Passkey Name</label>
<label for="passkey-nickname" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Passkey Name</label>
<input type="text"
id="passkey-nickname"
data-webauthn-target="nickname"
placeholder="e.g., MacBook Touch ID, iPhone Face ID"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
<p class="mt-1 text-sm text-gray-500">Give this passkey a memorable name so you can identify it later.</p>
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Give this passkey a memorable name so you can identify it later.</p>
</div>
<div>
<button type="button"
data-action="click->webauthn#register"
data-webauthn-target="submitButton"
class="inline-flex items-center rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2">
class="inline-flex items-center rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
@@ -284,11 +284,11 @@
<!-- Existing Passkeys List -->
<div class="mt-8">
<h4 class="text-md font-medium text-gray-900 mb-4">Your Passkeys</h4>
<h4 class="text-md font-medium text-gray-900 dark:text-gray-100 mb-4">Your Passkeys</h4>
<% if @user.webauthn_credentials.exists? %>
<div class="space-y-3">
<% @user.webauthn_credentials.order(created_at: :desc).each do |credential| %>
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<% if credential.platform_authenticator? %>
@@ -304,10 +304,10 @@
<% end %>
</div>
<div>
<div class="text-sm font-medium text-gray-900">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
<%= credential.nickname %>
</div>
<div class="text-sm text-gray-500">
<div class="text-sm text-gray-500 dark:text-gray-400">
<%= credential.authenticator_type.humanize %> •
Last used <%= credential.last_used_ago %>
<% if credential.backed_up? %>
@@ -318,7 +318,7 @@
</div>
<div class="flex items-center space-x-2">
<% if credential.created_recently? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200">
New
</span>
<% end %>
@@ -338,7 +338,7 @@
<% end %>
</div>
<div class="mt-4 p-3 bg-blue-50 rounded-lg">
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-900/30 rounded-lg">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
@@ -346,7 +346,7 @@
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-blue-800">
<p class="text-sm text-blue-800 dark:text-blue-200">
<strong>Tip:</strong> Add passkeys on multiple devices for easy access. Platform authenticators (like Touch ID) are synced across your devices if you use iCloud Keychain or Google Password Manager.
</p>
</div>
@@ -354,11 +354,11 @@
</div>
<% else %>
<div class="text-center py-8">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No passkeys</h3>
<p class="mt-1 text-sm text-gray-500">Get started by adding your first passkey for passwordless sign-in.</p>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No passkeys</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by adding your first passkey for passwordless sign-in.</p>
</div>
<% end %>
</div>

View File

@@ -6,7 +6,7 @@
<%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %>
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
<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,
required: true,
autofocus: true,
@@ -14,17 +14,17 @@
placeholder: "your@email.com",
value: @login_hint || params[:email_address],
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>
<!-- WebAuthn section - initially 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">
<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>
</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.
</p>
</div>
@@ -38,18 +38,19 @@
</svg>
Continue with Passkey
</button>
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600" style="display: none;"></div>
</div>
<!-- Password section - shown by default, hidden if WebAuthn is required -->
<div id="password-section" data-login-form-target="passwordSection">
<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,
required: true,
autocomplete: "current-password",
placeholder: "Enter your password",
maxlength: 72,
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
</div>
<div class="my-5">
@@ -58,14 +59,16 @@
</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" %>
</div>
<% end %>
<!-- 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 class="bg-white rounded-lg p-6 flex items-center">
<div id="loading-overlay" data-login-form-target="loadingOverlay"
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">
<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>

View File

@@ -1,8 +1,8 @@
<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">
<h2 class="text-2xl font-bold text-gray-900">Two-Factor Authentication</h2>
<p class="mt-2 text-sm text-gray-600">
<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 dark:text-gray-400">
Enter the 6-digit code from your authenticator app to complete sign in.
</p>
</div>
@@ -13,7 +13,7 @@
} do |form| %>
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
<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,
nil,
placeholder: "000000",
@@ -21,8 +21,8 @@
required: true,
autofocus: true,
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" %>
<p class="mt-2 text-xs text-gray-500">
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 dark:text-gray-400">
Enter your 6-digit authenticator code or an 8-character backup code
</p>
</div>
@@ -30,25 +30,50 @@
<div>
<%= form.submit "Verify",
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>
<% end %>
<div class="mt-6">
<div class="relative">
<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 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 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?
</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.
</p>
</div>

View File

@@ -8,34 +8,34 @@
# Map flash types to styling
case type.to_s
when 'notice'
bg_class = 'bg-green-50'
text_class = 'text-green-800'
icon_class = 'text-green-400'
bg_class = 'bg-green-50 dark:bg-green-900/30'
text_class = 'text-green-800 dark:text-green-200'
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'
auto_dismiss = true
when 'alert', 'error'
bg_class = 'bg-red-50'
text_class = 'text-red-800'
icon_class = 'text-red-400'
bg_class = 'bg-red-50 dark:bg-red-900/30'
text_class = 'text-red-800 dark:text-red-200'
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'
auto_dismiss = false
when 'warning'
bg_class = 'bg-yellow-50'
text_class = 'text-yellow-800'
icon_class = 'text-yellow-400'
bg_class = 'bg-yellow-50 dark:bg-yellow-900/30'
text_class = 'text-yellow-800 dark:text-yellow-200'
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'
auto_dismiss = false
when 'info'
bg_class = 'bg-blue-50'
text_class = 'text-blue-800'
icon_class = 'text-blue-400'
bg_class = 'bg-blue-50 dark:bg-blue-900/30'
text_class = 'text-blue-800 dark:text-blue-200'
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'
auto_dismiss = true
else
# Default styling for unknown types
bg_class = 'bg-gray-50'
text_class = 'text-gray-800'
icon_class = 'text-gray-400'
bg_class = 'bg-gray-50 dark:bg-gray-800'
text_class = 'text-gray-800 dark:text-gray-200'
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'
auto_dismiss = false
end
@@ -60,7 +60,7 @@
<div class="-mx-1.5 -my-1.5">
<button type="button"
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">
<span class="sr-only">Dismiss</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">

View File

@@ -3,19 +3,19 @@
<% form_object = form.respond_to?(:object) ? form.object : (object || form) %>
<% 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-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"/>
</svg>
</div>
<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:
</h3>
<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| %>
<li><%= message %></li>
<% end %>
@@ -24,7 +24,7 @@
</div>
<div class="ml-auto pl-3">
<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">
<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>

View File

@@ -6,16 +6,16 @@
<!-- Desktop sidebar -->
<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 -->
<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">
<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 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? %>
<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
</span>
<% end %>
@@ -28,7 +28,7 @@
<ul role="list" class="-mx-2 space-y-1">
<!-- Dashboard -->
<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">
<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>
@@ -39,7 +39,7 @@
<% if user.admin? %>
<!-- Admin: Users -->
<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">
<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>
@@ -49,7 +49,7 @@
<!-- Admin: Applications -->
<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">
<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>
@@ -59,7 +59,7 @@
<!-- Admin: Groups -->
<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">
<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>
@@ -70,7 +70,7 @@
<!-- Profile -->
<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">
<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>
@@ -80,7 +80,7 @@
<!-- Sessions -->
<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">
<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>
@@ -88,9 +88,25 @@
<% end %>
</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 -->
<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">
<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>
@@ -124,16 +140,16 @@
</button>
</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 -->
<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">
<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 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? %>
<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
</span>
<% end %>
@@ -144,7 +160,7 @@
<!-- Same nav items as desktop -->
<ul role="list" class="-mx-2 space-y-1">
<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">
<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>
@@ -153,7 +169,7 @@
</li>
<% if user.admin? %>
<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">
<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>
@@ -161,7 +177,7 @@
<% end %>
</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">
<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>
@@ -169,7 +185,7 @@
<% end %>
</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">
<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>
@@ -178,7 +194,7 @@
</li>
<% end %>
<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">
<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>
@@ -186,15 +202,30 @@
<% end %>
</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">
<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>
Sessions
<% end %>
</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>
<%= 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">
<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>

View File

@@ -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="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Backup Codes</h1>
<p class="mt-2 text-sm text-gray-600">
<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 dark:text-gray-400">
Save these backup codes in a safe place. Each code can only be used once.
</p>
</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="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">
<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">
<div class="text-sm text-yellow-800 dark:text-yellow-200">
<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>
</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| %>
<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 %>
</div>
<% end %>
</div>
<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">
<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>
Download Codes
</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">
<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>
@@ -47,13 +47,12 @@
<div class="mt-8">
<% if @auto_signin_pending %>
<%= 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 %>
<%= 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 %>
</div>
</div>
</div>
</div>

View File

@@ -1,17 +1,17 @@
<div class="max-w-2xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Enable Two-Factor Authentication</h1>
<p class="mt-2 text-sm text-gray-600">
<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 dark:text-gray-400">
Scan the QR code below with your authenticator app, then enter the verification code to confirm.
</p>
</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">
<!-- Step 1: Scan QR Code -->
<div class="mb-8">
<h3 class="text-lg font-medium text-gray-900 mb-4">Step 1: Scan QR Code</h3>
<div class="flex justify-center p-6 bg-gray-50 rounded-lg">
<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 dark:bg-gray-700 rounded-lg">
<%= @qr_code.as_svg(
module_size: 4,
color: "000",
@@ -19,26 +19,26 @@
standalone: true
).html_safe %>
</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.
</p>
</div>
<!-- Manual Entry Option -->
<div class="mb-8 p-4 bg-blue-50 rounded-lg">
<p class="text-sm font-medium text-blue-900 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>
<code class="mt-2 block p-2 bg-white rounded text-sm font-mono break-all"><%= @totp_secret %></code>
<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 dark:text-blue-200 mb-2">Can't scan the QR code?</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 dark:bg-gray-700 dark:text-gray-200 rounded text-sm font-mono break-all"><%= @totp_secret %></code>
</div>
<!-- Step 2: Verify -->
<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| %>
<%= hidden_field_tag :totp_secret, @totp_secret %>
<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,
nil,
placeholder: "000000",
@@ -46,27 +46,27 @@
required: true,
autofocus: true,
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" %>
<p class="mt-1 text-sm text-gray-500">Enter the 6-digit code from your authenticator app</p>
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-center text-2xl tracking-widest font-mono" %>
<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 class="flex gap-3">
<%= 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,
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>
<% end %>
</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">
<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">
<div class="text-sm text-yellow-800 dark:text-yellow-200">
<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>
</div>

View File

@@ -1,19 +1,19 @@
<div class="max-w-2xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Regenerate Backup Codes</h1>
<p class="mt-2 text-sm text-gray-600">
<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 dark:text-gray-400">
This will invalidate all existing backup codes and generate new ones.
</p>
</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="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">
<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">
<div class="text-sm text-yellow-800 dark:text-yellow-200">
<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>
</div>
@@ -22,22 +22,22 @@
<%= form_with(url: create_new_backup_codes_totp_path, method: :post, class: "space-y-6") do |form| %>
<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">
<%= 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>
<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.
</p>
</div>
<div class="flex gap-3">
<%= 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,
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>
<% end %>
</div>

View File

@@ -1,41 +1,41 @@
<div class="mx-auto md:w-2/3 w-full">
<div class="mb-8">
<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>
<%= form_with model: @user, url: signup_path, class: "contents", data: { controller: "form-errors" } do |form| %>
<%= render "shared/form_errors", form: form %>
<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,
required: true,
autofocus: true,
autocomplete: "email",
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 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,
required: true,
autocomplete: "new-password",
placeholder: "Enter a strong password",
maxlength: 72,
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
<p class="mt-1 text-sm text-gray-500">Must be at least 8 characters</p>
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 dark:text-gray-400">Must be at least 8 characters</p>
</div>
<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,
required: true,
autocomplete: "new-password",
placeholder: "Re-enter your password",
maxlength: 72,
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
</div>
<div class="my-5">
@@ -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" %>
</div>
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
<p class="text-sm text-blue-900">
<div class="mt-4 p-4 bg-blue-50 rounded-lg dark:bg-blue-900/30">
<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.
After this, you'll be able to invite other users from the admin dashboard.
</p>

View File

@@ -59,6 +59,7 @@ Rails.application.configure do
# Use Solid Queue for background jobs
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.
# Set this to true and configure the email server for immediate delivery to raise delivery errors.

View File

@@ -0,0 +1,2 @@
Rails.application.config.forward_auth_cache =
ActiveSupport::Cache::MemoryStore.new(size: 8.megabytes)

View File

@@ -1,5 +1,5 @@
# frozen_string_literal: true
module Clinch
VERSION = "0.8.4"
VERSION = "0.9.0"
end

View File

@@ -40,6 +40,8 @@ Rails.application.routes.draw do
end
# Authenticated routes
resources :api_keys, only: [:index, :new, :create, :show, :destroy]
root "dashboard#index"
resource :profile, only: [:show, :update] do
member do

View File

@@ -0,0 +1,5 @@
class AddSkipConsentToApplications < ActiveRecord::Migration[8.1]
def change
add_column :applications, :skip_consent, :boolean, default: false, null: false
end
end

View File

@@ -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

View File

@@ -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

View 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
View File

@@ -10,7 +10,7 @@
#
# 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|
t.bigint "blob_id", 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
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|
t.integer "application_id", 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.integer "refresh_token_ttl", default: 2592000
t.boolean "require_pkce", default: true, null: false
t.boolean "skip_consent", default: false, null: false
t.string "slug", null: false
t.datetime "updated_at", null: false
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.integer "application_id", null: false
t.integer "auth_time"
t.json "claims_requests", default: {}, null: false
t.string "code_challenge"
t.string "code_challenge_method"
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|
t.integer "application_id", null: false
t.json "claims_requests", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "granted_at", 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_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", "groups"
add_foreign_key "application_user_claims", "applications", on_delete: :cascade

View File

@@ -56,7 +56,8 @@ This checklist ensures Clinch meets security, quality, and documentation standar
- [x] Authorization code flow with PKCE support
- [x] Refresh token rotation
- [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] One-time use authorization codes
- [x] Pairwise subject identifiers (privacy)
@@ -130,8 +131,7 @@ This checklist ensures Clinch meets security, quality, and documentation standar
## Code Quality
- [x] **RuboCop** - Code style and linting
- Configuration: Rails Omakase
- [x] **StandardRB** - Code style and linting
- CI: Runs on every PR and push to main
- [x] **Documentation** - Comprehensive README
@@ -158,7 +158,7 @@ This checklist ensures Clinch meets security, quality, and documentation standar
### Performance
- [ ] Review N+1 queries
- [ ] Add database indexes where needed
- [x] Add database indexes where needed
- [ ] Test with realistic data volumes
- [ ] Review token cleanup job performance

44
package-lock.json generated Normal file
View 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
View 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"
}
}

View 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

View File

@@ -279,7 +279,7 @@ module Api
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
refute_match evil_url, response.location, "Should not redirect to evil URL after authentication"
# 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"]
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
test "should handle requests efficiently under load" do
sign_in_as(@user)

View 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

View 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

View File

@@ -31,7 +31,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
# Step 3: Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
assert_response 303
redirect_uri = URI.parse(response.location)
assert_equal "https", redirect_uri.scheme
assert_equal "app.example.com", redirect_uri.host
@@ -64,7 +64,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
# Sign in once
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
assert_response 303
assert_redirected_to "/"
# Test access to different applications
@@ -101,7 +101,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
# Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
assert_response 303
# Should have access (in allowed group)
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
@@ -139,7 +139,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
# Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
assert_response 303
# Should have access (bypass mode)
get "/api/verify", headers: {"X-Forwarded-Host" => "public.example.com"}
@@ -255,7 +255,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
# Sign in once
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
assert_response 303
# Test access to each application
apps.each do |app|

View File

@@ -27,7 +27,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Step 2: Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
assert_response 303
# Signin now redirects back with fa_token parameter
assert_match(/\?fa_token=/, response.location)
assert cookies[:session_id]
@@ -130,7 +130,9 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Rails normalizes header keys to lowercase
assert_equal @user.email_address, response.headers["x-remote-user"]
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
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
assert_equal @user.email_address, response.headers["x-webauth-user"]
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
get "/api/verify", headers: {"X-Forwarded-Host" => "noheaders.example.com"}

View 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

View 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

View 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

View File

@@ -32,5 +32,10 @@ module ActiveSupport
fixtures :all
# 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