Skip to content

feat(auth): invite users without a password and let them set it on first login - #348

Merged
cristim merged 2 commits into
feat/multicloud-web-frontendfrom
claude/feat-invite-user-set-password-on-first-login
May 13, 2026
Merged

feat(auth): invite users without a password and let them set it on first login#348
cristim merged 2 commits into
feat/multicloud-web-frontendfrom
claude/feat-invite-user-set-password-on-first-login

Conversation

@cristim

@cristim cristim commented May 13, 2026

Copy link
Copy Markdown
Member

Summary

Admins can now create a user with the password field left blank. When that
happens the backend mails the recipient a "Set your password" link and
keeps the account inactive until they follow it — the recipient picks their
own password, never one the admin has to type and transmit.

Independent of #347 (role-allowlist fix); both branch off
feat/multicloud-web-frontend and can land in either order.

What changes

Backend (auth service)internal/auth/service_user.go

  • When req.Password == "":
    • Hashes a random unguessable token as the password_hash so the
      users.password_hash NOT NULL constraint stays satisfied and no
      client input can ever match the placeholder.
    • Stores the user with Active = false, a hashed setup token, and a
      PasswordResetExpiry 7 days out (new PasswordSetupExpiry constant
      — longer than the 1-hour PasswordResetExpiry because invites
      typically sit in an inbox before the recipient acts).
    • Calls the new emailSender.SendUserInviteEmail(ctx, email, setupURL).
    • Re-uses the existing /reset-password?token=… URL — the
      ConfirmPasswordReset flow already activates inactive users on first
      password set (admin-bootstrap flow), so the welcome link doubles as
      activation with no schema migration.
  • Email-send failures log + continue rather than failing the API call,
    mirroring the password-reset endpoint's anti-enumeration behaviour.

Emailinternal/email/{interfaces,templates,template_renderers,smtp_sender,nop_sender}.go

  • New SendUserInviteEmail(ctx, email, setupURL) across the
    SenderInterface, SMTP sender and no-op sender.
  • New userInviteTemplate ("Set your password" copy, 7-day expiry hint,
    pointer to "Forgot password?" if the link expires).

Frontendfrontend/src/users/userModals.ts, frontend/src/api/users.ts, frontend/src/index.html

  • Password input is no longer required; the placeholder + a <small>
    hint explain the invite behaviour.
  • Client-side strength checks now run only when a password was actually
    typed.
  • createUser API helper passes an empty password through verbatim
    instead of base64-wrapping it.
  • The success toast distinguishes the two paths:

    "User created successfully" vs "Invitation email sent to … —
    they will set their password on first login"

Tests / interface mocks

  • New service_user_test.go cases: invite happy path captures the
    stored User and asserts Active == false, non-empty
    PasswordResetToken, an PasswordResetExpiry in the future, and a
    non-empty placeholder PasswordHash. Second case asserts the API
    call still succeeds when SendUserInviteEmail returns an error.
  • All cross-package SenderInterface impls / mocks updated:
    internal/auth/test_helpers.go, internal/api/coverage_gaps_test.go,
    internal/purchase/mocks_test.go,
    internal/scheduler/scheduler_test.go,
    internal/server/app_test.go,
    internal/server/handler_ri_exchange_test.go.

Why not a schema migration

The users.password_hash NOT NULL constraint stays — the placeholder is
a real bcrypt hash of a fresh 32-byte token, just one no client can
reproduce. This avoids an irreversible migration on a constraint that's
defended other code paths since 000001_initial_schema.up.sql. The
admin-bootstrap flow (SetupAdminConfirmPasswordReset) has
already established the "inactive user, password set later" pattern in
production, so we're slotting into an existing groove rather than
inventing a new account state.

Test plan

  • go build ./...
  • go test ./... (full repo, all green)
  • go test -v -run TestService_CreateUser ./internal/auth/...
    both new cases pass: invite_flow_when_password_omitted,
    invite_flow_still_succeeds_when_email_send_fails.
  • Manual end-to-end: create a user in the admin UI with the password
    field blank → confirm the success toast names the invitee's email,
    the invite email arrives with a /reset-password?token=… link,
    following the link lets the user set a password and lands them
    logged in.
  • Manual sanity: create a user with a password filled in → existing
    flow unchanged, toast says "User created successfully".

Out of scope (separate work if needed)

  • The reset-password modal still says "Reset Your Password" — works for
    invites but a friendlier "Set your password" heading when the user is
    inactive would be nicer.
  • No "Resend invite" button in the Users list — for now admins can use
    the existing Forgot Password flow on the invitee's behalf.
  • Invite-revocation / "uninvite" — admins can already delete the user.

Generated by Claude Code

Summary by CodeRabbit

