92 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
Dan Milne
e631f606e7 Better error messages
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-03 12:29:27 +11:00
Dan Milne
f4a697ae9b More OpenID Conformance test fixes - work with POST, correct auth code character set, correct no-store cache headers 2026-01-03 12:28:43 +11:00
Dan Milne
16e34ffaf0 Updates for oidc conformance 2026-01-03 10:11:10 +11:00
Dan Milne
0bb84f08d6 OpenID conformance test: we get a warning for not having a value for every claim. But we can explictly list support claims. Nothing we can do about a warning in the complience.
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-02 16:35:12 +11:00
Dan Milne
182682024d OpenID Conformance: Include all required scopes when profile is requested, even if they're empty
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-02 15:47:40 +11:00
Dan Milne
b517ebe809 OpenID conformance test: Allow posting the access token in the body for userinfo endpoint
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-02 15:41:07 +11:00
Dan Milne
dd8bd15a76 CSRF issue with API endpoint
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-02 15:29:34 +11:00
Dan Milne
f67a73821c OpenID Conformance: user info endpoint should support get and post requets, not just get
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-02 15:26:39 +11:00
Dan Milne
b09ddf6db5 OpenID Conformance: We need to return to the redirect_uri in the case of errors.
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-02 15:12:55 +11:00
Dan Milne
abbb11a41d Return only scopes requested, add tests ( OpenID conformance test )
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-02 14:55:06 +11:00
Dan Milne
b2030df8c2 Return only scopes requested ( OpenID conformance test. Update README 2026-01-02 14:05:54 +11:00
Dan Milne
07cddf5823 Version 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-02 12:57:28 +11:00
Dan Milne
46aa983189 Don't use secret scanner for trivy - github already does it and it's hard to ignore the test key
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-02 12:56:03 +11:00
Dan Milne
d0d79ee1da Try ignore capybara's test tripping trivy
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-02 12:52:24 +11:00
Dan Milne
2f6a2c7406 Update ruby 3.4.6 -> 3.4.7. Update gems. Add trivy scanning and ignore unfixable Debian CVEs. Ignore a test fixture key for Capybara
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-02 12:48:40 +11:00
Dan Milne
5137a25626 Add remainging rate limits. Add docker compose production example. Update beta-checklist.
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (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-02 12:14:13 +11:00
Dan Milne
fed7c3cedb Some beta-checklist updates 2026-01-02 11:53:41 +11:00
Dan Milne
e288fcad7c Remove old docs 2026-01-01 21:04:26 +11:00
Dan Milne
c1c6e0112e ADd backup / restore documentation
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (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-01 15:40:49 +11:00
Dan Milne
7f834fb7fa Version bump 2026-01-01 15:27:19 +11:00
Dan Milne
ae99d3d9cf Fix webauthn bug. Fix tests. Update docs
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (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-01 15:24:56 +11:00
Dan Milne
1afcd041f9 Update README, fix a test 2026-01-01 15:17:28 +11:00
Dan Milne
71198340d0 fix tests and add a Claude.md file
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (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-01 15:11:46 +11:00
Dan Milne
d597ca8810 Fix tests 2026-01-01 14:52:24 +11:00
Dan Milne
9b81aee490 Fix linting error
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (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-01 13:45:10 +11:00
Dan Milne
265518ab25 Move integration tests into right directory
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (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-01 13:43:13 +11:00
Dan Milne
adb789bbea Fix StandardRB
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (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-01 13:35:37 +11:00
Dan Milne
93a0edb0a2 StandardRB fixes
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (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-01 13:29:44 +11:00
Dan Milne
7d3af2bcec SRB fixes 2026-01-01 13:19:17 +11:00
Dan Milne
c03034c49f Add files to support brakeman and standardrb. Fix some SRB warnings 2026-01-01 13:18:30 +11:00
Dan Milne
9234904e47 Add security-todo and beta-checklists, and some security rake tasks
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (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-01 13:06:54 +11:00
Dan Milne
e36a9a781a Add new claims to the discovery endpoint
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-31 17:27:28 +11:00
Dan Milne
d036e25fef Add auth_time, acr and azp support for OIDC claims 2025-12-31 17:07:54 +11:00
Dan Milne
fcdd2b6de7 Continue adding auth_time - need it in the refresh token too, so we can accurately create new access tokens. 2025-12-31 16:57:28 +11:00
Dan Milne
3939ea773f We already have a login_time stored - the time stamp of the Session instance creation ( created after successful login ). 2025-12-31 16:45:45 +11:00
Dan Milne
4b4afe277e Include auth_time in ID token. Switch from upsert -> find_and_create_by so we actually get sid values for consent on the creation of the record 2025-12-31 16:36:32 +11:00
Dan Milne
364e6e21dd Fixes for tests and AR Encryption
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-31 16:08:05 +11:00
Dan Milne
9d352ab8ec Fix tests - add missing files 2025-12-31 16:01:31 +11:00
Dan Milne
d1d4ac745f Version bump 2025-12-31 15:48:52 +11:00
Dan Milne
3db466f5a2 Switch Access / Refresh tokens / Auth Code from bcrypt ( and plain ) to hmac. BCrypt is for low entropy passwords and prevents dictionary attacks - HMAC is suitable for 256-bit random data.
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-31 15:48:32 +11:00
Dan Milne
7c6ae7ab7e Store only HMAC'd Auth codes, rather than plain text auth codes. 2025-12-31 15:00:00 +11:00
Dan Milne
ed7ceedef5 Include the hash of the access token in the JWT / ID Token under the key at_hash as per the requirements. Update the discovery endpoint to describe subject_type as 'pairwise', rather than 'public', since we do pairwise subject ids.
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-31 14:45:38 +11:00
Dan Milne
40815d3576 Use SolidQueue in production. Use the find_by_token method, rather than iterating over refresh tokens, as we already fixed for tokens 2025-12-31 14:32:34 +11:00
Dan Milne
a17c08c890 Improve the README 2025-12-31 14:31:53 +11:00
Dan Milne
4f31fadc6c Improve the README and remove incorrect claims.
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-31 12:17:15 +11:00
Dan Milne
29c0981a59 Improve readme and tests
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-31 11:56:09 +11:00
Dan Milne
9d402fcd92 Clean up and secure web_authn controller
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-31 11:44:11 +11:00
Dan Milne
9530c8284f Version bump
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-31 10:35:27 +11:00
Dan Milne
bb5aa2e6d6 Add rails encryption for totp - allow configuration of encryption secrets from env, or derive them from SECRET_KEY_BASE. Don't leak email address via web_authn, rate limit web_authn, escape oidc state value, require password for changing email address, allow settings the hmac secret for token prefix generation 2025-12-31 10:33:56 +11:00
Dan Milne
cc7beba9de PKCE is now default enabled. You can now create public / no-secret apps OIDC apps 2025-12-31 09:22:18 +11:00
Dan Milne
00eca6d8b2 Default deny forward_auth requests 2025-12-30 16:04:01 +11:00
Dan Milne
32235f9647 version bump
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-30 11:58:31 +11:00
Dan Milne
71d59e7367 Remove plain text token from everywhere
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-30 11:58:11 +11:00
Dan Milne
99c3ac905f Add a token prefix column, generate the token_prefix and the token_digest, removing the plaintext token from use. 2025-12-30 09:45:16 +11:00
Dan Milne
0761c424c1 Fix tests. Remove tests which test rails functionality
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-30 00:18:19 +11:00
Dan Milne
2a32d75895 Fix tests - don't test standard rails features 2025-12-29 19:45:01 +11:00
Dan Milne
4c1df53fd5 Fix more tests
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-29 19:22:08 +11:00
Dan Milne
acab15ce30 Fix more tests 2025-12-29 18:48:41 +11:00
Dan Milne
0361bfe470 Fix forward_auth bugs - including disabled apps still working. Fix forward_auth tests
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-29 15:37:12 +11:00
Dan Milne
5b9d15584a Add more rate limiting, and more restrictive headers 2025-12-29 13:29:14 +11:00
Dan Milne
898fd69a5d Add permissions initializer and missing image paste controller
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-29 13:27:30 +11:00
181 changed files with 12562 additions and 2987 deletions

View File

@@ -1,5 +1,21 @@
# Rails Configuration # Rails Configuration
SECRET_KEY_BASE=generate-with-bin-rails-secret # SECRET_KEY_BASE is used for:
# - Session cookie encryption
# - Signed token verification
# - ActiveRecord encryption (currently: TOTP secrets)
# - OIDC token prefix HMAC derivation
#
# CRITICAL: Do NOT change SECRET_KEY_BASE after deployment. Changing it will:
# - Invalidate all user sessions (users must re-login)
# - Break encrypted data (users must re-setup 2FA)
# - Invalidate all OIDC access/refresh tokens (clients must re-authenticate)
#
# Optional: Override encryption keys with env vars for key rotation:
# - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
# - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
# - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
# - OIDC_TOKEN_PREFIX_HMAC
SECRET_KEY_BASE=generate-with-bin/rails/secret
RAILS_ENV=development RAILS_ENV=development
# Database # Database

View File

@@ -19,7 +19,9 @@ jobs:
bundler-cache: true bundler-cache: true
- name: Scan for common Rails security vulnerabilities using static analysis - name: Scan for common Rails security vulnerabilities using static analysis
run: bin/brakeman --no-pager run: bin/brakeman --no-pager --no-exit-on-warn
# Note: 2 weak warnings exist and are documented as acceptable
# See docs/beta-checklist.md for details
- name: Scan for known security vulnerabilities in gems used - name: Scan for known security vulnerabilities in gems used
run: bin/bundler-audit run: bin/bundler-audit
@@ -39,10 +41,36 @@ jobs:
- name: Scan for security vulnerabilities in JavaScript dependencies - name: Scan for security vulnerabilities in JavaScript dependencies
run: bin/importmap audit run: bin/importmap audit
scan_container:
runs-on: ubuntu-latest
permissions:
security-events: write # Required for uploading SARIF results
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Build Docker image
run: docker build -t clinch:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: clinch:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
scanners: 'vuln' # Only scan vulnerabilities, not secrets (avoids false positives in vendored gems)
- name: Upload Trivy results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: 'trivy-results.sarif'
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
RUBOCOP_CACHE_ROOT: tmp/rubocop
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -52,18 +80,8 @@ jobs:
with: with:
bundler-cache: true bundler-cache: true
- name: Prepare RuboCop cache
uses: actions/cache@v4
env:
DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }}
with:
path: ${{ env.RUBOCOP_CACHE_ROOT }}
key: rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }}
restore-keys: |
rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-
- name: Lint code for consistent style - name: Lint code for consistent style
run: bin/rubocop -f github run: bin/standardrb
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1 +1 @@
3.4.6 4.0.1

7
.standard.yml Normal file
View File

@@ -0,0 +1,7 @@
ignore:
- 'test_*.rb' # Ignore test files in root directory
- 'tmp/**/*'
- 'vendor/**/*'
- 'node_modules/**/*'
- 'config/initializers/csp_local_logger.rb' # Complex CSP logger with intentional block structure
- 'config/initializers/sentry_subscriber.rb' # Sentry subscriber with module structure

48
.trivyignore Normal file
View File

@@ -0,0 +1,48 @@
# Trivy ignore file
# This file tells Trivy to skip specific vulnerabilities or files
# See: https://aquasecurity.github.io/trivy/latest/docs/configuration/filtering/
# =============================================================================
# False Positives - Test Fixtures
# =============================================================================
# Capybara test fixture - not a real private key
# Ignore secrets in test fixtures
# Format: secret:<rule-id>:<exact-file-path>
secret:private-key:/usr/local/bundle/ruby/3.4.0/gems/capybara-3.40.0/spec/fixtures/key.pem
# =============================================================================
# Unfixable CVEs - No Patches Available (Status: affected/fix_deferred)
# =============================================================================
# GnuPG vulnerabilities - not used by Clinch at runtime
# Low risk: dirmngr/gpg tools not invoked during normal operation
CVE-2025-68973
# Image processing library vulnerabilities
# Low risk for Clinch: Only admins upload images (app icons), not untrusted users
# Waiting on Debian security team to release patches
# ImageMagick - Integer overflow (32-bit only)
CVE-2025-66628
# glib - Integer overflow in URI escaping
CVE-2025-13601
# HDF5 - Critical vulnerabilities in scientific data format library
CVE-2025-2153
CVE-2025-2308
CVE-2025-2309
CVE-2025-2310
# libmatio - MATLAB file format library
CVE-2025-2338
# OpenEXR - Image format vulnerabilities
CVE-2025-12495
CVE-2025-12839
CVE-2025-12840
CVE-2025-64181
# libvips - Image processing library
CVE-2025-59933

65
Claude.md Normal file
View File

@@ -0,0 +1,65 @@
# Claude Code Guidelines for Clinch
This document provides guidelines for AI assistants (Claude, ChatGPT, etc.) working on this codebase.
## Project Context
Clinch is a lightweight identity provider (IdP) supporting:
- **OIDC/OAuth2** - Standard OAuth flows for modern apps
- **ForwardAuth** - Trusted-header SSO for reverse proxies (Traefik, Caddy, Nginx)
- **WebAuthn/Passkeys** - Passwordless authentication
- Group-based access control
Key characteristics:
- Rails 8 application with SQLite database
- Focus on simplicity and self-hosting
- No external dependencies for core functionality
## Testing Guidelines
### Do Not Test Rails Framework Functionality
When writing tests, focus on testing **our application's specific behavior and logic**, not standard Rails framework functionality.
**Examples of what NOT to test:**
- Session isolation between users (Rails handles this)
- Basic ActiveRecord associations (Rails handles this)
- Standard cookie signing/verification (Rails handles this)
- Default controller rendering behavior (Rails handles this)
- Infrastructure-level error handling (database connection failures, network issues, etc.)
**Examples of what TO test:**
- Forward auth business logic (group-based access control, header configuration, etc.)
- Custom authentication flows
- Application-specific session expiration behavior
- Domain pattern matching logic
- Custom response header generation
**Why:**
Testing Rails framework functionality adds no value and can create maintenance burden. Trust that Rails works correctly and focus tests on verifying our application's unique behavior.
### Integration Test Patterns
**Session handling:**
- Do NOT manually manipulate cookies in integration tests
- Use the session provided by the test framework
- To get the actual session ID, use `Session.last.id` after sign-in, not `cookies[:session_id]` (which is signed)
**Application setup:**
- Always create Application records for the domains you're testing
- Use wildcard patterns (e.g., `*.example.com`) when testing multiple subdomains
- Remember: `*` matches one level only (`*.example.com` matches `app.example.com` but NOT `sub.app.example.com`)
**Header assertions:**
- Always normalize header names to lowercase when asserting (HTTP headers are case-insensitive)
- Use `response.headers["x-remote-user"]` not `response.headers["X-Remote-User"]`
**Avoid threading in integration tests:**
- Rails integration tests use a single cookie jar
- Convert threaded tests to sequential requests instead
### Common Testing Pitfalls
1. **Don't test concurrent users with manual cookie manipulation** - Integration tests can't properly simulate multiple concurrent sessions
2. **Don't expect `cookies[:session_id]` to be the actual ID** - It's a signed cookie value
3. **Don't assume wildcard patterns match multiple levels** - `*.domain.com` only matches one level

View File

@@ -8,7 +8,7 @@
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version # Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.4.6 ARG RUBY_VERSION=4.0.1
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
@@ -16,8 +16,9 @@ LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
# Rails app lives here # Rails app lives here
WORKDIR /rails WORKDIR /rails
# Install base packages # Install base packages and upgrade to latest security patches
RUN apt-get update -qq && \ RUN apt-get update -qq && \
apt-get upgrade -y && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \ apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives rm -rf /var/lib/apt/lists /var/cache/apt/archives

17
Gemfile
View File

@@ -1,7 +1,7 @@
source "https://rubygems.org" source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.1" gem "rails", "~> 8.1.2"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft] # The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft" gem "propshaft"
# Use sqlite3 as the database for Active Record # Use sqlite3 as the database for Active Record
@@ -42,11 +42,12 @@ gem "sentry-ruby", "~> 6.2"
gem "sentry-rails", "~> 6.2" gem "sentry-rails", "~> 6.2"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ] gem "tzinfo-data", platforms: %i[windows jruby]
# Use the database-backed adapters for Rails.cache and Action Cable # Use the database-backed adapters for Rails.cache and Action Cable
gem "solid_cache" gem "solid_cache"
gem "solid_cable" gem "solid_cable"
gem "solid_queue", "~> 1.2"
# Reduces boot times through caching; required in config/boot.rb # Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false gem "bootsnap", require: false
@@ -62,7 +63,7 @@ gem "image_processing", "~> 1.2"
group :development, :test do group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" gem "debug", platforms: %i[mri windows], require: "debug/prelude"
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
gem "bundler-audit", require: false gem "bundler-audit", require: false
@@ -70,8 +71,8 @@ group :development, :test do
# Static analysis for security vulnerabilities [https://brakemanscanner.org/] # Static analysis for security vulnerabilities [https://brakemanscanner.org/]
gem "brakeman", require: false gem "brakeman", require: false
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] # Standard Ruby style guide, linter, and formatter [https://github.com/standardrb/standard]
gem "rubocop-rails-omakase", require: false gem "standard", require: false
end end
group :development do group :development do
@@ -86,4 +87,10 @@ group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara" gem "capybara"
gem "selenium-webdriver" gem "selenium-webdriver"
# Code coverage analysis
gem "simplecov", require: false
# Pin minitest to < 6.0 until Rails 8.1 supports the new API
gem "minitest", "< 6.0"
end end

View File

@@ -1,31 +1,31 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
action_text-trix (2.1.15) action_text-trix (2.1.16)
railties railties
actioncable (8.1.1) actioncable (8.1.2)
actionpack (= 8.1.1) actionpack (= 8.1.2)
activesupport (= 8.1.1) activesupport (= 8.1.2)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (8.1.1) actionmailbox (8.1.2)
actionpack (= 8.1.1) actionpack (= 8.1.2)
activejob (= 8.1.1) activejob (= 8.1.2)
activerecord (= 8.1.1) activerecord (= 8.1.2)
activestorage (= 8.1.1) activestorage (= 8.1.2)
activesupport (= 8.1.1) activesupport (= 8.1.2)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (8.1.1) actionmailer (8.1.2)
actionpack (= 8.1.1) actionpack (= 8.1.2)
actionview (= 8.1.1) actionview (= 8.1.2)
activejob (= 8.1.1) activejob (= 8.1.2)
activesupport (= 8.1.1) activesupport (= 8.1.2)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (8.1.1) actionpack (8.1.2)
actionview (= 8.1.1) actionview (= 8.1.2)
activesupport (= 8.1.1) activesupport (= 8.1.2)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
rack (>= 2.2.4) rack (>= 2.2.4)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
@@ -33,36 +33,36 @@ GEM
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (8.1.1) actiontext (8.1.2)
action_text-trix (~> 2.1.15) action_text-trix (~> 2.1.15)
actionpack (= 8.1.1) actionpack (= 8.1.2)
activerecord (= 8.1.1) activerecord (= 8.1.2)
activestorage (= 8.1.1) activestorage (= 8.1.2)
activesupport (= 8.1.1) activesupport (= 8.1.2)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (8.1.1) actionview (8.1.2)
activesupport (= 8.1.1) activesupport (= 8.1.2)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (8.1.1) activejob (8.1.2)
activesupport (= 8.1.1) activesupport (= 8.1.2)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (8.1.1) activemodel (8.1.2)
activesupport (= 8.1.1) activesupport (= 8.1.2)
activerecord (8.1.1) activerecord (8.1.2)
activemodel (= 8.1.1) activemodel (= 8.1.2)
activesupport (= 8.1.1) activesupport (= 8.1.2)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (8.1.1) activestorage (8.1.2)
actionpack (= 8.1.1) actionpack (= 8.1.2)
activejob (= 8.1.1) activejob (= 8.1.2)
activerecord (= 8.1.1) activerecord (= 8.1.2)
activesupport (= 8.1.1) activesupport (= 8.1.2)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (8.1.1) activesupport (8.1.2)
base64 base64
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1) concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -80,14 +80,14 @@ GEM
android_key_attestation (0.3.0) android_key_attestation (0.3.0)
ast (2.4.3) ast (2.4.3)
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.20) bcrypt (3.1.21)
bcrypt_pbkdf (1.1.1) bcrypt_pbkdf (1.1.2)
bigdecimal (3.3.1) bigdecimal (4.0.1)
bindata (2.5.1) bindata (2.5.1)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.19.0) bootsnap (1.20.1)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (7.1.1) brakeman (7.1.2)
racc racc
builder (3.3.0) builder (3.3.0)
bundler-audit (0.9.3) bundler-audit (0.9.3)
@@ -106,31 +106,37 @@ GEM
childprocess (5.1.0) childprocess (5.1.0)
logger (~> 1.5) logger (~> 1.5)
chunky_png (1.4.0) chunky_png (1.4.0)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.6)
connection_pool (2.5.5) connection_pool (3.0.2)
cose (1.3.1) cose (1.3.1)
cbor (~> 0.5.9) cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0) openssl-signature_algorithm (~> 1.0)
crass (1.0.6) crass (1.0.6)
date (3.5.0) date (3.5.1)
debug (1.11.0) debug (1.11.1)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
dotenv (3.1.8) docile (1.4.1)
dotenv (3.2.0)
drb (2.2.3) drb (2.2.3)
ed25519 (1.4.0) ed25519 (1.4.0)
erb (6.0.0) erb (6.0.2)
erubi (1.13.1) erubi (1.13.1)
ffi (1.17.2-aarch64-linux-gnu) et-orbi (1.4.0)
ffi (1.17.2-aarch64-linux-musl) tzinfo
ffi (1.17.2-arm-linux-gnu) ffi (1.17.3-aarch64-linux-gnu)
ffi (1.17.2-arm-linux-musl) ffi (1.17.3-aarch64-linux-musl)
ffi (1.17.2-arm64-darwin) ffi (1.17.3-arm-linux-gnu)
ffi (1.17.2-x86_64-linux-gnu) ffi (1.17.3-arm-linux-musl)
ffi (1.17.2-x86_64-linux-musl) ffi (1.17.3-arm64-darwin)
ffi (1.17.3-x86_64-linux-gnu)
ffi (1.17.3-x86_64-linux-musl)
fugit (1.12.1)
et-orbi (~> 1.4)
raabro (~> 1.4)
globalid (1.3.0) globalid (1.3.0)
activesupport (>= 6.1) activesupport (>= 6.1)
i18n (1.14.7) i18n (1.14.8)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.14.0) image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6) mini_magick (>= 4.9.5, < 6)
@@ -139,18 +145,19 @@ GEM
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
io-console (0.8.1) io-console (0.8.2)
irb (1.15.3) irb (1.17.0)
pp (>= 0.6.0) pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jbuilder (2.14.1) jbuilder (2.14.1)
actionview (>= 7.0.0) actionview (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
json (2.16.0) json (2.19.0)
jwt (3.1.2) jwt (3.1.2)
base64 base64
kamal (2.9.0) kamal (2.10.1)
activesupport (>= 7.0) activesupport (>= 7.0)
base64 (~> 0.2) base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0) bcrypt_pbkdf (~> 1.0)
@@ -170,7 +177,7 @@ GEM
launchy (>= 2.2, < 4) launchy (>= 2.2, < 4)
lint_roller (1.1.0) lint_roller (1.1.0)
logger (1.7.0) logger (1.7.0)
loofah (2.24.1) loofah (2.25.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.9.0) mail (2.9.0)
@@ -184,9 +191,9 @@ GEM
mini_magick (5.3.1) mini_magick (5.3.1)
logger logger
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.26.2) minitest (5.27.0)
msgpack (1.8.0) msgpack (1.8.0)
net-imap (0.5.12) net-imap (0.6.3)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -201,21 +208,21 @@ GEM
net-protocol net-protocol
net-ssh (7.3.0) net-ssh (7.3.0)
nio4r (2.7.5) nio4r (2.7.5)
nokogiri (1.18.10-aarch64-linux-gnu) nokogiri (1.19.1-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-musl) nokogiri (1.19.1-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-arm-linux-gnu) nokogiri (1.19.1-arm-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-arm-linux-musl) nokogiri (1.19.1-arm-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin) nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu) nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-musl) nokogiri (1.19.1-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
openssl (3.3.2) openssl (4.0.0)
openssl-signature_algorithm (1.3.0) openssl-signature_algorithm (1.3.0)
openssl (> 2.0) openssl (> 2.0)
ostruct (0.6.3) ostruct (0.6.3)
@@ -226,50 +233,51 @@ GEM
pp (0.6.3) pp (0.6.3)
prettyprint prettyprint
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.6.0) prism (1.7.0)
propshaft (1.3.1) propshaft (1.3.1)
actionpack (>= 7.0.0) actionpack (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
rack rack
psych (5.2.6) psych (5.3.1)
date date
stringio stringio
public_suffix (7.0.0) public_suffix (7.0.0)
puma (7.1.0) puma (7.1.0)
nio4r (~> 2.0) nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.4) rack (3.2.5)
rack-session (2.1.1) rack-session (2.1.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.2.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.2.1) rackup (2.3.1)
rack (>= 3) rack (>= 3)
rails (8.1.1) rails (8.1.2)
actioncable (= 8.1.1) actioncable (= 8.1.2)
actionmailbox (= 8.1.1) actionmailbox (= 8.1.2)
actionmailer (= 8.1.1) actionmailer (= 8.1.2)
actionpack (= 8.1.1) actionpack (= 8.1.2)
actiontext (= 8.1.1) actiontext (= 8.1.2)
actionview (= 8.1.1) actionview (= 8.1.2)
activejob (= 8.1.1) activejob (= 8.1.2)
activemodel (= 8.1.1) activemodel (= 8.1.2)
activerecord (= 8.1.1) activerecord (= 8.1.2)
activestorage (= 8.1.1) activestorage (= 8.1.2)
activesupport (= 8.1.1) activesupport (= 8.1.2)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.1.1) railties (= 8.1.2)
rails-dom-testing (2.3.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.7.0)
loofah (~> 2.21) loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.1) railties (8.1.2)
actionpack (= 8.1.1) actionpack (= 8.1.2)
activesupport (= 8.1.1) activesupport (= 8.1.2)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
@@ -278,7 +286,7 @@ GEM
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.3.1) rake (13.3.1)
rdoc (6.16.1) rdoc (7.2.0)
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort tsort
@@ -302,32 +310,22 @@ GEM
rubocop-ast (>= 1.47.1, < 2.0) rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.48.0) rubocop-ast (1.49.0)
parser (>= 3.3.7.2) parser (>= 3.3.7.2)
prism (~> 1.4) prism (~> 1.7)
rubocop-performance (1.26.1) rubocop-performance (1.26.1)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0) rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0) rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.2)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails-omakase (1.1.0)
rubocop (>= 1.72)
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-vips (2.2.5) ruby-vips (2.3.0)
ffi (~> 1.12) ffi (~> 1.12)
logger logger
rubyzip (3.2.2) rubyzip (3.2.2)
safety_net_attestation (0.5.0) safety_net_attestation (0.5.0)
jwt (>= 2.0, < 4.0) jwt (>= 2.0, < 4.0)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.38.0) selenium-webdriver (4.39.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
@@ -339,6 +337,12 @@ GEM
sentry-ruby (6.2.0) sentry-ruby (6.2.0)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
solid_cable (3.0.12) solid_cable (3.0.12)
actioncable (>= 7.2) actioncable (>= 7.2)
activejob (>= 7.2) activejob (>= 7.2)
@@ -348,38 +352,57 @@ GEM
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
railties (>= 7.2) railties (>= 7.2)
sqlite3 (2.8.1-aarch64-linux-gnu) solid_queue (1.2.4)
sqlite3 (2.8.1-aarch64-linux-musl) activejob (>= 7.1)
sqlite3 (2.8.1-arm-linux-gnu) activerecord (>= 7.1)
sqlite3 (2.8.1-arm-linux-musl) concurrent-ruby (>= 1.3.1)
sqlite3 (2.8.1-arm64-darwin) fugit (~> 1.11)
sqlite3 (2.8.1-x86_64-linux-gnu) railties (>= 7.1)
sqlite3 (2.8.1-x86_64-linux-musl) thor (>= 1.3.1)
sshkit (1.24.0) sqlite3 (2.9.0-aarch64-linux-gnu)
sqlite3 (2.9.0-aarch64-linux-musl)
sqlite3 (2.9.0-arm-linux-gnu)
sqlite3 (2.9.0-arm-linux-musl)
sqlite3 (2.9.0-arm64-darwin)
sqlite3 (2.9.0-x86_64-linux-gnu)
sqlite3 (2.9.0-x86_64-linux-musl)
sshkit (1.25.0)
base64 base64
logger logger
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-sftp (>= 2.1.2) net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
ostruct ostruct
standard (1.52.0)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.0)
rubocop (~> 1.81.7)
standard-custom (~> 1.0.0)
standard-performance (~> 1.8)
standard-custom (1.0.2)
lint_roller (~> 1.0)
rubocop (~> 1.50)
standard-performance (1.9.0)
lint_roller (~> 1.1)
rubocop-performance (~> 1.26.0)
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
stringio (3.1.8) stringio (3.2.0)
tailwindcss-rails (4.4.0) tailwindcss-rails (4.4.0)
railties (>= 7.0.0) railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0) tailwindcss-ruby (~> 4.0)
tailwindcss-ruby (4.1.16) tailwindcss-ruby (4.1.18)
tailwindcss-ruby (4.1.16-aarch64-linux-gnu) tailwindcss-ruby (4.1.18-aarch64-linux-gnu)
tailwindcss-ruby (4.1.16-aarch64-linux-musl) tailwindcss-ruby (4.1.18-aarch64-linux-musl)
tailwindcss-ruby (4.1.16-arm64-darwin) tailwindcss-ruby (4.1.18-arm64-darwin)
tailwindcss-ruby (4.1.16-x86_64-linux-gnu) tailwindcss-ruby (4.1.18-x86_64-linux-gnu)
tailwindcss-ruby (4.1.16-x86_64-linux-musl) tailwindcss-ruby (4.1.18-x86_64-linux-musl)
thor (1.4.0) thor (1.5.0)
thruster (0.1.16) thruster (0.1.17)
thruster (0.1.16-aarch64-linux) thruster (0.1.17-aarch64-linux)
thruster (0.1.16-arm64-darwin) thruster (0.1.17-arm64-darwin)
thruster (0.1.16-x86_64-linux) thruster (0.1.17-x86_64-linux)
timeout (0.4.4) timeout (0.6.0)
tpm-key_attestation (0.14.1) tpm-key_attestation (0.14.1)
bindata (~> 2.4) bindata (~> 2.4)
openssl (> 2.0) openssl (> 2.0)
@@ -392,7 +415,7 @@ GEM
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0) unicode-display_width (3.2.0)
unicode-emoji (~> 4.1) unicode-emoji (~> 4.1)
unicode-emoji (4.1.0) unicode-emoji (4.2.0)
uri (1.1.1) uri (1.1.1)
useragent (0.16.11) useragent (0.16.11)
web-console (4.2.1) web-console (4.2.1)
@@ -415,7 +438,7 @@ GEM
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.7.3) zeitwerk (2.7.5)
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux
@@ -424,6 +447,7 @@ PLATFORMS
arm-linux-gnu arm-linux-gnu
arm-linux-musl arm-linux-musl
arm64-darwin-24 arm64-darwin-24
arm64-darwin-25
x86_64-linux x86_64-linux
x86_64-linux-gnu x86_64-linux-gnu
x86_64-linux-musl x86_64-linux-musl
@@ -441,19 +465,22 @@ DEPENDENCIES
jwt (~> 3.1) jwt (~> 3.1)
kamal kamal
letter_opener letter_opener
minitest (< 6.0)
propshaft propshaft
public_suffix (~> 7.0) public_suffix (~> 7.0)
puma (>= 5.0) puma (>= 5.0)
rails (~> 8.1.1) rails (~> 8.1.2)
rotp (~> 6.3) rotp (~> 6.3)
rqrcode (~> 3.1) rqrcode (~> 3.1)
rubocop-rails-omakase
selenium-webdriver selenium-webdriver
sentry-rails (~> 6.2) sentry-rails (~> 6.2)
sentry-ruby (~> 6.2) sentry-ruby (~> 6.2)
simplecov
solid_cable solid_cable
solid_cache solid_cache
solid_queue (~> 1.2)
sqlite3 (>= 2.1) sqlite3 (>= 2.1)
standard
stimulus-rails stimulus-rails
tailwindcss-rails tailwindcss-rails
thruster thruster
@@ -463,4 +490,4 @@ DEPENDENCIES
webauthn (~> 3.0) webauthn (~> 3.0)
BUNDLED WITH BUNDLED WITH
2.7.2 4.0.3

