Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions internal/app/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ package app

import (
"context"
"strconv"
"strings"
"time"
Comment on lines 8 to +11

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Import the strings package to support joining failed logins in the bulk action summary.

Suggested change
"context"
"strconv"
"time"
"context"
"strconv"
"strings"
"time"


tea "github.com/charmbracelet/bubbletea"

Expand All @@ -16,11 +19,25 @@ import (
// for the currently-selected user. Falls back to a transient toast
// when no Users target is available (e.g., the operator fired the
// command from a non-Users screen).
//
// MW-8 — when the active screen publishes a non-empty
// SelectedUsers() (multi-select set), the confirm targets that
// entire set. Otherwise falls back to the single-user cursor row
// via SelectedUserStater.
func (m Model) openActionConfirm(kind UserActionKind) (tea.Model, tea.Cmd) {
child, ok := m.screens[m.active]
if !ok {
return m, toastCmdInfo("no active screen")
}
if bs, ok := child.(BulkSelectedUsersStater); ok {
if users := bs.SelectedUsers(); len(users) > 0 {
m.pendingBulkAction = pendingBulkUserAction{Kind: kind, Users: users}
m.pendingAction = pendingUserAction{}
m.pendingRule = pendingRuleAction{}
m.overlay = OverlayActionConfirm
return m, nil
}
}
stater, ok := child.(SelectedUserStater)
if !ok {
return m, toastCmdInfo("action not available on this screen")
Expand All @@ -30,6 +47,7 @@ func (m Model) openActionConfirm(kind UserActionKind) (tea.Model, tea.Cmd) {
return m, toastCmdInfo("no user selected")
}
m.pendingAction = pendingUserAction{Kind: kind, User: user}
m.pendingBulkAction = pendingBulkUserAction{}
m.pendingRule = pendingRuleAction{}
m.overlay = OverlayActionConfirm
return m, nil
Expand Down Expand Up @@ -215,6 +233,106 @@ func runUserActionCmd(port domain.UsersPort, action pendingUserAction) tea.Cmd {
}
}

// bulkSleep is overridable so tests can run the bulk runner without
// waiting on the real 50ms per step. Defaults to time.Sleep.
var bulkSleep = time.Sleep

// SetBulkSleepForTests overrides the inter-step sleep. Tests call it
// with a no-op to keep runs sub-millisecond; production keeps the
// 50ms throttle that spaces port calls.
func SetBulkSleepForTests(f func(time.Duration)) { bulkSleep = f }

// bulkUserCompletedMsg is the internal signal fired at the end of a
// bulk run. Handled by the App Shell as: emit summary toast +
// broadcast shared.ClearSelectionMsg via tea.Batch (which drops the
// active list's pinned rows).
type bulkUserCompletedMsg struct {
summary string
}

// runBulkUserActionCmd dispatches an entire pendingBulkUserAction
// against the UsersPort — one port call per user with a 50 ms sleep
// in between so a bulk deactivate on N users hits the API at a
// steady pace rather than a spike (MW-8). Errors are counted and
// rolled up into the summary — one failure doesn't abort the loop
// (unlike single-user runUserActionCmd which surfaces a red toast).
//
// Returns a single Cmd that runs the loop synchronously inside its
// own goroutine (Bubbletea drains Cmds off the main loop). The
// summary lands as a bulkUserCompletedMsg the App Shell expands into
// toast + selection clear.
func runBulkUserActionCmd(port domain.UsersPort, action pendingBulkUserAction) tea.Cmd {
if port == nil {
return toastCmdInfo("UsersPort not wired — action skipped")
}
total := len(action.Users)
if total == 0 {
return nil
}
return func() tea.Msg {
label := userActionLabel(action.Kind)
ctx := context.Background()
var ok, failed int
var failedLogins []string
for i, u := range action.Users {
if i > 0 {
bulkSleep(50 * time.Millisecond)
}
if err := runSingleUserAction(ctx, port, action.Kind, u.ID); err != nil {
failed++
// Fall back to the user ID when the profile has no
// login — SCIM-provisioned edge cases surface bare
// IDs and blank names should never mean "empty".
login := u.Profile.Login
if login == "" {
login = u.ID
}
failedLogins = append(failedLogins, login)
} else {
ok++
}
}
var summary string
if failed == 0 {
summary = label + " completed for " + strconv.Itoa(ok) + " users"
} else {
summary = label + " completed · " + strconv.Itoa(ok) + " ok, " +
strconv.Itoa(failed) + " failed (" +
strings.Join(failedLogins, ", ") + ")"
}
return bulkUserCompletedMsg{summary: summary}
}
Comment on lines +272 to +304

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

When a bulk action fails for some users, the summary toast only displays the count of failed actions without indicating which users failed. Collecting the failed logins/IDs and appending them to the summary provides crucial feedback to the operator.

	return func() tea.Msg {
		label := userActionLabel(action.Kind)
		ctx := context.Background()
		var ok, failed int
		var failedLogins []string
		for i, u := range action.Users {
			if i > 0 {
				bulkSleep(50 * time.Millisecond)
			}
			if err := runSingleUserAction(ctx, port, action.Kind, u.ID); err != nil {
				failed++
				login := u.Profile.Login
				if login == "" {
					login = u.ID
				}
				failedLogins = append(failedLogins, login)
			} else {
				ok++
			}
		}
		var summary string
		if failed == 0 {
			summary = label + " completed for " + strconv.Itoa(ok) + " users"
		} else {
			summary = label + " completed · " + strconv.Itoa(ok) + " ok, " +
				strconv.Itoa(failed) + " failed"
			if len(failedLogins) > 0 {
				summary += " (" + strings.Join(failedLogins, ", ") + ")"
			}
		}
		return bulkUserCompletedMsg{summary: summary}
	}

}

// runSingleUserAction is the shared per-user dispatcher used by both
// the single-user runUserActionCmd path and the bulk runner. Any
// lifecycle Kind that reaches the UsersPort has its route here so
// the two paths can't drift.
func runSingleUserAction(ctx context.Context, port domain.UsersPort, kind UserActionKind, id string) error {
switch kind {
case UserActionResetPassword:
_, err := port.ResetPassword(ctx, id, true)
return err
case UserActionUnlock:
return port.Unlock(ctx, id)
case UserActionResetFactors:
return port.ResetFactors(ctx, id)
case UserActionActivate:
return port.Activate(ctx, id, true)
case UserActionDeactivate:
return port.Deactivate(ctx, id, false)
case UserActionExpirePassword:
return port.ExpirePassword(ctx, id)
case UserActionDelete:
return port.Delete(ctx, id)
case UserActionSuspend:
return port.Suspend(ctx, id)
case UserActionUnsuspend:
return port.Unsuspend(ctx, id)
}
return nil
}

// runRuleActionCmd dispatches the active pendingRule against the
// GroupRulesPort and emits a toast with the result.
func runRuleActionCmd(port domain.GroupRulesPort, action pendingRuleAction) tea.Cmd {
Expand Down
145 changes: 144 additions & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,16 @@ type pendingUserAction struct {
User domain.User
}

// pendingBulkUserAction is the multi-target sibling — populated when
// the active screen publishes ≥1 rows in SelectedUsers() (MW-8). One
// of pendingAction / pendingBulkAction is set at any time; the
// confirm modal renders whichever is populated and the runner
// dispatches sequentially against the whole slice.
type pendingBulkUserAction struct {
Kind UserActionKind
Users []domain.User
}

// RuleActionKind classifies the Group Rule lifecycle action a
// confirmation modal is gating (issue #188 v0.2.2). Mirrors
// UserActionKind for the Group Rules screen — Activate /
Expand Down Expand Up @@ -343,6 +353,14 @@ type SelectedUserStater interface {
SelectedUser() (domain.User, bool)
}

// BulkSelectedUsersStater is implemented by screens that publish an
// explicit multi-select set (MW-8). openActionConfirm consults it
// before the single-user path — non-empty result routes into the
// bulk runner, empty falls back to SelectedUserStater.
type BulkSelectedUsersStater interface {
SelectedUsers() []domain.User
}

// Deps bundles the App Shell's runtime dependencies.
type Deps struct {
Services *service.Bundle
Expand Down Expand Up @@ -457,6 +475,11 @@ type Model struct {
// when the modal closes either way.
pendingAction pendingUserAction

// pendingBulkAction is the multi-target sibling (MW-8). At most
// one of pendingAction / pendingBulkAction is set at a time —
// the confirm modal renders whichever holds a non-zero Kind.
pendingBulkAction pendingBulkUserAction

// pendingRule is the Group Rule counterpart of pendingAction
// (issue #188 v0.2.2). Only one of pendingAction / pendingRule
// is set at any time; the confirmation modal renders whichever
Expand Down Expand Up @@ -709,6 +732,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.openRuleActionConfirm(RuleActionDelete)
}
return m, nil
case bulkUserCompletedMsg:
// MW-8 — bulk runner finished. Fan out: summary toast (info),
// selection clear so the active list drops its pinned rows,
// and a refresh so the list reflects the new server state
// without waiting for the auto-tick.
toast := toastInfo(msg.summary)
return m, tea.Batch(
func() tea.Msg { return toast },
func() tea.Msg { return shared.ClearSelectionMsg{} },
refreshScreenCmd(),
)
case actionCompletedMsg:
// Issue #192 v0.2.3 — destructive op finished. Surface the
// toast AND fan out a screen refresh so the list / detail
Expand Down Expand Up @@ -1542,6 +1576,12 @@ func (m Model) renderQuitConfirmModal(tk shared.Tokens) string {
// v0.2.4). Mirrors the format the palette help and quit-confirm use,
// so the visual language stays consistent.
func (m Model) renderActionConfirmModal(tk shared.Tokens) string {
// MW-8 — bulk wording splices a per-login preview onto a
// dedicated modal path so operators see exactly who the confirm
// hits before the port fires.
if m.pendingBulkAction.Kind != UserActionNone {
return m.renderBulkActionConfirmModal(tk)
}
var label, target string
switch {
case m.pendingRule.Kind != RuleActionNone:
Expand Down Expand Up @@ -1596,6 +1636,58 @@ func (m Model) renderActionConfirmModal(tk shared.Tokens) string {
})
}

// renderBulkActionConfirmModal is the MW-8 sibling of the single-
// user confirm — the headline reads "Deactivate 5 users?" and the
// body previews the first 3 logins followed by "… and N more" when
// the set is larger. Operators see exactly who the confirm will hit
// before the port fires.
func (m Model) renderBulkActionConfirmModal(tk shared.Tokens) string {
label := userActionLabel(m.pendingBulkAction.Kind)
users := m.pendingBulkAction.Users
n := len(users)
suffix := "users"
if n == 1 {
suffix = "user"
}
headline := tk.Danger.Render(label) + " for " +
tk.Accent.Render(itoaSimple(n)+" "+suffix) + "?"
var b strings.Builder
b.WriteString(headline)
preview := 3
if preview > n {
preview = n
}
for i := 0; i < preview; i++ {
login := users[i].Profile.Login
if login == "" {
login = users[i].ID
}
b.WriteString("\n • ")
b.WriteString(login)
}
if n > preview {
b.WriteString("\n " + tk.Muted.Render("… and "+itoaSimple(n-preview)+" more"))
}
body := b.String()
width := 60
for _, line := range strings.Split(body, "\n") {
if w := shared.VisibleWidth(line) + 6; w > width {
width = w
}
}
if cap := clampWidth(m.width) - 8; cap > 0 && width > cap {
width = cap
}
return shared.MountModal(shared.ModalIn{
Title: "Confirm bulk action",
Body: body,
Footer: "y / Enter to confirm — n / Esc to cancel",
Tone: shared.ModalToneDanger,
Width: width,
Tokens: tk,
})
}

// centerInBody horizontally centers a multi-line block inside the
// chrome's content width. Each line gets enough leading spaces to push
// the block to the visual center; lines wider than contentWidth are
Expand Down Expand Up @@ -1900,6 +1992,17 @@ func (m Model) composeChromeBadges() []shared.ChromeBadge {
var out []shared.ChromeBadge
if m.overlay == OverlayActionConfirm {
switch {
case m.pendingBulkAction.Kind != UserActionNone:
// MW-8 — the ACTION badge stamps the target count so
// operators glance at the chrome and read "5 users about
// to be deactivated" without reading the modal body.
label := userActionLabel(m.pendingBulkAction.Kind) +
" (" + itoaSimple(len(m.pendingBulkAction.Users)) + ")"
out = append(out, shared.ChromeBadge{
Key: "ACTION",
Value: label,
Tone: shared.BadgeDanger,
})
case m.pendingRule.Kind != RuleActionNone:
out = append(out, shared.ChromeBadge{
Key: "ACTION",
Expand Down Expand Up @@ -2839,6 +2942,18 @@ func (m Model) handlePaletteKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.openActionConfirm(UserActionUnlock)
case paletteCmdResetFactors:
return m.openActionConfirm(UserActionResetFactors)
case paletteCmdActivate:
return m.openActionConfirm(UserActionActivate)
case paletteCmdDeactivate:
return m.openActionConfirm(UserActionDeactivate)
case paletteCmdExpirePassword:
return m.openActionConfirm(UserActionExpirePassword)
case paletteCmdDelete:
return m.openActionConfirm(UserActionDelete)
case paletteCmdSuspend:
return m.openActionConfirm(UserActionSuspend)
case paletteCmdUnsuspend:
return m.openActionConfirm(UserActionUnsuspend)
case paletteCmdPolicyType:
// Issue #165: jump straight to the typed list, replacing
// any existing Policies wrapper so the picker doesn't
Expand Down Expand Up @@ -2927,6 +3042,8 @@ func paletteCommandPool() []string {
"administrator",
"unmask", "mask",
"reset-password", "unlock", "reset-mfa",
"activate", "deactivate", "expire-password", "delete",
"suspend", "unsuspend",
// REQ-W01: `:edit` is the canonical SCR-012 palette entry
// (TUI_DESIGN §3.4 / §11.2a). Surfaced in autocomplete so
// operators discover it via Tab.
Expand Down Expand Up @@ -3000,6 +3117,12 @@ func (m Model) handleOverlayKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// refresh after the action completes so the operator
// sees the new state without waiting for the next
// auto-tick.
if m.pendingBulkAction.Kind != UserActionNone {
ba := m.pendingBulkAction
m.pendingBulkAction = pendingBulkUserAction{}
m.overlay = OverlayNone
return m, runBulkUserActionCmd(m.deps.UsersPort, ba)
}
if m.pendingRule.Kind != RuleActionNone {
ra := m.pendingRule
m.pendingRule = pendingRuleAction{}
Expand Down Expand Up @@ -3030,6 +3153,7 @@ func (m Model) handleOverlayKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, runUserActionCmd(m.deps.UsersPort, action)
case cancelled:
m.pendingAction = pendingUserAction{}
m.pendingBulkAction = pendingBulkUserAction{}
m.pendingRule = pendingRuleAction{}
m.pendingPolicy = pendingPolicyAction{}
m.pendingApp = pendingAppAction{}
Expand Down Expand Up @@ -3207,10 +3331,17 @@ const (
paletteCmdUnmask
paletteCmdMask
// Users lifecycle actions (issue #125). Each opens a confirmation
// modal targeting the active screen's selected user.
// modal targeting the active screen's selected user (or the
// multi-select set — MW-8).
paletteCmdResetPassword
paletteCmdUnlock
paletteCmdResetFactors
paletteCmdActivate
paletteCmdDeactivate
paletteCmdExpirePassword
paletteCmdDelete
paletteCmdSuspend
paletteCmdUnsuspend
// paletteCmdPolicyType opens ScreenPolicies straight on a list
// for the given PolicyType — issue #165 (`:okta-sign-on`,
// `:password-policy`, etc.).
Expand Down Expand Up @@ -3277,6 +3408,18 @@ func resolvePaletteCommand(raw string) (kind paletteCmdKind, screen Screen, arg
return paletteCmdUnlock, 0, "", true
case "reset-mfa", "reset-factors", "reset_mfa", "reset_factors", "resetfactors":
return paletteCmdResetFactors, 0, "", true
case "activate":
return paletteCmdActivate, 0, "", true
case "deactivate":
return paletteCmdDeactivate, 0, "", true
case "expire-password", "expire_password", "expirepassword":
return paletteCmdExpirePassword, 0, "", true
case "delete":
return paletteCmdDelete, 0, "", true
case "suspend":
return paletteCmdSuspend, 0, "", true
case "unsuspend":
return paletteCmdUnsuspend, 0, "", true
case "apilog", "api-log", "api_log", "apitimeline":
return paletteCmdAPILog, 0, "", true
case "xray", "x-ray", "x_ray":
Expand Down
Loading