Release Notes

  • New Features
    • User invitations: Create user accounts without immediately assigning passwords. New users receive an invitation email with a secure setup link to configure their own password on first login. Password setup must be completed within 7 days.

Review Change Stack

…rst login

Admins can now create a user with the password field left blank. When
that happens the backend:

- Stores the user inactive, with an unguessable random bcrypt hash so
  the password_hash NOT NULL constraint stays satisfied and no client
  input can match the placeholder.
- Generates a setup token, hashes it for storage, and stamps a 7-day
  expiry (new PasswordSetupExpiry constant — longer than the 1-hour
  PasswordResetExpiry because invites typically sit in an inbox before
  the recipient acts).
- Mails the recipient a "Set your password" link that lands on the
  existing /reset-password page; ConfirmPasswordReset already activates
  the user on first password set (admin-bootstrap flow), so the welcome
  link doubles as activation with no schema changes.

The user-create modal drops the password "required" attribute, runs the
strength checks only when a password was actually typed, and shows the
admin a distinct success toast pointing at the invitee's mailbox.

Added SendUserInviteEmail across EmailSenderInterface, the SMTP sender,
the no-op sender, all test mocks, the Sender's high-level method and
the template renderer / template constant. Two new unit tests cover the
invite happy path and the email-send-failure-still-succeeds case so
admins don't see a 5xx when SES rate-limits or the recipient address is
malformed.
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f0eb1644-ce28-440e-baf2-2b080f5875c8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR implements a complete user invitation workflow, enabling admins to create users without passwords. Invited users receive time-limited setup emails and set their own passwords on first login. The change spans email infrastructure, backend user creation logic, frontend UX, and test infrastructure.

Changes

User Invitation Feature

Layer / File(s) Summary
Email Invitation Infrastructure
internal/email/interfaces.go, internal/email/templates.go, internal/email/template_renderers.go, internal/email/smtp_sender.go, internal/email/nop_sender.go
New SendUserInviteEmail interface method, UserInviteData contract, and RenderUserInviteEmail rendering function. SMTP and Nop sender implementations send/suppress the invite email with a subject line and setup URL.
Auth Service Configuration & Contracts
internal/auth/service.go, internal/auth/interfaces.go
Introduces PasswordSetupExpiry constant (7 days) and extends EmailSenderInterface with the SendUserInviteEmail method.
User Creation with Invitation Logic
internal/auth/service_user.go, internal/auth/service_user_test.go
validateCreateUserRequest now permits empty passwords. CreateUser detects invitation requests (empty password), generates a placeholder for hashing, creates inactive users, stores setup tokens, sends invite emails asynchronously (without blocking on failure), and logs either "User invited" or "User created". Two new tests verify invite creation and graceful handling of email send errors.
Frontend User Interface & API Integration
frontend/src/api/users.ts, frontend/src/index.html, frontend/src/users/userModals.ts
createUser API conditionally base64-encodes passwords or sends empty strings. Create modal makes password optional, updates placeholder to reference invitation flow, and adds password strength help text. saveUser skips validation for blank passwords and shows "invitation sent" messaging instead of "user created".
Test Infrastructure: Mock & Stub Updates
internal/auth/test_helpers.go, internal/api/coverage_gaps_test.go, internal/purchase/mocks_test.go, internal/scheduler/scheduler_test.go, internal/server/app_test.go, internal/server/handler_ri_exchange_test.go
All test mocks and stubs implement SendUserInviteEmail using testify/mock or no-op patterns to satisfy the extended interface.

Sequence Diagram

sequenceDiagram
  participant Admin
  participant Frontend
  participant APIServer
  participant UserService
  participant EmailService
  participant Database
  Admin->>Frontend: Create user without password
  Frontend->>APIServer: POST /users (password="")
  APIServer->>UserService: CreateUser(password="")
  alt Invitation Flow
    UserService->>Database: Insert user (Active=false)
    UserService->>Database: Store setup token + expiry
    UserService->>EmailService: SendUserInviteEmail(setupURL)
    EmailService-->>EmailService: (async, fire-and-forget)
  end
  UserService-->>APIServer: created User
  APIServer-->>Frontend: success + "invitation sent"
  Frontend-->>Admin: confirmation message
  Note over Admin,EmailService: User receives invite email with setup link
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

triaged, priority/p2, severity/low, urgency/this-quarter, impact/many, effort/s, type/feat

Poem

🐰 A hop towards open doors,
Where invites bloom on sender shores,
New users set passwords of their own,
With tokens and tokens, no longer alone!
Seven days to claim their place,
Welcome to CUDly's warm embrace! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately and specifically describes the main feature being introduced: enabling admins to invite users without a password, with users setting it on first login.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/feat-invite-user-set-password-on-first-login

Comment @coderabbitai help to get the list of available commands and usage tips.

cristim commented May 13, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

