From 8c31e5d3050403fd1b20003e3695929702a64e0c Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Thu, 19 Sep 2024 21:30:25 +0200 Subject: [PATCH] Address autofill security concerns with copy changes (#3211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/1199230911884351/1207411921782782/f **Description**: [✓ Implement Survey for Password Manager Users](https://app.asana.com/0/72649045549333/1206568003117818) showed that a proportion of users are hesitant to use our Password Manager because they don't know how secure it is. Easing these concerns should increase the adoption of DuckDuckGo's Password Manager. These changes update and add copy to more clearly explain the security safe-guards of using the Password Manager. **Steps to test this PR**: - Go to the screens from the designs in [Figma](https://www.figma.com/design/wAWx1a0mAooj6sDCmoTFbS/Password-Manager-security?node-id=192-10952&node-type=FRAME&t=0sLE1hdNaQCkC7A8-0) and check they match. Double check Ship Review for any copy divergences. **Definition of Done**: * [ ] Does this PR satisfy our [Definition of Done](https://app.asana.com/0/1202500774821704/1207634633537039/f)? --- ###### Internal references: [Pull Request Review Checklist](https://app.asana.com/0/1202500774821704/1203764234894239/f) [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) [Pull Request Documentation](https://app.asana.com/0/1202500774821704/1204012835277482/f) --- DuckDuckGo.xcodeproj/project.pbxproj | 14 ++ .../InfoHoverButton.colorset/Contents.json | 38 ++++ .../Contents.json | 38 ++++ .../Lock-Color-16.imageset/Contents.json | 12 ++ .../Lock-Color-16.imageset/Lock-Color-16.pdf | Bin 0 -> 1167 bytes .../Lock-Solid-16.imageset/Contents.json | 15 ++ .../Lock-Solid-16.imageset/Lock-Solid-16.pdf | Bin 0 -> 2602 bytes .../Common/Extensions/URLExtension.swift | 4 + DuckDuckGo/Common/Localizables/UserText.swift | 2 + .../Model/DataImportViewModel.swift | 6 +- .../DataImport/View/DataImportView.swift | 21 ++- .../InfoViews/PopoverInfoViewController.swift | 171 ++++++++++++++++++ DuckDuckGo/Localizable.xcstrings | 62 ++++++- .../Model/AutofillPreferences.swift | 6 + .../Extensions/UserText+PasswordManager.swift | 10 +- .../PasswordManagementItemListModel.swift | 17 ++ .../PasswordManagementViewController.swift | 70 ++++++- .../View/PasswordManager.storyboard | 101 +++++++++-- .../View/SaveCredentialsViewController.swift | 50 +++++ .../Statistics/ATB/LocalStatisticsStore.swift | 20 ++ 20 files changed, 620 insertions(+), 37 deletions(-) create mode 100644 DuckDuckGo/Assets.xcassets/Colors/InfoHoverButton.colorset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Colors/InfoHoverButtonHovered.colorset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Color-16.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Color-16.imageset/Lock-Color-16.pdf create mode 100644 DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Solid-16.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Solid-16.imageset/Lock-Solid-16.pdf create mode 100644 DuckDuckGo/InfoViews/PopoverInfoViewController.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 5a39eb4c63..3b5b68023d 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2882,6 +2882,8 @@ EEC8EB402982CD550065AA39 /* JSAlertViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEF53E172950CED5002D78F4 /* JSAlertViewModelTests.swift */; }; EECE10E529DD77E60044D027 /* FeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = EECE10E429DD77E60044D027 /* FeatureFlag.swift */; }; EECE10E629DD77E60044D027 /* FeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = EECE10E429DD77E60044D027 /* FeatureFlag.swift */; }; + EED4D3D82C874AE200C79EEA /* PopoverInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EED4D3D72C874AE200C79EEA /* PopoverInfoViewController.swift */; }; + EED4D3D92C874AE200C79EEA /* PopoverInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EED4D3D72C874AE200C79EEA /* PopoverInfoViewController.swift */; }; EED4D3DF2C8A298D00C79EEA /* AutofillPixelEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EED4D3DE2C8A298D00C79EEA /* AutofillPixelEvent.swift */; }; EED4D3E02C8A298D00C79EEA /* AutofillPixelEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EED4D3DE2C8A298D00C79EEA /* AutofillPixelEvent.swift */; }; EED735362BB46B6000F173D6 /* AutocompleteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EED735352BB46B6000F173D6 /* AutocompleteTests.swift */; }; @@ -4616,6 +4618,7 @@ EEC4A6702B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNLocationPreferenceItem.swift; sourceTree = ""; }; EEC7BE2D2BC6C09400F86835 /* AddressBarKeyboardShortcutsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddressBarKeyboardShortcutsTests.swift; sourceTree = ""; }; EECE10E429DD77E60044D027 /* FeatureFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlag.swift; sourceTree = ""; }; + EED4D3D72C874AE200C79EEA /* PopoverInfoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopoverInfoViewController.swift; sourceTree = ""; }; EED4D3DE2C8A298D00C79EEA /* AutofillPixelEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillPixelEvent.swift; sourceTree = ""; }; EED735352BB46B6000F173D6 /* AutocompleteTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutocompleteTests.swift; sourceTree = ""; }; EED9A6732C37FE6800E0FAB9 /* login_deduplication_test_data.csv */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = login_deduplication_test_data.csv; sourceTree = ""; }; @@ -7415,6 +7418,7 @@ B65536902684409300085A79 /* Geolocation */, AAE75275263B036300B973F8 /* History */, AAE71DB225F66A0900D74437 /* HomePage */, + EED4D3D62C87480B00C79EEA /* InfoViews */, 56CEE9092B7A66C500CF10AA /* Info.plist */, 56CEE90D2B7A6DE100CF10AA /* InfoPlist.xcstrings */, EEAEA3F4294D05CF00D04DF3 /* JSAlert */, @@ -9168,6 +9172,14 @@ path = AppAndExtensionAndAgentTargets; sourceTree = ""; }; + EED4D3D62C87480B00C79EEA /* InfoViews */ = { + isa = PBXGroup; + children = ( + EED4D3D72C874AE200C79EEA /* PopoverInfoViewController.swift */, + ); + path = InfoViews; + sourceTree = ""; + }; EEE0E1CB2C32F53C0058E148 /* DataImport */ = { isa = PBXGroup; children = ( @@ -10656,6 +10668,7 @@ FD22255E2C64B68500199373 /* AutoconsentExperiment.swift in Sources */, 3706FAD7293F65D500E42796 /* Feedback.swift in Sources */, 1D0DE9422C3BB9CC0037ABC2 /* ReleaseNotesParser.swift in Sources */, + EED4D3D92C874AE200C79EEA /* PopoverInfoViewController.swift in Sources */, 3707C722294B5D2900682A9F /* WKWebViewExtension.swift in Sources */, 3706FAD9293F65D500E42796 /* FirefoxFaviconsReader.swift in Sources */, 3706FADB293F65D500E42796 /* ContentBlockingRulesUpdateObserver.swift in Sources */, @@ -12564,6 +12577,7 @@ 856CADF0271710F400E79BB0 /* HoverUserScript.swift in Sources */, B6DE57F62B05EA9000CD54B9 /* SheetHostingWindow.swift in Sources */, AA6EF9B525081B4C004754E6 /* MainMenuActions.swift in Sources */, + EED4D3D82C874AE200C79EEA /* PopoverInfoViewController.swift in Sources */, 56A0541F2C1CA1F5007D8FAB /* OnboardingTabExtension.swift in Sources */, B63D466925BEB6C200874977 /* WKWebView+SessionState.swift in Sources */, B6F1B0222BCE5658005E863C /* BrokenSiteInfoTabExtension.swift in Sources */, diff --git a/DuckDuckGo/Assets.xcassets/Colors/InfoHoverButton.colorset/Contents.json b/DuckDuckGo/Assets.xcassets/Colors/InfoHoverButton.colorset/Contents.json new file mode 100644 index 0000000000..524f806670 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Colors/InfoHoverButton.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.090", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.090", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Colors/InfoHoverButtonHovered.colorset/Contents.json b/DuckDuckGo/Assets.xcassets/Colors/InfoHoverButtonHovered.colorset/Contents.json new file mode 100644 index 0000000000..6c1feac8d2 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Colors/InfoHoverButtonHovered.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.180", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.180", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Color-16.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Color-16.imageset/Contents.json new file mode 100644 index 0000000000..d159b38fd4 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Color-16.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Lock-Color-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Color-16.imageset/Lock-Color-16.pdf b/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Color-16.imageset/Lock-Color-16.pdf new file mode 100644 index 0000000000000000000000000000000000000000..067bb5fa626156818a7ba7d7891f5bfeaa89e122 GIT binary patch literal 1167 zcmY!laBvK;I`dFTEr~!5FAK2q*+Jp}3?dH8Gc~f^q2-Uq2Q_f!6PH7F}0Uy6`9O z>XEHgEJkjV=Xst{nRnma|1(#*QRlH+0e>0SuhN*c`}p1K?sxav-1X;Q|H^OQtH4^V z)n~INzdWaFd+z7E2w8&zE2~pWX3S1}zO#4@yHB!g&ZYwr54YM%Bu*~1nwj*R>12pc zU)iR8>n9hugu1@n#@jOU^$N9)z|~4iJ}C8klx*-Uvh+M!nS1)=ix|Ja1$EcwEHDb&W#?gf3Y03i;ImEw>#CG$~b=2DXILVSmGUnB|hm1hws@> zb8HXRV9w=`)Mev2CTb%!qj%fH?9>UVCFk02{JRq4w0h4MO*u)!ZYj43im}X0U(ZkmRju@mbZLQJKVaP0yZf#)7W$(D zm|yL3xzZq~DBO0BAv`fBjYD(Z^p=blJdxIC-)w^R5|nzN(GN>Upo9TQU#12I z@YH1j7X_v|eaF1K{E}jY=vZi)3aKneRnQMeOot^h-_(@MM5p`;g=hr>Jp(X6FbvIL zLI@Vjq@dK|{L-T2)M5otItL|CP?~kl&nrpI1KJ2ms}$QbBO5I_i-8CqZnnP6zNFa$;%szwV7 zbGT4RQDSCJY7rMG_B>sH!J$!{pPQG SEGhwe$I`@%OI6j?-wgoeVu;iL literal 0 HcmV?d00001 diff --git a/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Solid-16.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Solid-16.imageset/Contents.json new file mode 100644 index 0000000000..ce371320cc --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Solid-16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Lock-Solid-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Solid-16.imageset/Lock-Solid-16.pdf b/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Solid-16.imageset/Lock-Solid-16.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b1a5584f9c8e20ce5de1f860ce2ce42208ada181 GIT binary patch literal 2602 zcmd^B&rjPh6u$ef@C6AbEwPh0sU=hiTBxQC!P*^=&<-IrrL0Z5B%NS?{hl4?IUz-C z=lKB6=lAn__Io|LnO=V6UP1_`j28D#gwoSf>VJFLDeB)|-#@9W1`L+}Mdhpdkp>_* zx6rIC_PdUP`EJgPPNTR0du5teyR=k!_R>2akFBZ2{=PO&FN<<(vAL>>eVM6ByFkhF z^WvG_QRXqT+=d4&Sl`m;|K;ev_4a7FUwKaiQXY=|94P>rH7_GMTIw=OF5Z5Ig>eW&-3=u1@PXvyD)*n~)|Gb6`Qo{r zE$XyZ=KO-2e~YWM`eAm>T+yGsDd2`hf9KDrzH)Zl^v?!gKFpc3&F(RM-*h`m1~xQc zhaYD0mbvk`NgIr{?<4LpWd~-`GW1tN$Oq0=74d=EX>s^%YJ*Lcq8MF|5Y$ieEYFL& zqIY`yxs-0wm9oH Passwords & Autofill.", comment: "Explanatory text for the Passwords import option to alleviate security concerns and explain usage.") + static let importLoginsPasswordsExplainerAutolockOff = NSLocalizedString("import.logins.passwords.explainer.autolock.off", value: "Passwords are encrypted. We recommend setting up Auto-lock to keep your passwords even more secure. Set it up in DuckDuckGo Settings > Passwords & Autofill.", comment: "Explanatory text for the Passwords import option to alleviate security concerns and explain usage when autolock is disabled") static let importBookmarksButtonTitle = NSLocalizedString("bookmarks.import.button.title", value: "Import", comment: "Button text to open bookmark import dialog") static let initiateImport = NSLocalizedString("import.data.initiate", value: "Import", comment: "Button text for importing data") diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 5aa2833b48..c11c0868b0 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -133,11 +133,14 @@ struct DataImportViewModel { #endif + let isPasswordManagerAutolockEnabled: Bool + init(importSource: Source? = nil, screen: Screen? = nil, availableImportSources: [DataImport.Source] = Source.allCases.filter { $0.canImportData }, preferredImportSources: [Source] = [.chrome, .firefox, .safari], summary: [DataTypeImportResult] = [], + isPasswordManagerAutolockEnabled: Bool = AutofillPreferences().isAutoLockEnabled, loadProfiles: @escaping (ThirdPartyBrowser) -> BrowserProfileList = { $0.browserProfiles() }, dataImporterFactory: @escaping DataImporterFactory = dataImporter, requestPrimaryPasswordCallback: @escaping @MainActor (Source) -> String? = Self.requestPrimaryPasswordCallback, @@ -161,6 +164,7 @@ struct DataImportViewModel { self.selectedDataTypes = importSource.supportedDataTypes self.summary = summary + self.isPasswordManagerAutolockEnabled = isPasswordManagerAutolockEnabled self.requestPrimaryPasswordCallback = requestPrimaryPasswordCallback self.openPanelCallback = openPanelCallback @@ -683,7 +687,7 @@ extension DataImportViewModel { } mutating func update(with importSource: Source) { - self = .init(importSource: importSource, loadProfiles: loadProfiles, dataImporterFactory: dataImporterFactory, requestPrimaryPasswordCallback: requestPrimaryPasswordCallback, reportSenderFactory: reportSenderFactory, onFinished: onFinished, onCancelled: onCancelled) + self = .init(importSource: importSource, isPasswordManagerAutolockEnabled: isPasswordManagerAutolockEnabled, loadProfiles: loadProfiles, dataImporterFactory: dataImporterFactory, requestPrimaryPasswordCallback: requestPrimaryPasswordCallback, reportSenderFactory: reportSenderFactory, onFinished: onFinished, onCancelled: onCancelled) } @MainActor diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index ab880b0415..dee9ecc021 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -119,7 +119,7 @@ struct DataImportView: ModalView { DataImportTypePicker(viewModel: $model) .disabled(model.isImportSourcePickerDisabled) - importPasswordSubtitle() + passwordsExplainerView().padding(.top, 20) case .moreInfo: // you will be asked for your keychain password blah blah... @@ -159,7 +159,7 @@ struct DataImportView: ModalView { } if dataType == .passwords { - importPasswordSubtitle() + passwordsExplainerView().padding(.top, 20) } case .summary(let dataTypes, let isFileImport): @@ -208,11 +208,18 @@ struct DataImportView: ModalView { } } - private func importPasswordSubtitle() -> some View { - Text(UserText.importDataSubtitle) - .font(.subheadline) - .foregroundColor(Color(.greyText)) - .padding(.top, 16) + private func passwordsExplainerView() -> some View { + HStack(alignment: .top, spacing: 8) { + Image(.lockColor16) + Text(model.isPasswordManagerAutolockEnabled ? UserText.importLoginsPasswordsExplainer : UserText.importLoginsPasswordsExplainerAutolockOff) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .topLeading) + .padding(14) + .background(Color.blackWhite1) + .roundedBorder() } private func handleImportProgress(_ progress: TaskProgress) async { diff --git a/DuckDuckGo/InfoViews/PopoverInfoViewController.swift b/DuckDuckGo/InfoViews/PopoverInfoViewController.swift new file mode 100644 index 0000000000..a8c37f6f73 --- /dev/null +++ b/DuckDuckGo/InfoViews/PopoverInfoViewController.swift @@ -0,0 +1,171 @@ +// +// PopoverInfoViewController.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppKit +import SwiftUI +import SwiftUIExtensions + +final class PopoverInfoViewController: NSHostingController { + + enum Constants { + static let autoDismissDuration: TimeInterval = 0.5 + } + + let onDismiss: (() -> Void)? + let autoDismissDuration: TimeInterval + private var timer: Timer? + private var trackingArea: NSTrackingArea? + + init(message: String, + autoDismissDuration: TimeInterval = Constants.autoDismissDuration, + onDismiss: (() -> Void)? = nil) { + self.onDismiss = onDismiss + self.autoDismissDuration = autoDismissDuration + super.init(rootView: InfoView(info: message)) + let popoverBackground = PopoverInfoContentView() + view.addSubview(popoverBackground, positioned: .below, relativeTo: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + cancelAutoDismiss() + onDismiss?() + } + + override func viewDidAppear() { + super.viewDidAppear() + scheduleAutoDismiss() + createTrackingArea() + } + + func show(onParent parent: NSViewController, rect: NSRect, of view: NSView) { + // Set the content size to match the SwiftUI view's intrinsic size + self.preferredContentSize = self.view.fittingSize + + parent.present(self, + asPopoverRelativeTo: rect, + of: view, + preferredEdge: .maxY, + behavior: .applicationDefined) + } + + func show(onParent parent: NSViewController, relativeTo view: NSView) { + // Set the content size to match the SwiftUI view's intrinsic size + self.preferredContentSize = self.view.fittingSize + // For shorter strings, the positioning can be off unless the width is set a second time + self.preferredContentSize.width = self.view.fittingSize.width + + parent.present(self, + asPopoverRelativeTo: self.view.bounds, + of: view, + preferredEdge: .maxY, + behavior: .applicationDefined) + let presentingViewTrackingArea = NSTrackingArea(rect: self.view.convert(self.view.frame, from: view), + options: [.mouseEnteredAndExited, .activeInKeyWindow], + owner: self) + view.addTrackingArea(presentingViewTrackingArea) + } + + // MARK: - Auto Dismissal + func cancelAutoDismiss() { + timer?.invalidate() + timer = nil + } + + func scheduleAutoDismiss() { + cancelAutoDismiss() + timer = Timer.scheduledTimer(withTimeInterval: autoDismissDuration, repeats: false) { [weak self] _ in + guard let self = self else { return } + self.presentingViewController?.dismiss(self) + } + } + + // MARK: - Mouse Tracking + private func createTrackingArea() { + trackingArea = NSTrackingArea(rect: view.bounds, + options: [.mouseEnteredAndExited, .activeInKeyWindow], + owner: self, + userInfo: nil) + view.addTrackingArea(trackingArea!) + } + + override func mouseEntered(with event: NSEvent) { + cancelAutoDismiss() + } + + override func mouseExited(with event: NSEvent) { + scheduleAutoDismiss() + } + + override func mouseDown(with event: NSEvent) { + dismissPopover() + } + + private func dismissPopover() { + presentingViewController?.dismiss(self) + } +} + +struct InfoView: View { + let info: String + + var body: some View { + Text(.init(info)) + .onURLTap { url in + if let pane = PreferencePaneIdentifier(url: url) { + WindowControllersManager.shared.showPreferencesTab(withSelectedPane: pane) + } else { + WindowControllersManager.shared.showTab(with: .url(url, source: .link)) + } + } + .padding(16) + .frame(width: 250, alignment: .leading) + .frame(minHeight: 22) + .lineLimit(nil) + } +} + +private final class PopoverInfoContentView: NSView { + var backgroundView: PopoverInfoBackgroundView? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if let frameView = self.window?.contentView?.superview { + if backgroundView == nil { + backgroundView = PopoverInfoBackgroundView(frame: frameView.bounds) + backgroundView!.autoresizingMask = NSView.AutoresizingMask([.width, .height]) + frameView.addSubview(backgroundView!, positioned: NSWindow.OrderingMode.below, relativeTo: frameView) + } + } + } +} + +private final class PopoverInfoBackgroundView: NSView { + var backgroundColor: NSColor = NSColor.controlColor { + didSet { + draw(bounds) + } + } + override func draw(_ dirtyRect: NSRect) { + backgroundColor.set() + self.bounds.fill() + } +} diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index 1a2b36e9a4..6c784cd314 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -29340,6 +29340,30 @@ } } }, + "import.logins.passwords.explainer" : { + "comment" : "Explanatory text for the Passwords import option to alleviate security concerns and explain usage.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Passwords are encrypted. Viewing them or filling out forms requires Touch ID or a password. Nobody but you can see your passwords, not even us. Find Passwords in DuckDuckGo Settings > Passwords & Autofill." + } + } + } + }, + "import.logins.passwords.explainer.autolock.off" : { + "comment" : "Explanatory text for the Passwords import option to alleviate security concerns and explain usage when autolock is disabled", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Passwords are encrypted. We recommend setting up Auto-lock to keep your passwords even more secure. Set it up in DuckDuckGo Settings > Passwords & Autofill." + } + } + } + }, "import.logins.select-csv-file" : { "comment" : "Button text for selecting a CSV file", "extractionState" : "extracted_with_value", @@ -44489,7 +44513,7 @@ }, "pm.empty.default.description" : { "comment" : "Label for default empty state description", - "extractionState" : "extracted_with_value", + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -44547,6 +44571,18 @@ } } }, + "pm.empty.default.description.extended.v2" : { + "comment" : "Label for default empty state description\n Label for default empty state description when the autolock feature is off", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Passwords are encrypted. Nobody but you can see them, not even us." + } + } + } + }, "pm.empty.default.title" : { "comment" : "Label for default empty state title", "extractionState" : "extracted_with_value", @@ -44667,6 +44703,18 @@ } } }, + "pm.empty.learn.more.link" : { + "comment" : "Text for link to learn more about DuckDuckGo password manager", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Learn more" + } + } + } + }, "pm.empty.logins.title" : { "comment" : "Label for logins empty state title", "extractionState" : "extracted_with_value", @@ -47367,6 +47415,18 @@ } } }, + "pm.save-credentials.security.info" : { + "comment" : "Info message for the save credentials dialog\n Info message for the save credentials dialog when the autolock feature is off", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Passwords are encrypted. Nobody but you can see them, not even us. [Learn More]( (URL.passwordManagerLearnMore))" + } + } + } + }, "pm.signin.to.manage" : { "comment" : "Message displayed to the user when they are logged out of Email protection.", "extractionState" : "extracted_with_value", diff --git a/DuckDuckGo/Preferences/Model/AutofillPreferences.swift b/DuckDuckGo/Preferences/Model/AutofillPreferences.swift index 08c5b5d8ad..f9ef1b3a15 100644 --- a/DuckDuckGo/Preferences/Model/AutofillPreferences.swift +++ b/DuckDuckGo/Preferences/Model/AutofillPreferences.swift @@ -163,6 +163,12 @@ final class AutofillPreferences: AutofillPreferencesPersistor { private let injectedDependencyStore: StatisticsStore? private lazy var defaultDependencyStore: StatisticsStore = { +#if DEBUG + // To prevent an assertion failure deep within dependencies in Database.makeDatabase + if [.unitTests, .xcPreviews].contains(NSApp.runType) { + return StubStatisticsStore() + } +#endif return LocalStatisticsStore() }() diff --git a/DuckDuckGo/SecureVault/Extensions/UserText+PasswordManager.swift b/DuckDuckGo/SecureVault/Extensions/UserText+PasswordManager.swift index 1324cf72bb..a61f15e218 100644 --- a/DuckDuckGo/SecureVault/Extensions/UserText+PasswordManager.swift +++ b/DuckDuckGo/SecureVault/Extensions/UserText+PasswordManager.swift @@ -22,12 +22,18 @@ extension UserText { static let pmSaveCredentialsEditableTitle = NSLocalizedString("pm.save-credentials.editable.title", value: "Save password in DuckDuckGo?", comment: "Title for the editable Save Credentials popover") static let pmSaveCredentialsNonEditableTitle = NSLocalizedString("pm.save-credentials.non-editable.title", value: "New password saved", comment: "Title for the non-editable Save Credentials popover") + static let pmSaveCredentialsSecurityInfo = NSLocalizedString("pm.save-credentials.security.info", value: "Passwords are encrypted. Nobody but you can see them, not even us. [Learn More]( \(URL.passwordManagerLearnMore))", comment: "Info message for the save credentials dialog") + static let pmSaveCredentialsSecurityInfoAutolockOff = NSLocalizedString("pm.save-credentials.security.info", value: "Passwords are encrypted. We recommend setting up Auto-lock to keep your passwords even more secure. [Go to Settings](\(URL.settingsPane(.autofill)))", comment: "Info message for the save credentials dialog when the autolock feature is off") static let pmUpdateCredentialsTitle = NSLocalizedString("pm.update-credentials.title", value: "Update password?", comment: "Title for the Update Credentials popover") static let pmEmptyStateDefaultTitle = NSLocalizedString("pm.empty.default.title", value: "No passwords or credit cards saved yet", comment: "Label for default empty state title") - static let pmEmptyStateDefaultDescription = NSLocalizedString("pm.empty.default.description", - value: "If your passwords are saved in another browser, you can import them into DuckDuckGo.", + static let pmEmptyStateDefaultDescription = NSLocalizedString("pm.empty.default.description.extended.v2", + value: "Passwords are encrypted. Nobody but you can see them, not even us.", comment: "Label for default empty state description") + static let pmEmptyStateDefaultDescriptionAutolockOff = NSLocalizedString("pm.empty.default.description.extended.v2", + value: "Passwords are encrypted.", + comment: "Label for default empty state description when the autolock feature is off") + static let pmEmptyStateLearnMoreLink = NSLocalizedString("pm.empty.learn.more.link", value: "Learn more", comment: "Text for link to learn more about DuckDuckGo password manager") static let pmEmptyStateDefaultButtonTitle = NSLocalizedString("pm.empty.default.button.title", value: "Import Passwords", comment: "Import passwords button title for default empty state") static let pmEmptyStateLoginsTitle = NSLocalizedString("pm.empty.logins.title", value: "No passwords saved yet", comment: "Label for logins empty state title") diff --git a/DuckDuckGo/SecureVault/Model/PasswordManagementItemListModel.swift b/DuckDuckGo/SecureVault/Model/PasswordManagementItemListModel.swift index 2c14c32cd3..21eef35196 100644 --- a/DuckDuckGo/SecureVault/Model/PasswordManagementItemListModel.swift +++ b/DuckDuckGo/SecureVault/Model/PasswordManagementItemListModel.swift @@ -290,6 +290,7 @@ final class PasswordManagementItemListModel: ObservableObject { } } } + @Published var syncPromoSelected: Bool = false { didSet { if syncPromoSelected { @@ -297,12 +298,26 @@ final class PasswordManagementItemListModel: ObservableObject { } } } + + var emptyStateMessageDescription: String { + autofillPreferences.isAutoLockEnabled ? UserText.pmEmptyStateDefaultDescription : UserText.pmEmptyStateDefaultDescriptionAutolockOff + } + + var emptyStateMessageLinkText: String { + UserText.learnMore + } + + var emptyStateMessageLinkURL: URL { + URL.passwordManagerLearnMore + } + @Published private(set) var emptyState: EmptyState = .none @Published var canChangeCategory: Bool = true private var onItemSelected: (_ old: SecureVaultItem?, _ new: SecureVaultItem?) -> Void private var onAddItemSelected: (_ category: SecureVaultSorting.Category) -> Void private let tld: TLD + private let autofillPreferences: AutofillPreferencesPersistor private let urlMatcher: AutofillDomainNameUrlMatcher private static let randomColorsCount = 15 @@ -310,6 +325,7 @@ final class PasswordManagementItemListModel: ObservableObject { syncPromoManager: SyncPromoManaging, urlMatcher: AutofillDomainNameUrlMatcher = AutofillDomainNameUrlMatcher(), tld: TLD = ContentBlocking.shared.tld, + autofillPreferences: AutofillPreferencesPersistor = AutofillPreferences(), onItemSelected: @escaping (_ old: SecureVaultItem?, _ new: SecureVaultItem?) -> Void, onAddItemSelected: @escaping (_ category: SecureVaultSorting.Category) -> Void) { self.onItemSelected = onItemSelected @@ -318,6 +334,7 @@ final class PasswordManagementItemListModel: ObservableObject { self.syncPromoManager = syncPromoManager self.urlMatcher = urlMatcher self.tld = tld + self.autofillPreferences = autofillPreferences } func update(items: [SecureVaultItem]) { diff --git a/DuckDuckGo/SecureVault/View/PasswordManagementViewController.swift b/DuckDuckGo/SecureVault/View/PasswordManagementViewController.swift index 6d9ddcdd7d..3d322ed17d 100644 --- a/DuckDuckGo/SecureVault/View/PasswordManagementViewController.swift +++ b/DuckDuckGo/SecureVault/View/PasswordManagementViewController.swift @@ -63,7 +63,8 @@ final class PasswordManagementViewController: NSViewController { @IBOutlet var emptyState: NSView! @IBOutlet var emptyStateImageView: NSImageView! @IBOutlet var emptyStateTitle: NSTextField! - @IBOutlet var emptyStateMessage: NSTextField! + @IBOutlet var emptyStateMessage: NSTextView! + @IBOutlet var emptyStateMessageHeight: NSLayoutConstraint! @IBOutlet var emptyStateButton: NSButton! @IBOutlet weak var exportLoginItem: NSMenuItem! @IBOutlet var lockScreen: NSView! @@ -171,7 +172,10 @@ final class PasswordManagementViewController: NSViewController { reloadDataAfterSyncCancellable = bindSyncDidFinish() emptyStateTitle.attributedStringValue = NSAttributedString.make(emptyStateTitle.stringValue, lineHeight: 1.14, kern: -0.23) - emptyStateMessage.attributedStringValue = NSAttributedString.make(emptyStateMessage.stringValue, lineHeight: 1.05, kern: -0.08) + + emptyStateMessage.isSelectable = true + emptyStateMessage.delegate = self + setUpEmptyStateMessageAttributedText() addVaultItemButton.toolTip = UserText.addItemTooltip moreButton.toolTip = UserText.moreOptionsTooltip @@ -197,6 +201,49 @@ final class PasswordManagementViewController: NSViewController { .store(in: &cancellables) } + private func setUpEmptyStateMessageAttributedText() { + guard let listModel else { return } + emptyStateMessage.delegate = self + + let linkAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.linkBlue, + .cursor: NSCursor.pointingHand + ] + + emptyStateMessage.linkTextAttributes = linkAttributes + + let attachment = NSTextAttachment() + attachment.image = NSImage(resource: .lockSolid16).tinted(with: NSColor.blackWhite80) + attachment.bounds = CGRect(x: 0, y: -1, width: 12, height: 12) + let attributedTextImage = NSMutableAttributedString(attachment: attachment) + + let string = NSMutableAttributedString(attributedString: attributedTextImage) + + let messageString = NSMutableAttributedString(string: " " + listModel.emptyStateMessageDescription + " ") + string.append(messageString) + + let linkString = NSMutableAttributedString(string: listModel.emptyStateMessageLinkText, attributes: [ + .link: listModel.emptyStateMessageLinkURL + ]) + string.append(linkString) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + string.addAttributes([ + .cursor: NSCursor.arrow, + .paragraphStyle: paragraphStyle, + .font: NSFont.systemFont(ofSize: 13, weight: .regular), + .foregroundColor: NSColor.blackWhite80 + ], range: NSRange(location: 0, length: string.length)) + + let maxSize = NSSize(width: 280, height: 20000) + let bounds = string.boundingRect(with: maxSize, options: .usesLineFragmentOrigin) + + emptyStateMessageHeight.constant = bounds.height + + emptyStateMessage.textStorage?.setAttributedString(string) + } + private func setupStrings() { importPasswordMenuItem.title = UserText.importPasswords exportLoginItem.title = UserText.exportLogins @@ -205,7 +252,7 @@ final class PasswordManagementViewController: NSViewController { unlockYourAutofillLabel.title = UserText.passwordManagerUnlockAutofill autofillTitleLabel.stringValue = UserText.passwordManagementTitle emptyStateTitle.stringValue = UserText.pmEmptyStateDefaultTitle - emptyStateMessage.stringValue = UserText.pmEmptyStateDefaultDescription + setUpEmptyStateMessageAttributedText() emptyStateButton.title = UserText.pmEmptyStateDefaultButtonTitle } @@ -1016,7 +1063,7 @@ final class PasswordManagementViewController: NSViewController { private func showEmptyState(category: SecureVaultSorting.Category) { switch category { - case .allItems: showEmptyState(image: .passwordsAdd128, title: UserText.pmEmptyStateDefaultTitle, message: UserText.pmEmptyStateDefaultDescription, hideMessage: false, hideButton: false) + case .allItems: showEmptyState(image: .passwordsAdd128, title: UserText.pmEmptyStateDefaultTitle, hideMessage: false, hideButton: false) case .logins: showEmptyState(image: .passwordsAdd128, title: UserText.pmEmptyStateLoginsTitle, hideMessage: false, hideButton: false) case .identities: showEmptyState(image: .identityAdd128, title: UserText.pmEmptyStateIdentitiesTitle) case .cards: showEmptyState(image: .creditCardsAdd128, title: UserText.pmEmptyStateCardsTitle) @@ -1027,12 +1074,12 @@ final class PasswordManagementViewController: NSViewController { emptyState.isHidden = true } - private func showEmptyState(image: NSImage, title: String, message: String? = nil, hideMessage: Bool = true, hideButton: Bool = true) { + private func showEmptyState(image: NSImage, title: String, hideMessage: Bool = true, hideButton: Bool = true) { emptyState.isHidden = false emptyStateImageView.image = image emptyStateTitle.attributedStringValue = NSAttributedString.make(title, lineHeight: 1.14, kern: -0.23) - if let message { - emptyStateMessage.attributedStringValue = NSAttributedString.make(message, lineHeight: 1.05, kern: -0.08) + if !hideMessage { + setUpEmptyStateMessageAttributedText() } emptyStateMessage.isHidden = hideMessage emptyStateButton.isHidden = hideButton @@ -1069,14 +1116,17 @@ extension PasswordManagementViewController: NSTextFieldDelegate { func controlTextDidChange(_ obj: Notification) { updateFilter() } - } extension PasswordManagementViewController: NSTextViewDelegate { func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { - if let link = link as? URL, let pane = PreferencePaneIdentifier(url: link) { - WindowControllersManager.shared.showPreferencesTab(withSelectedPane: pane) + if let link = link as? URL { + if let pane = PreferencePaneIdentifier(url: link) { + WindowControllersManager.shared.showPreferencesTab(withSelectedPane: pane) + } else { + WindowControllersManager.shared.showTab(with: .url(link, source: .link)) + } self.dismiss() } diff --git a/DuckDuckGo/SecureVault/View/PasswordManager.storyboard b/DuckDuckGo/SecureVault/View/PasswordManager.storyboard index e8f73afdc7..f256d250f0 100644 --- a/DuckDuckGo/SecureVault/View/PasswordManager.storyboard +++ b/DuckDuckGo/SecureVault/View/PasswordManager.storyboard @@ -46,11 +46,11 @@ - - + + - + @@ -58,9 +58,9 @@ - + - + @@ -68,19 +68,47 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + - + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -792,11 +856,13 @@ DQ + + @@ -824,6 +890,7 @@ DQ + @@ -833,6 +900,7 @@ DQ + @@ -1097,6 +1165,7 @@ DQ + diff --git a/DuckDuckGo/SecureVault/View/SaveCredentialsViewController.swift b/DuckDuckGo/SecureVault/View/SaveCredentialsViewController.swift index ae751fb401..3fa5fe71b0 100644 --- a/DuckDuckGo/SecureVault/View/SaveCredentialsViewController.swift +++ b/DuckDuckGo/SecureVault/View/SaveCredentialsViewController.swift @@ -30,6 +30,38 @@ protocol SaveCredentialsDelegate: AnyObject { } +extension SaveCredentialsViewController: MouseOverViewDelegate { + func mouseOverView(_ mouseOverView: MouseOverView, isMouseOver: Bool) { + if isMouseOver { + lockImageBackgroundView.fillColor = NSColor.infoHoverButtonHovered + presentSecurityInfoPopover() + } else { + dismissSecurityInfoPopover() + } + } + + private func presentSecurityInfoPopover() { + // Only show the popover if we aren't already presenting one: + guard infoViewController == nil else { + infoViewController?.cancelAutoDismiss() + return + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let message = autofillPreferences.isAutoLockEnabled ? UserText.pmSaveCredentialsSecurityInfo : UserText.pmSaveCredentialsSecurityInfoAutolockOff + let infoViewController = PopoverInfoViewController(message: message) { [weak self] in + self?.lockImageBackgroundView.fillColor = NSColor.infoHoverButton + } + infoViewController.show(onParent: self, relativeTo: self.tooltipView) + } + } + + private func dismissSecurityInfoPopover() { + infoViewController?.scheduleAutoDismiss() + } +} + final class SaveCredentialsViewController: NSViewController { static func create() -> SaveCredentialsViewController { @@ -64,6 +96,14 @@ final class SaveCredentialsViewController: NSViewController { @IBOutlet weak var passwordManagerNotNowButton: NSButton! @IBOutlet var fireproofCheck: NSButton! @IBOutlet weak var fireproofCheckDescription: NSTextFieldCell! + @IBOutlet weak var tooltipView: MouseOverView! + @IBOutlet weak var lockImageBackgroundView: NSBox! + + private var infoViewController: PopoverInfoViewController? { + presentedViewControllers?.first { + ($0 as? PopoverInfoViewController) != nil + } as? PopoverInfoViewController + } private enum Action { case displayed @@ -79,6 +119,8 @@ final class SaveCredentialsViewController: NSViewController { private var passwordManagerCoordinator = PasswordManagerCoordinator.shared + private var autofillPreferences: AutofillPreferencesPersistor = AutofillPreferences() + private var passwordManagerStateCancellable: AnyCancellable? private var saveButtonAction: (() -> Void)? @@ -97,6 +139,7 @@ final class SaveCredentialsViewController: NSViewController { saveButton.becomeFirstResponder() updateSaveSegmentedControl() setUpStrings() + setUpSecurityInfoViews() } override func viewWillAppear() { @@ -134,6 +177,13 @@ final class SaveCredentialsViewController: NSViewController { passwordManagerNotNowButton.title = UserText.notNow } + private func setUpSecurityInfoViews() { + tooltipView.delegate = self + lockImageBackgroundView.cornerRadius = lockImageBackgroundView.bounds.height / 2 + lockImageBackgroundView.fillColor = NSColor.infoHoverButton + lockImageBackgroundView.boxType = .custom + } + /// Note that if the credentials.account.id is not nil, then we consider this an update rather than a save. func update(credentials: SecureVaultModels.WebsiteCredentials, automaticallySaved: Bool) { self.credentials = credentials diff --git a/DuckDuckGo/Statistics/ATB/LocalStatisticsStore.swift b/DuckDuckGo/Statistics/ATB/LocalStatisticsStore.swift index 4885a848c6..0cda196c2c 100644 --- a/DuckDuckGo/Statistics/ATB/LocalStatisticsStore.swift +++ b/DuckDuckGo/Statistics/ATB/LocalStatisticsStore.swift @@ -231,3 +231,23 @@ final class LocalStatisticsStore: StatisticsStore { } } + +#if DEBUG + +// For use in tests to avoid indirect access of Database.makeDatabase + +final class StubStatisticsStore: StatisticsStore { + var installDate: Date? + var atb: String? + var searchRetentionAtb: String? + var appRetentionAtb: String? + var variant: String? + var lastAppRetentionRequestDate: Date? + + var waitlistUnlocked: Bool = false + + var autoLockEnabled: Bool = false + var autoLockThreshold: String? +} + +#endif