diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index f3a17c82f..4f1e6f754 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -305,6 +305,11 @@ def send_welcome_instructions notice: "Invitation sent to #{@user.email}." end + # Visual reference for admins triaging user account challenges + def flow_diagram + authorize! + end + # ========================================================= # PRIVATE # ========================================================= diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 48d8f2f28..b68c9a989 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -96,6 +96,9 @@ application.register("tags-combination-highlight", TagsCombinationHighlightContr import TimeframeController from "./timeframe_controller" application.register("timeframe", TimeframeController) +import ToggleDetailsController from "./toggle_details_controller" +application.register("toggle-details", ToggleDetailsController) + import ToggleLockController from "./toggle_lock_controller" application.register("toggle-lock", ToggleLockController) diff --git a/app/frontend/javascript/controllers/toggle_details_controller.js b/app/frontend/javascript/controllers/toggle_details_controller.js new file mode 100644 index 000000000..d0736f843 --- /dev/null +++ b/app/frontend/javascript/controllers/toggle_details_controller.js @@ -0,0 +1,13 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["toggleBtn"] + + toggleAll() { + const details = this.element.querySelectorAll("details") + const allOpen = Array.from(details).every(d => d.open) + + details.forEach(d => d.open = !allOpen) + this.toggleBtnTarget.textContent = allOpen ? "Expand all" : "Collapse all" + } +} diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 938efd324..93b1c0d76 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -15,6 +15,7 @@ def process_email_change? = admin? def confirm_email_manual? = admin? def process_email_manual? = admin? def send_welcome_instructions? = admin? + def flow_diagram? = admin? def search? = admin? def change_password? = authenticated? def update_password? = authenticated? diff --git a/app/views/users/flow_diagram.html.erb b/app/views/users/flow_diagram.html.erb new file mode 100644 index 000000000..f1fb7e27b --- /dev/null +++ b/app/views/users/flow_diagram.html.erb @@ -0,0 +1,465 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> + +
How invitation, password reset, email change, lockout, and unlock work from an admin's perspective
+ + <%# Triage cheat sheet %> +Invite may not have been sent, or ended up in spam
+Check Confirmed column on users index:
+Note: Confirmation and welcome tokens do not expire
+Confirmation link may be invalid or already used
+Check Confirmed column on users index:
+Note: This invalidates old links
+ +Note: Confirmation and welcome tokens do not expire
+Account likely locked after too many failed attempts
+Check Access column on users index:
+Note: Accounts don't auto-unlock — needs an admin.
+User needs a password reset
+User clicks Forgot your password? from login or admin clicks Send reset password email from user's Edit
+ +Note: Reset links expire after 7 days.
Note: Each new reset invalidates previous email links.
Note: User will be automatically signed in after reset.
Email confirmed but user never set a password
+User confirmed their email but never set a password. They see "Invalid email or password" because they never had one.
+Note: Accounts are created with a random password the user doesn't know. Ideally they set a real one during the welcome invite flow.
Note: Welcome tokens no longer expire, so this mainly applies to legacy accounts created before that change.
New email needs confirmation to become active
+After an email change, the old email stays active until the new one is confirmed. User must sign in with the old email until then.
+Note: User keeps signing in with old email until new email is confirmed.
+Admin creates a user and sends welcome instructions • Tokens do not expire
+
+%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#dbeafe', 'primaryTextColor': '#1e3a5f', 'primaryBorderColor': '#93c5fd', 'secondaryColor': '#dcfce7', 'tertiaryColor': '#fef9c3', 'lineColor': '#94a3b8', 'fontSize': '14px' }}}%%
+flowchart TD
+ A["fa:fa-user-plus Admin: creates user
(Users#create)"]:::admin
+ A2["fa:fa-paper-plane Admin: clicks 'Invite'
on users index or
'Resend invite email'
on user edit page"]:::admin
+ B["fa:fa-key System generates
welcome_instructions_token
for confirmation + password"]:::system
+ C["fa:fa-envelope System sends
welcome instructions /
confirmation email"]:::system
+ D{"fa:fa-clock User clicks
'Set your password'
link in email?"}:::decision
+ E["fa:fa-check-circle Confirmations#show
confirms email"]:::system
+ F{"fa:fa-key Welcome token
still valid?"}:::decision
+ F2["Redirect to
/welcome/:token"]:::system
+ G["fa:fa-lock User: sets password
(min 5 chars)"]:::user
+ H["fa:fa-circle-check Account active!
Auto-signed in"]:::success
+ I["fa:fa-clock Link not clicked"]:::error
+ J["fa:fa-rotate Admin resends invite
from user edit page
(see flow 2)"]:::admin
+ STUCK["fa:fa-triangle-exclamation Redirected to
password reset form
(user has no known password)"]:::error
+ FIX["fa:fa-envelope User requests
password reset
(see flow 3)"]:::admin
+
+ A --> A2 --> B --> C --> D
+ D -- "Yes" --> E --> F
+ F -- "Yes" --> F2 --> G --> H
+ F -- "No (expired)" --> STUCK --> FIX
+ D -- "No / too late" --> I --> J
+
+ linkStyle 10 stroke:#f87171
+ linkStyle 11 stroke:#f87171
+
+ classDef admin fill:#dbeafe,stroke:#93c5fd,color:#1e3a5f
+ classDef system fill:#dcfce7,stroke:#86efac,color:#14532d
+ classDef user fill:#fef9c3,stroke:#fde047,color:#713f12
+ classDef decision fill:#f3e8ff,stroke:#c4b5fd,color:#4c1d95
+ classDef error fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d
+ classDef success fill:#d1fae5,stroke:#6ee7b7,color:#065f46
+
+ Same endpoint as invite • Button shows on users index and user edit page when user has not yet confirmed
+
+%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#dbeafe', 'primaryTextColor': '#1e3a5f', 'primaryBorderColor': '#93c5fd', 'secondaryColor': '#dcfce7', 'tertiaryColor': '#fef9c3', 'lineColor': '#94a3b8', 'fontSize': '14px' }}}%%
+flowchart TD
+ A{"fa:fa-question User's email
is not confirmed?"}:::decision
+ B["fa:fa-paper-plane Button visible:
'Invite' on users index
'Resend invite email'
on user edit page"]:::admin
+ C["fa:fa-eye-slash Buttons hidden
(user already confirmed)"]:::error
+ D["POST /users/:id/
send_welcome_instructions"]:::system
+ E["fa:fa-key Regenerates
welcome_instructions_token"]:::system
+ F["fa:fa-envelope Sends new
confirmation email"]:::system
+ G["fa:fa-inbox User receives
fresh link"]:::user
+ H["fa:fa-arrow-right User clicks
'Set your password'
(see flow 1)"]:::system
+ click H href "#flow-1" "Go to flow 1"
+
+ A -- "Yes" --> B --> D --> E --> F --> G --> H
+ A -- "No, email is
already confirmed" --> C
+
+ linkStyle 5 stroke:#f87171
+
+ classDef admin fill:#dbeafe,stroke:#93c5fd,color:#1e3a5f
+ classDef system fill:#dcfce7,stroke:#86efac,color:#14532d
+ classDef user fill:#fef9c3,stroke:#fde047,color:#713f12
+ classDef decision fill:#f3e8ff,stroke:#c4b5fd,color:#4c1d95
+ classDef error fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d
+
+ Admin or user can initiate • Token valid 7 days • User auto-signed in after reset
+
+%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#dbeafe', 'primaryTextColor': '#1e3a5f', 'primaryBorderColor': '#93c5fd', 'secondaryColor': '#dcfce7', 'tertiaryColor': '#fef9c3', 'lineColor': '#94a3b8', 'fontSize': '14px' }}}%%
+flowchart TD
+ A["fa:fa-paper-plane Admin: clicks
'Send reset password email'
on user edit page"]:::admin
+ A2["fa:fa-question User: clicks
'Forgot your password?'
on sign-in page"]:::user
+ B["fa:fa-key System generates
reset_password_token
(valid 7 days)"]:::system
+ C["fa:fa-envelope Email sent to user
+ FYI notification to admin"]:::system
+ D{"fa:fa-clock User clicks
'Reset your password'
within 7 days?"}:::decision
+ E["Passwords#edit
shows reset form"]:::system
+ F["fa:fa-lock User: enters new
password (min 5 chars)"]:::user
+ G{"fa:fa-check-circle Was user
unconfirmed?"}:::decision
+ H["fa:fa-envelope-circle-check Also confirms email"]:::system
+ I["fa:fa-circle-check Password updated
Auto-signed in"]:::success
+ J["fa:fa-clock Link expired
(7 days)"]:::error
+ K["fa:fa-rotate Admin sends another
from user edit page,
or user requests from
sign-in page"]:::admin
+
+ A --> B
+ A2 --> B
+ B --> C --> D
+ D -- "Yes" --> E --> F --> G
+ G -- "Yes" --> H --> I
+ G -- "No" --> I
+ D -- "No / too late" --> J --> K
+
+ linkStyle 9 stroke:#f87171
+ linkStyle 10 stroke:#f87171
+
+ classDef admin fill:#dbeafe,stroke:#93c5fd,color:#1e3a5f
+ classDef system fill:#dcfce7,stroke:#86efac,color:#14532d
+ classDef user fill:#fef9c3,stroke:#fde047,color:#713f12
+ classDef decision fill:#f3e8ff,stroke:#c4b5fd,color:#4c1d95
+ classDef error fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d
+ classDef success fill:#d1fae5,stroke:#6ee7b7,color:#065f46
+
+ Devise reconfirmable • Old email stays active until new email is confirmed • Confirmation token does not expire
+
+%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#dbeafe', 'primaryTextColor': '#1e3a5f', 'primaryBorderColor': '#93c5fd', 'secondaryColor': '#dcfce7', 'tertiaryColor': '#fef9c3', 'lineColor': '#94a3b8', 'fontSize': '14px' }}}%%
+flowchart TD
+ A["fa:fa-pen-to-square Admin: changes email
on user edit page"]:::admin
+ B["Old email stays active
New email saved to
unconfirmed_email"]:::system
+ C["Interstitial page:
'Email change saved'"]:::system
+ D{"fa:fa-question Admin chooses to
send confirmation
email?"}:::decision
+ E["fa:fa-envelope Confirmation email
sent to new address"]:::system
+ F["fa:fa-triangle-exclamation Skip — no email sent
Warning icon shown on
users index and user show page"]:::admin
+ G{"fa:fa-clock User clicks
confirm link
in email?"}:::decision
+ H["fa:fa-circle-check New email becomes
active, old email
replaced"]:::success
+ I["fa:fa-clock Link not clicked"]:::error
+ J["fa:fa-rotate Admin can resend
confirmation email from
the user show page"]:::admin
+ K["fa:fa-right-to-bracket User keeps signing in
with old email
until confirmed"]:::user
+
+ A --> B --> C --> D
+ D -- "Yes" --> E --> G
+ D -- "No / Skip" --> F --> K
+ G -- "Yes" --> H
+ G -- "No / too late" --> I --> J
+ E --> K
+
+ linkStyle 5 stroke:#f87171
+ linkStyle 8 stroke:#f87171
+
+ classDef admin fill:#dbeafe,stroke:#93c5fd,color:#1e3a5f
+ classDef system fill:#dcfce7,stroke:#86efac,color:#14532d
+ classDef user fill:#fef9c3,stroke:#fde047,color:#713f12
+ classDef decision fill:#f3e8ff,stroke:#c4b5fd,color:#4c1d95
+ classDef error fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d
+ classDef success fill:#d1fae5,stroke:#6ee7b7,color:#065f46
+
+ 10 attempts before lockout • Unlock strategy: none (admin must unlock manually) • Last attempt warning shown
+
+%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#dbeafe', 'primaryTextColor': '#1e3a5f', 'primaryBorderColor': '#93c5fd', 'secondaryColor': '#dcfce7', 'tertiaryColor': '#fef9c3', 'lineColor': '#94a3b8', 'fontSize': '14px' }}}%%
+flowchart TD
+ A["fa:fa-xmark User: enters
wrong password
on sign-in page"]:::user
+ B["fa:fa-plus failed_attempts
counter +1"]:::system
+ C{"failed_attempts
== 9?"}:::decision
+ D["fa:fa-triangle-exclamation Warning shown on
sign-in page:
'Last attempt before
account is locked'"]:::error
+ E{"failed_attempts
≥ 10?"}:::decision
+ F["fa:fa-lock Account LOCKED
(locked_at = now)"]:::error
+ G["fa:fa-ban User sees on sign-in
page: 'Account is locked'"]:::error
+ H["fa:fa-rotate User can
try again"]:::user
+ I["fa:fa-lock Admin: sees lock icon
in Access col on
users index"]:::admin
+ J["fa:fa-unlock Admin: clicks
'Unlock account'
on user edit page"]:::admin
+ K["fa:fa-eraser locked_at cleared
failed_attempts reset to 0"]:::system
+ L["fa:fa-circle-check User can
sign in again"]:::success
+
+ A --> B --> C
+ C -- "Yes" --> D --> E
+ C -- "No" --> E
+ E -- "Yes" --> F --> G --> I --> J --> K --> L
+ E -- "No" --> H --> A
+
+ linkStyle 4 stroke:#f87171
+ linkStyle 11 stroke:#f87171
+
+ classDef admin fill:#dbeafe,stroke:#93c5fd,color:#1e3a5f
+ classDef system fill:#dcfce7,stroke:#86efac,color:#14532d
+ classDef user fill:#fef9c3,stroke:#fde047,color:#713f12
+ classDef decision fill:#f3e8ff,stroke:#c4b5fd,color:#4c1d95
+ classDef error fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d
+ classDef success fill:#d1fae5,stroke:#6ee7b7,color:#065f46
+
+ Admin can lock or unlock any account at any time, independent of failed attempts
+
+%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#dbeafe', 'primaryTextColor': '#1e3a5f', 'primaryBorderColor': '#93c5fd', 'secondaryColor': '#dcfce7', 'tertiaryColor': '#fef9c3', 'lineColor': '#94a3b8', 'fontSize': '14px' }}}%%
+flowchart LR
+ A["fa:fa-pen-to-square Admin: user edit page"]:::admin
+ B{"fa:fa-question Account
currently
locked?"}:::decision
+ C["fa:fa-unlock Button: 'Unlock account'"]:::admin
+ D["fa:fa-lock Button: 'Lock account'"]:::admin
+ E["fa:fa-eraser Account unlocked
failed_attempts reset to 0"]:::system
+ F["fa:fa-lock Account locked
immediately"]:::system
+
+ A --> B
+ B -- "Yes" --> C --> E
+ B -- "No" --> D --> F
+
+ linkStyle 3 stroke:#f87171
+
+ classDef admin fill:#dbeafe,stroke:#93c5fd,color:#1e3a5f
+ classDef system fill:#dcfce7,stroke:#86efac,color:#14532d
+ classDef decision fill:#f3e8ff,stroke:#c4b5fd,color:#4c1d95
+
+ + Source: UsersController, WelcomeController, ConfirmationsController, PasswordsController, User model, Devise config +
+