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") %> + +
+
+

Account management flows

+
+ <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <%= link_to "Users", users_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> +
+
+

How invitation, password reset, email change, lockout, and unlock work from an admin's perspective

+ + <%# Triage cheat sheet %> +
+
+
+

Admin triage cheat sheet

+ +
+
+
+ +
+ "I never got the invite email" +

Invite may not have been sent, or ended up in spam

+
+ +
+
+

Check Confirmed column on users index:

+
+
+ Invite button +
    +
  • No invite has been sent — click Invite to send
  • +
+ +
+
+ Clock icon + (hover on index for details) +
    +
  • Invite was sent — ask user to check spam
  • +
  • Otherwise click Resend invite email on user's Edit
  • +
+ +
+
+ Check icon + (hover on index for details) +
    +
  • Already confirmed — user doesn't need an invite, they need a password reset
  • +
+ +
+
+

Note: Confirmation and welcome tokens do not expire

+
+
+
+ +
+ "My invite link doesn't work" +

Confirmation link may be invalid or already used

+
+ +
+
+

Check Confirmed column on users index:

+
+
+ Clock icon + (hover on index for details) +
    +
  • Invite was sent but not yet confirmed
  • +
  • Click Resend invite email on user's Edit
  • +
+

Note: This invalidates old links

+ +
+
+ Check icon + (hover on index for details) +
    +
  • Already confirmed — user doesn't need an invite, they need a password reset
  • +
+ +
+
+

Note: Confirmation and welcome tokens do not expire

+
+
+
+ +
+ "I'm locked out" +

Account likely locked after too many failed attempts

+
+ +
+
+

Check Access column on users index:

+
+
+ No lock icon +
    +
  • Clock icon in Confirmed — email unconfirmed, Resend invite email on user's Edit
  • +
  • Ban icon — account is inactive, set to active on user's Edit under Account flags
  • +
  • Maybe they mean they forgot their password?
  • +
+ +
+
+ Lock icon +
    +
  • Account is locked — go to user's Edit and click Unlock account (sets failed attempts back to 0)
  • +
  • User may also need a password reset
  • +
+ +
+
+

Note: Accounts don't auto-unlock — needs an admin.

+
+
+
+ +
+ "I forgot my password" / "My reset expired" +

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.

+
+
+
+ +
+ "I confirmed my email but can't sign in" +

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.

+
    +
  • Check Confirmed column on users index — should show (confirmed)
  • +
  • Admin clicks Send reset password email from user's Edit or user clicks Forgot your password? from login
  • +
  • User sets a password via the reset link — this also auto-signs them in
  • +
+ +

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.

+
+
+
+ +
+ "I changed my email but can't log in with it" +

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.

+
    +
  • Check if user has a warning icon on users index or user show — means confirmation email wasn't sent
  • +
  • Admin can resend confirmation email from the user show page
  • +
  • Confirmation link does not expire — admin can resend if user lost the email
  • +
+ +

Note: User keeps signing in with old email until new email is confirmed.

+
+
+
+
+
+ + <%# Flow index + Legend card %> +
+

Flows

+
    +
  1. 1. Invite new user
  2. +
  3. 2. Resend invite email
  4. +
  5. 3. Reset password
  6. +
  7. 4. Email change
  8. +
  9. 5. Failed attempts & lockout
  10. +
  11. 6. Manual lock / unlock
  12. +
+ +

Key

+
+
+ Admin action +
+
+ System / email +
+
+ User action +
+
+ Error / blocked state +
+
+ Decision point +
+
+
+ + <%# Flow 1: Invite new user %> +
+

1. Invite new user

+

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 +
+
+ + <%# Flow 2: Resend invite %> +
+

2. Resend invite email

+

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 +
+
+ + <%# Flow 3: Reset password %> +
+

3. Reset password

+

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 +
+
+ + <%# Flow 4: Email change %> +
+

4. Email change (admin-initiated)

+

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 +
+
+ + <%# Flow 5: Failed passwords & lockout %> +
+

5. Failed password attempts & account lockout

+

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 +
+
+ + <%# Flow 6: Admin manual lock/unlock %> +
+

6. Admin manual lock / unlock

+

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 +

+
+ + diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 95f98c8f0..69fdfc13b 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -10,7 +10,10 @@
-
+
+ <%= link_to flow_diagram_users_path, class: "text-sm text-gray-500 hover:text-gray-700" do %> + Account flow diagram + <% end %> <% if allowed_to?(:new?, User) %> <%= link_to "New #{User.model_name.human.downcase}", new_user_path, diff --git a/config/routes.rb b/config/routes.rb index 470d39f72..eab4cd7bd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -35,6 +35,7 @@ resources :users, only: [ :new, :index, :show, :edit, :update, :create, :destroy ] do collection do get :check_duplicates + get :flow_diagram end member do post :send_reset_password_instructions diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 5cfa33794..6cb38d478 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -92,6 +92,7 @@ "app/views/admin/ahoy_activities/index.html.erb" => "admin-only bg-blue-100", "app/views/admin/analytics/index.html.erb" => "admin-only bg-blue-100", "app/views/bookmarks/tally.html.erb" => "admin-only bg-blue-100", + "app/views/users/flow_diagram.html.erb" => "admin-only bg-blue-100", "app/views/dedupes/index.html.erb" => "admin-only bg-blue-100", "app/views/dedupes/preview.html.erb" => "admin-only bg-blue-100", "app/views/taggings/matrix.html.erb" => "admin-only bg-blue-100",