Add notification feedback settings
This commit is contained in:
@@ -50,9 +50,18 @@ const (
|
||||
const (
|
||||
maxAttachmentBytes = 10 << 20
|
||||
statusBannerDuration = 2600 * time.Millisecond
|
||||
statusBannerLong = 6 * time.Second
|
||||
autofillStatusTTL = 12 * time.Second
|
||||
)
|
||||
|
||||
type autofillNoticeMode string
|
||||
|
||||
const (
|
||||
autofillNoticeAll autofillNoticeMode = "all"
|
||||
autofillNoticeApprovals autofillNoticeMode = "approvals"
|
||||
autofillNoticeSuppressed autofillNoticeMode = "suppressed"
|
||||
)
|
||||
|
||||
type bannerKind string
|
||||
|
||||
const (
|
||||
@@ -138,10 +147,12 @@ type recentRemoteRecord struct {
|
||||
}
|
||||
|
||||
type uiPreferences struct {
|
||||
GroupControlsHidden bool `json:"groupControlsHidden"`
|
||||
LifecycleAdvancedHidden bool `json:"lifecycleAdvancedHidden"`
|
||||
HistoryHidden bool `json:"historyHidden"`
|
||||
DenseLayout bool `json:"denseLayout"`
|
||||
GroupControlsHidden bool `json:"groupControlsHidden"`
|
||||
LifecycleAdvancedHidden bool `json:"lifecycleAdvancedHidden"`
|
||||
HistoryHidden bool `json:"historyHidden"`
|
||||
DenseLayout bool `json:"denseLayout"`
|
||||
StatusBannerMillis int `json:"statusBannerMillis,omitempty"`
|
||||
AutofillNoticeMode string `json:"autofillNoticeMode,omitempty"`
|
||||
}
|
||||
|
||||
type entriesSectionState struct {
|
||||
@@ -265,6 +276,12 @@ type ui struct {
|
||||
toggleHistory widget.Clickable
|
||||
togglePasswordInline widget.Clickable
|
||||
toggleSyncPassword widget.Clickable
|
||||
setStatusBannerShort widget.Clickable
|
||||
setStatusBannerStandard widget.Clickable
|
||||
setStatusBannerLong widget.Clickable
|
||||
showAllAutofillNotices widget.Clickable
|
||||
showApprovalAutofillOnly widget.Clickable
|
||||
hideAutofillNotices widget.Clickable
|
||||
showEntries widget.Clickable
|
||||
showTemplates widget.Clickable
|
||||
showRecycle widget.Clickable
|
||||
@@ -359,6 +376,8 @@ type ui struct {
|
||||
lifecycleAdvancedHidden bool
|
||||
historyHidden bool
|
||||
denseLayout bool
|
||||
statusBannerTTL time.Duration
|
||||
autofillNoticePreference autofillNoticeMode
|
||||
recentVaults []string
|
||||
recentRemotes []recentRemoteRecord
|
||||
recentVaultGroups map[string][]string
|
||||
@@ -482,25 +501,27 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
||||
lifecycleList: widget.List{
|
||||
List: layout.List{Axis: layout.Vertical},
|
||||
},
|
||||
state: appstate.State{},
|
||||
selectedHistoryIndex: -1,
|
||||
selectedAuditIndex: -1,
|
||||
lifecycleMode: "local",
|
||||
defaultSaveAsPath: paths.DefaultSaveAsPath,
|
||||
recentVaultsPath: paths.RecentVaultsPath,
|
||||
uiPreferencesPath: paths.UIPreferencesPath,
|
||||
recentRemotesPath: paths.RecentRemotesPath,
|
||||
autofillCachePath: paths.AutofillCachePath,
|
||||
recentVaultGroups: map[string][]string{},
|
||||
recentVaultUsedAt: map[string]time.Time{},
|
||||
lifecycleAdvancedHidden: true,
|
||||
historyHidden: true,
|
||||
now: time.Now,
|
||||
syncSourceMode: syncSourceLocal,
|
||||
syncDirection: syncDirectionPull,
|
||||
apiPolicyGroupScope: true,
|
||||
backgroundResults: make(chan backgroundActionResult, 8),
|
||||
requestMasterPassFocus: true,
|
||||
state: appstate.State{},
|
||||
selectedHistoryIndex: -1,
|
||||
selectedAuditIndex: -1,
|
||||
lifecycleMode: "local",
|
||||
defaultSaveAsPath: paths.DefaultSaveAsPath,
|
||||
recentVaultsPath: paths.RecentVaultsPath,
|
||||
uiPreferencesPath: paths.UIPreferencesPath,
|
||||
recentRemotesPath: paths.RecentRemotesPath,
|
||||
autofillCachePath: paths.AutofillCachePath,
|
||||
recentVaultGroups: map[string][]string{},
|
||||
recentVaultUsedAt: map[string]time.Time{},
|
||||
lifecycleAdvancedHidden: true,
|
||||
historyHidden: true,
|
||||
statusBannerTTL: statusBannerDuration,
|
||||
now: time.Now,
|
||||
syncSourceMode: syncSourceLocal,
|
||||
syncDirection: syncDirectionPull,
|
||||
apiPolicyGroupScope: true,
|
||||
autofillNoticePreference: autofillNoticeAll,
|
||||
backgroundResults: make(chan backgroundActionResult, 8),
|
||||
requestMasterPassFocus: true,
|
||||
}
|
||||
u.apiPolicyAllow.Value = true
|
||||
u.apiPolicyGroupScopeW.Value = true
|
||||
@@ -1299,6 +1320,8 @@ func (u *ui) loadUIPreferences() {
|
||||
u.lifecycleAdvancedHidden = prefs.LifecycleAdvancedHidden
|
||||
u.historyHidden = prefs.HistoryHidden
|
||||
u.denseLayout = prefs.DenseLayout
|
||||
u.statusBannerTTL = normalizedStatusBannerTTL(prefs.StatusBannerMillis)
|
||||
u.autofillNoticePreference = normalizedAutofillNoticeMode(prefs.AutofillNoticeMode)
|
||||
}
|
||||
|
||||
func (u *ui) saveUIPreferences() {
|
||||
@@ -1313,6 +1336,8 @@ func (u *ui) saveUIPreferences() {
|
||||
LifecycleAdvancedHidden: u.lifecycleAdvancedHidden,
|
||||
HistoryHidden: u.historyHidden,
|
||||
DenseLayout: u.denseLayout,
|
||||
StatusBannerMillis: int(u.statusBannerTTL / time.Millisecond),
|
||||
AutofillNoticeMode: string(u.autofillNoticePreference),
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
@@ -1334,6 +1359,38 @@ func (u *ui) applySettingsFormToPreferences() {
|
||||
u.denseLayout = u.settingsDenseLayout.Value
|
||||
}
|
||||
|
||||
func normalizedStatusBannerTTL(valueMillis int) time.Duration {
|
||||
switch {
|
||||
case valueMillis <= 0:
|
||||
return statusBannerDuration
|
||||
case time.Duration(valueMillis)*time.Millisecond > statusBannerLong:
|
||||
return statusBannerLong
|
||||
default:
|
||||
return time.Duration(valueMillis) * time.Millisecond
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedAutofillNoticeMode(value string) autofillNoticeMode {
|
||||
switch autofillNoticeMode(strings.TrimSpace(value)) {
|
||||
case autofillNoticeApprovals:
|
||||
return autofillNoticeApprovals
|
||||
case autofillNoticeSuppressed:
|
||||
return autofillNoticeSuppressed
|
||||
default:
|
||||
return autofillNoticeAll
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) setStatusBannerTTL(value time.Duration) {
|
||||
u.statusBannerTTL = normalizedStatusBannerTTL(int(value / time.Millisecond))
|
||||
u.saveUIPreferences()
|
||||
}
|
||||
|
||||
func (u *ui) setAutofillNoticePreference(value autofillNoticeMode) {
|
||||
u.autofillNoticePreference = normalizedAutofillNoticeMode(string(value))
|
||||
u.saveUIPreferences()
|
||||
}
|
||||
|
||||
func (u *ui) noteRecentRemote(baseURL, path, username, password string, rememberAuth bool) {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
path = strings.TrimSpace(path)
|
||||
@@ -1705,7 +1762,7 @@ func (u *ui) armDeleteCurrentGroupAction() {
|
||||
u.deleteGroupPath = append([]string(nil), u.currentPath...)
|
||||
u.state.ErrorMessage = ""
|
||||
u.state.StatusMessage = fmt.Sprintf("Confirm deleting empty group %q.", strings.Join(u.displayPath(), " / "))
|
||||
u.statusExpiresAt = u.now().Add(statusBannerDuration)
|
||||
u.statusExpiresAt = u.now().Add(u.statusBannerTTL)
|
||||
}
|
||||
|
||||
func (u *ui) runAction(label string, action func() error) {
|
||||
@@ -1732,7 +1789,7 @@ func (u *ui) runAction(label string, action func() error) {
|
||||
return
|
||||
}
|
||||
u.state.StatusMessage = label + " complete"
|
||||
u.statusExpiresAt = u.now().Add(statusBannerDuration)
|
||||
u.statusExpiresAt = u.now().Add(u.statusBannerTTL)
|
||||
}
|
||||
|
||||
func (u *ui) runBackgroundAction(label string, prepare func() (func() error, error)) {
|
||||
@@ -1791,7 +1848,7 @@ func (u *ui) applyBackgroundResult(result backgroundActionResult) {
|
||||
return
|
||||
}
|
||||
u.state.StatusMessage = result.label + " complete"
|
||||
u.statusExpiresAt = u.now().Add(statusBannerDuration)
|
||||
u.statusExpiresAt = u.now().Add(u.statusBannerTTL)
|
||||
}
|
||||
|
||||
func (u *ui) cancelLifecycleBusyState() {
|
||||
@@ -1933,6 +1990,9 @@ func (u *ui) statusToastSurface() uiBanner {
|
||||
}
|
||||
|
||||
func (u *ui) autofillStatusSurface() uiAutofillStatus {
|
||||
if u.autofillNoticePreference == autofillNoticeSuppressed {
|
||||
return uiAutofillStatus{}
|
||||
}
|
||||
if request, ok := u.pendingAutofillApproval(); ok {
|
||||
detail := approvalResourceText(request)
|
||||
if strings.TrimSpace(detail) == "" {
|
||||
@@ -1948,6 +2008,9 @@ func (u *ui) autofillStatusSurface() uiAutofillStatus {
|
||||
if u.auditLog == nil {
|
||||
return uiAutofillStatus{}
|
||||
}
|
||||
if u.autofillNoticePreference == autofillNoticeApprovals {
|
||||
return uiAutofillStatus{}
|
||||
}
|
||||
for _, event := range u.auditLog.Events() {
|
||||
if status, ok := autofillStatusFromAuditEvent(event, u.now()); ok {
|
||||
return status
|
||||
@@ -2467,6 +2530,24 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
||||
u.loadSettingsFormFromPreferences()
|
||||
u.securityDialogOpen = true
|
||||
}
|
||||
for u.setStatusBannerShort.Clicked(gtx) {
|
||||
u.setStatusBannerTTL(2 * time.Second)
|
||||
}
|
||||
for u.setStatusBannerStandard.Clicked(gtx) {
|
||||
u.setStatusBannerTTL(statusBannerDuration)
|
||||
}
|
||||
for u.setStatusBannerLong.Clicked(gtx) {
|
||||
u.setStatusBannerTTL(statusBannerLong)
|
||||
}
|
||||
for u.showAllAutofillNotices.Clicked(gtx) {
|
||||
u.setAutofillNoticePreference(autofillNoticeAll)
|
||||
}
|
||||
for u.showApprovalAutofillOnly.Clicked(gtx) {
|
||||
u.setAutofillNoticePreference(autofillNoticeApprovals)
|
||||
}
|
||||
for u.hideAutofillNotices.Clicked(gtx) {
|
||||
u.setAutofillNoticePreference(autofillNoticeSuppressed)
|
||||
}
|
||||
for u.closeAdvancedSync.Clicked(gtx) {
|
||||
u.syncDialogOpen = false
|
||||
u.showSyncPassword = false
|
||||
@@ -2970,7 +3051,7 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions {
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(14), "Choose how KeePassGO remembers UI layout behavior and which KDBX security settings it should use for new or future saves.")
|
||||
lbl := material.Label(u.theme, unit.Sp(14), "Choose how KeePassGO remembers UI layout behavior, tune noncritical feedback, and set the KDBX security defaults used for new or future saves.")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
@@ -3014,10 +3095,68 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions {
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(labeledEditorHelp(u.theme, "KDF", "Supported values: "+strings.Join([]string{vault.KDFAES, vault.KDFArgon2}, ", "), &u.securityKDF, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(syncDialogSectionLabel(u.theme, "Feedback")),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(14), "Success and reminder banners")
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "Choose how long noncritical status banners stay visible.")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.closeSecuritySettings, "Cancel")
|
||||
return syncChoiceButton(gtx, u.theme, &u.setStatusBannerShort, "Short", u.statusBannerTTL == 2*time.Second)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.setStatusBannerStandard, "Standard", u.statusBannerTTL == statusBannerDuration)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.setStatusBannerLong, "Long", u.statusBannerTTL == statusBannerLong)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(14), "Autofill notices")
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "Keep recent autofill results visible, reduce them to approval-only, or hide them.")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.showAllAutofillNotices, "All", u.autofillNoticePreference == autofillNoticeAll)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.showApprovalAutofillOnly, "Approval Only", u.autofillNoticePreference == autofillNoticeApprovals)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.hideAutofillNotices, "Hidden", u.autofillNoticePreference == autofillNoticeSuppressed)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.closeSecuritySettings, "Close")
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
|
||||
@@ -2914,6 +2914,27 @@ func TestUIStatusToastExpiresAfterTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIStatusToastExpiresAfterConfiguredTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC)
|
||||
u := newUIWithModel("desktop", vault.Model{})
|
||||
u.now = func() time.Time { return now }
|
||||
u.statusBannerTTL = statusBannerLong
|
||||
u.state.StatusMessage = "save complete"
|
||||
u.statusExpiresAt = now.Add(u.statusBannerTTL)
|
||||
|
||||
now = now.Add(statusBannerDuration + time.Second)
|
||||
if got := u.statusToastSurface(); got.Kind != bannerStatus {
|
||||
t.Fatalf("statusToastSurface() before configured expiry = %#v, want visible status toast", got)
|
||||
}
|
||||
|
||||
now = now.Add(statusBannerLong)
|
||||
if got := u.statusToastSurface(); got.Kind != bannerNone {
|
||||
t.Fatalf("statusToastSurface() after configured expiry = %#v, want no toast", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIAutofillStatusSurfaceUsesPendingApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2945,6 +2966,41 @@ func TestUIAutofillStatusSurfaceUsesPendingApproval(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIAutofillStatusSurfaceRespectsNoticePreference(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC)
|
||||
u := newUIWithModel("desktop", vault.Model{})
|
||||
u.now = func() time.Time { return now }
|
||||
u.auditLog = &apiaudit.Log{}
|
||||
u.auditLog.Record(apiaudit.Event{
|
||||
Type: apiaudit.EventAutofillFound,
|
||||
TokenName: "Browser Extension",
|
||||
ClientName: "Firefox",
|
||||
Operation: apitokens.OperationCopyPassword,
|
||||
At: now,
|
||||
})
|
||||
|
||||
u.autofillNoticePreference = autofillNoticeApprovals
|
||||
if got := u.autofillStatusSurface(); got.Kind != autofillStatusNone {
|
||||
t.Fatalf("autofillStatusSurface() with approvals-only preference = %#v, want no recent notice", got)
|
||||
}
|
||||
|
||||
u.autofillNoticePreference = autofillNoticeSuppressed
|
||||
u.state.Approvals = &mainStubApprovalManager{
|
||||
pending: []apiapproval.Request{{
|
||||
ID: "approval-1",
|
||||
TokenName: "Browser Extension",
|
||||
ClientName: "Firefox",
|
||||
Operation: apitokens.OperationCopyPassword,
|
||||
Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "entry-1"},
|
||||
}},
|
||||
}
|
||||
if got := u.autofillStatusSurface(); got.Kind != autofillStatusNone {
|
||||
t.Fatalf("autofillStatusSurface() with suppressed preference = %#v, want no notice", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIAutofillStatusSurfaceUsesAuditEventsForFoundAmbiguousAndBlocked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -3502,6 +3558,31 @@ func TestUIEntryRowMetricsUseDenseLayout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUINotificationPreferencesPersist(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "ui-prefs.json")
|
||||
|
||||
first := newUIWithSession("desktop", &session.Manager{})
|
||||
first.uiPreferencesPath = configPath
|
||||
first.statusBannerTTL = statusBannerLong
|
||||
first.autofillNoticePreference = autofillNoticeApprovals
|
||||
first.saveUIPreferences()
|
||||
|
||||
second := newUIWithSession("desktop", &session.Manager{})
|
||||
second.uiPreferencesPath = configPath
|
||||
second.statusBannerTTL = statusBannerDuration
|
||||
second.autofillNoticePreference = autofillNoticeAll
|
||||
second.loadUIPreferences()
|
||||
|
||||
if got := second.statusBannerTTL; got != statusBannerLong {
|
||||
t.Fatalf("statusBannerTTL after reload = %v, want %v", got, statusBannerLong)
|
||||
}
|
||||
if got := second.autofillNoticePreference; got != autofillNoticeApprovals {
|
||||
t.Fatalf("autofillNoticePreference after reload = %q, want %q", got, autofillNoticeApprovals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectingRecentRemoteConnectionKeepsPasswordMasked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user