Skip to content

Commit 9b25d55

Browse files
BIT-2349: Add more options menu to vault item selection view (#718)
1 parent 59f184b commit 9b25d55

13 files changed

+1100
-1220
lines changed
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import Foundation
2+
3+
// MARK: - VaultItemMoreOptionsHelper
4+
5+
/// A protocol for a helper object to handle displaying the more options menu for a vault item and
6+
/// responding to the user's selection.
7+
///
8+
protocol VaultItemMoreOptionsHelper {
9+
/// Show the more options alert for the selected item.
10+
///
11+
/// - Parameters
12+
/// - item: The selected item to show the options for.
13+
/// - handleDisplayToast: A closure called to handle displaying a toast.
14+
/// - handleOpenURL: A closure called to open a URL.
15+
///
16+
func showMoreOptionsAlert(
17+
for item: VaultListItem,
18+
handleDisplayToast: @escaping (Toast) -> Void,
19+
handleOpenURL: @escaping (URL) -> Void
20+
) async
21+
}
22+
23+
// MARK: - DefaultVaultItemMoreOptionsHelper
24+
25+
/// A default implementation of `VaultItemMoreOptionsHelper`.
26+
///
27+
@MainActor
28+
class DefaultVaultItemMoreOptionsHelper: VaultItemMoreOptionsHelper {
29+
// MARK: Types
30+
31+
typealias Services = HasAuthRepository
32+
& HasErrorReporter
33+
& HasEventService
34+
& HasPasteboardService
35+
& HasStateService
36+
& HasVaultRepository
37+
38+
// MARK: Private Properties
39+
40+
/// The `Coordinator` that handles navigation.
41+
private var coordinator: AnyCoordinator<VaultRoute, AuthAction>
42+
43+
/// The services used by this helper.
44+
private var services: Services
45+
46+
// MARK: Initialization
47+
48+
/// Initialize a `VaultItemMoreOptionsHelper`.
49+
///
50+
/// - Parameters:
51+
/// - coordinator: The coordinator that handles navigation.
52+
/// - services: The services used by this helper.
53+
///
54+
init(
55+
coordinator: AnyCoordinator<VaultRoute, AuthAction>,
56+
services: Services
57+
) {
58+
self.coordinator = coordinator
59+
self.services = services
60+
}
61+
62+
// MARK: Methods
63+
64+
func showMoreOptionsAlert(
65+
for item: VaultListItem,
66+
handleDisplayToast: @escaping (Toast) -> Void,
67+
handleOpenURL: @escaping (URL) -> Void
68+
) async {
69+
do {
70+
// Only ciphers have more options.
71+
guard case let .cipher(cipherView, _) = item.itemType else { return }
72+
73+
let hasPremium = try await services.vaultRepository.doesActiveAccountHavePremium()
74+
let hasMasterPassword = try await services.stateService.getUserHasMasterPassword()
75+
76+
coordinator.showAlert(.moreOptions(
77+
canCopyTotp: hasPremium || cipherView.organizationUseTotp,
78+
cipherView: cipherView,
79+
hasMasterPassword: hasMasterPassword,
80+
id: item.id,
81+
showEdit: true
82+
) { action in
83+
await self.handleMoreOptionsAction(
84+
action,
85+
handleDisplayToast: handleDisplayToast,
86+
handleOpenURL: handleOpenURL
87+
)
88+
})
89+
} catch {
90+
services.errorReporter.log(error: error)
91+
coordinator.showAlert(.defaultAlert(title: Localizations.anErrorHasOccurred))
92+
}
93+
}
94+
95+
// MARK: Private Methods
96+
97+
/// Generates and copies a TOTP code for the cipher's TOTP key.
98+
///
99+
/// - Parameter totpKey: The TOTP key used to generate a TOTP code.
100+
///
101+
private func generateAndCopyTotpCode(
102+
totpKey: TOTPKeyModel,
103+
handleDisplayToast: @escaping (Toast) -> Void
104+
) async {
105+
do {
106+
let response = try await services.vaultRepository.refreshTOTPCode(for: totpKey)
107+
guard let code = response.codeModel?.code else {
108+
throw TOTPServiceError.unableToGenerateCode(nil)
109+
}
110+
services.pasteboardService.copy(code)
111+
handleDisplayToast(
112+
Toast(text: Localizations.valueHasBeenCopied(Localizations.verificationCodeTotp))
113+
)
114+
} catch {
115+
coordinator.showAlert(.defaultAlert(title: Localizations.anErrorHasOccurred))
116+
services.errorReporter.log(error: error)
117+
}
118+
}
119+
120+
/// Handle the result of the selected option on the More Options alert..
121+
///
122+
/// - Parameter action: The selected action.
123+
///
124+
private func handleMoreOptionsAction(
125+
_ action: MoreOptionsAction,
126+
handleDisplayToast: @escaping (Toast) -> Void,
127+
handleOpenURL: (URL) -> Void
128+
) async {
129+
switch action {
130+
case let .copy(toast, value, requiresMasterPasswordReprompt, event, cipherId):
131+
let copyBlock = {
132+
self.services.pasteboardService.copy(value)
133+
handleDisplayToast(Toast(text: Localizations.valueHasBeenCopied(toast)))
134+
if let event {
135+
Task {
136+
await self.services.eventService.collect(
137+
eventType: event,
138+
cipherId: cipherId
139+
)
140+
}
141+
}
142+
}
143+
if requiresMasterPasswordReprompt {
144+
presentMasterPasswordRepromptAlert(completion: copyBlock)
145+
} else {
146+
copyBlock()
147+
}
148+
case let .copyTotp(totpKey, requiresMasterPasswordReprompt):
149+
if requiresMasterPasswordReprompt {
150+
presentMasterPasswordRepromptAlert {
151+
await self.generateAndCopyTotpCode(totpKey: totpKey, handleDisplayToast: handleDisplayToast)
152+
}
153+
} else {
154+
await generateAndCopyTotpCode(totpKey: totpKey, handleDisplayToast: handleDisplayToast)
155+
}
156+
case let .edit(cipherView, requiresMasterPasswordReprompt):
157+
if requiresMasterPasswordReprompt {
158+
presentMasterPasswordRepromptAlert {
159+
self.coordinator.navigate(to: .editItem(cipherView), context: self)
160+
}
161+
} else {
162+
coordinator.navigate(to: .editItem(cipherView), context: self)
163+
}
164+
case let .launch(url):
165+
handleOpenURL(url.sanitized)
166+
case let .view(id):
167+
coordinator.navigate(to: .viewItem(id: id))
168+
}
169+
}
170+
171+
/// Presents the master password reprompt alert and calls the completion handler when the user's
172+
/// master password has been confirmed.
173+
///
174+
/// - Parameter completion: A completion handler that is called when the user's master password
175+
/// has been confirmed.
176+
///
177+
private func presentMasterPasswordRepromptAlert(completion: @escaping () async -> Void) {
178+
let alert = Alert.masterPasswordPrompt { password in
179+
do {
180+
let isValid = try await self.services.authRepository.validatePassword(password)
181+
guard isValid else {
182+
self.coordinator.showAlert(.defaultAlert(title: Localizations.invalidMasterPassword))
183+
return
184+
}
185+
await completion()
186+
} catch {
187+
self.coordinator.showAlert(.defaultAlert(title: Localizations.anErrorHasOccurred))
188+
self.services.errorReporter.log(error: error)
189+
}
190+
}
191+
coordinator.showAlert(alert)
192+
}
193+
}

0 commit comments

Comments
 (0)