@cristim Sure, I'll review the changes in this PR now!

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/auth/service_user.go`:
- Around line 180-187: The invite path currently swallows SendUserInviteEmail
errors (s.emailSender.SendUserInviteEmail) and only logs them, causing the
caller to see success for an unreachable/inactive account; change the flow so
that when SendUserInviteEmail returns an error (during the invite branch that
builds setupURL using s.dashboardURL and inviteToken) you return that error (or
a wrapped warning/error result) to the caller instead of only logging it, or add
a boolean status field to the service response indicating inviteDeliveryFailed
and set it on failure so callers (admin/UI) can surface the problem; update the
function that invokes this block to propagate the error/status to its API layer.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7a10bb5f-334a-42ea-8b16-dd6ed147f4eb

📥 Commits

Reviewing files that changed from the base of the PR and between 83b09bf and 6371e6a.

📒 Files selected for processing (18)
  • frontend/src/api/users.ts
  • frontend/src/index.html
  • frontend/src/users/userModals.ts
  • internal/api/coverage_gaps_test.go
  • internal/auth/interfaces.go
  • internal/auth/service.go
  • internal/auth/service_user.go
  • internal/auth/service_user_test.go
  • internal/auth/test_helpers.go
  • internal/email/interfaces.go
  • internal/email/nop_sender.go
  • internal/email/smtp_sender.go
  • internal/email/template_renderers.go
  • internal/email/templates.go
  • internal/purchase/mocks_test.go
  • internal/scheduler/scheduler_test.go
  • internal/server/app_test.go
  • internal/server/handler_ri_exchange_test.go

Comment on lines +180 to +187
if invite {
setupURL := fmt.Sprintf("%s/reset-password?token=%s", s.dashboardURL, inviteToken)
if err := s.emailSender.SendUserInviteEmail(ctx, user.Email, setupURL); err != nil {
// Mirror the password-reset flow: log but don't fail the
// caller — the admin already sees a created user, and the
// invite can be re-sent through the password-reset endpoint.
logging.Errorf("Failed to send user invite email: %v", err)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Surface invite-delivery failure to the caller.

This path still returns success for an inactive account even when SendUserInviteEmail fails, so the admin/UI cannot tell that the user is currently unreachable. In this admin-only flow, logging the failure is not enough; return a warning/status the caller can act on, or fail the request. Otherwise the new invite success UX can report a false positive.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auth/service_user.go` around lines 180 - 187, The invite path
currently swallows SendUserInviteEmail errors
(s.emailSender.SendUserInviteEmail) and only logs them, causing the caller to
see success for an unreachable/inactive account; change the flow so that when
SendUserInviteEmail returns an error (during the invite branch that builds
setupURL using s.dashboardURL and inviteToken) you return that error (or a
wrapped warning/error result) to the caller instead of only logging it, or add a
boolean status field to the service response indicating inviteDeliveryFailed and
set it on failure so callers (admin/UI) can surface the problem; update the
function that invokes this block to propagate the error/status to its API layer.

CodeRabbit on #348: the invite path silently swallowed
SendUserInviteEmail errors and returned a success-shaped response, so
the admin UI reported "Invitation email sent to ..." even when the
recipient couldn't have received anything — the account would exist
but be unreachable.

Mirror the email_sent / email_reason pattern already used by purchase
executions:

- internal/auth/service_user.go: CreateUser now returns
  *CreateUserResult with optional InviteEmailSent + InviteEmailError
  fields. The user-row write still succeeds on email failure (a 5xx
  would imply rollback and push admins toward re-submitting, which the
  duplicate-email guard would then block); the failure is reported via
  the result so the API layer can pass it to the UI.
- internal/auth/service_api.go: new APICreateUserResponse embeds
  APIUser to keep the existing flat JSON contract intact and only adds
  optional invite_email_sent / invite_email_error fields. POST
  /api/users now returns this wrapper.
- frontend/src/api/types.ts, users.ts: createUser() resolves to the
  new CreateUserResponse type.
- frontend/src/users/userModals.ts: three-way toast — password-up-front
  success, invite-delivered success, invite-failed warning that points
  the admin at Forgot Password as the recovery path.
- Tests updated: the existing "still succeeds when email send fails"
  case is renamed to "surfaces delivery failure without 5xx" and now
  asserts InviteEmailSent==false + non-empty InviteEmailError. The
  happy path asserts InviteEmailSent==true. Non-invite paths assert
  InviteEmailSent is nil.