629
README.md
View File

@@ -1,43 +1,34 @@
# Clinch # Clinch
## Position and Control for your Authentication
> [!NOTE] > [!NOTE]
> This software is experiemental. If you'd like to try it out, find bugs, security flaws and improvements, please do. > This software is experimental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
We do these things not because they're easy, but because we thought they'd be easy.
**A lightweight, self-hosted identity & SSO / IpD portal** **A lightweight, self-hosted identity & SSO / IpD portal**
Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table. Clinch gives you one place to manage users and lets any web app authenticate against it without managing its own users.
I've completed all planned features:
* Create Admin user on first login
* TOTP ( QR Code ) 2FA, with backup codes ( encrypted at rest )
* Passkey generation and login, with detection of Passkey during login
* Forward Auth configured and working
* OIDC provider with auto discovery, refresh tokens, and token revocation
* Configurable token expiry per application (access, refresh, ID tokens)
* Backchannel Logout
* Per-application logout / revoke
* Invite users by email, assign to groups
* Self managed password reset by email
* Use Groups to assign Applications ( Family group can access Kavita, Developers can access Gitea )
* Configurable Group, User & App+User custom claims for OIDC token
* Display all Applications available to the user on their Dashboard
* Display all logged in sessions and OIDC logged in sessions
What remains now is ensure test coverage,
## Why Clinch? ## Why Clinch?
Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management. Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea, Grafana, Proxmox? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management.
Clinch sits in a sweet spot between two excellent open-source identity solutions: 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 among several excellent open-source identity solutions:
**[Authelia](https://www.authelia.com)** is a fantastic choice for those who prefer external user management through LDAP and enjoy comprehensive YAML-based configuration. It's lightweight, secure, and works beautifully with reverse proxies. **[Authelia](https://www.authelia.com)** is a fantastic choice for those who prefer external user management through LDAP and enjoy comprehensive YAML-based configuration. It's lightweight, secure, and works beautifully with reverse proxies.
**[VoidAuth](https://voidauth.app/)** is an open-source SSO provider with a similar feature set to Clinch — OIDC, ForwardAuth, passkeys, user management, and easy Docker deployment. If you're evaluating self-hosted auth solutions, it's well worth a look.
**[Authentik](https://goauthentik.io)** is an enterprise-grade powerhouse offering extensive protocol support (OAuth2, SAML, LDAP, RADIUS), advanced policy engines, and distributed "outpost" architecture for complex deployments. **[Authentik](https://goauthentik.io)** is an enterprise-grade powerhouse offering extensive protocol support (OAuth2, SAML, LDAP, RADIUS), advanced policy engines, and distributed "outpost" architecture for complex deployments.
**Clinch** offers a middle ground with built-in user management, a modern web interface, and focused SSO capabilities (OIDC + ForwardAuth). It's perfect for users who want self-hosted simplicity without external dependencies or enterprise complexity. **Clinch** offers a middle ground with built-in user management, a modern web interface, and focused SSO capabilities (OIDC + ForwardAuth). It's perfect for users who want self-hosted simplicity without external dependencies or enterprise complexity.
- **[Passes the OpenID Connect Conformance Tests](https://www.certification.openid.net/plan-detail.html?plan=FbQNTJuYVzrzs&public=true)** — verified against the official OIDC test suite
- **450+ tests, 1800+ assertions** — comprehensive test coverage across integration, model, controller, and security tests
- **Single Docker container** — SQLite, job queue, and cache all in one process
--- ---
## Screenshots ## Screenshots
@@ -86,7 +77,11 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
### SSO Protocols ### SSO Protocols
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) #### OpenID Connect (OIDC)
Standard OAuth2/OIDC provider with endpoints: Standard OAuth2/OIDC provider with endpoints:
- `/.well-known/openid-configuration` - Discovery endpoint - `/.well-known/openid-configuration` - Discovery endpoint
- `/authorize` - Authorization endpoint with PKCE support - `/authorize` - Authorization endpoint with PKCE support
@@ -98,26 +93,78 @@ Features:
- **Refresh tokens** - Long-lived tokens (30 days default) with automatic rotation and revocation - **Refresh tokens** - Long-lived tokens (30 days default) with automatic rotation and revocation
- **Token family tracking** - Advanced security detects token replay attacks and revokes compromised token families - **Token family tracking** - Advanced security detects token replay attacks and revokes compromised token families
- **Configurable token expiry** - Set access token (5min-24hr), refresh token (1-90 days), and ID token TTL per application - **Configurable token expiry** - Set access token (5min-24hr), refresh token (1-90 days), and ID token TTL per application
- **Token security** - BCrypt-hashed tokens, automatic cleanup of expired tokens - **Token security** - All tokens HMAC-SHA256 hashed (suitable for 256-bit random data), automatic cleanup of expired tokens
- **Pairwise subject identifiers** - Each user gets a unique, stable `sub` claim per application for enhanced privacy - **Pairwise subject identifiers** - Each user gets a unique, stable `sub` claim per application for enhanced privacy
Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens. **ID Token Claims** (JWT with RS256 signature):
| Claim | Description | Notes |
|-------|-------------|-------|
| Standard Claims | | |
| `iss` | Issuer (Clinch URL) | From `CLINCH_HOST` |
| `sub` | Subject (user identifier) | Pairwise SID - unique per app |
| `aud` | Audience | OAuth client_id |
| `exp` | Expiration timestamp | Configurable TTL |
| `iat` | Issued-at timestamp | Token creation time |
| `email` | User email | |
| `email_verified` | Email verification | Always `true` |
| `preferred_username` | Username/email | Fallback to email |
| `name` | Display name | User's name or email |
| `nonce` | Random value | From auth request (prevents replay) |
| **Security Claims** | | |
| `at_hash` | Access token hash | SHA-256 hash of access_token (OIDC Core §3.1.3.6) |
| `auth_time` | Authentication time | Unix timestamp of when user logged in (OIDC Core §2) |
| `acr` | Auth context class | `"1"` = password, `"2"` = 2FA/passkey (OIDC Core §2) |
| `azp` | Authorized party | OAuth client_id (OIDC Core §2) |
| Custom Claims | | |
| `groups` | User's groups | Array of group names |
| *custom* | Arbitrary key-values | From groups, users, or app-specific config |
**Authentication Context Class Reference (`acr`):**
- `"1"` - Something you know (password only)
- `"2"` - Two-factor or phishing-resistant (TOTP, backup codes, WebAuthn/passkey)
Client apps (Audiobookshelf, Kavita, Proxmox, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens.
#### Trusted-Header SSO (ForwardAuth) #### Trusted-Header SSO (ForwardAuth)
Works with reverse proxies (Caddy, Traefik, Nginx): Works with reverse proxies (Caddy, Traefik, Nginx):
1. Proxy sends every request to `/api/verify` 1. Proxy sends every request to `/api/verify`
2. **200 OK** → Proxy injects headers (`Remote-User`, `Remote-Groups`, `Remote-Email`) and forwards to app 2. Response handling:
3. **401/403** → Proxy redirects to Clinch login; after login, user returns to original URL - **200 OK** → Proxy injects headers (`Remote-User`, `Remote-Groups`, `Remote-Email`) and forwards to app
- **Any other status** → Proxy returns that response directly to client (typically 302 redirect to login page)
Apps that speak OIDC use the OIDC flow; apps that only need "who is it?" headers use ForwardAuth.
**Note:** ForwardAuth requires applications to run on the same domain as Clinch (e.g., `app.yourdomain.com` with Clinch at `auth.yourdomain.com`) for secure session cookie sharing. Take a look at Authentik if you need multi domain support. **Note:** ForwardAuth requires applications to run on the same domain as Clinch (e.g., `app.yourdomain.com` with Clinch at `auth.yourdomain.com`) for secure session cookie sharing. Take a look at Authentik if you need multi domain support.
#### API Keys (Bearer Tokens)
For server-to-server access to ForwardAuth-protected services (e.g., a video player accessing WebDAV, rclone syncing files), Clinch supports API keys that work as bearer tokens — no browser or cookies needed.
- **Token format:** `clk_<base64>` prefix for easy identification
- **Storage:** HMAC-SHA256 hashed (plaintext shown once at creation, never stored)
- **Scope:** Each key is tied to one ForwardAuth application and one user
- **Expiration:** Optional — set a date or leave blank for no expiry
- **Auth flow:** `Authorization: Bearer clk_...` header checked before cookie auth
- **Failure response:** 401 JSON `{"error": "..."}` (no redirect)
**Creating an API key:**
1. Go to **Dashboard → Manage API Keys** (or `/api_keys`)
2. Click **New API Key**, select a ForwardAuth application, and give it a name
3. Copy the `clk_...` token — it's shown only once
**Usage:**
```bash
curl -H "Authorization: Bearer clk_..." \
-H "X-Forwarded-Host: webdav.example.com" \
https://auth.example.com/api/verify
# Returns 200 with X-Remote-User headers on success
```
API keys respect the same access controls as browser sessions — the user must have access to the application, the application must be active, and the user's account must be active.
### SMTP Integration ### SMTP Integration
Send emails for: Send emails for:
- Invitation links (one-time token, 7-day expiry) - Invitation links (one-time token, 7-day expiry)
- Password reset links (one-time token, 1-hour expiry) - Password reset links (one-time token, 1-hour expiry)
- 2FA backup codes
### Session Management ### Session Management
- **Device tracking** - See all active sessions with device names and IPs - **Device tracking** - See all active sessions with device names and IPs
@@ -217,9 +264,9 @@ Configure different claims for different applications on a per-user basis:
- Many-to-many with Groups (allowlist) - Many-to-many with Groups (allowlist)
**OIDC Tokens** **OIDC Tokens**
- Authorization codes (10-minute expiry, one-time use, PKCE support) - Authorization codes (opaque, HMAC-SHA256 hashed, 10-minute expiry, one-time use, PKCE support)
- Access tokens (opaque, BCrypt-hashed, configurable expiry 5min-24hr, revocable) - Access tokens (opaque, HMAC-SHA256 hashed, configurable expiry 5min-24hr, revocable)
- Refresh tokens (opaque, BCrypt-hashed, configurable expiry 1-90 days, single-use with rotation) - Refresh tokens (opaque, HMAC-SHA256 hashed, configurable expiry 1-90 days, single-use with rotation)
- ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr) - ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr)
--- ---
@@ -247,12 +294,30 @@ Configure different claims for different applications on a per-user basis:
- Proxy redirects to Clinch login page - Proxy redirects to Clinch login page
- After login, redirect back to original URL - After login, redirect back to original URL
#### Race Condition Handling
After successful login, you may notice an `fa_token` query parameter appended to redirect URLs (e.g., `https://app.example.com/dashboard?fa_token=...`). This solves a timing issue:
**The Problem:**
1. User signs in → session cookie is set
2. Browser gets redirected to protected resource
3. Browser may not have processed the `Set-Cookie` header yet
4. Reverse proxy checks `/api/verify` → no cookie yet → auth fails ❌
**The Solution:**
- A one-time token (`fa_token`) is added to the redirect URL as a query parameter
- `/api/verify` checks for this token first, before checking cookies
- Token is cached for 60 seconds and deleted immediately after use
- This gives the browser's cookie handling time to catch up
This is transparent to end users and requires no configuration.
--- ---
## Setup & Installation ## Setup & Installation
### Requirements ### Requirements
- Ruby 3.3+ - Ruby 4.0+
- SQLite 3.8+ - SQLite 3.8+
- SMTP server (for sending emails) - SMTP server (for sending emails)
@@ -272,56 +337,207 @@ bin/rails db:migrate
bin/dev bin/dev
``` ```
### Docker Deployment ---
## Production Deployment
### Docker Compose (Recommended)
Create a `docker-compose.yml` file:
```yaml
services:
clinch:
image: ghcr.io/dkam/clinch:latest
ports:
- "127.0.0.1:3000:3000" # Bind to localhost only (reverse proxy on same host)
# Use "3000:3000" if reverse proxy is in Docker network or different host
environment:
# Rails Configuration
RAILS_ENV: production
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
# Application Configuration
CLINCH_HOST: ${CLINCH_HOST}
CLINCH_FROM_EMAIL: ${CLINCH_FROM_EMAIL:-noreply@example.com}
# SMTP Configuration
SMTP_ADDRESS: ${SMTP_ADDRESS}
SMTP_PORT: ${SMTP_PORT}
SMTP_DOMAIN: ${SMTP_DOMAIN}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_AUTHENTICATION: ${SMTP_AUTHENTICATION:-plain}
SMTP_ENABLE_STARTTLS: ${SMTP_ENABLE_STARTTLS:-true}
# OIDC Configuration (optional - generates temporary key if not provided)
OIDC_PRIVATE_KEY: ${OIDC_PRIVATE_KEY}
# Optional Configuration
FORCE_SSL: ${FORCE_SSL:-false}
volumes:
- ./storage:/rails/storage
restart: unless-stopped
```
Create a `.env` file in the same directory:
**Generate required secrets first:**
```bash ```bash
# Build image # Generate SECRET_KEY_BASE (required)
docker build -t clinch . openssl rand -hex 64
# Run container # Generate OIDC private key (optional - auto-generated if not provided)
docker run -p 3000:3000 \ openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
-v clinch-storage:/rails/storage \ cat private_key.pem # Copy the output into OIDC_PRIVATE_KEY below
-e SECRET_KEY_BASE=your-secret-key \
-e SMTP_ADDRESS=smtp.example.com \
-e SMTP_PORT=587 \
-e SMTP_USERNAME=your-username \
-e SMTP_PASSWORD=your-password \
clinch
``` ```
**Then create `.env`:**
```bash
# Rails Secret (REQUIRED)
SECRET_KEY_BASE=paste-output-from-openssl-rand-hex-64-here
# Application URLs (REQUIRED)
CLINCH_HOST=https://auth.yourdomain.com
CLINCH_FROM_EMAIL=noreply@yourdomain.com
# SMTP Settings (REQUIRED for invitations and password resets)
SMTP_ADDRESS=smtp.example.com
SMTP_PORT=587
SMTP_DOMAIN=yourdomain.com
SMTP_USERNAME=your-smtp-username
SMTP_PASSWORD=your-smtp-password
# OIDC Private Key (OPTIONAL - generates temporary key if not provided)
# For production, generate a persistent key and paste the ENTIRE contents here
OIDC_PRIVATE_KEY=
# Optional: Force SSL redirects (only if NOT behind a reverse proxy handling SSL)
FORCE_SSL=false
```
Start Clinch:
```bash
docker compose up -d
```
**First Run:**
1. Visit `http://localhost:3000` (or your configured domain)
2. Complete the first-run wizard to create your admin account
3. Configure applications and invite users
**Upgrading:**
```bash
# Pull latest image
docker compose pull
# Restart with new image (migrations run automatically)
docker compose up -d
```
**Logs:**
```bash
# View logs
docker compose logs -f clinch
# View last 100 lines
docker compose logs --tail=100 clinch
```
### Backup & Restore
Clinch stores all persistent data in the `storage/` directory (or `/rails/storage` in Docker):
- SQLite database (`production.sqlite3`)
- Uploaded files via ActiveStorage (application icons)
**Database Backup:**
Use SQLite's `VACUUM INTO` command for safe, atomic backups of a running database:
```bash
# Local development
sqlite3 storage/production.sqlite3 "VACUUM INTO 'backup.sqlite3';"
```
This creates an optimized copy of the database that's safe to make even while Clinch is running.
**Full Backup (Database + Uploads):**
For complete backups including uploaded files, backup the database and uploads separately:
```bash
# 1. Backup database (safe while running)
sqlite3 storage/production.sqlite3 "VACUUM INTO 'backup-$(date +%Y%m%d).sqlite3';"
# 2. Backup uploaded files (ActiveStorage files are immutable)
tar -czf uploads-backup-$(date +%Y%m%d).tar.gz storage/uploads/
# Docker Compose equivalent
docker compose exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup-$(date +%Y%m%d).sqlite3';"
docker compose exec clinch tar -czf /rails/storage/uploads-backup-$(date +%Y%m%d).tar.gz /rails/storage/uploads/
```
**Restore:**
```bash
# Stop Clinch first
# Then restore database
cp backup-YYYYMMDD.sqlite3 storage/production.sqlite3
# Restore uploads
tar -xzf uploads-backup-YYYYMMDD.tar.gz -C storage/
```
**Docker Volume Backup:**
**Option 1: While Running (Online Backup)**
a) **Mapped volumes** (recommended, e.g., `-v /host/path:/rails/storage`):
```bash
# Database backup (safe while running)
sqlite3 /host/path/production.sqlite3 "VACUUM INTO '/host/path/backup-$(date +%Y%m%d).sqlite3';"
# Then sync to off-server storage
rsync -av /host/path/backup-*.sqlite3 /host/path/uploads/ remote:/backups/clinch/
```
b) **Docker volumes** (e.g., using named volumes in compose):
```bash
# Database backup (safe while running)
docker compose exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup.sqlite3';"
# Copy out of container
docker compose cp clinch:/rails/storage/backup.sqlite3 ./backup-$(date +%Y%m%d).sqlite3
```
**Option 2: While Stopped (Offline Backup)**
If Docker is stopped, you can copy the entire storage:
```bash
docker compose down
# For mapped volumes
tar -czf clinch-backup-$(date +%Y%m%d).tar.gz /host/path/
# For docker volumes
docker run --rm -v clinch_storage:/data -v $(pwd):/backup ubuntu \
tar czf /backup/clinch-backup-$(date +%Y%m%d).tar.gz /data
docker compose up -d
```
**Important:** Do not use tar/snapshots on a running database - use `VACUUM INTO` instead or stop the container first.
--- ---
## Configuration ## Configuration
### Environment Variables All configuration is handled via environment variables (see the `.env` file in the Docker Compose section above).
Create a `.env` file (see `.env.example`):
```bash
# Rails
SECRET_KEY_BASE=generate-with-bin-rails-secret
RAILS_ENV=production
# Database
# SQLite database stored in storage/ directory (Docker volume mount point)
# SMTP (for sending emails)
SMTP_ADDRESS=smtp.example.com
SMTP_PORT=587
SMTP_DOMAIN=example.com
SMTP_USERNAME=your-username
SMTP_PASSWORD=your-password
SMTP_AUTHENTICATION=plain
SMTP_ENABLE_STARTTLS=true
# Application
CLINCH_HOST=https://auth.example.com
CLINCH_FROM_EMAIL=noreply@example.com
# OIDC (optional - generates temporary key in development)
# Generate with: openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
OIDC_PRIVATE_KEY=<contents-of-private-key.pem>
```
### First Run ### First Run
1. Visit Clinch at `http://localhost:3000` (or your configured domain) 1. Visit Clinch at `http://localhost:3000` (or your configured domain)
@@ -334,24 +550,255 @@ OIDC_PRIVATE_KEY=<contents-of-private-key.pem>
--- ---
## Roadmap ## Rails Console
### In Progress One advantage of being a Rails application is direct access to the Rails console for administrative tasks. This is particularly useful for debugging, emergency access, or bulk operations.
- OIDC provider implementation
- ForwardAuth endpoint
- Admin UI for user/group/app management
- First-run wizard
### Planned Features ### Starting the Console
- **Audit logging** - Track all authentication events
- **WebAuthn/Passkeys** - Hardware key support
#### Maybe ```bash
- **SAML support** - SAML 2.0 identity provider # Docker / Docker Compose
- **Policy engine** - Rule-based access control docker exec -it clinch bin/rails console
- Example: `IF user.email =~ "*@gmail.com" AND app.slug == "kavita" THEN DENY` # or
- Stored as JSON, evaluated after auth but before consent docker compose exec -it clinch bin/rails console
- **LDAP sync** - Import users from LDAP/Active Directory
# Local development
bin/rails console
```
### Finding Users
```ruby
# Find by email
user = User.find_by(email_address: 'alice@example.com')
# Find by username
user = User.find_by(username: 'alice')
# List all users
User.all.pluck(:id, :email_address, :status)
# Find admins
User.admins.pluck(:email_address)
# Find users in a specific status
User.active.count
User.disabled.pluck(:email_address)
User.pending_invitation.pluck(:email_address)
```
### Creating Users
```ruby
# Create a regular user
User.create!(
email_address: 'newuser@example.com',
password: 'secure-password-here',
status: :active
)
# Create an admin user
User.create!(
email_address: 'admin@example.com',
password: 'secure-password-here',
status: :active,
admin: true
)
```
### Managing Passwords
```ruby
user = User.find_by(email_address: 'alice@example.com')
user.password = 'new-secure-password'
user.save!
```
### Two-Factor Authentication (TOTP)
```ruby
user = User.find_by(email_address: 'alice@example.com')
# Check if TOTP is enabled
user.totp_enabled?
# Get current TOTP code (useful for testing/debugging)
puts user.console_totp
# Enable TOTP (generates secret and backup codes)
backup_codes = user.enable_totp!
puts backup_codes # Display backup codes to give to user
# Disable TOTP
user.disable_totp!
# Force user to set up TOTP on next login
user.update!(totp_required: true)
```
### Managing User Status
```ruby
user = User.find_by(email_address: 'alice@example.com')
# Disable a user (prevents login)
user.disabled!
# Re-enable a user
user.active!
# Check current status
user.status # => "active", "disabled", or "pending_invitation"
# Grant admin privileges
user.update!(admin: true)
# Revoke admin privileges
user.update!(admin: false)
```
### Managing Groups
```ruby
user = User.find_by(email_address: 'alice@example.com')
# View user's groups
user.groups.pluck(:name)
# Add user to a group
family = Group.find_by(name: 'family')
user.groups << family
# Remove user from a group
user.groups.delete(family)
# Create a new group
Group.create!(name: 'developers', description: 'Development team')
```
### Managing Sessions
```ruby
user = User.find_by(email_address: 'alice@example.com')
# View active sessions
user.sessions.pluck(:id, :device_name, :client_ip, :created_at)
# Revoke all sessions (force logout everywhere)
user.sessions.destroy_all
# Revoke a specific session
user.sessions.find(123).destroy
```
### Managing Applications
```ruby
# List all OIDC applications
Application.oidc.pluck(:name, :client_id)
# Find an application
app = Application.find_by(slug: 'kavita')
# Regenerate client secret
new_secret = app.generate_new_client_secret!
puts new_secret # Display once - not stored in plain text
# Check which users can access an app
app.allowed_groups.flat_map(&:users).uniq.pluck(:email_address)
# Revoke all tokens for an application
app.oidc_access_tokens.destroy_all
app.oidc_refresh_tokens.destroy_all
```
### Revoking OIDC Consents
```ruby
user = User.find_by(email_address: 'alice@example.com')
app = Application.find_by(slug: 'kavita')
# Revoke consent for a specific app
user.revoke_consent!(app)
# Revoke all OIDC consents
user.revoke_all_consents!
```
---
## Testing & Security
### Running Tests
Clinch has comprehensive test coverage with 450 tests covering integration, models, controllers, services, and system tests.
```bash
# Run all tests
bin/rails test
# Run specific test types
bin/rails test:integration
bin/rails test:models
bin/rails test:controllers
bin/rails test:system
# Run with code coverage report
COVERAGE=1 bin/rails test
# View coverage report at coverage/index.html
```
### Security Scanning
Clinch uses multiple automated security tools to ensure code quality and security:
```bash
# Run all security checks
bin/rake security
# Individual security scans
bin/brakeman --no-pager # Static security analysis
bin/bundler-audit check --update # Dependency vulnerability scan
bin/importmap audit # JavaScript dependency scan
```
**Container Image Scanning:**
```bash
# Install Trivy
brew install trivy # macOS
# or use Docker: alias trivy='docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy'
# Build and scan image (CRITICAL and HIGH severity only, like CI)
docker build -t clinch:local .
trivy image --severity CRITICAL,HIGH --scanners vuln clinch:local
# Scan only for fixable vulnerabilities
trivy image --severity CRITICAL,HIGH --scanners vuln --ignore-unfixed clinch:local
```
**CI/CD Integration:**
All security scans run automatically on every pull request and push to main via GitHub Actions.
**Security Tools:**
- **Brakeman** - Static analysis for Rails security vulnerabilities
- **bundler-audit** - Checks gems for known CVEs
- **Trivy** - Container image vulnerability scanning (OS/system packages)
- **Dependabot** - Automated dependency updates
- **GitHub Secret Scanning** - Detects leaked credentials with push protection
- **SimpleCov** - Code coverage tracking
- **RuboCop** - Code style and quality enforcement
**Current Status:**
- ✅ All security scans passing
- ✅ 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)
**Security Documentation:**
- [docs/security-todo.md](docs/security-todo.md) - Detailed vulnerability tracking and remediation history
- [docs/beta-checklist.md](docs/beta-checklist.md) - Beta release readiness criteria
--- ---

View File

@@ -1 +1,23 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "@tailwindcss/forms";
@custom-variant dark (&:where(.dark, .dark *));
@layer base {
.dark input:where([type="text"], [type="email"], [type="password"], [type="number"], [type="url"], [type="tel"], [type="search"]),
.dark textarea,
.dark select {
background-color: var(--color-gray-800);
border-color: var(--color-gray-600);
color: var(--color-gray-100);
}
.dark input::placeholder,
.dark textarea::placeholder {
color: var(--color-gray-400);
}
.dark input:where([type="checkbox"], [type="radio"]) {
background-color: var(--color-gray-700);
border-color: var(--color-gray-500);
}
}

View File

@@ -7,8 +7,9 @@ module ApplicationCable
end end
private private
def set_current_user def set_current_user
if session = Session.find_by(id: cookies.signed[:session_id]) if (session = Session.find_by(id: cookies.signed[:session_id]))
self.current_user = session.user self.current_user = session.user
end end
end end

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

View File

@@ -26,18 +26,17 @@ module Admin
@application.allowed_groups = Group.where(id: group_ids) @application.allowed_groups = Group.where(id: group_ids)
end end
# Get the plain text client secret to show one time # Get the plain text client secret to show one time (confidential clients only)
client_secret = nil client_secret = nil
if @application.oidc? if @application.oidc? && @application.confidential_client?
client_secret = @application.generate_new_client_secret! client_secret = @application.generate_new_client_secret!
end end
if @application.oidc? && client_secret
flash[:notice] = "Application created successfully." flash[:notice] = "Application created successfully."
if @application.oidc?
flash[:client_id] = @application.client_id flash[:client_id] = @application.client_id
flash[:client_secret] = client_secret flash[:client_secret] = client_secret if client_secret
else flash[:public_client] = true if @application.public_client?
flash[:notice] = "Application created successfully."
end end
redirect_to admin_application_path(@application) redirect_to admin_application_path(@application)
@@ -74,15 +73,20 @@ module Admin
def regenerate_credentials def regenerate_credentials
if @application.oidc? if @application.oidc?
# Generate new client ID and secret # Generate new client ID (always)
new_client_id = SecureRandom.urlsafe_base64(32) new_client_id = SecureRandom.urlsafe_base64(32)
client_secret = @application.generate_new_client_secret!
@application.update!(client_id: new_client_id) @application.update!(client_id: new_client_id)
flash[:notice] = "Credentials regenerated successfully." flash[:notice] = "Credentials regenerated successfully."
flash[:client_id] = @application.client_id flash[:client_id] = @application.client_id
# Generate new client secret only for confidential clients
if @application.confidential_client?
client_secret = @application.generate_new_client_secret!
flash[:client_secret] = client_secret flash[:client_secret] = client_secret
else
flash[:public_client] = true
end
redirect_to admin_application_path(@application) redirect_to admin_application_path(@application)
else else
@@ -97,15 +101,24 @@ module Admin
end end
def application_params def application_params
params.require(:application).permit( permitted = params.require(:application).permit(
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata, :name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl, :domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
:icon, :backchannel_logout_uri, :icon, :backchannel_logout_uri, :is_public_client, :require_pkce, :skip_consent
headers_config: {} )
).tap do |whitelisted|
# Remove client_secret from params if present (shouldn't be updated via form) # Handle headers_config - it comes as a JSON string from the text area
whitelisted.delete(:client_secret) if params[:application][:headers_config].present?
begin
permitted[:headers_config] = JSON.parse(params[:application][:headers_config])
rescue JSON::ParserError
permitted[:headers_config] = {}
end end
end end
# Remove client_secret from params if present (shouldn't be updated via form)
permitted.delete(:client_secret)
permitted
end
end end
end end

View File

@@ -8,7 +8,7 @@ module Api
def violation_report def violation_report
# Parse CSP violation report # Parse CSP violation report
report_data = JSON.parse(request.body.read) report_data = JSON.parse(request.body.read)
csp_report = report_data['csp-report'] csp_report = report_data["csp-report"]
# Validate that we have a proper CSP report # Validate that we have a proper CSP report
unless csp_report.is_a?(Hash) && csp_report.present? unless csp_report.is_a?(Hash) && csp_report.present?
@@ -19,28 +19,28 @@ module Api
# Log the violation for security monitoring # Log the violation for security monitoring
Rails.logger.warn "CSP Violation Report:" Rails.logger.warn "CSP Violation Report:"
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}" Rails.logger.warn " Blocked URI: #{csp_report["blocked-uri"]}"
Rails.logger.warn " Document URI: #{csp_report['document-uri']}" Rails.logger.warn " Document URI: #{csp_report["document-uri"]}"
Rails.logger.warn " Referrer: #{csp_report['referrer']}" Rails.logger.warn " Referrer: #{csp_report["referrer"]}"
Rails.logger.warn " Violated Directive: #{csp_report['violated-directive']}" Rails.logger.warn " Violated Directive: #{csp_report["violated-directive"]}"
Rails.logger.warn " Original Policy: #{csp_report['original-policy']}" Rails.logger.warn " Original Policy: #{csp_report["original-policy"]}"
Rails.logger.warn " User Agent: #{request.user_agent}" Rails.logger.warn " User Agent: #{request.user_agent}"
Rails.logger.warn " IP Address: #{request.remote_ip}" Rails.logger.warn " IP Address: #{request.remote_ip}"
# Emit structured event for CSP violation # Emit structured event for CSP violation
# This allows multiple subscribers to process the event (Sentry, local logging, etc.) # This allows multiple subscribers to process the event (Sentry, local logging, etc.)
Rails.event.notify("csp.violation", { Rails.event.notify("csp.violation", {
blocked_uri: csp_report['blocked-uri'], blocked_uri: csp_report["blocked-uri"],
document_uri: csp_report['document-uri'], document_uri: csp_report["document-uri"],
referrer: csp_report['referrer'], referrer: csp_report["referrer"],
violated_directive: csp_report['violated-directive'], violated_directive: csp_report["violated-directive"],
original_policy: csp_report['original-policy'], original_policy: csp_report["original-policy"],
disposition: csp_report['disposition'], disposition: csp_report["disposition"],
effective_directive: csp_report['effective-directive'], effective_directive: csp_report["effective-directive"],
source_file: csp_report['source-file'], source_file: csp_report["source-file"],
line_number: csp_report['line-number'], line_number: csp_report["line-number"],
column_number: csp_report['column-number'], column_number: csp_report["column-number"],
status_code: csp_report['status-code'], status_code: csp_report["status-code"],
user_agent: request.user_agent, user_agent: request.user_agent,
ip_address: request.remote_ip, ip_address: request.remote_ip,
current_user_id: Current.user&.id, current_user_id: Current.user&.id,

View File

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

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

@@ -1,5 +1,6 @@
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include Authentication include Authentication
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern allow_browser versions: :modern
@@ -8,4 +9,31 @@ class ApplicationController < ActionController::Base
# CSRF protection # CSRF protection
protect_from_forgery with: :exception protect_from_forgery with: :exception
helper_method :remove_query_param
private
# Remove a query parameter from a URL using proper URI parsing
# More robust than regex - handles URL encoding, edge cases, etc.
#
# @param url [String] The URL to modify
# @param param_name [String] The query parameter name to remove
# @return [String] The URL with the parameter removed
#
# @example
# remove_query_param("https://example.com?foo=bar&baz=qux", "foo")
# # => "https://example.com?baz=qux"
def remove_query_param(url, param_name)
uri = URI.parse(url)
return url unless uri.query
params = Rack::Utils.parse_query(uri.query)
params.delete(param_name)
uri.query = params.any? ? Rack::Utils.build_query(params) : nil
uri.to_s
rescue URI::InvalidURIError
url
end
end end

View File

@@ -1,6 +1,6 @@
require 'uri' require "uri"
require 'public_suffix' require "public_suffix"
require 'ipaddr' require "ipaddr"
module Authentication module Authentication
extend ActiveSupport::Concern extend ActiveSupport::Concern
@@ -17,6 +17,7 @@ module Authentication
end end
private private
def authenticated? def authenticated?
resume_session resume_session
end end
@@ -39,25 +40,35 @@ module Authentication
end end
def after_authentication_url def after_authentication_url
return_url = session[:return_to_after_authenticating] session.delete(:return_to_after_authenticating) || root_url
final_url = session.delete(:return_to_after_authenticating) || root_url
final_url
end end
def start_new_session_for(user) def start_new_session_for(user, acr: "1")
user.update!(last_sign_in_at: Time.current) user.update!(last_sign_in_at: Time.current)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr).tap do |session|
Current.session = session Current.session = session
# Extract root domain for cross-subdomain cookies (required for forward auth) # Extract root domain for cross-subdomain cookies (required for forward auth)
domain = extract_root_domain(request.host) domain = extract_root_domain(request.host)
cookie_options = { # Set cookie options based on environment
# Production: Use SameSite=None to allow cross-site cookies (needed for OIDC conformance testing)
# Development: Use SameSite=Lax since HTTPS might not be available
cookie_options = if Rails.env.production?
{
value: session.id,
httponly: true,
same_site: :none, # Allow cross-site cookies for OIDC testing
secure: true # Required for SameSite=None
}
else
{
value: session.id, value: session.id,
httponly: true, httponly: true,
same_site: :lax, same_site: :lax,
secure: Rails.env.production? secure: false
} }
end
# Set domain for cross-subdomain authentication if we can extract it # Set domain for cross-subdomain authentication if we can extract it
cookie_options[:domain] = domain if domain.present? cookie_options[:domain] = domain if domain.present?
@@ -101,10 +112,14 @@ module Authentication
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/) return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
# Strip port number for domain parsing # Strip port number for domain parsing
host_without_port = host.split(':').first host_without_port = host.split(":").first
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie # Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
return nil if IPAddr.new(host_without_port) rescue false begin
return nil if IPAddr.new(host_without_port)
rescue
false
end
# Use Public Suffix List for accurate domain parsing # Use Public Suffix List for accurate domain parsing
domain = PublicSuffix.parse(host_without_port) domain = PublicSuffix.parse(host_without_port)
@@ -138,7 +153,7 @@ module Authentication
unless uri.path&.start_with?("/oauth/") unless uri.path&.start_with?("/oauth/")
# Add token as query parameter # Add token as query parameter
query_params = URI.decode_www_form(uri.query || "").to_h query_params = URI.decode_www_form(uri.query || "").to_h
query_params['fa_token'] = token query_params["fa_token"] = token
uri.query = URI.encode_www_form(query_params) uri.query = URI.encode_www_form(query_params)
# Update the session with the tokenized URL # Update the session with the tokenized URL

View File

@@ -1,7 +1,9 @@
class InvitationsController < ApplicationController class InvitationsController < ApplicationController
include Authentication include Authentication
allow_unauthenticated_access allow_unauthenticated_access
before_action :set_user_by_invitation_token, only: %i[ show update ] before_action :set_user_by_invitation_token, only: %i[show update]
rate_limit to: 10, within: 10.minutes, only: :update, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
def show def show
# Show the password setup form # Show the password setup form
@@ -35,16 +37,16 @@ class InvitationsController < ApplicationController
# Check if user is still pending invitation # Check if user is still pending invitation
if @user.nil? if @user.nil?
redirect_to signin_path, alert: "Invitation link is invalid or has expired." redirect_to signin_path, alert: "Invitation link is invalid or has expired."
return false false
elsif @user.pending_invitation? elsif @user.pending_invitation?
# User is valid and pending - proceed # User is valid and pending - proceed
return true true
else else
redirect_to signin_path, alert: "This invitation has already been used or is no longer valid." redirect_to signin_path, alert: "This invitation has already been used or is no longer valid."
return false false
end end
rescue ActiveSupport::MessageVerifier::InvalidSignature rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to signin_path, alert: "Invitation link is invalid or has expired." redirect_to signin_path, alert: "Invitation link is invalid or has expired."
return false false
end end
end end

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,14 @@
class PasswordsController < ApplicationController class PasswordsController < ApplicationController
allow_unauthenticated_access allow_unauthenticated_access
before_action :set_user_by_token, only: %i[ edit update ] before_action :set_user_by_token, only: %i[edit update]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." } rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
rate_limit to: 10, within: 10.minutes, only: :update, with: -> { redirect_to new_password_path, alert: "Too many attempts. Try again later." }
def new def new
end end
def create def create
if user = User.find_by(email_address: params[:email_address]) if (user = User.find_by(email_address: params[:email_address]))
PasswordsMailer.reset(user).deliver_later PasswordsMailer.reset(user).deliver_later
end end
@@ -27,6 +28,7 @@ class PasswordsController < ApplicationController
end end
private private
def set_user_by_token def set_user_by_token
@user = User.find_by_token_for(:password_reset, params[:token]) @user = User.find_by_token_for(:password_reset, params[:token])
redirect_to new_password_path, alert: "Password reset link is invalid or has expired." if @user.nil? redirect_to new_password_path, alert: "Password reset link is invalid or has expired." if @user.nil?

View File

@@ -19,13 +19,21 @@ class ProfilesController < ApplicationController
else else
render :show, status: :unprocessable_entity render :show, status: :unprocessable_entity
end end
else elsif params[:user][:email_address].present?
# Updating email # Updating email - requires current password (security: prevents account takeover)
unless @user.authenticate(params[:user][:current_password])
@user.errors.add(:current_password, "is required to change email")
render :show, status: :unprocessable_entity
return
end
if @user.update(email_params) if @user.update(email_params)
redirect_to profile_path, notice: "Email updated successfully." redirect_to profile_path, notice: "Email updated successfully."
else else
render :show, status: :unprocessable_entity render :show, status: :unprocessable_entity
end end
else
render :show, status: :unprocessable_entity
end end
end end

View File

@@ -1,22 +1,36 @@
class SessionsController < ApplicationController class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create verify_totp webauthn_challenge webauthn_verify ] allow_unauthenticated_access only: %i[new create verify_totp webauthn_challenge webauthn_verify]
rate_limit to: 20, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." } rate_limit to: 20, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
rate_limit to: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." } rate_limit to: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
rate_limit to: 10, within: 3.minutes, only: [:webauthn_challenge, :webauthn_verify], with: -> { render json: { error: "Too many attempts. Try again later." }, status: :too_many_requests } rate_limit to: 10, within: 3.minutes, only: [:webauthn_challenge, :webauthn_verify], with: -> { render json: {error: "Too many attempts. Try again later."}, status: :too_many_requests }
def new def new
# Redirect to signup if this is first run # Redirect to signup if this is first run
if User.count.zero? if User.count.zero?
respond_to do |format| respond_to do |format|
format.html { redirect_to signup_path } format.html { redirect_to signup_path }
format.json { render json: { error: "No users exist. Please complete initial setup." }, status: :service_unavailable } format.json { render json: {error: "No users exist. Please complete initial setup."}, status: :service_unavailable }
end end
return return
end end
# Extract login_hint from the return URL for pre-filling the email field (OIDC spec)
@login_hint = nil
if session[:return_to_after_authenticating].present?
begin
uri = URI.parse(session[:return_to_after_authenticating])
if uri.query.present?
query_params = Rack::Utils.parse_query(uri.query)
@login_hint = query_params["login_hint"]
end
rescue URI::InvalidURIError
# Ignore parsing errors
end
end
respond_to do |format| respond_to do |format|
format.html # render HTML login page format.html # render HTML login page
format.json { render json: { error: "Authentication required" }, status: :unauthorized } format.json { render json: {error: "Authentication required"}, status: :unauthorized }
end end
end end
@@ -71,9 +85,12 @@ class SessionsController < ApplicationController
return return
end end
# Sign in successful # Sign in successful (password only)
start_new_session_for user start_new_session_for user, acr: "1"
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
# Use status: :see_other to ensure browser makes a GET request
# This prevents Turbo from converting it to a TURBO_STREAM request
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true, status: :see_other
end end
def verify_totp def verify_totp
@@ -101,35 +118,39 @@ class SessionsController < ApplicationController
return return
end end
# Try TOTP verification first # Try TOTP verification first (password + TOTP = 2FA)
if user.verify_totp(code) if user.verify_totp(code)
session.delete(:pending_totp_user_id) session.delete(:pending_totp_user_id)
# Restore redirect URL if it was preserved # Restore redirect URL if it was preserved
if session[:totp_redirect_url].present? if session[:totp_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url) session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
end end
start_new_session_for user start_new_session_for user, acr: "2"
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
return return
end end
# Try backup code verification # Try backup code verification (password + backup code = 2FA)
if user.verify_backup_code(code) if user.verify_backup_code(code)
session.delete(:pending_totp_user_id) session.delete(:pending_totp_user_id)
# Restore redirect URL if it was preserved # Restore redirect URL if it was preserved
if session[:totp_redirect_url].present? if session[:totp_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url) session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
end end
start_new_session_for user start_new_session_for user, acr: "2"
redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true
return return
end end
# Invalid code # Invalid code
redirect_to totp_verification_path, alert: "Invalid verification code. Please try again." redirect_to totp_verification_path, alert: "Invalid verification code. Please try again."
return nil
end end
# Pass data to the view for passkey option
@user_has_webauthn = user&.can_authenticate_with_webauthn?
@pending_email = user&.email_address
# Just render the form # Just render the form
end end
@@ -155,14 +176,14 @@ class SessionsController < ApplicationController
email = params[:email]&.strip&.downcase email = params[:email]&.strip&.downcase
if email.blank? if email.blank?
render json: { error: "Email is required" }, status: :unprocessable_entity render json: {error: "Email is required"}, status: :unprocessable_entity
return return
end end
user = User.find_by(email_address: email) user = User.find_by(email_address: email)
if user.nil? || !user.can_authenticate_with_webauthn? if user.nil? || !user.can_authenticate_with_webauthn?
render json: { error: "User not found or WebAuthn not available" }, status: :unprocessable_entity render json: {error: "User not found or WebAuthn not available"}, status: :unprocessable_entity
return return
end end
@@ -191,10 +212,9 @@ class SessionsController < ApplicationController
session[:webauthn_challenge] = options.challenge session[:webauthn_challenge] = options.challenge
render json: options render json: options
rescue => e rescue => e
Rails.logger.error "WebAuthn challenge generation error: #{e.message}" Rails.logger.error "WebAuthn challenge generation error: #{e.message}"
render json: { error: "Failed to generate WebAuthn challenge" }, status: :internal_server_error render json: {error: "Failed to generate WebAuthn challenge"}, status: :internal_server_error
end end
end end
@@ -202,21 +222,21 @@ class SessionsController < ApplicationController
# Get pending user from session # Get pending user from session
user_id = session[:pending_webauthn_user_id] user_id = session[:pending_webauthn_user_id]
unless user_id unless user_id
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity render json: {error: "Session expired. Please try again."}, status: :unprocessable_entity
return return
end end
user = User.find_by(id: user_id) user = User.find_by(id: user_id)
unless user unless user
session.delete(:pending_webauthn_user_id) session.delete(:pending_webauthn_user_id)
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity render json: {error: "Session expired. Please try again."}, status: :unprocessable_entity
return return
end end
# Get the credential and assertion from params # Get the credential and assertion from params
credential_data = params[:credential] credential_data = params[:credential]
if credential_data.blank? if credential_data.blank?
render json: { error: "Credential data is required" }, status: :unprocessable_entity render json: {error: "Credential data is required"}, status: :unprocessable_entity
return return
end end
@@ -224,7 +244,7 @@ class SessionsController < ApplicationController
challenge = session.delete(:webauthn_challenge) challenge = session.delete(:webauthn_challenge)
if challenge.blank? if challenge.blank?
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity render json: {error: "Invalid or expired session"}, status: :unprocessable_entity
return return
end end
@@ -237,7 +257,7 @@ class SessionsController < ApplicationController
stored_credential = user.webauthn_credential_for(external_id) stored_credential = user.webauthn_credential_for(external_id)
if stored_credential.nil? if stored_credential.nil?
render json: { error: "Credential not found" }, status: :unprocessable_entity render json: {error: "Credential not found"}, status: :unprocessable_entity
return return
end end
@@ -268,24 +288,23 @@ class SessionsController < ApplicationController
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url) session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
end end
# Create session # Create session (WebAuthn/passkey = phishing-resistant, ACR = "2")
start_new_session_for user start_new_session_for user, acr: "2"
render json: { render json: {
success: true, success: true,
redirect_to: after_authentication_url, redirect_to: after_authentication_url,
message: "Signed in successfully with passkey" message: "Signed in successfully with passkey"
} }
rescue WebAuthn::Error => e rescue WebAuthn::Error => e
Rails.logger.error "WebAuthn verification error: #{e.message}" Rails.logger.error "WebAuthn verification error: #{e.message}"
render json: { error: "Authentication failed: #{e.message}" }, status: :unprocessable_entity render json: {error: "Authentication failed: #{e.message}"}, status: :unprocessable_entity
rescue JSON::ParserError => e rescue JSON::ParserError => e
Rails.logger.error "WebAuthn JSON parsing error: #{e.message}" Rails.logger.error "WebAuthn JSON parsing error: #{e.message}"
render json: { error: "Invalid credential format" }, status: :unprocessable_entity render json: {error: "Invalid credential format"}, status: :unprocessable_entity
rescue => e rescue => e
Rails.logger.error "Unexpected WebAuthn verification error: #{e.class} - #{e.message}" Rails.logger.error "Unexpected WebAuthn verification error: #{e.class} - #{e.message}"
render json: { error: "An unexpected error occurred" }, status: :internal_server_error render json: {error: "An unexpected error occurred"}, status: :internal_server_error
end end
end end
@@ -301,7 +320,7 @@ class SessionsController < ApplicationController
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
# Only allow HTTPS in production # Only allow HTTPS in production
return nil unless Rails.env.development? || uri.scheme == 'https' return nil unless Rails.env.development? || uri.scheme == "https"
redirect_domain = uri.host.downcase redirect_domain = uri.host.downcase
return nil unless redirect_domain.present? return nil unless redirect_domain.present?
@@ -312,7 +331,6 @@ class SessionsController < ApplicationController
end end
matching_app ? url : nil matching_app ? url : nil
rescue URI::InvalidURIError rescue URI::InvalidURIError
nil nil
end end

View File

@@ -1,6 +1,6 @@
class UsersController < ApplicationController class UsersController < ApplicationController
allow_unauthenticated_access only: %i[ new create ] allow_unauthenticated_access only: %i[new create]
before_action :ensure_first_run, only: %i[ new create ] before_action :ensure_first_run, only: %i[new create]
def new def new
@user = User.new @user = User.new

View File

@@ -2,6 +2,11 @@ class WebauthnController < ApplicationController
before_action :set_webauthn_credential, only: [:destroy] before_action :set_webauthn_credential, only: [:destroy]
skip_before_action :require_authentication, only: [:check] skip_before_action :require_authentication, only: [:check]
# Rate limit check endpoint to prevent enumeration attacks
rate_limit to: 10, within: 1.minute, only: [:check], with: -> {
render json: {error: "Too many requests. Try again later."}, status: :too_many_requests
}
# GET /webauthn/new # GET /webauthn/new
def new def new
@webauthn_credential = WebauthnCredential.new @webauthn_credential = WebauthnCredential.new
@@ -11,7 +16,7 @@ class WebauthnController < ApplicationController
# Generate registration challenge for creating a new passkey # Generate registration challenge for creating a new passkey
def challenge def challenge
user = Current.session&.user user = Current.session&.user
return render json: { error: "Not authenticated" }, status: :unauthorized unless user return render json: {error: "Not authenticated"}, status: :unauthorized unless user
registration_options = WebAuthn::Credential.options_for_create( registration_options = WebAuthn::Credential.options_for_create(
user: { user: {
@@ -39,7 +44,7 @@ class WebauthnController < ApplicationController
credential_data, nickname = extract_credential_params credential_data, nickname = extract_credential_params
if credential_data.blank? || nickname.blank? if credential_data.blank? || nickname.blank?
render json: { error: "Credential and nickname are required" }, status: :unprocessable_entity render json: {error: "Credential and nickname are required"}, status: :unprocessable_entity
return return
end end
@@ -47,7 +52,7 @@ class WebauthnController < ApplicationController
challenge = session.delete(:webauthn_challenge) challenge = session.delete(:webauthn_challenge)
if challenge.blank? if challenge.blank?
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity render json: {error: "Invalid or expired session"}, status: :unprocessable_entity
return return
end end
@@ -74,7 +79,7 @@ class WebauthnController < ApplicationController
# Store the credential # Store the credential
user = Current.session&.user user = Current.session&.user
return render json: { error: "Not authenticated" }, status: :unauthorized unless user return render json: {error: "Not authenticated"}, status: :unauthorized unless user
@webauthn_credential = user.webauthn_credentials.create!( @webauthn_credential = user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64(webauthn_credential.id), external_id: Base64.urlsafe_encode64(webauthn_credential.id),
@@ -91,27 +96,18 @@ class WebauthnController < ApplicationController
message: "Passkey '#{nickname}' registered successfully", message: "Passkey '#{nickname}' registered successfully",
credential_id: @webauthn_credential.id credential_id: @webauthn_credential.id
} }
rescue WebAuthn::Error => e rescue WebAuthn::Error => e
Rails.logger.error "WebAuthn registration error: #{e.message}" Rails.logger.error "WebAuthn registration error: #{e.message}"
render json: { error: "Failed to register passkey: #{e.message}" }, status: :unprocessable_entity render json: {error: "Failed to register passkey: #{e.message}"}, status: :unprocessable_entity
rescue => e rescue => e
Rails.logger.error "Unexpected WebAuthn registration error: #{e.class} - #{e.message}" Rails.logger.error "Unexpected WebAuthn registration error: #{e.class} - #{e.message}"
render json: { error: "An unexpected error occurred" }, status: :internal_server_error render json: {error: "An unexpected error occurred"}, status: :internal_server_error
end end
end end
# DELETE /webauthn/:id # DELETE /webauthn/:id
# Remove a passkey # Remove a passkey
def destroy def destroy
user = Current.session&.user
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
if @webauthn_credential.user != user
render json: { error: "Unauthorized" }, status: :forbidden
return
end
nickname = @webauthn_credential.nickname nickname = @webauthn_credential.nickname
@webauthn_credential.destroy @webauthn_credential.destroy
@@ -131,26 +127,29 @@ class WebauthnController < ApplicationController
# GET /webauthn/check # GET /webauthn/check
# Check if user has WebAuthn credentials (for login page detection) # Check if user has WebAuthn credentials (for login page detection)
# Security: Returns identical responses for non-existent users to prevent enumeration
def check def check
email = params[:email]&.strip&.downcase email = params[:email]&.strip&.downcase
if email.blank? if email.blank?
render json: { has_webauthn: false, error: "Email is required" } render json: {has_webauthn: false, requires_webauthn: false}
return return
end end
user = User.find_by(email_address: email) user = User.find_by(email_address: email)
# Security: Return identical response for non-existent users
# Combined with rate limiting (10/min), this prevents account enumeration
if user.nil? if user.nil?
render json: { has_webauthn: false, message: "User not found" } render json: {has_webauthn: false, requires_webauthn: false}
return return
end end
# Only return minimal necessary info - no user_id or preferred_method
render json: { render json: {
has_webauthn: user.can_authenticate_with_webauthn?, has_webauthn: user.can_authenticate_with_webauthn?,
user_id: user.id, requires_webauthn: user.require_webauthn?,
preferred_method: user.preferred_authentication_method, has_totp: user.totp_enabled?
requires_webauthn: user.require_webauthn?
} }
end end
@@ -159,7 +158,7 @@ class WebauthnController < ApplicationController
def extract_credential_params def extract_credential_params
# Use require.permit which is working and reliable # Use require.permit which is working and reliable
# The JavaScript sends params both directly and wrapped in webauthn key # The JavaScript sends params both directly and wrapped in webauthn key
begin
# Try direct parameters first # Try direct parameters first
credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {}) credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {})
nickname = params.require(:nickname) nickname = params.require(:nickname)
@@ -170,29 +169,25 @@ class WebauthnController < ApplicationController
webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}]) webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}])
[webauthn_params[:credential], webauthn_params[:nickname]] [webauthn_params[:credential], webauthn_params[:nickname]]
end end
end
def set_webauthn_credential def set_webauthn_credential
@webauthn_credential = WebauthnCredential.find(params[:id]) user = Current.session&.user
return render json: {error: "Not authenticated"}, status: :unauthorized unless user
@webauthn_credential = user.webauthn_credentials.find(params[:id])
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
respond_to do |format| respond_to do |format|
format.html { format.html { redirect_to profile_path, alert: "Passkey not found" }
redirect_to profile_path, format.json { render json: {error: "Passkey not found"}, status: :not_found }
alert: "Passkey not found"
}
format.json {
render json: { error: "Passkey not found" }, status: :not_found
}
end end
end end
# Helper method to convert Base64 to Base64URL if needed # Helper method to convert Base64 to Base64URL if needed
def base64_to_base64url(str) def base64_to_base64url(str)
str.gsub('+', '-').gsub('/', '_').gsub(/=+$/, '') str.tr("+", "-").tr("/", "_").gsub(/=+$/, "")
end end
# Helper method to convert Base64URL to Base64 if needed # Helper method to convert Base64URL to Base64 if needed
def base64url_to_base64(str) def base64url_to_base64(str)
str.gsub('-', '+').gsub('_', '/') + '=' * (4 - str.length % 4) % 4 str.tr("-", "+").tr("_", "/") + "=" * (4 - str.length % 4) % 4
end end
end end

View File

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

View File

@@ -25,9 +25,7 @@ module ClaimsHelper
claims = deep_merge_claims(claims, user.parsed_custom_claims) claims = deep_merge_claims(claims, user.parsed_custom_claims)
# Merge app-specific claims (arrays are combined) # Merge app-specific claims (arrays are combined)
claims = deep_merge_claims(claims, application.custom_claims_for_user(user)) deep_merge_claims(claims, application.custom_claims_for_user(user))
claims
end end
# Get claim sources breakdown for display # Get claim sources breakdown for display

View File

@@ -1,7 +1,7 @@
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus"
export default class extends Controller { export default class extends Controller {
static targets = ["appTypeSelect", "oidcFields", "forwardAuthFields"] static targets = ["appTypeSelect", "oidcFields", "forwardAuthFields", "pkceOptions"]
connect() { connect() {
this.updateFieldVisibility() this.updateFieldVisibility()
@@ -21,4 +21,17 @@ export default class extends Controller {
this.forwardAuthFieldsTarget.classList.add('hidden') this.forwardAuthFieldsTarget.classList.add('hidden')
} }
} }
updatePkceVisibility(event) {
// Show PKCE options for confidential clients, hide for public clients
const isPublicClient = event.target.value === "true"
if (this.hasPkceOptionsTarget) {
if (isPublicClient) {
this.pkceOptionsTarget.classList.add('hidden')
} else {
this.pkceOptionsTarget.classList.remove('hidden')
}
}
}
} }

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

@@ -0,0 +1,121 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "dropzone"]
connect() {
// Listen for paste events on the dropzone
this.dropzoneTarget.addEventListener("paste", this.handlePaste.bind(this))
}
disconnect() {
this.dropzoneTarget.removeEventListener("paste", this.handlePaste.bind(this))
}
handlePaste(e) {
e.preventDefault()
e.stopPropagation()
const clipboardData = e.clipboardData || e.originalEvent.clipboardData
// First, try to get image data
for (let item of clipboardData.items) {
if (item.type.indexOf("image") !== -1) {
const blob = item.getAsFile()
this.handleImageBlob(blob)
return
}
}
// If no image found, check for SVG text
const text = clipboardData.getData("text/plain")
if (text && this.isSVG(text)) {
this.handleSVGText(text)
return
}
}
isSVG(text) {
// Check if the text looks like SVG code
const trimmed = text.trim()
return trimmed.startsWith("<svg") && trimmed.includes("</svg>")
}
handleSVGText(svgText) {
// Validate file size (2MB)
const size = new Blob([svgText]).size
if (size > 2 * 1024 * 1024) {
alert("SVG code is too large (must be less than 2MB)")
return
}
// Create a blob from the SVG text
const blob = new Blob([svgText], { type: "image/svg+xml" })
// Create a File object
const file = new File([blob], `pasted-svg-${Date.now()}.svg`, {
type: "image/svg+xml"
})
// Create a DataTransfer object to set files on the input
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
this.inputTarget.files = dataTransfer.files
// Trigger change event to update preview (file-drop controller will handle it)
const event = new Event("change", { bubbles: true })
this.inputTarget.dispatchEvent(event)
// Visual feedback
this.dropzoneTarget.classList.add("border-green-500", "bg-green-50")
setTimeout(() => {
this.dropzoneTarget.classList.remove("border-green-500", "bg-green-50")
}, 500)
}
handleImageBlob(blob) {
// Validate file type
const validTypes = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
if (!validTypes.includes(blob.type)) {
alert("Please paste a PNG, JPG, GIF, or SVG image")
return
}
// Validate file size (2MB)
if (blob.size > 2 * 1024 * 1024) {
alert("Image size must be less than 2MB")
return
}
// Create a File object from the blob with a default name
const file = new File([blob], `pasted-image-${Date.now()}.${this.getExtension(blob.type)}`, {
type: blob.type
})
// Create a DataTransfer object to set files on the input
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
this.inputTarget.files = dataTransfer.files
// Trigger change event to update preview (file-drop controller will handle it)
const event = new Event("change", { bubbles: true })
this.inputTarget.dispatchEvent(event)
// Visual feedback
this.dropzoneTarget.classList.add("border-green-500", "bg-green-50")
setTimeout(() => {
this.dropzoneTarget.classList.remove("border-green-500", "bg-green-50")
}, 500)
}
getExtension(mimeType) {
const extensions = {
"image/png": "png",
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/gif": "gif",
"image/svg+xml": "svg"
}
return extensions[mimeType] || "png"
}
}

View File

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

View File

@@ -29,10 +29,10 @@ class BackchannelLogoutJob < ApplicationJob
uri = URI.parse(application.backchannel_logout_uri) uri = URI.parse(application.backchannel_logout_uri)
begin begin
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 5) do |http| response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
request = Net::HTTP::Post.new(uri.path.presence || '/') request = Net::HTTP::Post.new(uri.path.presence || "/")
request['Content-Type'] = 'application/x-www-form-urlencoded' request["Content-Type"] = "application/x-www-form-urlencoded"
request.set_form_data({ logout_token: logout_token }) request.set_form_data({logout_token: logout_token})
http.request(request) http.request(request)
end end
@@ -44,7 +44,7 @@ class BackchannelLogoutJob < ApplicationJob
rescue Net::OpenTimeout, Net::ReadTimeout => e rescue Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.warn "BackchannelLogout: Timeout sending logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.message}" Rails.logger.warn "BackchannelLogout: Timeout sending logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.message}"
raise # Retry on timeout raise # Retry on timeout
rescue StandardError => e rescue => e
Rails.logger.error "BackchannelLogout: Failed to send logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.class} - #{e.message}" Rails.logger.error "BackchannelLogout: Failed to send logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.class} - #{e.message}"
raise # Retry on error raise # Retry on error
end end

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

View File

@@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base class ApplicationMailer < ActionMailer::Base
default from: ENV.fetch('CLINCH_FROM_EMAIL', 'clinch@example.com') default from: ENV.fetch("CLINCH_FROM_EMAIL", "clinch@example.com")
layout "mailer" layout "mailer"
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

@@ -1,6 +1,29 @@
class Application < ApplicationRecord class Application < ApplicationRecord
has_secure_password :client_secret, validations: false has_secure_password :client_secret, validations: false
# Virtual attribute to control client type during creation
# 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 has_one_attached :icon
# Fix SVG content type after attachment # Fix SVG content type after attachment
@@ -13,18 +36,19 @@ class Application < ApplicationRecord
has_many :oidc_access_tokens, dependent: :destroy has_many :oidc_access_tokens, dependent: :destroy
has_many :oidc_refresh_tokens, dependent: :destroy has_many :oidc_refresh_tokens, dependent: :destroy
has_many :oidc_user_consents, dependent: :destroy has_many :oidc_user_consents, dependent: :destroy
has_many :api_keys, dependent: :destroy
validates :name, presence: true validates :name, presence: true
validates :slug, presence: true, uniqueness: { case_sensitive: false }, validates :slug, presence: true, uniqueness: {case_sensitive: false},
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" } format: {with: /\A[a-z0-9-]+\z/, message: "only lowercase letters, numbers, and hyphens"}
validates :app_type, presence: true, validates :app_type, presence: true,
inclusion: { in: %w[oidc forward_auth] } inclusion: {in: %w[oidc forward_auth]}
validates :client_id, uniqueness: { allow_nil: true } validates :client_id, uniqueness: {allow_nil: true}
validates :client_secret, presence: true, on: :create, if: -> { oidc? } validates :client_secret, presence: true, on: :create, if: -> { oidc? && confidential_client? }
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth? validates :domain_pattern, presence: true, uniqueness: {case_sensitive: false}, if: :forward_auth?
validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" } validates :landing_url, format: {with: URI::RFC2396_PARSER.make_regexp(%w[http https]), allow_nil: true, message: "must be a valid URL"}
validates :backchannel_logout_uri, format: { validates :backchannel_logout_uri, format: {
with: URI::regexp(%w[http https]), with: URI::RFC2396_PARSER.make_regexp(%w[http https]),
allow_nil: true, allow_nil: true,
message: "must be a valid HTTP or HTTPS URL" message: "must be a valid HTTP or HTTPS URL"
} }
@@ -34,9 +58,9 @@ class Application < ApplicationRecord
validate :icon_validation, if: -> { icon.attached? } validate :icon_validation, if: -> { icon.attached? }
# Token TTL validations (for OIDC apps) # Token TTL validations (for OIDC apps)
validates :access_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
validates :refresh_token_ttl, numericality: { greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000 }, if: :oidc? # 1 day - 90 days validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 7776000}, if: :oidc? # 5 min - 90 days
validates :id_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
normalizes :slug, with: ->(slug) { slug.strip.downcase } normalizes :slug, with: ->(slug) { slug.strip.downcase }
normalizes :domain_pattern, with: ->(pattern) { normalizes :domain_pattern, with: ->(pattern) {
@@ -52,11 +76,12 @@ class Application < ApplicationRecord
# Default header configuration for ForwardAuth # Default header configuration for ForwardAuth
DEFAULT_HEADERS = { DEFAULT_HEADERS = {
user: 'X-Remote-User', user: "X-Remote-User",
email: 'X-Remote-Email', email: "X-Remote-Email",
name: 'X-Remote-Name', name: "X-Remote-Name",
groups: 'X-Remote-Groups', username: "X-Remote-Username",
admin: 'X-Remote-Admin' groups: "X-Remote-Groups",
admin: "X-Remote-Admin"
}.freeze }.freeze
# Scopes # Scopes
@@ -74,6 +99,24 @@ class Application < ApplicationRecord
app_type == "forward_auth" app_type == "forward_auth"
end end
# Client type checks (for OIDC)
def public_client?
client_secret_digest.blank?
end
def confidential_client?
!public_client?
end
# PKCE requirement check
# Public clients MUST use PKCE (no client secret to protect auth code)
# Confidential clients can optionally require PKCE (OAuth 2.1 recommendation)
def requires_pkce?
return false unless oidc?
return true if public_client? # Always require PKCE for public clients
require_pkce? # Check the flag for confidential clients
end
# Access control # Access control
def user_allowed?(user) def user_allowed?(user)
return false unless active? return false unless active?
@@ -113,8 +156,8 @@ class Application < ApplicationRecord
def matches_domain?(domain) def matches_domain?(domain)
return false if domain.blank? || !forward_auth? return false if domain.blank? || !forward_auth?
pattern = domain_pattern.gsub('.', '\.') pattern = domain_pattern.gsub(".", '\.')
pattern = pattern.gsub('*', '[^.]*') pattern = pattern.gsub("*", "[^.]*")
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE) regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
regex.match?(domain.downcase) regex.match?(domain.downcase)
@@ -122,18 +165,18 @@ class Application < ApplicationRecord
# Policy determination based on user status (for ForwardAuth) # Policy determination based on user status (for ForwardAuth)
def policy_for_user(user) def policy_for_user(user)
return 'deny' unless active? return "deny" unless active?
return 'deny' unless user.active? return "deny" unless user.active?
# If no groups specified, bypass authentication # If no groups specified, bypass authentication
return 'bypass' if allowed_groups.empty? return "bypass" if allowed_groups.empty?
# If user is in allowed groups, determine auth level # If user is in allowed groups, determine auth level
if user_allowed?(user) if user_allowed?(user)
# Require 2FA if user has TOTP configured, otherwise one factor # Require 2FA if user has TOTP configured, otherwise one factor
user.totp_enabled? ? 'two_factor' : 'one_factor' user.totp_enabled? ? "two_factor" : "one_factor"
else else
'deny' "deny"
end end
end end
@@ -156,8 +199,10 @@ class Application < ApplicationRecord
headers[header_name] = user.email_address headers[header_name] = user.email_address
when :name when :name
headers[header_name] = user.name.presence || user.email_address headers[header_name] = user.name.presence || user.email_address
when :username
headers[header_name] = user.username if user.username.present?
when :groups when :groups
headers[header_name] = user.groups.pluck(:name).join(",") if user.groups.any? headers[header_name] = user.groups.map(&:name).join(",") if user.groups.any?
when :admin when :admin
headers[header_name] = user.admin? ? "true" : "false" headers[header_name] = user.admin? ? "true" : "false"
end end
@@ -175,7 +220,7 @@ class Application < ApplicationRecord
def generate_new_client_secret! def generate_new_client_secret!
secret = SecureRandom.urlsafe_base64(48) secret = SecureRandom.urlsafe_base64(48)
self.client_secret = secret self.client_secret = secret
self.save! save!
secret secret
end end
@@ -225,6 +270,10 @@ class Application < ApplicationRecord
private private
def bust_forward_auth_cache
Rails.application.config.forward_auth_cache&.delete("fa_apps")
end
def fix_icon_content_type def fix_icon_content_type
return unless icon.attached? return unless icon.attached?
@@ -238,14 +287,14 @@ class Application < ApplicationRecord
return unless icon.attached? return unless icon.attached?
# Check content type # Check content type
allowed_types = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'image/svg+xml'] allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
unless allowed_types.include?(icon.content_type) unless allowed_types.include?(icon.content_type)
errors.add(:icon, 'must be a PNG, JPG, GIF, or SVG image') errors.add(:icon, "must be a PNG, JPG, GIF, or SVG image")
end end
# Check file size (2MB limit) # Check file size (2MB limit)
if icon.blob.byte_size > 2.megabytes if icon.blob.byte_size > 2.megabytes
errors.add(:icon, 'must be less than 2MB') errors.add(:icon, "must be less than 2MB")
end end
end end
@@ -261,21 +310,27 @@ class Application < ApplicationRecord
def generate_client_credentials def generate_client_credentials
self.client_id ||= SecureRandom.urlsafe_base64(32) self.client_id ||= SecureRandom.urlsafe_base64(32)
# Generate and hash the client secret # Generate client secret only for confidential clients
if new_record? && client_secret.blank? # Public clients (is_public_client checked) don't get a secret - they use PKCE only
if new_record? && client_secret.blank? && !is_public_client_selected?
secret = SecureRandom.urlsafe_base64(48) secret = SecureRandom.urlsafe_base64(48)
self.client_secret = secret self.client_secret = secret
end end
end end
# Check if the user selected public client option
def is_public_client_selected?
ActiveModel::Type::Boolean.new.cast(is_public_client)
end
def backchannel_logout_uri_must_be_https_in_production def backchannel_logout_uri_must_be_https_in_production
return unless Rails.env.production? return unless Rails.env.production?
return unless backchannel_logout_uri.present? return unless backchannel_logout_uri.present?
begin begin
uri = URI.parse(backchannel_logout_uri) uri = URI.parse(backchannel_logout_uri)
unless uri.scheme == 'https' unless uri.scheme == "https"
errors.add(:backchannel_logout_uri, 'must use HTTPS in production') errors.add(:backchannel_logout_uri, "must use HTTPS in production")
end end
rescue URI::InvalidURIError rescue URI::InvalidURIError
# Let the format validator handle invalid URIs # Let the format validator handle invalid URIs

View File

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

View File

@@ -9,7 +9,7 @@ class ApplicationUserClaim < ApplicationRecord
groups groups
].freeze ].freeze
validates :user_id, uniqueness: { scope: :application_id } validates :user_id, uniqueness: {scope: :application_id}
validate :no_reserved_claim_names validate :no_reserved_claim_names
# Parse custom_claims JSON field # Parse custom_claims JSON field
@@ -25,7 +25,7 @@ class ApplicationUserClaim < ApplicationRecord
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any? if reserved_used.any?
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}") errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
end end
end end
end end

View File

@@ -11,7 +11,7 @@ class Group < ApplicationRecord
groups groups
].freeze ].freeze
validates :name, presence: true, uniqueness: { case_sensitive: false } validates :name, presence: true, uniqueness: {case_sensitive: false}
normalizes :name, with: ->(name) { name.strip.downcase } normalizes :name, with: ->(name) { name.strip.downcase }
validate :no_reserved_claim_names validate :no_reserved_claim_names
@@ -28,7 +28,7 @@ class Group < ApplicationRecord
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any? if reserved_used.any?
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}") errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
end end
end end
end end

View File

@@ -6,7 +6,7 @@ class OidcAccessToken < ApplicationRecord
before_validation :generate_token, on: :create before_validation :generate_token, on: :create
before_validation :set_expiry, on: :create before_validation :set_expiry, on: :create
validates :token, uniqueness: true, presence: true validates :token_hmac, presence: true, uniqueness: true
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) } scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
scope :expired, -> { where("expires_at <= ?", Time.current) } scope :expired, -> { where("expires_at <= ?", Time.current) }
@@ -15,6 +15,19 @@ class OidcAccessToken < ApplicationRecord
attr_accessor :plaintext_token # Store plaintext temporarily for returning to client attr_accessor :plaintext_token # Store plaintext temporarily for returning to client
# Find access token by plaintext token using HMAC verification
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
# Compute HMAC for token lookup
def self.compute_token_hmac(plaintext_token)
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
end
def expired? def expired?
expires_at <= Time.current expires_at <= Time.current
end end
@@ -33,48 +46,13 @@ class OidcAccessToken < ApplicationRecord
oidc_refresh_tokens.each(&:revoke!) oidc_refresh_tokens.each(&:revoke!)
end end
# Check if a plaintext token matches the hashed token
def token_matches?(plaintext_token)
return false if plaintext_token.blank?
# Use BCrypt to compare if token_digest exists
if token_digest.present?
BCrypt::Password.new(token_digest) == plaintext_token
# Fall back to direct comparison for backward compatibility
elsif token.present?
token == plaintext_token
else
false
end
end
# Find by token (validates and checks if revoked)
def self.find_by_token(plaintext_token)
return nil if plaintext_token.blank?
# Find all non-revoked, non-expired tokens
valid.find_each do |access_token|
# Use BCrypt to compare (if token_digest exists) or direct comparison
if access_token.token_digest.present?
return access_token if BCrypt::Password.new(access_token.token_digest) == plaintext_token
elsif access_token.token == plaintext_token
return access_token
end
end
nil
end
private private
def generate_token def generate_token
return if token.present? # Generate random plaintext token
self.plaintext_token ||= SecureRandom.urlsafe_base64(48)
# Generate opaque access token # Store HMAC in database (not plaintext)
plaintext = SecureRandom.urlsafe_base64(48) self.token_hmac ||= self.class.compute_token_hmac(plaintext_token)
self.plaintext_token = plaintext # Store temporarily for returning to client
self.token_digest = BCrypt::Password.create(plaintext)
# Keep token column for backward compatibility during migration
self.token = plaintext
end end
def set_expiry def set_expiry

View File

@@ -2,17 +2,32 @@ class OidcAuthorizationCode < ApplicationRecord
belongs_to :application belongs_to :application
belongs_to :user belongs_to :user
attr_accessor :plaintext_code
before_validation :generate_code, on: :create before_validation :generate_code, on: :create
before_validation :set_expiry, on: :create before_validation :set_expiry, on: :create
validates :code, presence: true, uniqueness: true validates :code_hmac, presence: true, uniqueness: true
validates :redirect_uri, presence: true validates :redirect_uri, presence: true
validates :code_challenge_method, inclusion: { in: %w[plain S256], allow_nil: true } validates :code_challenge_method, inclusion: {in: %w[plain S256], allow_nil: true}
validate :validate_code_challenge_format, if: -> { code_challenge.present? } validate :validate_code_challenge_format, if: -> { code_challenge.present? }
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) } scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
scope :expired, -> { where("expires_at <= ?", Time.current) } scope :expired, -> { where("expires_at <= ?", Time.current) }
# Find authorization code by plaintext code using HMAC verification
def self.find_by_plaintext(plaintext_code)
return nil if plaintext_code.blank?
code_hmac = compute_code_hmac(plaintext_code)
find_by(code_hmac: code_hmac)
end
# Compute HMAC for code lookup
def self.compute_code_hmac(plaintext_code)
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_code)
end
def expired? def expired?
expires_at <= Time.current expires_at <= Time.current
end end
@@ -29,10 +44,19 @@ class OidcAuthorizationCode < ApplicationRecord
code_challenge.present? code_challenge.present?
end end
# Parse claims_requests JSON field
def parsed_claims_requests
return {} if claims_requests.blank?
claims_requests.is_a?(Hash) ? claims_requests : {}
end
private private
def generate_code def generate_code
self.code ||= SecureRandom.urlsafe_base64(32) # Generate random plaintext code
self.plaintext_code ||= SecureRandom.urlsafe_base64(32)
# Store HMAC in database (not plaintext)
self.code_hmac ||= self.class.compute_code_hmac(plaintext_code)
end end
def set_expiry def set_expiry

View File

@@ -2,13 +2,12 @@ class OidcRefreshToken < ApplicationRecord
belongs_to :application belongs_to :application
belongs_to :user belongs_to :user
belongs_to :oidc_access_token belongs_to :oidc_access_token
has_many :oidc_access_tokens, foreign_key: :oidc_access_token_id, dependent: :nullify
before_validation :generate_token, on: :create before_validation :generate_token, on: :create
before_validation :set_expiry, on: :create before_validation :set_expiry, on: :create
before_validation :set_token_family_id, on: :create before_validation :set_token_family_id, on: :create
validates :token_digest, presence: true, uniqueness: true validates :token_hmac, presence: true, uniqueness: true
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) } scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
scope :expired, -> { where("expires_at <= ?", Time.current) } scope :expired, -> { where("expires_at <= ?", Time.current) }
@@ -20,6 +19,19 @@ class OidcRefreshToken < ApplicationRecord
attr_accessor :token # Store plaintext token temporarily for returning to client attr_accessor :token # Store plaintext token temporarily for returning to client
# Find refresh token by plaintext token using HMAC verification
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
# Compute HMAC for token lookup
def self.compute_token_hmac(plaintext_token)
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
end
def expired? def expired?
expires_at <= Time.current expires_at <= Time.current
end end
@@ -43,35 +55,13 @@ class OidcRefreshToken < ApplicationRecord
OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current) OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current)
end end
# Verify a plaintext token against the stored digest
def self.find_by_token(plaintext_token)
return nil if plaintext_token.blank?
# Try to find tokens that could match (we can't search by hash directly)
# This is less efficient but necessary with BCrypt
# In production, you might want to add a token prefix or other optimization
all.find do |refresh_token|
refresh_token.token_matches?(plaintext_token)
end
end
def token_matches?(plaintext_token)
return false if plaintext_token.blank? || token_digest.blank?
BCrypt::Password.new(token_digest) == plaintext_token
rescue BCrypt::Errors::InvalidHash
false
end
private private
def generate_token def generate_token
# Generate a secure random token # Generate random plaintext token
plaintext = SecureRandom.urlsafe_base64(48) self.token ||= SecureRandom.urlsafe_base64(48)
self.token = plaintext # Store temporarily for returning to client # Store HMAC in database (not plaintext)
self.token_hmac ||= self.class.compute_token_hmac(token)
# Hash it with BCrypt for storage
self.token_digest = BCrypt::Password.create(plaintext)
end end
def set_expiry def set_expiry

View File

@@ -3,19 +3,19 @@ class OidcUserConsent < ApplicationRecord
belongs_to :application belongs_to :application
validates :user, :application, :scopes_granted, :granted_at, presence: true validates :user, :application, :scopes_granted, :granted_at, presence: true
validates :user_id, uniqueness: { scope: :application_id } validates :user_id, uniqueness: {scope: :application_id}
before_validation :set_granted_at, on: :create before_validation :set_granted_at, on: :create
before_validation :set_sid, on: :create before_validation :set_sid, on: :create
# Parse scopes_granted into an array # Parse scopes_granted into an array
def scopes def scopes
scopes_granted.split(' ') scopes_granted.split(" ")
end end
# Set scopes from an array # Set scopes from an array
def scopes=(scope_array) def scopes=(scope_array)
self.scopes_granted = Array(scope_array).uniq.join(' ') self.scopes_granted = Array(scope_array).uniq.join(" ")
end end
# Check if this consent covers the requested scopes # Check if this consent covers the requested scopes
@@ -31,18 +31,18 @@ class OidcUserConsent < ApplicationRecord
def formatted_scopes def formatted_scopes
scopes.map do |scope| scopes.map do |scope|
case scope case scope
when 'openid' when "openid"
'Basic authentication' "Basic authentication"
when 'profile' when "profile"
'Profile information' "Profile information"
when 'email' when "email"
'Email address' "Email address"
when 'groups' when "groups"
'Group membership' "Group membership"
else else
scope.humanize scope.humanize
end end
end.join(', ') end.join(", ")
end end
# Find consent by SID # Find consent by SID
@@ -50,6 +50,12 @@ class OidcUserConsent < ApplicationRecord
find_by(sid: sid) find_by(sid: sid)
end end
# Parse claims_requests JSON field
def parsed_claims_requests
return {} if claims_requests.blank?
claims_requests.is_a?(Hash) ? claims_requests : {}
end
private private
def set_granted_at def set_granted_at

View File

@@ -1,4 +1,7 @@
class User < ApplicationRecord class User < ApplicationRecord
# Encrypt TOTP secrets at rest (key derived from SECRET_KEY_BASE)
encrypts :totp_secret
has_secure_password has_secure_password
has_many :sessions, dependent: :destroy has_many :sessions, dependent: :destroy
has_many :user_groups, dependent: :destroy has_many :user_groups, dependent: :destroy
@@ -6,6 +9,7 @@ class User < ApplicationRecord
has_many :application_user_claims, dependent: :destroy has_many :application_user_claims, dependent: :destroy
has_many :oidc_user_consents, dependent: :destroy has_many :oidc_user_consents, dependent: :destroy
has_many :webauthn_credentials, dependent: :destroy has_many :webauthn_credentials, dependent: :destroy
has_many :api_keys, dependent: :destroy
# Token generation for passwordless flows # Token generation for passwordless flows
generates_token_for :invitation_login, expires_in: 24.hours do generates_token_for :invitation_login, expires_in: 24.hours do
@@ -16,10 +20,6 @@ class User < ApplicationRecord
updated_at updated_at
end end
generates_token_for :magic_login, expires_in: 15.minutes do
last_sign_in_at
end
normalizes :email_address, with: ->(e) { e.strip.downcase } normalizes :email_address, with: ->(e) { e.strip.downcase }
normalizes :username, with: ->(u) { u.strip.downcase if u.present? } normalizes :username, with: ->(u) { u.strip.downcase if u.present? }
@@ -30,16 +30,16 @@ class User < ApplicationRecord
groups groups
].freeze ].freeze
validates :email_address, presence: true, uniqueness: { case_sensitive: false }, validates :email_address, presence: true, uniqueness: {case_sensitive: false},
format: { with: URI::MailTo::EMAIL_REGEXP } format: {with: URI::MailTo::EMAIL_REGEXP}
validates :username, uniqueness: { case_sensitive: false }, allow_nil: true, validates :username, uniqueness: {case_sensitive: false}, allow_nil: true,
format: { with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens" }, format: {with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens"},
length: { minimum: 2, maximum: 30 } length: {minimum: 2, maximum: 30}
validates :password, length: { minimum: 8 }, allow_nil: true validates :password, length: {minimum: 8}, allow_nil: true
validate :no_reserved_claim_names validate :no_reserved_claim_names
# Enum - automatically creates scopes (User.active, User.disabled, etc.) # Enum - automatically creates scopes (User.active, User.disabled, etc.)
enum :status, { active: 0, disabled: 1, pending_invitation: 2 } enum :status, {active: 0, disabled: 1, pending_invitation: 2}
# Scopes # Scopes
scope :admins, -> { where(admin: true) } scope :admins, -> { where(admin: true) }
@@ -78,6 +78,14 @@ class User < ApplicationRecord
totp.verify(code, drift_behind: 30, drift_ahead: 30) totp.verify(code, drift_behind: 30, drift_ahead: 30)
end end
# Console/debug helper: get current TOTP code
def console_totp
return nil unless totp_enabled?
require "rotp"
ROTP::TOTP.new(totp_secret).now
end
def verify_backup_code(code) def verify_backup_code(code)
return false unless backup_codes.present? return false unless backup_codes.present?
@@ -115,12 +123,7 @@ class User < ApplicationRecord
cache_key = "backup_code_failed_attempts_#{id}" cache_key = "backup_code_failed_attempts_#{id}"
attempts = Rails.cache.read(cache_key) || 0 attempts = Rails.cache.read(cache_key) || 0
if attempts >= 5 # Allow max 5 failed attempts per hour attempts >= 5
true
else
# Don't increment here - increment only on failed attempts
false
end
end end
# Increment failed attempt counter # Increment failed attempt counter
@@ -224,7 +227,7 @@ class User < ApplicationRecord
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any? if reserved_used.any?
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}") errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
end end
end end

View File

@@ -2,5 +2,5 @@ class UserGroup < ApplicationRecord
belongs_to :user belongs_to :user
belongs_to :group belongs_to :group
validates :user_id, uniqueness: { scope: :group_id } validates :user_id, uniqueness: {scope: :group_id}
end end

View File

@@ -1,12 +1,15 @@
class WebauthnCredential < ApplicationRecord class WebauthnCredential < ApplicationRecord
belongs_to :user belongs_to :user
# Set default authenticator_type if not provided
after_initialize :set_default_authenticator_type, if: :new_record?
# Validations # Validations
validates :external_id, presence: true, uniqueness: true validates :external_id, presence: true, uniqueness: true
validates :public_key, presence: true validates :public_key, presence: true
validates :sign_count, presence: true, numericality: { greater_than_or_equal_to: 0, only_integer: true } validates :sign_count, presence: true, numericality: {greater_than_or_equal_to: 0, only_integer: true}
validates :nickname, presence: true validates :nickname, presence: true
validates :authenticator_type, inclusion: { in: %w[platform cross-platform] } validates :authenticator_type, inclusion: {in: %w[platform cross-platform]}
# Scopes for querying # Scopes for querying
scope :active, -> { where(nil) } # All credentials are active (we can add revoked_at later if needed) scope :active, -> { where(nil) } # All credentials are active (we can add revoked_at later if needed)
@@ -77,6 +80,10 @@ class WebauthnCredential < ApplicationRecord
private private
def set_default_authenticator_type
self.authenticator_type ||= "cross-platform"
end
def time_ago_in_words(time) def time_ago_in_words(time)
seconds = Time.current - time seconds = Time.current - time
minutes = seconds / 60 minutes = seconds / 60
@@ -84,11 +91,11 @@ class WebauthnCredential < ApplicationRecord
days = hours / 24 days = hours / 24
if days > 0 if days > 0
"#{days.floor} day#{'s' if days > 1} ago" "#{days.floor} day#{"s" if days > 1} ago"
elsif hours > 0 elsif hours > 0
"#{hours.floor} hour#{'s' if hours > 1} ago" "#{hours.floor} hour#{"s" if hours > 1} ago"
elsif minutes > 0 elsif minutes > 0
"#{minutes.floor} minute#{'s' if minutes > 1} ago" "#{minutes.floor} minute#{"s" if minutes > 1} ago"
else else
"Just now" "Just now"
end end

View File

@@ -13,20 +13,20 @@ module ClaimsMerger
result = base.dup result = base.dup
incoming.each do |key, value| incoming.each do |key, value|
if result.key?(key) result[key] = if result.key?(key)
# If both values are arrays, combine them (union to avoid duplicates) # If both values are arrays, combine them (union to avoid duplicates)
if result[key].is_a?(Array) && value.is_a?(Array) if result[key].is_a?(Array) && value.is_a?(Array)
result[key] = (result[key] + value).uniq (result[key] + value).uniq
# If both values are hashes, recursively merge them # If both values are hashes, recursively merge them
elsif result[key].is_a?(Hash) && value.is_a?(Hash) elsif result[key].is_a?(Hash) && value.is_a?(Hash)
result[key] = deep_merge_claims(result[key], value) deep_merge_claims(result[key], value)
else else
# Otherwise, incoming value wins (override) # Otherwise, incoming value wins (override)
result[key] = value value
end end
else else
# New key, just add it # New key, just add it
result[key] = value value
end end
end end

View File

@@ -3,7 +3,7 @@ class OidcJwtService
class << self class << self
# Generate an ID token (JWT) for the user # Generate an ID token (JWT) for the user
def generate_id_token(user, application, consent: nil, nonce: nil) def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil, scopes: "openid", claims_requests: {})
now = Time.current.to_i now = Time.current.to_i
# Use application's configured ID token TTL (defaults to 1 hour) # Use application's configured ID token TTL (defaults to 1 hour)
ttl = application.id_token_expiry_seconds ttl = application.id_token_expiry_seconds
@@ -11,27 +11,74 @@ class OidcJwtService
# Use pairwise SID from consent if available, fallback to user ID # Use pairwise SID from consent if available, fallback to user ID
subject = consent&.sid || user.id.to_s subject = consent&.sid || user.id.to_s
# Parse scopes (space-separated string)
requested_scopes = scopes.to_s.split
# Parse claims_requests parameter for id_token context
id_token_claims = claims_requests["id_token"] || {}
# Required claims (always included per OIDC Core spec)
payload = { payload = {
iss: issuer_url, iss: issuer_url,
sub: subject, sub: subject,
aud: application.client_id, aud: application.client_id,
exp: now + ttl, exp: now + ttl,
iat: now, iat: now
email: user.email_address,
email_verified: true,
preferred_username: user.username.presence || user.email_address,
name: user.name.presence || user.email_address
} }
# 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) # Add nonce if provided (OIDC requires this for implicit flow)
payload[:nonce] = nonce if nonce.present? payload[:nonce] = nonce if nonce.present?
# Add groups if user has any # Add auth_time if provided (OIDC Core §2 - required when max_age is used)
if user.groups.any? payload[:auth_time] = auth_time if auth_time.present?
# Add acr if provided (OIDC Core §2 - authentication context class reference)
payload[:acr] = acr if acr.present?
# Add azp (authorized party) - the client_id this token was issued to
# OIDC Core §2 - required when aud has multiple values, optional but useful for single
payload[:azp] = application.client_id
# Add at_hash if access token is provided (OIDC Core spec §3.1.3.6)
# at_hash = left-most 128 bits of SHA-256 hash of access token, base64url encoded
if access_token.present?
sha256 = Digest::SHA256.digest(access_token)
at_hash = Base64.urlsafe_encode64(sha256[0..15], padding: false)
payload[:at_hash] = at_hash
end
# 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) payload[:groups] = user.groups.pluck(:name)
end end
end
# Merge custom claims from groups (arrays are combined, not overwritten) # Merge custom claims from groups (arrays are combined, not overwritten)
# Note: Custom claims from groups are always merged (not scope-dependent)
user.groups.each do |group| user.groups.each do |group|
payload = deep_merge_claims(payload, group.parsed_custom_claims) payload = deep_merge_claims(payload, group.parsed_custom_claims)
end end
@@ -42,7 +89,13 @@ class OidcJwtService
# Merge app-specific custom claims (highest priority, arrays are combined) # Merge app-specific custom claims (highest priority, arrays are combined)
payload = deep_merge_claims(payload, application.custom_claims_for_user(user)) payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" }) # 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 end
# Generate a backchannel logout token (JWT) # Generate a backchannel logout token (JWT)
@@ -66,12 +119,12 @@ class OidcJwtService
} }
# Important: Do NOT include nonce in logout tokens (spec requirement) # Important: Do NOT include nonce in logout tokens (spec requirement)
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" }) JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
end end
# Decode and verify an ID token # Decode and verify an ID token
def decode_id_token(token) def decode_id_token(token)
JWT.decode(token, public_key, true, { algorithm: "RS256" }) JWT.decode(token, public_key, true, {algorithm: "RS256"})
end end
# Get the public key in JWK format for the JWKS endpoint # Get the public key in JWK format for the JWKS endpoint
@@ -154,5 +207,69 @@ class OidcJwtService
def key_id def key_id
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15] @key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
end end
# Check if a claim should be included based on claims parameter
# Returns true if:
# - No claims parameter specified (include all scope-based claims)
# - Claim is explicitly requested (even with null spec or essential: true)
def should_include_claim?(claim_name, id_token_claims)
# No claims parameter = include all scope-based claims
return true if id_token_claims.empty?
# Check if claim is requested
return false unless id_token_claims.key?(claim_name)
# Claim specification can be:
# - null (requested)
# - true (essential, requested)
# - false (not requested)
# - Hash with essential/value/values
claim_spec = id_token_claims[claim_name]
return true if claim_spec.nil? || claim_spec == true
return false if claim_spec == false
# If it's a hash, the claim is requested (filtering happens later)
true if claim_spec.is_a?(Hash)
end
# Filter custom claims based on claims parameter
# Removes claims not explicitly requested
# Applies value/values filtering if specified
def filter_custom_claims(payload, id_token_claims)
# Get all claim names that are NOT standard OIDC claims
standard_claims = %w[iss sub aud exp iat nbf jti nonce azp at_hash auth_time acr email email_verified name preferred_username updated_at groups]
custom_claim_names = payload.keys.map(&:to_s) - standard_claims
filtered = payload.dup
custom_claim_names.each do |claim_name|
claim_sym = claim_name.to_sym
# If claim is not requested, remove it
unless id_token_claims.key?(claim_name) || id_token_claims.key?(claim_sym)
filtered.delete(claim_sym)
next
end
# Apply value/values filtering if specified
claim_spec = id_token_claims[claim_name] || id_token_claims[claim_sym]
next unless claim_spec.is_a?(Hash)
current_value = filtered[claim_sym]
# Check value constraint
if claim_spec["value"].present?
filtered.delete(claim_sym) unless current_value == claim_spec["value"]
end
# Check values constraint (array of allowed values)
if claim_spec["values"].is_a?(Array)
filtered.delete(claim_sym) unless claim_spec["values"].include?(current_value)
end
end
filtered
end
end end
end end

View File

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

View File

@@ -2,24 +2,43 @@
<%= render "shared/form_errors", form: form %> <%= render "shared/form_errors", form: form %>
<div> <div>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %> <%= form.label :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "My Application" %> <%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "My Application" %>
</div> </div>
<div> <div>
<%= form.label :slug, class: "block text-sm font-medium text-gray-700" %> <%= form.label :slug, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_field :slug, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "my-app" %> <%= form.text_field :slug, required: true, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "my-app" %>
<p class="mt-1 text-sm text-gray-500">Lowercase letters, numbers, and hyphens only. Used in URLs and API calls.</p> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Lowercase letters, numbers, and hyphens only. Used in URLs and API calls.</p>
</div> </div>
<div> <div>
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %> <% if application.persisted? %>
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %> <span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Application Type</span>
<div class="mt-1 flex items-center gap-2">
<span class="inline-flex items-center rounded-md bg-blue-50 dark:bg-blue-900/30 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300 ring-1 ring-inset ring-blue-600/20">
<%= application.oidc? ? "OpenID Connect (OIDC)" : "Forward Auth (Reverse Proxy)" %>
</span>
</div>
<%= form.hidden_field :app_type %>
<select class="hidden" data-application-form-target="appTypeSelect"><option value="<%= application.app_type %>" selected></option></select>
<% else %>
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, {
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
data: { action: "change->application-form#updateFieldVisibility", application_form_target: "appTypeSelect" }
} %>
<% end %>
</div>
<div>
<%= form.label :description, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %>
</div> </div>
<div> <div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<%= form.label :icon, "Application Icon", class: "block text-sm font-medium text-gray-700" %> <%= form.label :icon, "Application Icon", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<a href="https://dashboardicons.com" target="_blank" rel="noopener noreferrer" class="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1"> <a href="https://dashboardicons.com" target="_blank" rel="noopener noreferrer" class="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
@@ -32,8 +51,8 @@
<%# Only show icon if we can successfully get its URL (blob is persisted) %> <%# Only show icon if we can successfully get its URL (blob is persisted) %>
<% if application.icon.blob&.persisted? && application.icon.blob.key.present? %> <% if application.icon.blob&.persisted? && application.icon.blob.key.present? %>
<div class="mt-2 mb-3 flex items-center gap-4"> <div class="mt-2 mb-3 flex items-center gap-4">
<%= image_tag application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200", alt: "Current icon" %> <%= image_tag application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700", alt: "Current icon" %>
<div class="text-sm text-gray-600"> <div class="text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium">Current icon</p> <p class="font-medium">Current icon</p>
<p class="text-xs"><%= number_to_human_size(application.icon.blob.byte_size) %></p> <p class="text-xs"><%= number_to_human_size(application.icon.blob.byte_size) %></p>
</div> </div>
@@ -42,7 +61,7 @@
<% rescue ArgumentError => e %> <% rescue ArgumentError => e %>
<%# Handle case where icon attachment exists but can't generate signed_id %> <%# Handle case where icon attachment exists but can't generate signed_id %>
<% if e.message.include?("Cannot get a signed_id for a new record") %> <% if e.message.include?("Cannot get a signed_id for a new record") %>
<div class="mt-2 mb-3 text-sm text-gray-600"> <div class="mt-2 mb-3 text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium">Icon uploaded</p> <p class="font-medium">Icon uploaded</p>
<p class="text-xs">File will be processed shortly</p> <p class="text-xs">File will be processed shortly</p>
</div> </div>
@@ -54,17 +73,17 @@
<% end %> <% end %>
<div class="mt-2" data-controller="file-drop image-paste"> <div class="mt-2" data-controller="file-drop image-paste">
<div class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-blue-400 transition-colors" <div class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 dark:border-gray-600 border-dashed rounded-md hover:border-blue-400 transition-colors"
data-file-drop-target="dropzone" data-file-drop-target="dropzone"
data-image-paste-target="dropzone" data-image-paste-target="dropzone"
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste" data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste"
tabindex="0"> tabindex="0">
<div class="space-y-1 text-center"> <div class="space-y-1 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48"> <svg class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
<div class="flex text-sm text-gray-600"> <div class="flex text-sm text-gray-600 dark:text-gray-400">
<label for="<%= form.field_id(:icon) %>" class="relative cursor-pointer bg-white rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-blue-500"> <label for="<%= form.field_id(:icon) %>" class="relative cursor-pointer bg-white dark:bg-gray-800 rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 dark:focus-within:ring-offset-gray-900 focus-within:ring-blue-500">
<span>Upload a file</span> <span>Upload a file</span>
<%= form.file_field :icon, <%= form.file_field :icon,
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml", accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
@@ -77,18 +96,18 @@
</label> </label>
<p class="pl-1">or drag and drop</p> <p class="pl-1">or drag and drop</p>
</div> </div>
<p class="text-xs text-gray-500">PNG, JPG, GIF, or SVG up to 2MB</p> <p class="text-xs text-gray-500 dark:text-gray-400">PNG, JPG, GIF, or SVG up to 2MB</p>
<p class="text-xs text-blue-600 font-medium mt-2">💡 Tip: Click here and press Ctrl+V (or Cmd+V) to paste an image from your clipboard</p> <p class="text-xs text-blue-600 font-medium mt-2">💡 Tip: Click here and press Ctrl+V (or Cmd+V) to paste an image from your clipboard</p>
</div> </div>
</div> </div>
<div data-file-drop-target="preview" class="mt-3 hidden"> <div data-file-drop-target="preview" class="mt-3 hidden">
<div class="flex items-center gap-3 p-3 bg-blue-50 rounded-md border border-blue-200"> <div class="flex items-center gap-3 p-3 bg-blue-50 dark:bg-blue-900/30 rounded-md border border-blue-200 dark:border-blue-700">
<img data-file-drop-target="previewImage" class="h-12 w-12 rounded object-cover" alt="Preview"> <img data-file-drop-target="previewImage" class="h-12 w-12 rounded object-cover" alt="Preview">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900" data-file-drop-target="filename"></p> <p class="text-sm font-medium text-gray-900 dark:text-gray-100" data-file-drop-target="filename"></p>
<p class="text-xs text-gray-500" data-file-drop-target="filesize"></p> <p class="text-xs text-gray-500 dark:text-gray-400" data-file-drop-target="filesize"></p>
</div> </div>
<button type="button" data-action="click->file-drop#clear" class="text-gray-400 hover:text-gray-600"> <button type="button" data-action="click->file-drop#clear" class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg> </svg>
@@ -99,129 +118,240 @@
</div> </div>
<div> <div>
<%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700" %> <%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.url_field :landing_url, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %> <%= form.url_field :landing_url, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %>
<p class="mt-1 text-sm text-gray-500">The main URL users will visit to access this application. This will be shown as a link on their dashboard.</p> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">The main URL users will visit to access this application. This will be shown as a link on their dashboard.</p>
</div>
<div>
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %>
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, {
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
disabled: application.persisted?,
data: { action: "change->application-form#updateFieldVisibility", application_form_target: "appTypeSelect" }
} %>
<% if application.persisted? %>
<p class="mt-1 text-sm text-gray-500">Application type cannot be changed after creation.</p>
<% end %>
</div> </div>
<!-- OIDC-specific fields --> <!-- OIDC-specific fields -->
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.oidc? || !application.persisted? %>" data-application-form-target="oidcFields"> <div id="oidc-fields" class="space-y-6 border-t border-gray-200 dark:border-gray-700 pt-6 <%= 'hidden' unless application.oidc? || !application.persisted? %>" data-application-form-target="oidcFields">
<h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3> <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">OIDC Configuration</h3>
<!-- Client Type Selection (only for new applications) -->
<% unless application.persisted? %>
<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 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 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 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 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>
</div>
<% 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 dark:text-gray-300">Client Type:</span>
<% if application.public_client? %>
<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 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 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 dark:text-gray-400">
Recommended for enhanced security (OAuth 2.1 best practice).
<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> <div>
<%= form.label :redirect_uris, "Redirect URIs", class: "block text-sm font-medium text-gray-700" %> <%= form.label :redirect_uris, "Redirect URIs", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %> <%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
<p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">One URI per line. These are the allowed callback URLs for your application.</p>
</div> </div>
<div> <div>
<%= form.label :backchannel_logout_uri, "Backchannel Logout URI (Optional)", class: "block text-sm font-medium text-gray-700" %> <%= form.label :backchannel_logout_uri, "Backchannel Logout URI (Optional)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.url_field :backchannel_logout_uri, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://app.example.com/oidc/backchannel-logout" %> <%= form.url_field :backchannel_logout_uri, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://app.example.com/oidc/backchannel-logout" %>
<p class="mt-1 text-sm text-gray-500"> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
If the application supports OpenID Connect Backchannel Logout, enter the logout endpoint URL. If the application supports OpenID Connect Backchannel Logout, enter the logout endpoint URL.
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination. When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
Leave blank if the application doesn't support backchannel logout. Leave blank if the application doesn't support backchannel logout.
</p> </p>
</div> </div>
<div class="border-t border-gray-200 pt-4 mt-4"> <div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<h4 class="text-sm font-semibold text-gray-900 mb-3">Token Expiration Settings</h4> <h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Token Expiration Settings</h4>
<p class="text-sm text-gray-500 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p> <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<%= form.label :access_token_ttl, "Access Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %> <%= form.label :access_token_ttl, "Access Token TTL", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.number_field :access_token_ttl, value: application.access_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> <%= form.text_field :access_token_ttl,
<p class="mt-1 text-xs text-gray-500"> value: application.access_token_ttl || "1h",
Range: 5 min - 24 hours placeholder: "e.g., 1h, 30m, 3600",
<br>Default: 1 hour (3600s) class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human || "1 hour" %></span> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Range: 5m - 24h
<br>Default: 1h
<% if application.access_token_ttl.present? %>
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human %> (<%= application.access_token_ttl %>s)</span>
<% end %>
</p> </p>
</div> </div>
<div> <div>
<%= form.label :refresh_token_ttl, "Refresh Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %> <%= form.label :refresh_token_ttl, "Refresh Token TTL", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.number_field :refresh_token_ttl, value: application.refresh_token_ttl || 2592000, min: 86400, max: 7776000, step: 86400, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> <%= form.text_field :refresh_token_ttl,
<p class="mt-1 text-xs text-gray-500"> value: application.refresh_token_ttl || "30d",
Range: 1 day - 90 days placeholder: "e.g., 30d, 1M, 2592000",
<br>Default: 30 days (2592000s) class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human || "30 days" %></span> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Range: 5m - 90d
<br>Default: 30d
<% if application.refresh_token_ttl.present? %>
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human %> (<%= application.refresh_token_ttl %>s)</span>
<% end %>
</p> </p>
</div> </div>
<div> <div>
<%= form.label :id_token_ttl, "ID Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %> <%= form.label :id_token_ttl, "ID Token TTL", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.number_field :id_token_ttl, value: application.id_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> <%= form.text_field :id_token_ttl,
<p class="mt-1 text-xs text-gray-500"> value: application.id_token_ttl || "1h",
Range: 5 min - 24 hours placeholder: "e.g., 1h, 30m, 3600",
<br>Default: 1 hour (3600s) class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human || "1 hour" %></span> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Range: 5m - 24h
<br>Default: 1h
<% if application.id_token_ttl.present? %>
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human %> (<%= application.id_token_ttl %>s)</span>
<% end %>
</p> </p>
</div> </div>
</div> </div>
<details class="mt-3"> <details class="mt-3">
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types</summary> <summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types & Session Length</summary>
<div class="mt-2 ml-4 space-y-2 text-sm text-gray-600"> <div class="mt-2 ml-4 space-y-3 text-sm text-gray-600 dark:text-gray-400">
<div>
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">Token Types:</p>
<p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p> <p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p>
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Longer lifetime = better UX (less re-logins).</p> <p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Each refresh issues a new refresh token (token rotation).</p>
<p><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p> <p><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p>
<p class="text-xs italic mt-2">💡 Tip: Banking apps use 5-15 min access tokens. Internal tools use 1-4 hours.</p> </div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-2">
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">How Session Length Works:</p>
<p><strong>Refresh Token TTL = Maximum Inactivity Period</strong></p>
<p class="ml-3">Because refresh tokens are automatically rotated (new token = new expiry), active users can stay logged in indefinitely. The TTL controls how long they can be <em>inactive</em> before requiring re-authentication.</p>
<p class="mt-2"><strong>Example:</strong> Refresh TTL = 30 days</p>
<ul class="ml-6 list-disc space-y-1 text-xs">
<li>User logs in on Day 0, uses app daily → stays logged in forever (tokens keep rotating)</li>
<li>User logs in on Day 0, stops using app → must re-login after 30 days of inactivity</li>
</ul>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-2">
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">Forcing Re-Authentication:</p>
<p class="ml-3 text-xs">Because of token rotation, there's no way to force periodic re-authentication using TTL settings alone. Active users can stay logged in indefinitely by refreshing tokens before they expire.</p>
<p class="mt-2 ml-3 text-xs"><strong>To enforce absolute session limits:</strong> Clients can include the <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">max_age</code> parameter in their authorization requests to require re-authentication after a specific time, regardless of token rotation.</p>
<p class="mt-2 ml-3 text-xs"><strong>Example:</strong> A banking app might set <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">max_age=900</code> (15 minutes) in the authorization request to force re-authentication every 15 minutes, even if refresh tokens are still valid.</p>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-2">
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">Common Configurations:</p>
<ul class="ml-3 space-y-1 text-xs">
<li><strong>Banking/High Security:</strong> Access TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">5m</code>, Refresh TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">5m</code> → Re-auth every 5 minutes</li>
<li><strong>Corporate Tools:</strong> Access TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">1h</code>, Refresh TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">8h</code> → Re-auth after 8 hours inactive</li>
<li><strong>Personal Apps:</strong> Access TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">1h</code>, Refresh TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">30d</code> → Re-auth after 30 days inactive</li>
</ul>
</div>
</div> </div>
</details> </details>
</div> </div>
</div> </div>
<!-- Forward Auth-specific fields --> <!-- Forward Auth-specific fields -->
<div id="forward-auth-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.forward_auth? %>" data-application-form-target="forwardAuthFields"> <div id="forward-auth-fields" class="space-y-6 border-t border-gray-200 dark:border-gray-700 pt-6 <%= 'hidden' unless application.forward_auth? %>" data-application-form-target="forwardAuthFields">
<h3 class="text-base font-semibold text-gray-900">Forward Auth Configuration</h3> <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Forward Auth Configuration</h3>
<div> <div>
<%= form.label :domain_pattern, "Domain Pattern", class: "block text-sm font-medium text-gray-700" %> <%= form.label :domain_pattern, "Domain Pattern", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_field :domain_pattern, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "*.example.com or app.example.com" %> <%= form.text_field :domain_pattern, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "*.example.com or app.example.com" %>
<p class="mt-1 text-sm text-gray-500">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
</div> </div>
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600"> <div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
<%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %> <%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_area :headers_config, value: (application.headers_config.present? && application.headers_config.any? ? JSON.pretty_generate(application.headers_config) : ""), rows: 10, <%= form.text_area :headers_config, value: (application.headers_config.present? && application.headers_config.any? ? JSON.pretty_generate(application.headers_config) : ""), rows: 10,
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
placeholder: '{"user": "Remote-User", "groups": "Remote-Groups"}', placeholder: '{"user": "Remote-User", "groups": "Remote-Groups"}',
data: { data: {
action: "input->json-validator#validate blur->json-validator#format", action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea" json_validator_target: "textarea"
} %> } %>
<div class="mt-2 text-sm text-gray-600 space-y-1"> <div class="mt-2 text-sm text-gray-600 dark:text-gray-400 space-y-1">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="font-medium">Optional: Customize header names sent to your application.</p> <p class="font-medium">Optional: Customize header names sent to your application.</p>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button> <button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 px-2 py-1 rounded">Format JSON</button>
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"user": "Remote-User", "groups": "Remote-Groups", "email": "Remote-Email", "name": "Remote-Name", "admin": "Remote-Admin"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button> <button type="button" data-action="json-validator#insertSample" data-json-sample='{"user": "Remote-User", "groups": "Remote-Groups", "email": "Remote-Email", "name": "Remote-Name", "username": "Remote-Username", "admin": "Remote-Admin"}' class="text-xs bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-1 rounded">Insert Example</button>
</div> </div>
</div> </div>
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p> <p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Username, X-Remote-Groups, X-Remote-Admin</p>
<div data-json-validator-target="status" class="text-xs font-medium"></div> <div data-json-validator-target="status" class="text-xs font-medium"></div>
<details class="mt-2"> <details class="mt-2">
<summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary> <summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary>
<div class="mt-2 ml-4 space-y-1 text-xs"> <div class="mt-2 ml-4 space-y-1 text-xs">
<p><code class="bg-gray-100 px-1 rounded">user</code> - User's email address</p> <p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">user</code> - User's email address</p>
<p><code class="bg-gray-100 px-1 rounded">email</code> - User's email address</p> <p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">email</code> - User's email address</p>
<p><code class="bg-gray-100 px-1 rounded">name</code> - User's display name (falls back to email if not set)</p> <p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">name</code> - User's display name (falls back to email if not set)</p>
<p><code class="bg-gray-100 px-1 rounded">groups</code> - Comma-separated list of group names (e.g., "admin,developers")</p> <p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">username</code> - User's login username (only sent if set)</p>
<p><code class="bg-gray-100 px-1 rounded">admin</code> - "true" or "false" indicating admin status</p> <p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">groups</code> - Comma-separated list of group names (e.g., "admin,developers")</p>
<p class="mt-2 italic">Example: <code class="bg-gray-100 px-1 rounded">{"user": "Remote-User", "groups": "Remote-Groups"}</code></p> <p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">admin</code> - "true" or "false" indicating admin status</p>
<p class="mt-2 italic">Example: <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">{"user": "Remote-User", "groups": "Remote-Groups", "username": "Remote-Username"}</code></p>
<p class="italic">Need custom user fields? Add them to user's custom_claims for OIDC tokens</p> <p class="italic">Need custom user fields? Add them to user's custom_claims for OIDC tokens</p>
</div> </div>
</details> </details>
@@ -230,31 +360,30 @@
</div> </div>
<div> <div>
<%= form.label :group_ids, "Allowed Groups (Optional)", class: "block text-sm font-medium text-gray-700" %> <%= form.label :group_ids, "Allowed Groups (Optional)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto border border-gray-200 rounded-md p-3"> <div class="mt-2 space-y-2 max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
<% if @available_groups.any? %> <% if @available_groups.any? %>
<% @available_groups.each do |group| %> <% @available_groups.each do |group| %>
<div class="flex items-center"> <div class="flex items-center">
<%= check_box_tag "application[group_ids][]", group.id, application.allowed_groups.include?(group), class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %> <%= check_box_tag "application[group_ids][]", group.id, application.allowed_groups.include?(group), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= label_tag "application_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900" %> <%= label_tag "application_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
<span class="ml-2 text-xs text-gray-500">(<%= pluralize(group.users.count, "member") %>)</span> <span class="ml-2 text-xs text-gray-500 dark:text-gray-400">(<%= pluralize(group.users.count, "member") %>)</span>
</div> </div>
<% end %> <% end %>
<% else %> <% else %>
<p class="text-sm text-gray-500">No groups available. Create groups first to restrict access.</p> <p class="text-sm text-gray-500 dark:text-gray-400">No groups available. Create groups first to restrict access.</p>
<% end %> <% end %>
</div> </div>
<p class="mt-1 text-sm text-gray-500">If no groups are selected, all active users can access this application.</p> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">If no groups are selected, all active users can access this application.</p>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %> <%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %> <%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900 dark:text-gray-100" %>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<%= form.submit application.persisted? ? "Update Application" : "Create Application", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %> <%= form.submit application.persisted? ? "Update Application" : "Create Application", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
<%= link_to "Cancel", admin_applications_path, class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %> <%= link_to "Cancel", admin_applications_path, class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
</div> </div>
<% end %> <% end %>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,25 +6,25 @@
<%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %> <%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %>
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %> <%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
<div class="my-5"> <div class="my-5">
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %> <%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700 dark:text-gray-300" %>
<%= form.email_field :email_address, <%= form.email_field :email_address,
required: true, required: true,
autofocus: true, autofocus: true,
autocomplete: "username", autocomplete: "username",
placeholder: "your@email.com", placeholder: "your@email.com",
value: params[:email_address], value: @login_hint || params[:email_address],
data: { action: "blur->webauthn#checkWebAuthnSupport change->webauthn#checkWebAuthnSupport" }, data: { action: "blur->webauthn#checkWebAuthnSupport change->webauthn#checkWebAuthnSupport" },
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
</div> </div>
<!-- WebAuthn section - initially hidden --> <!-- WebAuthn section - initially hidden -->
<div id="webauthn-section" data-login-form-target="webauthnSection" class="my-5 hidden"> <div id="webauthn-section" data-login-form-target="webauthnSection" class="my-5 hidden">
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-4"> <div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-4 dark:bg-green-900/30 dark:border-green-700">
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg> </svg>
<p class="text-sm text-green-800"> <p class="text-sm text-green-800 dark:text-green-200">
<strong>Passkey detected!</strong> You can sign in without a password. <strong>Passkey detected!</strong> You can sign in without a password.
</p> </p>
</div> </div>
@@ -38,18 +38,19 @@
</svg> </svg>
Continue with Passkey Continue with Passkey
</button> </button>
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600" style="display: none;"></div>
</div> </div>
<!-- Password section - shown by default, hidden if WebAuthn is required --> <!-- Password section - shown by default, hidden if WebAuthn is required -->
<div id="password-section" data-login-form-target="passwordSection"> <div id="password-section" data-login-form-target="passwordSection">
<div class="my-5"> <div class="my-5">
<%= form.label :password, class: "block font-medium text-sm text-gray-700" %> <%= form.label :password, class: "block font-medium text-sm text-gray-700 dark:text-gray-300" %>
<%= form.password_field :password, <%= form.password_field :password,
required: true, required: true,
autocomplete: "current-password", autocomplete: "current-password",
placeholder: "Enter your password", placeholder: "Enter your password",
maxlength: 72, maxlength: 72,
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
</div> </div>
<div class="my-5"> <div class="my-5">
@@ -58,14 +59,16 @@
</div> </div>
</div> </div>
<div class="mt-4 text-sm text-gray-600 text-center"> <div class="mt-4 text-sm text-gray-600 dark:text-gray-400 text-center">
<%= link_to "Forgot your password?", new_password_path, class: "text-blue-600 hover:text-blue-500 underline" %> <%= link_to "Forgot your password?", new_password_path, class: "text-blue-600 hover:text-blue-500 underline" %>
</div> </div>
<% end %> <% end %>
<!-- Loading overlay --> <!-- Loading overlay -->
<div id="loading-overlay" data-login-form-target="loadingOverlay" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50"> <div id="loading-overlay" data-login-form-target="loadingOverlay"
<div class="bg-white rounded-lg p-6 flex items-center"> data-action="click->login-form#hideLoading"
class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50 cursor-pointer">
<div class="bg-white rounded-lg p-6 flex items-center dark:bg-gray-900">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24"> <svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>

View File

@@ -1,8 +1,8 @@
<div class="mx-auto max-w-md"> <div class="mx-auto max-w-md">
<div class="bg-white py-8 px-6 shadow rounded-lg sm:px-10"> <div class="bg-white py-8 px-6 shadow rounded-lg sm:px-10 dark:bg-gray-900">
<div class="mb-8"> <div class="mb-8">
<h2 class="text-2xl font-bold text-gray-900">Two-Factor Authentication</h2> <h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Two-Factor Authentication</h2>
<p class="mt-2 text-sm text-gray-600"> <p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Enter the 6-digit code from your authenticator app to complete sign in. Enter the 6-digit code from your authenticator app to complete sign in.
</p> </p>
</div> </div>
@@ -13,7 +13,7 @@
} do |form| %> } do |form| %>
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %> <%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
<div> <div>
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700" %> <%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= text_field_tag :code, <%= text_field_tag :code,
nil, nil,
placeholder: "000000", placeholder: "000000",
@@ -21,8 +21,8 @@
required: true, required: true,
autofocus: true, autofocus: true,
autocomplete: "off", autocomplete: "off",
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-center text-2xl tracking-widest font-mono sm:text-sm" %> class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-center text-2xl tracking-widest font-mono sm:text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
<p class="mt-2 text-xs text-gray-500"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Enter your 6-digit authenticator code or an 8-character backup code Enter your 6-digit authenticator code or an 8-character backup code
</p> </p>
</div> </div>
@@ -30,25 +30,50 @@
<div> <div>
<%= form.submit "Verify", <%= form.submit "Verify",
data: { form_submit_protection_target: "submit" }, data: { form_submit_protection_target: "submit" },
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-900" %>
</div>
<% end %>
<% if @user_has_webauthn %>
<div data-controller="webauthn" data-webauthn-check-url-value="/webauthn/check">
<input type="hidden" name="webauthn_email" value="<%= @pending_email %>">
<div class="mt-4">
<div class="relative my-4">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300 dark:border-gray-600"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500 dark:bg-gray-900 dark:text-gray-400">Or</span>
</div>
</div>
<button type="button"
data-action="click->webauthn#authenticate"
class="w-full rounded-md px-3.5 py-2.5 bg-green-600 hover:bg-green-500 text-white font-medium cursor-pointer flex items-center justify-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Use Passkey Instead
</button>
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600" style="display: none;"></div>
</div>
</div> </div>
<% end %> <% end %>
<div class="mt-6"> <div class="mt-6">
<div class="relative"> <div class="relative">
<div class="absolute inset-0 flex items-center"> <div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div> <div class="w-full border-t border-gray-300 dark:border-gray-600"></div>
</div> </div>
<div class="relative flex justify-center text-sm"> <div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">Need help?</span> <span class="px-2 bg-white text-gray-500 dark:bg-gray-900 dark:text-gray-400">Need help?</span>
</div> </div>
</div> </div>
<div class="mt-4 text-center"> <div class="mt-4 text-center">
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600 dark:text-gray-400">
Lost access to your authenticator? Lost access to your authenticator?
</p> </p>
<p class="mt-1 text-xs text-gray-500"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Contact an administrator for assistance. Contact an administrator for assistance.
</p> </p>
</div> </div>

View File

@@ -1,39 +1,41 @@
<%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %> <%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %>
<% flash.each do |type, message| %> <% flash.each do |type, message| %>
<% next if message.blank? %> <% next if message.blank? %>
<%# Skip credential-related flash messages - they're displayed in a special credentials box %>
<% next if %w[client_id client_secret public_client].include?(type.to_s) %>
<% <%
# Map flash types to styling # Map flash types to styling
case type.to_s case type.to_s
when 'notice' when 'notice'
bg_class = 'bg-green-50' bg_class = 'bg-green-50 dark:bg-green-900/30'
text_class = 'text-green-800' text_class = 'text-green-800 dark:text-green-200'
icon_class = 'text-green-400' icon_class = 'text-green-400 dark:text-green-300'
icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z' icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
auto_dismiss = true auto_dismiss = true
when 'alert', 'error' when 'alert', 'error'
bg_class = 'bg-red-50' bg_class = 'bg-red-50 dark:bg-red-900/30'
text_class = 'text-red-800' text_class = 'text-red-800 dark:text-red-200'
icon_class = 'text-red-400' icon_class = 'text-red-400 dark:text-red-300'
icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z' icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z'
auto_dismiss = false auto_dismiss = false
when 'warning' when 'warning'
bg_class = 'bg-yellow-50' bg_class = 'bg-yellow-50 dark:bg-yellow-900/30'
text_class = 'text-yellow-800' text_class = 'text-yellow-800 dark:text-yellow-200'
icon_class = 'text-yellow-400' icon_class = 'text-yellow-400 dark:text-yellow-300'
icon_path = 'M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z' icon_path = 'M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
auto_dismiss = false auto_dismiss = false
when 'info' when 'info'
bg_class = 'bg-blue-50' bg_class = 'bg-blue-50 dark:bg-blue-900/30'
text_class = 'text-blue-800' text_class = 'text-blue-800 dark:text-blue-200'
icon_class = 'text-blue-400' icon_class = 'text-blue-400 dark:text-blue-300'
icon_path = 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z' icon_path = 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
auto_dismiss = true auto_dismiss = true
else else
# Default styling for unknown types # Default styling for unknown types
bg_class = 'bg-gray-50' bg_class = 'bg-gray-50 dark:bg-gray-800'
text_class = 'text-gray-800' text_class = 'text-gray-800 dark:text-gray-200'
icon_class = 'text-gray-400' icon_class = 'text-gray-400 dark:text-gray-500'
icon_path = 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z' icon_path = 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
auto_dismiss = false auto_dismiss = false
end end
@@ -58,7 +60,7 @@
<div class="-mx-1.5 -my-1.5"> <div class="-mx-1.5 -my-1.5">
<button type="button" <button type="button"
data-action="click->flash#dismiss" data-action="click->flash#dismiss"
class="inline-flex rounded-md <%= bg_class %> p-1.5 <%= icon_class %> hover:bg-opacity-70 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-<%= bg_class.gsub('bg-', '') %>" class="inline-flex rounded-md <%= bg_class %> p-1.5 <%= icon_class %> hover:bg-opacity-70 focus:outline-none focus:ring-2 focus:ring-offset-2"
aria-label="Dismiss"> aria-label="Dismiss">
<span class="sr-only">Dismiss</span> <span class="sr-only">Dismiss</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">

View File

@@ -3,19 +3,19 @@
<% form_object = form.respond_to?(:object) ? form.object : (object || form) %> <% form_object = form.respond_to?(:object) ? form.object : (object || form) %>
<% if form_object&.errors&.any? %> <% if form_object&.errors&.any? %>
<div class="rounded-md bg-red-50 p-4 mb-6 border border-red-200" role="alert" aria-labelledby="form-errors-title" data-form-errors-target="container"> <div class="rounded-md bg-red-50 dark:bg-red-900/30 p-4 mb-6 border border-red-200 dark:border-red-700" role="alert" aria-labelledby="form-errors-title" data-form-errors-target="container">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="h-5 w-5 text-red-400 dark:text-red-300" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg> </svg>
</div> </div>
<div class="ml-3 flex-1"> <div class="ml-3 flex-1">
<h3 id="form-errors-title" class="text-sm font-medium text-red-800"> <h3 id="form-errors-title" class="text-sm font-medium text-red-800 dark:text-red-200">
<%= pluralize(form_object.errors.count, "error") %> prohibited this <%= form_object.class.name.downcase.gsub(/^admin::/, '') %> from being saved: <%= pluralize(form_object.errors.count, "error") %> prohibited this <%= form_object.class.name.downcase.gsub(/^admin::/, '') %> from being saved:
</h3> </h3>
<div class="mt-2"> <div class="mt-2">
<ul class="list-disc space-y-1 pl-5 text-sm text-red-700"> <ul class="list-disc space-y-1 pl-5 text-sm text-red-700 dark:text-red-300">
<% form_object.errors.full_messages.each do |message| %> <% form_object.errors.full_messages.each do |message| %>
<li><%= message %></li> <li><%= message %></li>
<% end %> <% end %>
@@ -24,7 +24,7 @@
</div> </div>
<div class="ml-auto pl-3"> <div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5"> <div class="-mx-1.5 -my-1.5">
<button type="button" data-action="click->form-errors#dismiss" class="inline-flex rounded-md bg-red-50 p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50" aria-label="Dismiss"> <button type="button" data-action="click->form-errors#dismiss" class="inline-flex rounded-md bg-red-50 dark:bg-red-900/30 p-1.5 text-red-500 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/50 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50 dark:focus:ring-offset-gray-900" aria-label="Dismiss">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" /> <path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg> </svg>

View File

@@ -6,16 +6,16 @@
<!-- Desktop sidebar --> <!-- Desktop sidebar -->
<div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-64 lg:flex-col"> <div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-64 lg:flex-col">
<div class="flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4"> <div class="flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-6 pb-4">
<!-- Branding and User Info --> <!-- Branding and User Info -->
<div class="flex flex-col pt-5 pb-4 border-b border-gray-200"> <div class="flex flex-col pt-5 pb-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center"> <div class="flex items-center">
<h1 class="text-2xl font-bold text-gray-900">Clinch</h1> <h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Clinch</h1>
</div> </div>
<div class="mt-2"> <div class="mt-2">
<p class="text-sm text-gray-600 truncate"><%= user.email_address %></p> <p class="text-sm text-gray-600 dark:text-gray-400 truncate"><%= user.email_address %></p>
<% if user.admin? %> <% if user.admin? %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 mt-1"> <span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-800 dark:text-blue-200 mt-1">
Administrator Administrator
</span> </span>
<% end %> <% end %>
@@ -28,7 +28,7 @@
<ul role="list" class="-mx-2 space-y-1"> <ul role="list" class="-mx-2 space-y-1">
<!-- Dashboard --> <!-- Dashboard -->
<li> <li>
<%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %> <%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/' ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /> <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg> </svg>
@@ -39,7 +39,7 @@
<% if user.admin? %> <% if user.admin? %>
<!-- Admin: Users --> <!-- Admin: Users -->
<li> <li>
<%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/users') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %> <%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/users') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg> </svg>
@@ -49,7 +49,7 @@
<!-- Admin: Applications --> <!-- Admin: Applications -->
<li> <li>
<%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/applications') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %> <%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/applications') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg> </svg>
@@ -59,7 +59,7 @@
<!-- Admin: Groups --> <!-- Admin: Groups -->
<li> <li>
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %> <%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg> </svg>
@@ -70,7 +70,7 @@
<!-- Profile --> <!-- Profile -->
<li> <li>
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %> <%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
@@ -80,7 +80,7 @@
<!-- Sessions --> <!-- Sessions -->
<li> <li>
<%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/active_sessions' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %> <%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/active_sessions' ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
</svg> </svg>
@@ -88,9 +88,25 @@
<% end %> <% end %>
</li> </li>
<!-- Dark Mode Toggle -->
<li data-controller="dark-mode">
<button type="button" data-action="click->dark-mode#toggle" class="group flex w-full gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800">
<!-- Moon icon (shown in light mode) -->
<svg data-dark-mode-target="icon" data-mode="light" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<!-- Sun icon (shown in dark mode) -->
<svg data-dark-mode-target="icon" data-mode="dark" class="hidden h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
<span data-dark-mode-target="icon" data-mode="light">Dark Mode</span>
<span data-dark-mode-target="icon" data-mode="dark" class="hidden">Light Mode</span>
</button>
</li>
<!-- Sign Out --> <!-- Sign Out -->
<li> <li>
<%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %> <%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg> </svg>
@@ -124,16 +140,16 @@
</button> </button>
</div> </div>
<div class="flex grow flex-col gap-y-5 overflow-y-auto bg-white px-6 pb-2"> <div class="flex grow flex-col gap-y-5 overflow-y-auto bg-white dark:bg-gray-900 px-6 pb-2">
<!-- Branding and User Info --> <!-- Branding and User Info -->
<div class="flex flex-col pt-5 pb-4 border-b border-gray-200"> <div class="flex flex-col pt-5 pb-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center"> <div class="flex items-center">
<h1 class="text-2xl font-bold text-gray-900">Clinch</h1> <h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Clinch</h1>
</div> </div>
<div class="mt-2"> <div class="mt-2">
<p class="text-sm text-gray-600 truncate"><%= user.email_address %></p> <p class="text-sm text-gray-600 dark:text-gray-400 truncate"><%= user.email_address %></p>
<% if user.admin? %> <% if user.admin? %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 mt-1"> <span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-800 dark:text-blue-200 mt-1">
Administrator Administrator
</span> </span>
<% end %> <% end %>
@@ -144,7 +160,7 @@
<!-- Same nav items as desktop --> <!-- Same nav items as desktop -->
<ul role="list" class="-mx-2 space-y-1"> <ul role="list" class="-mx-2 space-y-1">
<li> <li>
<%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %> <%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/' ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /> <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg> </svg>
@@ -153,7 +169,7 @@
</li> </li>
<% if user.admin? %> <% if user.admin? %>
<li> <li>
<%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/users') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %> <%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/users') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg> </svg>
@@ -161,7 +177,7 @@
<% end %> <% end %>
</li> </li>
<li> <li>
<%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/applications') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %> <%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/applications') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg> </svg>
@@ -169,7 +185,7 @@
<% end %> <% end %>
</li> </li>
<li> <li>
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %> <%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg> </svg>
@@ -178,7 +194,7 @@
</li> </li>
<% end %> <% end %>
<li> <li>
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %> <%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
@@ -186,15 +202,30 @@
<% end %> <% end %>
</li> </li>
<li> <li>
<%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/active_sessions' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %> <%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/active_sessions' ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
</svg> </svg>
Sessions Sessions
<% end %> <% end %>
</li> </li>
<!-- Dark Mode Toggle (mobile) -->
<li data-controller="dark-mode">
<button type="button" data-action="click->dark-mode#toggle" class="group flex w-full gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800">
<svg data-dark-mode-target="icon" data-mode="light" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg data-dark-mode-target="icon" data-mode="dark" class="hidden h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
<span data-dark-mode-target="icon" data-mode="light">Dark Mode</span>
<span data-dark-mode-target="icon" data-mode="dark" class="hidden">Light Mode</span>
</button>
</li>
<li> <li>
<%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %> <%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg> </svg>

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="max-w-2xl mx-auto" data-controller="backup-codes" data-backup-codes-codes-value="<%= @backup_codes.to_json %>">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Backup Codes</h1> <h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Backup Codes</h1>
<p class="mt-2 text-sm text-gray-600"> <p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Save these backup codes in a safe place. Each code can only be used once. Save these backup codes in a safe place. Each code can only be used once.
</p> </p>
</div> </div>
<div class="bg-white shadow sm:rounded-lg"> <div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6"> <div class="px-4 py-5 sm:p-6">
<div class="rounded-md bg-yellow-50 p-4 mb-6"> <div class="rounded-md bg-yellow-50 dark:bg-yellow-900/30 p-4 mb-6">
<div class="flex"> <div class="flex">
<svg class="h-5 w-5 text-yellow-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-5 w-5 text-yellow-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg> </svg>
<div class="text-sm text-yellow-800"> <div class="text-sm text-yellow-800 dark:text-yellow-200">
<p class="font-medium">Save these codes now!</p> <p class="font-medium">Save these codes now!</p>
<p class="mt-1">Store them somewhere safe. You won't be able to see them again without re-entering your password.</p> <p class="mt-1">Store them somewhere safe. You won't be able to see them again without re-entering your password.</p>
</div> </div>
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-4 p-6 bg-gray-50 rounded-lg font-mono"> <div class="grid grid-cols-2 gap-4 p-6 bg-gray-50 dark:bg-gray-700 rounded-lg font-mono">
<% @backup_codes.each do |code| %> <% @backup_codes.each do |code| %>
<div class="text-center text-lg tracking-wider py-2 px-4 bg-white rounded border border-gray-200"> <div class="text-center text-lg tracking-wider py-2 px-4 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 dark:text-gray-100">
<%= code %> <%= code %>
</div> </div>
<% end %> <% end %>
</div> </div>
<div class="mt-6 flex gap-3"> <div class="mt-6 flex gap-3">
<button data-action="click->backup-codes#download" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"> <button data-action="click->backup-codes#download" class="inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 py-2 px-4 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg> </svg>
Download Codes Download Codes
</button> </button>
<button data-action="click->backup-codes#print" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"> <button data-action="click->backup-codes#print" class="inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 py-2 px-4 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg> </svg>
@@ -47,13 +47,12 @@
<div class="mt-8"> <div class="mt-8">
<% if @auto_signin_pending %> <% if @auto_signin_pending %>
<%= button_to "Continue to Sign In", complete_totp_setup_path, method: :post, <%= button_to "Continue to Sign In", complete_totp_setup_path, method: :post,
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %> class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
<% else %> <% else %>
<%= link_to "Done", profile_path, <%= link_to "Done", profile_path,
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %> class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
<% end %> <% end %>
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,17 +1,17 @@
<div class="max-w-2xl mx-auto"> <div class="max-w-2xl mx-auto">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Enable Two-Factor Authentication</h1> <h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Enable Two-Factor Authentication</h1>
<p class="mt-2 text-sm text-gray-600"> <p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Scan the QR code below with your authenticator app, then enter the verification code to confirm. Scan the QR code below with your authenticator app, then enter the verification code to confirm.
</p> </p>
</div> </div>
<div class="bg-white shadow sm:rounded-lg"> <div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6"> <div class="px-4 py-5 sm:p-6">
<!-- Step 1: Scan QR Code --> <!-- Step 1: Scan QR Code -->
<div class="mb-8"> <div class="mb-8">
<h3 class="text-lg font-medium text-gray-900 mb-4">Step 1: Scan QR Code</h3> <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">Step 1: Scan QR Code</h3>
<div class="flex justify-center p-6 bg-gray-50 rounded-lg"> <div class="flex justify-center p-6 bg-gray-50 dark:bg-gray-700 rounded-lg">
<%= @qr_code.as_svg( <%= @qr_code.as_svg(
module_size: 4, module_size: 4,
color: "000", color: "000",
@@ -19,26 +19,26 @@
standalone: true standalone: true
).html_safe %> ).html_safe %>
</div> </div>
<p class="mt-4 text-sm text-gray-600 text-center"> <p class="mt-4 text-sm text-gray-600 dark:text-gray-400 text-center">
Use an authenticator app like Google Authenticator, Authy, or 1Password to scan this code. Use an authenticator app like Google Authenticator, Authy, or 1Password to scan this code.
</p> </p>
</div> </div>
<!-- Manual Entry Option --> <!-- Manual Entry Option -->
<div class="mb-8 p-4 bg-blue-50 rounded-lg"> <div class="mb-8 p-4 bg-blue-50 dark:bg-blue-900/30 rounded-lg">
<p class="text-sm font-medium text-blue-900 mb-2">Can't scan the QR code?</p> <p class="text-sm font-medium text-blue-900 dark:text-blue-200 mb-2">Can't scan the QR code?</p>
<p class="text-sm text-blue-800">Enter this key manually in your authenticator app:</p> <p class="text-sm text-blue-800 dark:text-blue-300">Enter this key manually in your authenticator app:</p>
<code class="mt-2 block p-2 bg-white rounded text-sm font-mono break-all"><%= @totp_secret %></code> <code class="mt-2 block p-2 bg-white dark:bg-gray-700 dark:text-gray-200 rounded text-sm font-mono break-all"><%= @totp_secret %></code>
</div> </div>
<!-- Step 2: Verify --> <!-- Step 2: Verify -->
<div> <div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Step 2: Verify</h3> <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">Step 2: Verify</h3>
<%= form_with url: totp_path, method: :post, class: "space-y-4" do |form| %> <%= form_with url: totp_path, method: :post, class: "space-y-4" do |form| %>
<%= hidden_field_tag :totp_secret, @totp_secret %> <%= hidden_field_tag :totp_secret, @totp_secret %>
<div> <div>
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700" %> <%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= text_field_tag :code, <%= text_field_tag :code,
nil, nil,
placeholder: "000000", placeholder: "000000",
@@ -46,27 +46,27 @@
required: true, required: true,
autofocus: true, autofocus: true,
autocomplete: "off", autocomplete: "off",
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-center text-2xl tracking-widest font-mono" %> class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-center text-2xl tracking-widest font-mono" %>
<p class="mt-1 text-sm text-gray-500">Enter the 6-digit code from your authenticator app</p> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Enter the 6-digit code from your authenticator app</p>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<%= form.submit "Verify and Enable 2FA", <%= form.submit "Verify and Enable 2FA",
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %> class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
<%= link_to "Cancel", profile_path, <%= link_to "Cancel", profile_path,
class: "inline-flex justify-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %> class: "inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 py-2 px-4 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
</div> </div>
<% end %> <% end %>
</div> </div>
</div> </div>
</div> </div>
<div class="mt-6 p-4 bg-yellow-50 rounded-lg"> <div class="mt-6 p-4 bg-yellow-50 dark:bg-yellow-900/30 rounded-lg">
<div class="flex"> <div class="flex">
<svg class="h-5 w-5 text-yellow-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-5 w-5 text-yellow-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg> </svg>
<div class="text-sm text-yellow-800"> <div class="text-sm text-yellow-800 dark:text-yellow-200">
<p class="font-medium">Important: Save your backup codes</p> <p class="font-medium">Important: Save your backup codes</p>
<p class="mt-1">After verifying, you'll be shown backup codes. Save these in a safe place - they can be used to access your account if you lose your device.</p> <p class="mt-1">After verifying, you'll be shown backup codes. Save these in a safe place - they can be used to access your account if you lose your device.</p>
</div> </div>

View File

@@ -1,19 +1,19 @@
<div class="max-w-2xl mx-auto"> <div class="max-w-2xl mx-auto">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Regenerate Backup Codes</h1> <h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Regenerate Backup Codes</h1>
<p class="mt-2 text-sm text-gray-600"> <p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
This will invalidate all existing backup codes and generate new ones. This will invalidate all existing backup codes and generate new ones.
</p> </p>
</div> </div>
<div class="bg-white shadow sm:rounded-lg"> <div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6"> <div class="px-4 py-5 sm:p-6">
<div class="rounded-md bg-yellow-50 p-4 mb-6"> <div class="rounded-md bg-yellow-50 dark:bg-yellow-900/30 p-4 mb-6">
<div class="flex"> <div class="flex">
<svg class="h-5 w-5 text-yellow-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-5 w-5 text-yellow-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg> </svg>
<div class="text-sm text-yellow-800"> <div class="text-sm text-yellow-800 dark:text-yellow-200">
<p class="font-medium">Important Security Notice</p> <p class="font-medium">Important Security Notice</p>
<p class="mt-1">All your current backup codes will become invalid after this action. Make sure you're ready to save the new codes.</p> <p class="mt-1">All your current backup codes will become invalid after this action. Make sure you're ready to save the new codes.</p>
</div> </div>
@@ -22,22 +22,22 @@
<%= form_with(url: create_new_backup_codes_totp_path, method: :post, class: "space-y-6") do |form| %> <%= form_with(url: create_new_backup_codes_totp_path, method: :post, class: "space-y-6") do |form| %>
<div> <div>
<%= form.label :password, "Enter your password to confirm", class: "block text-sm font-medium text-gray-700" %> <%= form.label :password, "Enter your password to confirm", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="mt-1"> <div class="mt-1">
<%= form.password_field :password, required: true, <%= form.password_field :password, required: true,
class: "block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm" %> class: "block w-full appearance-none rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 placeholder-gray-400 dark:placeholder-gray-500 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm" %>
</div> </div>
<p class="mt-2 text-sm text-gray-500"> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
This is required to verify your identity before regenerating backup codes. This is required to verify your identity before regenerating backup codes.
</p> </p>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<%= form.submit "Generate New Backup Codes", <%= form.submit "Generate New Backup Codes",
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %> class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
<%= link_to "Cancel", profile_path, <%= link_to "Cancel", profile_path,
class: "inline-flex justify-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %> class: "inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 py-2 px-4 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
</div> </div>
<% end %> <% end %>
</div> </div>

View File

@@ -1,41 +1,41 @@
<div class="mx-auto md:w-2/3 w-full"> <div class="mx-auto md:w-2/3 w-full">
<div class="mb-8"> <div class="mb-8">
<h1 class="font-bold text-4xl">Welcome to Clinch</h1> <h1 class="font-bold text-4xl">Welcome to Clinch</h1>
<p class="mt-2 text-gray-600">Create your admin account to get started</p> <p class="mt-2 text-gray-600 dark:text-gray-400">Create your admin account to get started</p>
</div> </div>
<%= form_with model: @user, url: signup_path, class: "contents", data: { controller: "form-errors" } do |form| %> <%= form_with model: @user, url: signup_path, class: "contents", data: { controller: "form-errors" } do |form| %>
<%= render "shared/form_errors", form: form %> <%= render "shared/form_errors", form: form %>
<div class="my-5"> <div class="my-5">
<%= form.label :email_address, class: "block font-medium text-sm text-gray-700" %> <%= form.label :email_address, class: "block font-medium text-sm text-gray-700 dark:text-gray-300" %>
<%= form.email_field :email_address, <%= form.email_field :email_address,
required: true, required: true,
autofocus: true, autofocus: true,
autocomplete: "email", autocomplete: "email",
placeholder: "admin@example.com", placeholder: "admin@example.com",
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
</div> </div>
<div class="my-5"> <div class="my-5">
<%= form.label :password, class: "block font-medium text-sm text-gray-700" %> <%= form.label :password, class: "block font-medium text-sm text-gray-700 dark:text-gray-300" %>
<%= form.password_field :password, <%= form.password_field :password,
required: true, required: true,
autocomplete: "new-password", autocomplete: "new-password",
placeholder: "Enter a strong password", placeholder: "Enter a strong password",
maxlength: 72, maxlength: 72,
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
<p class="mt-1 text-sm text-gray-500">Must be at least 8 characters</p> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Must be at least 8 characters</p>
</div> </div>
<div class="my-5"> <div class="my-5">
<%= form.label :password_confirmation, "Confirm Password", class: "block font-medium text-sm text-gray-700" %> <%= form.label :password_confirmation, "Confirm Password", class: "block font-medium text-sm text-gray-700 dark:text-gray-300" %>
<%= form.password_field :password_confirmation, <%= form.password_field :password_confirmation,
required: true, required: true,
autocomplete: "new-password", autocomplete: "new-password",
placeholder: "Re-enter your password", placeholder: "Re-enter your password",
maxlength: 72, maxlength: 72,
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
</div> </div>
<div class="my-5"> <div class="my-5">
@@ -43,8 +43,8 @@
class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %> class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %>
</div> </div>
<div class="mt-4 p-4 bg-blue-50 rounded-lg"> <div class="mt-4 p-4 bg-blue-50 rounded-lg dark:bg-blue-900/30">
<p class="text-sm text-blue-900"> <p class="text-sm text-blue-900 dark:text-blue-200">
<strong>Note:</strong> This is a first-run setup. You're creating the initial administrator account. <strong>Note:</strong> This is a first-run setup. You're creating the initial administrator account.
After this, you'll be able to invite other users from the admin dashboard. After this, you'll be able to invite other users from the admin dashboard.
</p> </p>

View File

@@ -2,6 +2,4 @@
require "rubygems" require "rubygems"
require "bundler/setup" require "bundler/setup"
ARGV.unshift("--ensure-latest")
load Gem.bin_path("brakeman", "brakeman") load Gem.bin_path("brakeman", "brakeman")

5
bin/standardrb Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env ruby
require "rubygems"
require "bundler/setup"
load Gem.bin_path("standard", "standardrb")

View File

@@ -27,13 +27,13 @@ module Clinch
# Configure SMTP settings using environment variables # Configure SMTP settings using environment variables
config.action_mailer.delivery_method = :smtp config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = { config.action_mailer.smtp_settings = {
address: ENV.fetch('SMTP_ADDRESS', 'localhost'), address: ENV.fetch("SMTP_ADDRESS", "localhost"),
port: ENV.fetch('SMTP_PORT', 587), port: ENV.fetch("SMTP_PORT", 587),
domain: ENV.fetch('SMTP_DOMAIN', 'localhost'), domain: ENV.fetch("SMTP_DOMAIN", "localhost"),
user_name: ENV.fetch('SMTP_USERNAME', nil), user_name: ENV.fetch("SMTP_USERNAME", nil),
password: ENV.fetch('SMTP_PASSWORD', nil), password: ENV.fetch("SMTP_PASSWORD", nil),
authentication: ENV.fetch('SMTP_AUTHENTICATION', 'plain').to_sym, authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain").to_sym,
enable_starttls_auto: ENV.fetch('SMTP_STARTTLS_AUTO', 'true') == 'true', enable_starttls_auto: ENV.fetch("SMTP_STARTTLS_AUTO", "true") == "true",
openssl_verify_mode: OpenSSL::SSL::VERIFY_PEER openssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
} }
end end

View File

@@ -20,7 +20,7 @@ Rails.application.configure do
if Rails.root.join("tmp/caching-dev.txt").exist? if Rails.root.join("tmp/caching-dev.txt").exist?
config.action_controller.perform_caching = true config.action_controller.perform_caching = true
config.action_controller.enable_fragment_cache_logging = true config.action_controller.enable_fragment_cache_logging = true
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } config.public_file_server.headers = {"cache-control" => "public, max-age=#{2.days.to_i}"}
else else
config.action_controller.perform_caching = false config.action_controller.perform_caching = false
end end
@@ -39,10 +39,10 @@ Rails.application.configure do
config.action_mailer.perform_caching = false config.action_mailer.perform_caching = false
# Set localhost to be used by links generated in mailer templates. # Set localhost to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "localhost", port: 3000 } config.action_mailer.default_url_options = {host: "localhost", port: 3000}
# Log with request_id as a tag (same as production). # Log with request_id as a tag (same as production).
config.log_tags = [ :request_id ] config.log_tags = [:request_id]
# Print deprecation notices to the Rails logger. # Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log config.active_support.deprecation = :log
@@ -62,7 +62,6 @@ Rails.application.configure do
# Use async processor for background jobs in development # Use async processor for background jobs in development
config.active_job.queue_adapter = :async config.active_job.queue_adapter = :async
# Highlight code that triggered redirect in logs. # Highlight code that triggered redirect in logs.
config.action_dispatch.verbose_redirect_logs = true config.action_dispatch.verbose_redirect_logs = true

View File

@@ -16,7 +16,7 @@ Rails.application.configure do
config.action_controller.perform_caching = true config.action_controller.perform_caching = true
# Cache assets for far-future expiry since they are all digest stamped. # Cache assets for far-future expiry since they are all digest stamped.
config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } config.public_file_server.headers = {"cache-control" => "public, max-age=#{1.year.to_i}"}
# Enable serving of images, stylesheets, and JavaScripts from an asset server. # Enable serving of images, stylesheets, and JavaScripts from an asset server.
# config.asset_host = "http://assets.example.com" # config.asset_host = "http://assets.example.com"
@@ -30,12 +30,20 @@ Rails.application.configure do
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
config.force_ssl = true config.force_ssl = true
# Additional security headers (beyond Rails defaults)
# Note: Rails already sets X-Content-Type-Options: nosniff by default
# Note: Permissions-Policy is configured in config/initializers/permissions_policy.rb
config.action_dispatch.default_headers.merge!(
"X-Frame-Options" => "DENY", # Override default SAMEORIGIN to prevent clickjacking
"Referrer-Policy" => "strict-origin-when-cross-origin" # Control referrer information
)
# Skip http-to-https redirect for the default health check endpoint. # Skip http-to-https redirect for the default health check endpoint.
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
# Log to STDOUT with the current request id as a default log tag. # Log to STDOUT with the current request id as a default log tag.
config.log_tags = [ :request_id ] config.log_tags = [:request_id]
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) config.logger = ActiveSupport::TaggedLogging.logger($stdout)
# Change to "debug" to log everything (including potentially personally-identifiable information!). # Change to "debug" to log everything (including potentially personally-identifiable information!).
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
@@ -49,8 +57,9 @@ Rails.application.configure do
# Replace the default in-process memory cache store with a durable alternative. # Replace the default in-process memory cache store with a durable alternative.
config.cache_store = :solid_cache_store config.cache_store = :solid_cache_store
# Use async processor for background jobs (modify as needed for production) # Use Solid Queue for background jobs
config.active_job.queue_adapter = :async config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = {database: {writing: :queue}}
# Ignore bad email addresses and do not raise email delivery errors. # Ignore bad email addresses and do not raise email delivery errors.
# Set this to true and configure the email server for immediate delivery to raise delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors.
@@ -58,7 +67,7 @@ Rails.application.configure do
# Set host to be used by links generated in mailer templates. # Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { config.action_mailer.default_url_options = {
host: ENV.fetch('CLINCH_HOST', 'example.com') host: ENV.fetch("CLINCH_HOST", "example.com")
} }
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit. # Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
@@ -78,13 +87,13 @@ Rails.application.configure do
config.active_record.dump_schema_after_migration = false config.active_record.dump_schema_after_migration = false
# Only use :id for inspections in production. # Only use :id for inspections in production.
config.active_record.attributes_for_inspect = [ :id ] config.active_record.attributes_for_inspect = [:id]
# Helper method to extract domain from CLINCH_HOST (removes protocol if present) # Helper method to extract domain from CLINCH_HOST (removes protocol if present)
def self.extract_domain(host) def self.extract_domain(host)
return host if host.blank? return host if host.blank?
# Remove protocol (http:// or https://) if present # Remove protocol (http:// or https://) if present
host.gsub(/^https?:\/\//, '') host.gsub(/^https?:\/\//, "")
end end
# Helper method to ensure URL has https:// protocol # Helper method to ensure URL has https:// protocol
@@ -97,11 +106,11 @@ Rails.application.configure do
# Enable DNS rebinding protection and other `Host` header attacks. # Enable DNS rebinding protection and other `Host` header attacks.
# Configure allowed hosts based on deployment scenario # Configure allowed hosts based on deployment scenario
allowed_hosts = [ allowed_hosts = [
extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com')), # External domain (auth service itself) extract_domain(ENV.fetch("CLINCH_HOST", "auth.example.com")) # External domain (auth service itself)
] ]
# Use PublicSuffix to extract registrable domain and allow all subdomains # Use PublicSuffix to extract registrable domain and allow all subdomains
host_domain = extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com')) host_domain = extract_domain(ENV.fetch("CLINCH_HOST", "auth.example.com"))
if host_domain.present? if host_domain.present?
begin begin
# Use PublicSuffix to properly extract the domain # Use PublicSuffix to properly extract the domain
@@ -115,20 +124,20 @@ Rails.application.configure do
rescue PublicSuffix::DomainInvalid rescue PublicSuffix::DomainInvalid
# Fallback to simple domain extraction if PublicSuffix fails # Fallback to simple domain extraction if PublicSuffix fails
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback" Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback"
base_domain = host_domain.split('.').last(2).join('.') base_domain = host_domain.split(".").last(2).join(".")
allowed_hosts << /.*#{Regexp.escape(base_domain)}/ allowed_hosts << /.*#{Regexp.escape(base_domain)}/
end end
end end
# Allow Docker service names if running in same compose # Allow Docker service names if running in same compose
if ENV['CLINCH_DOCKER_SERVICE_NAME'] if ENV["CLINCH_DOCKER_SERVICE_NAME"]
allowed_hosts << ENV['CLINCH_DOCKER_SERVICE_NAME'] allowed_hosts << ENV["CLINCH_DOCKER_SERVICE_NAME"]
end end
# Allow internal IP access for cross-compose or host networking # Allow internal IP access for cross-compose or host networking
if ENV['CLINCH_ALLOW_INTERNAL_IPS'] == 'true' if ENV["CLINCH_ALLOW_INTERNAL_IPS"] == "true"
# Specific host IP # Specific host IP
allowed_hosts << '192.168.2.246' allowed_hosts << "192.168.2.246"
# Private IP ranges for internal network access # Private IP ranges for internal network access
allowed_hosts += [ allowed_hosts += [
@@ -139,14 +148,14 @@ Rails.application.configure do
end end
# Local development fallbacks # Local development fallbacks
if ENV['CLINCH_ALLOW_LOCALHOST'] == 'true' if ENV["CLINCH_ALLOW_LOCALHOST"] == "true"
allowed_hosts += ['localhost', '127.0.0.1', '0.0.0.0'] allowed_hosts += ["localhost", "127.0.0.1", "0.0.0.0"]
end end
config.hosts = allowed_hosts config.hosts = allowed_hosts
# Skip DNS rebinding protection for the default health check endpoint. # Skip DNS rebinding protection for the default health check endpoint.
config.host_authorization = { exclude: ->(request) { request.path == "/up" } } config.host_authorization = {exclude: ->(request) { request.path == "/up" }}
# Sentry configuration for production # Sentry configuration for production
# Only enabled if SENTRY_DSN environment variable is set # Only enabled if SENTRY_DSN environment variable is set

View File

@@ -16,7 +16,7 @@ Rails.application.configure do
config.eager_load = ENV["CI"].present? config.eager_load = ENV["CI"].present?
# Configure public file server for tests with cache-control for performance. # Configure public file server for tests with cache-control for performance.
config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } config.public_file_server.headers = {"cache-control" => "public, max-age=3600"}
# Show full error reports. # Show full error reports.
config.consider_all_requests_local = true config.consider_all_requests_local = true
@@ -37,7 +37,7 @@ Rails.application.configure do
config.action_mailer.delivery_method = :test config.action_mailer.delivery_method = :test
# Set host to be used by links generated in mailer templates. # Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "example.com" } config.action_mailer.default_url_options = {host: "example.com"}
# Print deprecation notices to the stderr. # Print deprecation notices to the stderr.
config.active_support.deprecation = :stderr config.active_support.deprecation = :stderr

View File

@@ -0,0 +1,28 @@
# ActiveRecord Encryption Configuration
# Encryption keys derived from SECRET_KEY_BASE (no separate key storage needed)
# Used for encrypting sensitive columns (currently: TOTP secrets)
#
# Optional: Override with env vars (for key rotation or explicit key management):
# - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
# - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
# - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
# Use env vars if set, otherwise derive from SECRET_KEY_BASE (deterministic)
primary_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY") do
Rails.application.key_generator.generate_key("active_record_encryption_primary", 32)
end
deterministic_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY") do
Rails.application.key_generator.generate_key("active_record_encryption_deterministic", 32)
end
key_derivation_salt = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT") do
Rails.application.key_generator.generate_key("active_record_encryption_salt", 32)
end
# Configure Rails 7.1+ ActiveRecord encryption
Rails.application.config.active_record.encryption.primary_key = primary_key
Rails.application.config.active_record.encryption.deterministic_key = deterministic_key
Rails.application.config.active_record.encryption.key_derivation_salt = key_derivation_salt
# Allow unencrypted data for existing records (new/updated records will be encrypted)
# Set to false after all existing encrypted columns have been migrated
Rails.application.config.active_record.encryption.support_unencrypted_data = true

View File

@@ -59,7 +59,6 @@ Rails.application.configure do
policy.report_uri "/api/csp-violation-report" policy.report_uri "/api/csp-violation-report"
end end
# Start with CSP in report-only mode for testing # Start with CSP in report-only mode for testing
# Set to false after verifying everything works in production # Set to false after verifying everything works in production
config.content_security_policy_report_only = Rails.env.development? config.content_security_policy_report_only = Rails.env.development?

View File

@@ -8,7 +8,7 @@ Rails.application.config.after_initialize do
# Configure log rotation # Configure log rotation
csp_logger = Logger.new( csp_logger = Logger.new(
csp_log_path, csp_log_path,
'daily', # Rotate daily "daily", # Rotate daily
30 # Keep 30 old log files 30 # Keep 30 old log files
) )
@@ -16,7 +16,7 @@ Rails.application.config.after_initialize do
# Format: [TIMESTAMP] LEVEL MESSAGE # Format: [TIMESTAMP] LEVEL MESSAGE
csp_logger.formatter = proc do |severity, datetime, progname, msg| csp_logger.formatter = proc do |severity, datetime, progname, msg|
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n" "[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity} #{msg}\n"
end end
module CspViolationLocalLogger module CspViolationLocalLogger
@@ -69,7 +69,6 @@ Rails.application.config.after_initialize do
# Also log to main Rails logger for visibility # Also log to main Rails logger for visibility
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}" Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
rescue => e rescue => e
# Ensure logger errors don't break the CSP reporting flow # Ensure logger errors don't break the CSP reporting flow
Rails.logger.error "Failed to log CSP violation to file: #{e.message}" Rails.logger.error "Failed to log CSP violation to file: #{e.message}"
@@ -81,12 +80,12 @@ Rails.application.config.after_initialize do
csp_log_path = Rails.root.join("log", "csp_violations.log") csp_log_path = Rails.root.join("log", "csp_violations.log")
logger = Logger.new( logger = Logger.new(
csp_log_path, csp_log_path,
'daily', # Rotate daily "daily", # Rotate daily
30 # Keep 30 old log files 30 # Keep 30 old log files
) )
logger.level = Logger::INFO logger.level = Logger::INFO
logger.formatter = proc do |severity, datetime, progname, msg| logger.formatter = proc do |severity, datetime, progname, msg|
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n" "[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity} #{msg}\n"
end end
logger logger
end end
@@ -120,7 +119,6 @@ Rails.application.config.after_initialize do
# Test write to ensure permissions are correct # Test write to ensure permissions are correct
csp_logger.info "CSP Logger initialized at #{Time.current}" csp_logger.info "CSP Logger initialized at #{Time.current}"
rescue => e rescue => e
Rails.logger.error "Failed to initialize CSP local logger: #{e.message}" Rails.logger.error "Failed to initialize CSP local logger: #{e.message}"
Rails.logger.error "CSP violations will only be sent to Sentry (if configured)" Rails.logger.error "CSP violations will only be sent to Sentry (if configured)"

View File

@@ -4,5 +4,5 @@
# Use this to limit dissemination of sensitive information. # Use this to limit dissemination of sensitive information.
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
Rails.application.config.filter_parameters += [ Rails.application.config.filter_parameters += [
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc, :backup
] ]

View File

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

View File

@@ -0,0 +1,19 @@
# Configure the Permissions-Policy header
# See https://api.rubyonrails.org/classes/ActionDispatch/PermissionsPolicy.html
Rails.application.config.permissions_policy do |f|
# Disable sensitive browser features for security
f.camera :none
f.gyroscope :none
f.microphone :none
f.payment :none
f.usb :none
f.magnetometer :none
# You can enable specific features as needed:
# f.fullscreen :self
# f.geolocation :self
# You can also allow specific origins:
# f.payment :self, "https://secure.example.com"
end

View File

@@ -47,7 +47,7 @@ Rails.application.config.after_initialize do
timestamp: csp_data[:timestamp] timestamp: csp_data[:timestamp]
} }
}, },
user: csp_data[:current_user_id] ? { id: csp_data[:current_user_id] } : nil user: csp_data[:current_user_id] ? {id: csp_data[:current_user_id]} : nil
) )
# Log to Rails logger for redundancy # Log to Rails logger for redundancy
@@ -69,10 +69,10 @@ Rails.application.config.after_initialize do
parsed.host parsed.host
rescue URI::InvalidURIError rescue URI::InvalidURIError
# Handle cases where URI might be malformed or just a path # Handle cases where URI might be malformed or just a path
if uri.start_with?('/') if uri.start_with?("/")
nil # It's a relative path, no domain nil # It's a relative path, no domain
else else
uri.split('/').first # Best effort extraction uri.split("/").first # Best effort extraction
end end
end end
end end

View File

@@ -0,0 +1,7 @@
# Token HMAC key derivation
# This key is used to compute HMAC-based token prefixes for fast lookup
# Derived from SECRET_KEY_BASE - no storage needed, deterministic output
# Optional: Set OIDC_TOKEN_PREFIX_HMAC env var to override with explicit key
module TokenHmac
KEY = ENV["OIDC_TOKEN_PREFIX_HMAC"] || Rails.application.key_generator.generate_key("oidc_token_prefix", 32)
end

Some files were not shown because too many files have changed in this diff Show More