@cristim
cristim merged commit 9fe1796 into feat/multicloud-web-frontend May 13, 2026
5 checks passed
cristim added a commit that referenced this pull request May 13, 2026
) (#357)

The frontend at frontend/src/api/auth.ts:91-101 base64-encodes
new_password before POSTing to /api/auth/reset-password, matching
the convention used by login / change-password / update-profile.

Every other password-handling endpoint in handler_auth.go calls
decodeBase64Password before forwarding to the service. resetPassword
was the one that didn't, so the bcrypt hash stored represented the
base64 string rather than the plaintext. Login then decoded the
typed-in plaintext and bcrypt-compared against the wrong hash — and
the user got "invalid email or password" with no way to recover.

Effect: every user invited via the new invite flow (#348) was locked
out the moment they "set" a password. Forgot-Password for existing
users was equally broken. P0/critical for any non-admin access.

Tests:
  TestHandler_resetPassword_DecodesBase64 — happy path: base64
    payload → service sees the plaintext.
  TestHandler_resetPassword_InvalidBase64 — malformed payload →
    400 before the service call fires (#356 AC).
  TestHandler_resetPassword_Success / _Error updated to send
    properly-encoded payloads now that the handler decodes them.
cristim added a commit that referenced this pull request May 13, 2026
…358)

Four users.test.ts cases were stale on `feat/multicloud-web-frontend`
and are failing on every PR-merge ref against it (including #336, #352,
others). The tests assert the pre-#348 / pre-#347 behaviour:

1. `loadUsers › should load users and groups`
2. `loadUsers › should render groups after loading`
   `mockGroups` is missing the `allowed_accounts: []` field that the
   backend now ships on every group (multi-cloud account-scoping work).
   The actual groups from `api.listGroups` carry the field, so the
   deep-equal assertion against the fixture diverges.

3. `openCreateUserModal › should make password required`
4. `saveUser › should validate empty password for new user`
   Issue #348 ("invite users without a password and let them set it on
   first login") made password optional on create. userModals.ts now
   explicitly sets `passwordInput.required = false` (line 36) so the
   form-level HTML validation doesn't block the invite flow, and
   saveUser intentionally calls `api.createUser` with an empty password
   to trigger the backend invite-email path. Both old tests asserted
   the opposite behaviour and now fail.

Fix:
- Add `allowed_accounts: []` to the two `mockGroups` entries.
- Invert "should make password required" → "should not mark password
  as required (blank invites the user)" asserting `required === false`.
- Replace "should validate empty password" → "should allow empty
  password for new user (invite flow, issue #348)" asserting
  createUser was called with an empty password and no error toast was
  rendered.

Unblocks the frontend-build-sentinel workflow on PRs against
`feat/multicloud-web-frontend`. 1633 frontend tests pass.
cristim added a commit that referenced this pull request May 13, 2026
…oses #344) (#352)

* feat(frontend/recs): row-click toggles selection on Opportunities (closes #344)

Replace the row-click → detail-drawer behaviour (issue #44) with
row-click → toggle selection. Clicking anywhere on a recommendation
row's body now toggles the row's checkbox + dispatches the existing
change handler (which enforces the one-variant-per-cell radio
behaviour from #224). Clicks on interactive descendants (input /
button / a / label / select / [data-action]) still flow through to
their own handlers, untouched.

This closes the last task in the issue-344 phase-2 plan. The original
T4 (right-side Plan-builder drawer) was dropped in plan review: every
payload the drawer could carry (per-rec payment-option comparison,
target/baseline tracking) is explicitly backend-deferred, so the
drawer would have shipped as a prettier copy of the existing bottom
action-box with no new information. The detail drawer that already
opened on row-click had the same character — confidence + provenance
duplicated the table, and usage history is empty by design until the
collector wiring lands (known_issues/28).

Net changes:
- recommendations.ts: -227 lines (openDetailDrawer + fetchRecommend-
  ationDetail + detailFetchCache + clearRecommendationDetailCache +
  renderUsageSparkline removed); +13 lines (row-click toggle handler).
- styles/components.css: -84 lines (.detail-drawer*, .confidence-*).
- recommendations.test.ts: -136 lines (5 drawer tests removed) +
  +84 lines (4 row-click selection tests covering: toggle on
  non-interactive cell, second click unselects, interactive
  descendant click does NOT toggle, native checkbox click doesn't
  double-toggle).
- api/recommendations.ts: untouched. getRecommendationDetail stays
  exported so the backend endpoint contract is preserved; resurrecting
  the drawer if needed is a git revert of this commit.

Verification: 1629/1632 tests pass / 1 skipped (2 unrelated failures
in users.test.ts pre-date this branch — they came in with PR #348).
Coverage 80.70% stmts / 82.46% lines (slight dip from 80.93/82.70 as
expected when removing tested code paths; well within parity gates).
Build 517 KiB (down from 522 KiB after T3; the deleted drawer assets
shaved ~5 KiB).

* fix: apply CodeRabbit auto-fixes

Fixed 1 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
@cristim
cristim deleted the claude/feat-invite-user-set-password-on-first-login branch June 3, 2026 21:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants