mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-10 17:46:07 -06:00
Persist authenticator items to a database (#18)
This commit is contained in:
parent
798abea0e6
commit
b99af3c566
@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.bitwarden.authenticator</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.bitwarden.authenticator</string>
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
extension Bundle {
|
||||
/// Return's the app's action extension identifier.
|
||||
var appExtensionIdentifier: String {
|
||||
"\(bundleIdentifier!).find-login-action-extension"
|
||||
}
|
||||
|
||||
/// Returns the app's name.
|
||||
var appName: String {
|
||||
infoDictionary?["CFBundleName"] as? String ?? ""
|
||||
}
|
||||
|
||||
/// Returns the app's version string (e.g. "2023.8.0").
|
||||
var appVersion: String {
|
||||
infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||
}
|
||||
|
||||
/// Returns the app's build number (e.g. "123").
|
||||
var buildNumber: String {
|
||||
infoDictionary?["CFBundleVersion"] as? String ?? ""
|
||||
}
|
||||
|
||||
/// Return's the app's app identifier.
|
||||
var appIdentifier: String {
|
||||
infoDictionary?["BitwardenAppIdentifier"] as? String
|
||||
?? bundleIdentifier
|
||||
?? "com.x8bit.bitwarden"
|
||||
}
|
||||
|
||||
/// Return's the app's app group identifier.
|
||||
var groupIdentifier: String {
|
||||
"group." + appIdentifier
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
import CoreData
|
||||
|
||||
extension NSManagedObjectContext {
|
||||
/// Executes the batch delete request and/or batch insert request and merges any changes into
|
||||
/// the current context plus any additional contexts.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - batchDeleteRequest: The batch delete request to execute.
|
||||
/// - batchInsertRequest: The batch insert request to execute.
|
||||
/// - additionalContexts: Any additional contexts other than the current to merge the changes into.
|
||||
///
|
||||
func executeAndMergeChanges(
|
||||
batchDeleteRequest: NSBatchDeleteRequest? = nil,
|
||||
batchInsertRequest: NSBatchInsertRequest? = nil,
|
||||
additionalContexts: [NSManagedObjectContext] = []
|
||||
) throws {
|
||||
var changes: [AnyHashable: Any] = [:]
|
||||
|
||||
if let batchDeleteRequest {
|
||||
batchDeleteRequest.resultType = .resultTypeObjectIDs
|
||||
if let deleteResult = try execute(batchDeleteRequest) as? NSBatchDeleteResult {
|
||||
changes[NSDeletedObjectsKey] = deleteResult.result as? [NSManagedObjectID] ?? []
|
||||
}
|
||||
}
|
||||
|
||||
if let batchInsertRequest {
|
||||
batchInsertRequest.resultType = .objectIDs
|
||||
if let insertResult = try execute(batchInsertRequest) as? NSBatchInsertResult {
|
||||
changes[NSInsertedObjectsKey] = insertResult.result as? [NSManagedObjectID] ?? []
|
||||
}
|
||||
}
|
||||
|
||||
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [self] + additionalContexts)
|
||||
}
|
||||
|
||||
/// Performs the closure on the context's queue and saves the context if there are any changes.
|
||||
///
|
||||
/// - Parameter closure: The closure to perform.
|
||||
///
|
||||
func performAndSave(closure: @escaping () throws -> Void) async throws {
|
||||
try await perform {
|
||||
try closure()
|
||||
try self.saveIfChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the context if there are changes.
|
||||
func saveIfChanged() throws {
|
||||
guard hasChanges else { return }
|
||||
try save()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import CoreData
|
||||
import OSLog
|
||||
|
||||
/// A protocol for a `NSManagedObject` which persists a data model as JSON encoded data. The model
|
||||
/// can be set via the `model` property which encodes the model to the data property, which should
|
||||
/// be a `@NSManaged` property of the `NSManagedObject`. When the managed object is populated from
|
||||
/// the database, the `model` property can be read to decode the data.
|
||||
///
|
||||
protocol CodableModelData: AnyObject, NSManagedObject {
|
||||
associatedtype Model: Codable
|
||||
|
||||
/// A `@NSManaged` property of the manage object for storing the encoded model as data.
|
||||
var modelData: Data? { get set }
|
||||
}
|
||||
|
||||
extension CodableModelData {
|
||||
/// Encodes or decodes the model to/from the data instance.
|
||||
var model: Model? {
|
||||
get {
|
||||
guard let modelData else { return nil }
|
||||
do {
|
||||
return try JSONDecoder().decode(Model.self, from: modelData)
|
||||
} catch {
|
||||
Logger.application.error("Error decoding \(String(describing: Model.self)): \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
set {
|
||||
guard let newValue else {
|
||||
modelData = nil
|
||||
return
|
||||
}
|
||||
do {
|
||||
modelData = try JSONEncoder().encode(newValue)
|
||||
} catch {
|
||||
Logger.application.error("Error encoding \(String(describing: Model.self)): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
import CoreData
|
||||
|
||||
/// A protocol for an `NSManagedObject` data model that adds some convenience methods for working
|
||||
/// with Core Data.
|
||||
///
|
||||
protocol ManagedObject: AnyObject {
|
||||
/// The name of the entity of the managed object, as defined in the data model.
|
||||
static var entityName: String { get }
|
||||
}
|
||||
|
||||
extension ManagedObject where Self: NSManagedObject {
|
||||
static var entityName: String {
|
||||
String(describing: self)
|
||||
}
|
||||
|
||||
/// Returns a `NSBatchInsertRequest` for batch inserting an array of objects.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - objects: The objects (or objects that can be converted to managed objects) to insert.
|
||||
/// - handler: A handler that is called for each object to set the properties on the
|
||||
/// `NSManagedObject` to insert.
|
||||
/// - Returns: A `NSBatchInsertRequest` for batch inserting an array of objects.
|
||||
///
|
||||
static func batchInsertRequest<T>(
|
||||
objects: [T],
|
||||
handler: @escaping (Self, T) throws -> Void
|
||||
) throws -> NSBatchInsertRequest {
|
||||
var index = 0
|
||||
var errorToThrow: Error?
|
||||
let insertRequest = NSBatchInsertRequest(entityName: entityName) { (managedObject: NSManagedObject) -> Bool in
|
||||
guard index < objects.count else { return true }
|
||||
defer { index += 1 }
|
||||
|
||||
if let managedObject = (managedObject as? Self) {
|
||||
do {
|
||||
try handler(managedObject, objects[index])
|
||||
} catch {
|
||||
// The error can't be thrown directly in this closure, so capture it, return
|
||||
// from the closure, and then throw it.
|
||||
errorToThrow = error
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if let errorToThrow {
|
||||
throw errorToThrow
|
||||
}
|
||||
|
||||
return insertRequest
|
||||
}
|
||||
|
||||
/// Returns a `NSFetchRequest` for fetching instances of the managed object.
|
||||
///
|
||||
/// - Parameter predicate: An optional predicate to apply to the fetch request.
|
||||
/// - Returns: A `NSFetchRequest` used to fetch instances of the managed object.
|
||||
///
|
||||
static func fetchRequest(predicate: NSPredicate? = nil) -> NSFetchRequest<Self> {
|
||||
let fetchRequest = NSFetchRequest<Self>(entityName: entityName)
|
||||
fetchRequest.predicate = predicate
|
||||
return fetchRequest
|
||||
}
|
||||
|
||||
/// Returns a `NSFetchRequest` for fetching a generic `NSFetchRequestResult` instances of the
|
||||
/// managed object.
|
||||
///
|
||||
/// - Parameter predicate: An optional predicate to apply to the fetch request.
|
||||
/// - Returns: A `NSFetchRequest` used to fetch generic `NSFetchRequestResult` instances of the
|
||||
/// managed object.
|
||||
///
|
||||
static func fetchResultRequest(predicate: NSPredicate? = nil) -> NSFetchRequest<NSFetchRequestResult> {
|
||||
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entityName)
|
||||
fetchRequest.predicate = predicate
|
||||
return fetchRequest
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
import CoreData
|
||||
import XCTest
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class ManagedObjectTests: AuthenticatorTestCase {
|
||||
// MARK: Tests
|
||||
|
||||
/// `fetchRequest()` returns a `NSFetchRequest` for the entity.
|
||||
func test_fetchRequest() {
|
||||
let fetchRequest = TestManagedObject.fetchRequest()
|
||||
XCTAssertEqual(fetchRequest.entityName, "TestManagedObject")
|
||||
}
|
||||
|
||||
/// `fetchResultRequest()` returns a `NSFetchRequest` for the entity.
|
||||
func test_fetchResultRequest() {
|
||||
let fetchRequest = TestManagedObject.fetchResultRequest()
|
||||
XCTAssertEqual(fetchRequest.entityName, "TestManagedObject")
|
||||
}
|
||||
}
|
||||
|
||||
private class TestManagedObject: NSManagedObject, ManagedObject {
|
||||
static var entityName = "TestManagedObject"
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
import CoreData
|
||||
|
||||
/// A protocol for a `ManagedObject` data model associated with a user that adds some convenience
|
||||
/// methods for building `NSPersistentStoreRequest` for common CRUD operations.
|
||||
///
|
||||
protocol ManagedUserObject: ManagedObject {
|
||||
/// The value type (struct) associated with the managed object that is persisted in the database.
|
||||
associatedtype ValueType
|
||||
|
||||
/// Returns a `NSPredicate` used for filtering by a user's ID.
|
||||
///
|
||||
/// - Parameter userId: The user ID associated with the managed object.
|
||||
///
|
||||
static func userIdPredicate(userId: String) -> NSPredicate
|
||||
|
||||
/// Returns a `NSPredicate` used for filtering by a user and managed object ID.
|
||||
///
|
||||
/// - Parameter userId: The user ID associated with the managed object.
|
||||
///
|
||||
static func userIdAndIdPredicate(userId: String, id: String) -> NSPredicate
|
||||
|
||||
/// Updates the managed object from its associated value type object and user ID.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - value: The value type object used to update the managed object.
|
||||
/// - userId: The user ID associated with the object.
|
||||
///
|
||||
func update(with value: ValueType, userId: String) throws
|
||||
}
|
||||
|
||||
extension ManagedUserObject where Self: NSManagedObject {
|
||||
/// A `NSBatchInsertRequest` that inserts objects for the specified user.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - objects: The list of objects to insert.
|
||||
/// - userId: The user associated with the objects to insert.
|
||||
/// - Returns: A `NSBatchInsertRequest` that inserts the objects for the user.
|
||||
///
|
||||
static func batchInsertRequest(objects: [ValueType], userId: String) throws -> NSBatchInsertRequest {
|
||||
try batchInsertRequest(objects: objects) { object, value in
|
||||
try object.update(with: value, userId: userId)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `NSBatchDeleteRequest` that deletes all objects for the specified user.
|
||||
///
|
||||
/// - Parameter userId: The user associated with the objects to delete.
|
||||
/// - Returns: A `NSBatchDeleteRequest` that deletes all objects for the user.
|
||||
///
|
||||
static func deleteByUserIdRequest(userId: String) -> NSBatchDeleteRequest {
|
||||
let fetchRequest = fetchResultRequest(predicate: userIdPredicate(userId: userId))
|
||||
return NSBatchDeleteRequest(fetchRequest: fetchRequest)
|
||||
}
|
||||
|
||||
/// A `NSFetchRequest` that fetches objects for the specified user matching an ID.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - id: The ID of the object to fetch.
|
||||
/// - userId: The user associated with the object to fetch.
|
||||
/// - Returns: A `NSFetchRequest` that fetches all objects for the user.
|
||||
///
|
||||
static func fetchByIdRequest(id: String, userId: String) -> NSFetchRequest<Self> {
|
||||
fetchRequest(predicate: userIdAndIdPredicate(userId: userId, id: id))
|
||||
}
|
||||
|
||||
/// A `NSFetchRequest` that fetches all objects for the specified user.
|
||||
///
|
||||
/// - Parameter userId: The user associated with the objects to delete.
|
||||
/// - Returns: A `NSFetchRequest` that fetches all objects for the user.
|
||||
///
|
||||
static func fetchByUserIdRequest(userId: String) -> NSFetchRequest<Self> {
|
||||
fetchRequest(predicate: userIdPredicate(userId: userId))
|
||||
}
|
||||
}
|
||||
@ -18,12 +18,18 @@ public class ServiceContainer: Services {
|
||||
/// The application instance (i.e. `UIApplication`), if the app isn't running in an extension.
|
||||
let application: Application?
|
||||
|
||||
/// The service used for managing items
|
||||
let authenticatorItemRepository: AuthenticatorItemRepository
|
||||
|
||||
/// The service used by the application to manage camera use.
|
||||
let cameraService: CameraService
|
||||
|
||||
/// The service used by the application to handle encryption and decryption tasks.
|
||||
let clientService: ClientService
|
||||
|
||||
/// The service used by the application to encrypt and decrypt items
|
||||
let cryptographyService: CryptographyService
|
||||
|
||||
/// The service used by the application to report non-fatal errors.
|
||||
let errorReporter: ErrorReporter
|
||||
|
||||
@ -33,9 +39,6 @@ public class ServiceContainer: Services {
|
||||
/// Provides the present time for TOTP Code Calculation.
|
||||
let timeProvider: TimeProvider
|
||||
|
||||
/// The repository for managing tokens
|
||||
let tokenRepository: TokenRepository
|
||||
|
||||
/// The service used by the application to validate TOTP keys and produce TOTP values.
|
||||
let totpService: TOTPService
|
||||
|
||||
@ -45,31 +48,34 @@ public class ServiceContainer: Services {
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - application: The application instance.
|
||||
/// - authenticatorItemRepository: The service to manage items
|
||||
/// - cameraService: The service used by the application to manage camera use.
|
||||
/// - clientService: The service used by the application to handle encryption and decryption tasks.
|
||||
/// - cryptographyService: The service used by the application to encrypt and decrypt items
|
||||
/// - errorReporter: The service used by the application to report non-fatal errors.
|
||||
/// - pasteboardService: The service used by the application for sharing data with other apps.
|
||||
/// - timeProvider: Provides the present time for TOTP Code Calculation.
|
||||
/// - tokenRepository: The service to manage tokens.
|
||||
/// - totpService: The service used by the application to validate TOTP keys and produce TOTP values.
|
||||
///
|
||||
init(
|
||||
application: Application?,
|
||||
authenticatorItemRepository: AuthenticatorItemRepository,
|
||||
cameraService: CameraService,
|
||||
cryptographyService: CryptographyService,
|
||||
clientService: ClientService,
|
||||
errorReporter: ErrorReporter,
|
||||
pasteboardService: PasteboardService,
|
||||
timeProvider: TimeProvider,
|
||||
tokenRepository: TokenRepository,
|
||||
totpService: TOTPService
|
||||
) {
|
||||
self.application = application
|
||||
self.authenticatorItemRepository = authenticatorItemRepository
|
||||
self.cameraService = cameraService
|
||||
self.clientService = clientService
|
||||
self.cryptographyService = cryptographyService
|
||||
self.errorReporter = errorReporter
|
||||
self.pasteboardService = pasteboardService
|
||||
self.timeProvider = timeProvider
|
||||
self.tokenRepository = tokenRepository
|
||||
self.totpService = totpService
|
||||
}
|
||||
|
||||
@ -85,26 +91,36 @@ public class ServiceContainer: Services {
|
||||
) {
|
||||
let cameraService = DefaultCameraService()
|
||||
let clientService = DefaultClientService()
|
||||
let cryptographyService = DefaultCryptographyService()
|
||||
let dataStore = DataStore(errorReporter: errorReporter)
|
||||
let timeProvider = CurrentTime()
|
||||
let totpService = DefaultTOTPService()
|
||||
|
||||
let pasteboardService = DefaultPasteboardService(
|
||||
errorReporter: errorReporter
|
||||
)
|
||||
let tokenRepository = DefaultTokenRepository(
|
||||
let totpService = DefaultTOTPService(
|
||||
clientVault: clientService.clientVault(),
|
||||
errorReporter: errorReporter,
|
||||
timeProvider: timeProvider
|
||||
)
|
||||
|
||||
let pasteboardService = DefaultPasteboardService(
|
||||
errorReporter: errorReporter
|
||||
)
|
||||
let authenticatorItemService = DefaultAuthenticatorItemService(
|
||||
authenticatorItemDataStore: dataStore
|
||||
)
|
||||
let authenticatorItemRepository = DefaultAuthenticatorItemRepository(
|
||||
authenticatorItemService: authenticatorItemService,
|
||||
cryptographyService: cryptographyService
|
||||
)
|
||||
|
||||
self.init(
|
||||
application: application,
|
||||
authenticatorItemRepository: authenticatorItemRepository,
|
||||
cameraService: cameraService,
|
||||
cryptographyService: cryptographyService,
|
||||
clientService: clientService,
|
||||
errorReporter: errorReporter,
|
||||
pasteboardService: pasteboardService,
|
||||
timeProvider: timeProvider,
|
||||
tokenRepository: tokenRepository,
|
||||
totpService: totpService
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,12 +1,20 @@
|
||||
import BitwardenSdk
|
||||
|
||||
/// The services provided by the `ServiceContainer`.
|
||||
typealias Services = HasCameraService
|
||||
typealias Services = HasAuthenticatorItemRepository
|
||||
& HasCameraService
|
||||
& HasCryptographyService
|
||||
& HasErrorReporter
|
||||
& HasPasteboardService
|
||||
& HasTOTPService
|
||||
& HasTimeProvider
|
||||
& HasTokenRepository
|
||||
|
||||
/// Protocol for an object that provides an `AuthenticatorItemRepository`
|
||||
///
|
||||
protocol HasAuthenticatorItemRepository {
|
||||
/// The service used to interact with the data layer for items
|
||||
var authenticatorItemRepository: AuthenticatorItemRepository { get }
|
||||
}
|
||||
|
||||
/// Protocol for an object that provides a `CameraService`.
|
||||
///
|
||||
@ -15,6 +23,13 @@ protocol HasCameraService {
|
||||
var cameraService: CameraService { get }
|
||||
}
|
||||
|
||||
/// Protocol for an object that provides a `CryptographyService`
|
||||
///
|
||||
protocol HasCryptographyService {
|
||||
/// The service used by the application to encrypt and decrypt items
|
||||
var cryptographyService: CryptographyService { get }
|
||||
}
|
||||
|
||||
/// Protocol for an object that provides an `ErrorReporter`.
|
||||
///
|
||||
protocol HasErrorReporter {
|
||||
@ -42,8 +57,3 @@ protocol HasTimeProvider {
|
||||
/// Provides the present time for TOTP Code Calculation.
|
||||
var timeProvider: TimeProvider { get }
|
||||
}
|
||||
|
||||
protocol HasTokenRepository {
|
||||
/// The service that interacts with the data layer for tokens
|
||||
var tokenRepository: TokenRepository { get }
|
||||
}
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23E224" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="AuthenticatorItemData" representedClassName=".AuthenticatorItemData" syncable="YES">
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="modelData" attributeType="Binary"/>
|
||||
<attribute name="userId" attributeType="String"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="userId"/>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
</model>
|
||||
@ -0,0 +1,108 @@
|
||||
import BitwardenSdk
|
||||
import CoreData
|
||||
|
||||
// MARK: - StoreType
|
||||
|
||||
/// A type of data store.
|
||||
///
|
||||
enum StoreType {
|
||||
/// The data store is stored only in memory and isn't persisted to the device. This is used for
|
||||
/// unit testing.
|
||||
case memory
|
||||
|
||||
/// The data store is persisted to the device.
|
||||
case persisted
|
||||
}
|
||||
|
||||
// MARK: - DataStore
|
||||
|
||||
/// A data store that manages persisting data across app launches in Core Data.
|
||||
///
|
||||
class DataStore {
|
||||
// MARK: Properties
|
||||
|
||||
/// A managed object context which executes on a background queue.
|
||||
private(set) lazy var backgroundContext: NSManagedObjectContext = {
|
||||
let context = persistentContainer.newBackgroundContext()
|
||||
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
return context
|
||||
}()
|
||||
|
||||
/// The service used by the application to report non-fatal errors.
|
||||
let errorReporter: ErrorReporter
|
||||
|
||||
/// The Core Data persistent container.
|
||||
let persistentContainer: NSPersistentContainer
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Initialize a `DataStore`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - errorReporter: The service used by the application to report non-fatal errors.
|
||||
/// - storeType: The type of store to create.
|
||||
///
|
||||
init(errorReporter: ErrorReporter, storeType: StoreType = .persisted) {
|
||||
self.errorReporter = errorReporter
|
||||
|
||||
let modelURL = Bundle(for: type(of: self)).url(forResource: "Authenticator", withExtension: "momd")!
|
||||
let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL)!
|
||||
persistentContainer = NSPersistentContainer(name: "Authenticator", managedObjectModel: managedObjectModel)
|
||||
let storeDescription: NSPersistentStoreDescription
|
||||
switch storeType {
|
||||
case .memory:
|
||||
storeDescription = NSPersistentStoreDescription(url: URL(fileURLWithPath: "/dev/null"))
|
||||
case .persisted:
|
||||
let storeURL = FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: Bundle.main.groupIdentifier)!
|
||||
.appendingPathComponent("Authenticator.sqlite")
|
||||
storeDescription = NSPersistentStoreDescription(url: storeURL)
|
||||
}
|
||||
persistentContainer.persistentStoreDescriptions = [storeDescription]
|
||||
persistentContainer.loadPersistentStores { _, error in
|
||||
if let error {
|
||||
errorReporter.log(error: error)
|
||||
}
|
||||
}
|
||||
|
||||
persistentContainer.viewContext.automaticallyMergesChangesFromParent = true
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Deletes all data stored in the store
|
||||
///
|
||||
func deleteData() async throws {
|
||||
try await deleteAllAuthenticatorItems(userId: "local")
|
||||
}
|
||||
|
||||
/// Executes a batch delete request and merges the changes into the background and view contexts.
|
||||
///
|
||||
/// - Parameter request: The batch delete request to perform.
|
||||
///
|
||||
func executeBatchDelete(_ request: NSBatchDeleteRequest) async throws {
|
||||
try await backgroundContext.perform {
|
||||
try self.backgroundContext.executeAndMergeChanges(
|
||||
batchDeleteRequest: request,
|
||||
additionalContexts: [self.persistentContainer.viewContext]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes a batch delete and batch insert request and merges the changes into the background
|
||||
/// and view contexts.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - deleteRequest: The batch delete request to perform.
|
||||
/// - insertRequest: The batch insert request to perform.
|
||||
///
|
||||
func executeBatchReplace(deleteRequest: NSBatchDeleteRequest, insertRequest: NSBatchInsertRequest) async throws {
|
||||
try await backgroundContext.perform {
|
||||
try self.backgroundContext.executeAndMergeChanges(
|
||||
batchDeleteRequest: deleteRequest,
|
||||
batchInsertRequest: insertRequest,
|
||||
additionalContexts: [self.persistentContainer.viewContext]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,22 +6,24 @@ import Networking
|
||||
extension ServiceContainer {
|
||||
static func withMocks(
|
||||
application: Application? = nil,
|
||||
authenticatorItemRepository: AuthenticatorItemRepository = MockAuthenticatorItemRepository(),
|
||||
cameraService: CameraService = MockCameraService(),
|
||||
clientService: ClientService = MockClientService(),
|
||||
cryptographyService: CryptographyService = MockCryptographyService(),
|
||||
errorReporter: ErrorReporter = MockErrorReporter(),
|
||||
pasteboardService: PasteboardService = MockPasteboardService(),
|
||||
timeProvider: TimeProvider = MockTimeProvider(.currentTime),
|
||||
tokenRepository: TokenRepository = MockTokenRepository(),
|
||||
totpService: TOTPService = MockTOTPService()
|
||||
) -> ServiceContainer {
|
||||
ServiceContainer(
|
||||
application: application,
|
||||
authenticatorItemRepository: authenticatorItemRepository,
|
||||
cameraService: cameraService,
|
||||
cryptographyService: cryptographyService,
|
||||
clientService: clientService,
|
||||
errorReporter: errorReporter,
|
||||
pasteboardService: pasteboardService,
|
||||
timeProvider: timeProvider,
|
||||
tokenRepository: tokenRepository,
|
||||
timeProvider: timeProvider,
|
||||
totpService: totpService
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,145 @@
|
||||
import Combine
|
||||
import CoreData
|
||||
|
||||
// MARK: - FetchedResultsPublisher
|
||||
|
||||
/// A Combine publisher that publishes the initial result set and any future data changes for a
|
||||
/// Core Data fetch request.
|
||||
///
|
||||
/// Adapted from https://gist.github.com/darrarski/28d2f5a28ef2c5669d199069c30d3d52
|
||||
///
|
||||
class FetchedResultsPublisher<ResultType>: Publisher where ResultType: NSFetchRequestResult {
|
||||
// MARK: Types
|
||||
|
||||
typealias Output = [ResultType]
|
||||
|
||||
typealias Failure = Error
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The managed object context that the fetch request is executed against.
|
||||
let context: NSManagedObjectContext
|
||||
|
||||
/// The fetch request used to get the objects.
|
||||
let request: NSFetchRequest<ResultType>
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Initialize a `FetchedResultsPublisher`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - context: The managed object context that the fetch request is executed against.
|
||||
/// - request: The fetch request used to get the objects.
|
||||
///
|
||||
init(context: NSManagedObjectContext, request: NSFetchRequest<ResultType>) {
|
||||
self.context = context
|
||||
self.request = request
|
||||
}
|
||||
|
||||
// MARK: Publisher
|
||||
|
||||
func receive<S>(subscriber: S) where S: Subscriber, S.Failure == Failure, S.Input == Output {
|
||||
subscriber.receive(subscription: FetchedResultsSubscription(
|
||||
context: context,
|
||||
request: request,
|
||||
subscriber: subscriber
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FetchedResultsSubscription
|
||||
|
||||
/// A `Subscription` to a `FetchedResultsPublisher` which fetches results from Core Data via a
|
||||
/// `NSFetchedResultsController` and notifies the subscriber of any changes to the data.
|
||||
///
|
||||
private final class FetchedResultsSubscription<SubscriberType, ResultType>: NSObject, Subscription,
|
||||
NSFetchedResultsControllerDelegate
|
||||
where SubscriberType: Subscriber,
|
||||
SubscriberType.Input == [ResultType],
|
||||
SubscriberType.Failure == Error,
|
||||
ResultType: NSFetchRequestResult {
|
||||
// MARK: Properties
|
||||
|
||||
/// The fetched results controller to manage the results of a Core Data fetch request.
|
||||
private var controller: NSFetchedResultsController<ResultType>?
|
||||
|
||||
/// The current demand from the subscriber.
|
||||
private var demand: Subscribers.Demand = .none
|
||||
|
||||
/// Whether the subscription has changes to send to the subscriber.
|
||||
private var hasChangesToSend = false
|
||||
|
||||
/// The subscriber to the subscription that is notified of the fetched results.
|
||||
private var subscriber: SubscriberType?
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Initialize a `FetchedResultsSubscription`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - context: The managed object context that the fetch request is executed against.
|
||||
/// - request: The fetch request used to get the objects.
|
||||
/// - subscriber: The subscriber to the subscription that is notified of the fetched results.
|
||||
///
|
||||
init(
|
||||
context: NSManagedObjectContext,
|
||||
request: NSFetchRequest<ResultType>,
|
||||
subscriber: SubscriberType
|
||||
) {
|
||||
controller = NSFetchedResultsController(
|
||||
fetchRequest: request,
|
||||
managedObjectContext: context,
|
||||
sectionNameKeyPath: nil,
|
||||
cacheName: nil
|
||||
)
|
||||
self.subscriber = subscriber
|
||||
|
||||
super.init()
|
||||
|
||||
controller?.delegate = self
|
||||
|
||||
do {
|
||||
try controller?.performFetch()
|
||||
if controller?.fetchedObjects != nil {
|
||||
hasChangesToSend = true
|
||||
fulfillDemand()
|
||||
}
|
||||
} catch {
|
||||
subscriber.receive(completion: .failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Subscription
|
||||
|
||||
func request(_ demand: Subscribers.Demand) {
|
||||
self.demand += demand
|
||||
fulfillDemand()
|
||||
}
|
||||
|
||||
// MARK: Cancellable
|
||||
|
||||
func cancel() {
|
||||
controller = nil
|
||||
subscriber = nil
|
||||
}
|
||||
|
||||
// MARK: NSFetchedResultsControllerDelegate
|
||||
|
||||
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
|
||||
hasChangesToSend = true
|
||||
fulfillDemand()
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private func fulfillDemand() {
|
||||
guard demand > 0, hasChangesToSend,
|
||||
let subscriber,
|
||||
let fetchedObjects = controller?.fetchedObjects
|
||||
else { return }
|
||||
|
||||
hasChangesToSend = false
|
||||
demand -= 1
|
||||
demand += subscriber.receive(fetchedObjects)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
/// A data model for persisting authenticator items
|
||||
///
|
||||
class AuthenticatorItemData: NSManagedObject, ManagedUserObject, CodableModelData {
|
||||
typealias Model = AuthenticatorItemDataModel
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The item's ID
|
||||
@NSManaged var id: String
|
||||
|
||||
/// The data model encoded as encrypted JSON data
|
||||
@NSManaged var modelData: Data?
|
||||
|
||||
/// The ID of the user who owns the item
|
||||
@NSManaged var userId: String
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Initialize an `AuthenticatorItemData` object for insertion into the managed object context
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - context: The managed object context to insert the initialized item
|
||||
/// - userId: The ID of the user who owns the item
|
||||
/// - authenticatorItem: the `AuthenticatorItem` used to create the item
|
||||
convenience init(
|
||||
context: NSManagedObjectContext,
|
||||
userId: String,
|
||||
authenticatorItem: AuthenticatorItem
|
||||
) throws {
|
||||
self.init(context: context)
|
||||
id = authenticatorItem.id
|
||||
model = try AuthenticatorItemDataModel(item: authenticatorItem)
|
||||
self.userId = userId
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Updates the `AuthenticatorItemData` object from an `AuthenticatorItem` and user ID
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - authenticatorItem: The `AuthenticatorItem` used to update the `AuthenticatorItemData` instance
|
||||
/// - userId: The user ID associated with the item
|
||||
///
|
||||
func update(with authenticatorItem: AuthenticatorItem, userId: String) throws {
|
||||
id = authenticatorItem.id
|
||||
model = try AuthenticatorItemDataModel(item: authenticatorItem)
|
||||
self.userId = userId
|
||||
}
|
||||
}
|
||||
|
||||
extension AuthenticatorItemData {
|
||||
static func userIdPredicate(userId: String) -> NSPredicate {
|
||||
NSPredicate(format: "%K == %@", #keyPath(AuthenticatorItemData.userId), userId)
|
||||
}
|
||||
|
||||
static func userIdAndIdPredicate(userId: String, id: String) -> NSPredicate {
|
||||
NSPredicate(
|
||||
format: "%K == %@ AND %K == %@",
|
||||
#keyPath(AuthenticatorItemData.userId),
|
||||
userId,
|
||||
#keyPath(AuthenticatorItemData.id),
|
||||
id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthenticatorItemDataModel: Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
let totpKey: String?
|
||||
|
||||
init(item: AuthenticatorItem) throws {
|
||||
id = item.id
|
||||
name = item.name
|
||||
totpKey = item.totpKey
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors thrown from converting between SDK and app types.
|
||||
///
|
||||
enum DataMappingError: Error {
|
||||
/// Thrown if an object was unable to be constructed because the data was invalid.
|
||||
case invalidData
|
||||
|
||||
/// Thrown if a required object identifier is nil.
|
||||
case missingId
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
|
||||
/// Data model for an encrypted item
|
||||
///
|
||||
struct AuthenticatorItem: Equatable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let totpKey: String?
|
||||
|
||||
init(id: String, name: String, totpKey: String?) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.totpKey = totpKey
|
||||
}
|
||||
|
||||
init(itemData: AuthenticatorItemData) throws {
|
||||
guard let model = itemData.model else {
|
||||
throw DataMappingError.invalidData
|
||||
}
|
||||
id = model.id
|
||||
name = model.name
|
||||
totpKey = model.totpKey
|
||||
}
|
||||
}
|
||||
|
||||
extension AuthenticatorItem {
|
||||
static func fixture(
|
||||
id: String = "ID",
|
||||
name: String = "Example",
|
||||
totpKey: String? = "example"
|
||||
) -> AuthenticatorItem {
|
||||
AuthenticatorItem(
|
||||
id: id,
|
||||
name: name,
|
||||
totpKey: totpKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Data model for an unencrypted item
|
||||
///
|
||||
struct AuthenticatorItemView: Equatable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let totpKey: String?
|
||||
}
|
||||
|
||||
extension AuthenticatorItemView {
|
||||
static func fixture(
|
||||
id: String = "ID",
|
||||
name: String = "Example",
|
||||
totpKey: String? = "example"
|
||||
) -> AuthenticatorItemView {
|
||||
AuthenticatorItemView(
|
||||
id: id,
|
||||
name: name,
|
||||
totpKey: totpKey
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -7,16 +7,25 @@ extension ItemListItem {
|
||||
static func fixture(
|
||||
id: String = "123",
|
||||
name: String = "Name",
|
||||
token: Token = Token(
|
||||
name: "Name",
|
||||
authenticatorKey: "example"
|
||||
)!,
|
||||
totpCode: TOTPCodeModel = TOTPCodeModel(
|
||||
code: "123456",
|
||||
codeGenerationDate: .now,
|
||||
period: 30
|
||||
)
|
||||
totp: ItemListTotpItem
|
||||
) -> ItemListItem {
|
||||
ItemListItem(id: id, name: name, token: token, totpCode: totpCode)
|
||||
ItemListItem(
|
||||
id: id,
|
||||
name: name,
|
||||
itemType: .totp(model: totp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ItemListTotpItem {
|
||||
static func fixture(
|
||||
itemView: AuthenticatorItemView = .fixture(),
|
||||
totpCode: TOTPCodeModel = TOTPCodeModel(
|
||||
code: "123456",
|
||||
codeGenerationDate: Date(),
|
||||
period: 30
|
||||
)
|
||||
) -> ItemListTotpItem {
|
||||
ItemListTotpItem(itemView: itemView, totpCode: totpCode)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
extension AuthenticatorItem {
|
||||
init(authenticatorItemView: AuthenticatorItemView) {
|
||||
self.init(
|
||||
id: authenticatorItemView.id,
|
||||
name: authenticatorItemView.name,
|
||||
totpKey: authenticatorItemView.totpKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension AuthenticatorItemView {
|
||||
init(authenticatorItem: AuthenticatorItem) {
|
||||
self.init(
|
||||
id: authenticatorItem.id,
|
||||
name: authenticatorItem.name,
|
||||
totpKey: authenticatorItem.totpKey
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Data model for an OTP token
|
||||
///
|
||||
public struct Token: Equatable, Sendable {
|
||||
// MARK: Properties
|
||||
|
||||
let id: String
|
||||
|
||||
let key: TOTPKeyModel
|
||||
|
||||
let name: String
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
init?(
|
||||
id: String = UUID().uuidString,
|
||||
name: String,
|
||||
authenticatorKey: String
|
||||
) {
|
||||
guard let keyModel = TOTPKeyModel(authenticatorKey: authenticatorKey)
|
||||
else { return nil }
|
||||
|
||||
self.id = id
|
||||
self.name = name
|
||||
key = keyModel
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,170 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
// MARK: - AuthenticatorItemRepository
|
||||
|
||||
/// A protocol for an `AuthenticatorItemRepository` which manages access to the data layer for items
|
||||
///
|
||||
protocol AuthenticatorItemRepository: AnyObject {
|
||||
// MARK: Data Methods
|
||||
|
||||
/// Adds an item to the user's storage
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - authenticatorItem: The item to add
|
||||
///
|
||||
func addAuthenticatorItem(_ authenticatorItem: AuthenticatorItemView) async throws
|
||||
|
||||
/// Deletes an item from the user's storage
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - id: The item ID to delete
|
||||
///
|
||||
func deleteAuthenticatorItem(_ id: String) async throws
|
||||
|
||||
/// Attempt to fetch an item with the given ID
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - id: The ID of the item to find
|
||||
/// - Returns: The item if found and `nil` if not
|
||||
///
|
||||
func fetchAuthenticatorItem(withId id: String) async throws -> AuthenticatorItemView?
|
||||
|
||||
/// Fetch all items
|
||||
///
|
||||
/// Returns: An array of all items in storage
|
||||
///
|
||||
func fetchAllAuthenticatorItems() async throws -> [AuthenticatorItemView]
|
||||
|
||||
/// Updates an item in the user's storage
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - authenticatorItem: The updated item
|
||||
///
|
||||
func updateAuthenticatorItem(_ authenticatorItem: AuthenticatorItemView) async throws
|
||||
|
||||
// MARK: Publishers
|
||||
|
||||
/// A publisher for the details of an item
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - id: The ID of the item that should be published
|
||||
/// - Returns: A publisher for the details of the item,
|
||||
/// which will be notified as details of the item change
|
||||
///
|
||||
func authenticatorItemDetailsPublisher(
|
||||
id: String
|
||||
) async throws -> AsyncThrowingPublisher<AnyPublisher<AuthenticatorItemView?, Error>>
|
||||
|
||||
/// A publisher for the list of a user's items, which returns a list of sections
|
||||
/// of items that are displayed
|
||||
///
|
||||
/// - Returns: A publisher for the list of a user's items
|
||||
///
|
||||
func itemListPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[ItemListSection], Error>>
|
||||
}
|
||||
|
||||
// MARK: - DefaultAuthenticatorItemRepository
|
||||
|
||||
/// A default implementation of an `AuthenticatorItemRepository`
|
||||
///
|
||||
class DefaultAuthenticatorItemRepository {
|
||||
// MARK: Properties
|
||||
|
||||
private let authenticatorItemService: AuthenticatorItemService
|
||||
private let cryptographyService: CryptographyService
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Initialize a `DefaultAuthenticatorItemRepository`
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - authenticatorItemService
|
||||
/// - cryptographyService
|
||||
init(
|
||||
authenticatorItemService: AuthenticatorItemService,
|
||||
cryptographyService: CryptographyService
|
||||
) {
|
||||
self.authenticatorItemService = authenticatorItemService
|
||||
self.cryptographyService = cryptographyService
|
||||
}
|
||||
|
||||
// MARK: Private Methods
|
||||
|
||||
/// Returns a list of the sections in the item list
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - authenticatorItems: The items in the user's storage
|
||||
/// - Returns: A list of the sections to display in the item list
|
||||
///
|
||||
private func itemListSections(
|
||||
from authenticatorItems: [AuthenticatorItem]
|
||||
) async throws -> [ItemListSection] {
|
||||
let items = try await authenticatorItems.asyncMap { item in
|
||||
try await self.cryptographyService.decrypt(item)
|
||||
}
|
||||
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||||
|
||||
let allItems = items.compactMap(ItemListItem.init)
|
||||
|
||||
return [
|
||||
ItemListSection(
|
||||
id: "Everything",
|
||||
items: allItems,
|
||||
name: Localizations.all
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
extension DefaultAuthenticatorItemRepository: AuthenticatorItemRepository {
|
||||
// MARK: Data Methods
|
||||
|
||||
func addAuthenticatorItem(_ authenticatorItem: AuthenticatorItemView) async throws {
|
||||
let item = try await cryptographyService.encrypt(authenticatorItem)
|
||||
try await authenticatorItemService.addAuthenticatorItem(item)
|
||||
}
|
||||
|
||||
func deleteAuthenticatorItem(_ id: String) async throws {
|
||||
try await authenticatorItemService.deleteAuthenticatorItem(id: id)
|
||||
}
|
||||
|
||||
func fetchAllAuthenticatorItems() async throws -> [AuthenticatorItemView] {
|
||||
let items = try await authenticatorItemService.fetchAllAuthenticatorItems()
|
||||
return try await items.asyncMap { item in
|
||||
try await cryptographyService.decrypt(item)
|
||||
}
|
||||
.compactMap { $0 }
|
||||
}
|
||||
|
||||
func fetchAuthenticatorItem(withId id: String) async throws -> AuthenticatorItemView? {
|
||||
guard let item = try await authenticatorItemService.fetchAuthenticatorItem(withId: id) else { return nil }
|
||||
return try? await cryptographyService.decrypt(item)
|
||||
}
|
||||
|
||||
func updateAuthenticatorItem(_ authenticatorItem: AuthenticatorItemView) async throws {
|
||||
let item = try await cryptographyService.encrypt(authenticatorItem)
|
||||
try await authenticatorItemService.updateAuthenticatorItem(item)
|
||||
}
|
||||
|
||||
func authenticatorItemDetailsPublisher(
|
||||
id: String
|
||||
) async throws -> AsyncThrowingPublisher<AnyPublisher<AuthenticatorItemView?, Error>> {
|
||||
try await authenticatorItemService.authenticatorItemsPublisher()
|
||||
.asyncTryMap { items -> AuthenticatorItemView? in
|
||||
guard let item = items.first(where: { $0.id == id }) else { return nil }
|
||||
return try await self.cryptographyService.decrypt(item)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
.values
|
||||
}
|
||||
|
||||
func itemListPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[ItemListSection], Error>> {
|
||||
try await authenticatorItemService.authenticatorItemsPublisher()
|
||||
.asyncTryMap { items in
|
||||
try await self.itemListSections(from: items)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
.values
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import InlineSnapshotTesting
|
||||
import XCTest
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class AuthenticatorItemRepositoryTests: AuthenticatorTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var authenticatorItemService: MockAuthenticatorItemService!
|
||||
var cryptographyService: MockCryptographyService!
|
||||
var subject: DefaultAuthenticatorItemRepository!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
authenticatorItemService = MockAuthenticatorItemService()
|
||||
cryptographyService = MockCryptographyService()
|
||||
|
||||
subject = DefaultAuthenticatorItemRepository(
|
||||
authenticatorItemService: authenticatorItemService,
|
||||
cryptographyService: cryptographyService
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
|
||||
authenticatorItemService = nil
|
||||
cryptographyService = nil
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// `addAuthenticatorItem()` updates the items in storage
|
||||
func test_addAuthenticatorItem() async throws {
|
||||
let item = AuthenticatorItemView.fixture()
|
||||
try await subject.addAuthenticatorItem(item)
|
||||
|
||||
XCTAssertEqual(cryptographyService.encryptedAuthenticatorItems, [item])
|
||||
XCTAssertEqual(
|
||||
authenticatorItemService.addAuthenticatorItemAuthenticatorItems.last,
|
||||
AuthenticatorItem(authenticatorItemView: item)
|
||||
)
|
||||
}
|
||||
|
||||
/// `addAuthenticatorItem()` throws an error if encrypting the item fails
|
||||
func test_addAuthenticatorItem_encryptError() async {
|
||||
cryptographyService.encryptError = AuthenticatorTestError.example
|
||||
|
||||
await assertAsyncThrows(error: AuthenticatorTestError.example) {
|
||||
try await subject.addAuthenticatorItem(.fixture())
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Backfill tests
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class MockAuthenticatorItemRepository: AuthenticatorItemRepository {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var addAuthenticatorItemAuthenticatorItems = [AuthenticatorItemView]()
|
||||
var addAuthenticatorItemResult: Result<Void, Error> = .success(())
|
||||
|
||||
var deletedAuthenticatorItem = [String]()
|
||||
var deleteAuthenticatorItemResult: Result<Void, Error> = .success(())
|
||||
|
||||
var fetchAllAuthenticatorItemsResult: Result<[AuthenticatorItemView], Error> = .success([])
|
||||
|
||||
var fetchAuthenticatorItemId: String?
|
||||
var fetchAuthenticatorItemResult: Result<AuthenticatorItemView?, Error> = .success(nil)
|
||||
|
||||
var refreshTOTPCodeResult: Result<TOTPCodeModel, Error> = .success(
|
||||
TOTPCodeModel(code: .base32Key, codeGenerationDate: .now, period: 30)
|
||||
)
|
||||
var refreshedTOTPKeyConfig: TOTPKeyModel?
|
||||
|
||||
var authenticatorItemDetailsSubject = CurrentValueSubject<AuthenticatorItemView?, Error>(nil)
|
||||
var itemListSubject = CurrentValueSubject<[ItemListSection], Error>([])
|
||||
|
||||
var updateAuthenticatorItemItems = [AuthenticatorItemView]()
|
||||
var updateAuthenticatorItemResult: Result<Void, Error> = .success(())
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func addAuthenticatorItem(_ authenticatorItem: AuthenticatorItemView) async throws {
|
||||
addAuthenticatorItemAuthenticatorItems.append(authenticatorItem)
|
||||
try addAuthenticatorItemResult.get()
|
||||
}
|
||||
|
||||
func deleteAuthenticatorItem(_ id: String) async throws {
|
||||
deletedAuthenticatorItem.append(id)
|
||||
try deleteAuthenticatorItemResult.get()
|
||||
}
|
||||
|
||||
func fetchAllAuthenticatorItems() async throws -> [AuthenticatorShared.AuthenticatorItemView] {
|
||||
return try fetchAllAuthenticatorItemsResult.get()
|
||||
}
|
||||
|
||||
func fetchAuthenticatorItem(withId id: String) async throws -> AuthenticatorItemView? {
|
||||
fetchAuthenticatorItemId = id
|
||||
return try fetchAuthenticatorItemResult.get()
|
||||
}
|
||||
|
||||
func refreshTotpCode(for key: TOTPKeyModel) async throws -> AuthenticatorShared.TOTPCodeModel {
|
||||
refreshedTOTPKeyConfig = key
|
||||
return try refreshTOTPCodeResult.get()
|
||||
}
|
||||
|
||||
func authenticatorItemDetailsPublisher(
|
||||
id: String
|
||||
) async throws -> AsyncThrowingPublisher<AnyPublisher<AuthenticatorItemView?, Error>> {
|
||||
authenticatorItemDetailsSubject.eraseToAnyPublisher().values
|
||||
}
|
||||
|
||||
func itemListPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[ItemListSection], Error>> {
|
||||
itemListSubject.eraseToAnyPublisher().values
|
||||
}
|
||||
|
||||
func updateAuthenticatorItem(_ authenticatorItem: AuthenticatorItemView) async throws {
|
||||
updateAuthenticatorItemItems.append(authenticatorItem)
|
||||
try updateAuthenticatorItemResult.get()
|
||||
}
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
import BitwardenSdk
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class MockTokenRepository: TokenRepository {
|
||||
// MARK: Properties
|
||||
|
||||
var addTokenTokens = [Token]()
|
||||
var addTokenResult: Result<Void, Error> = .success(())
|
||||
|
||||
var deletedToken = [String]()
|
||||
var deleteTokenResult: Result<Void, Error> = .success(())
|
||||
|
||||
var fetchTokenId: String?
|
||||
var fetchTokenResult: Result<Token?, Error> = .success(nil)
|
||||
|
||||
var refreshTOTPCodeResult: Result<TOTPCodeModel, Error> = .success(
|
||||
TOTPCodeModel(code: .base32Key, codeGenerationDate: .now, period: 30)
|
||||
)
|
||||
var refreshedTOTPKeyConfig: TOTPKeyModel?
|
||||
|
||||
var tokenListSubject = CurrentValueSubject<[Token], Never>([])
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func addToken(_ token: Token) async throws {
|
||||
addTokenTokens.append(token)
|
||||
try addTokenResult.get()
|
||||
}
|
||||
|
||||
func deleteToken(_ id: String) async throws {
|
||||
deletedToken.append(id)
|
||||
try deleteTokenResult.get()
|
||||
}
|
||||
|
||||
func fetchToken(withId id: String) async throws -> Token? {
|
||||
fetchTokenId = id
|
||||
return try fetchTokenResult.get()
|
||||
}
|
||||
|
||||
func refreshTotpCode(for key: TOTPKeyModel) async throws -> AuthenticatorShared.TOTPCodeModel {
|
||||
refreshedTOTPKeyConfig = key
|
||||
return try refreshTOTPCodeResult.get()
|
||||
}
|
||||
|
||||
func tokenPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[AuthenticatorShared.Token], Never>> {
|
||||
tokenListSubject.eraseToAnyPublisher().values
|
||||
}
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
import BitwardenSdk
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// A protocol for a `TokenReposity` which manages access to the data layer for tokens
|
||||
///
|
||||
public protocol TokenRepository: AnyObject {
|
||||
// MARK: Data Methods
|
||||
|
||||
func addToken(_ token: Token) async throws
|
||||
|
||||
func deleteToken(_ id: String) async throws
|
||||
|
||||
func fetchToken(withId id: String) async throws -> Token?
|
||||
|
||||
func refreshTotpCode(for key: TOTPKeyModel) async throws -> TOTPCodeModel
|
||||
|
||||
func updateToken(_ token: Token) async throws
|
||||
|
||||
// MARK: Publishers
|
||||
|
||||
func tokenPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[Token], Never>>
|
||||
}
|
||||
|
||||
class DefaultTokenRepository {
|
||||
// MARK: Properties
|
||||
|
||||
/// The service to communicate with the SDK for encryption/decryption tasks.
|
||||
private let clientVault: ClientVaultService
|
||||
|
||||
/// The service used by the application to report non-fatal errors.
|
||||
private let errorReporter: ErrorReporter
|
||||
|
||||
/// The service used to get the present time.
|
||||
private let timeProvider: TimeProvider
|
||||
|
||||
@Published var tokens: [Token] = [
|
||||
Token(name: "Amazon", authenticatorKey: "amazon")!,
|
||||
]
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Initialize a `DefaultTokenRepository`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - clientVault: The service to communicate with the SDK for encryption/decryption tasks.
|
||||
/// - errorReporter: The service used by the application to report non-fatal errors.
|
||||
/// - timeProvider: The service used to get the present time.
|
||||
///
|
||||
init(
|
||||
clientVault: ClientVaultService,
|
||||
errorReporter: ErrorReporter,
|
||||
timeProvider: TimeProvider
|
||||
) {
|
||||
self.clientVault = clientVault
|
||||
self.errorReporter = errorReporter
|
||||
self.timeProvider = timeProvider
|
||||
}
|
||||
}
|
||||
|
||||
extension DefaultTokenRepository: TokenRepository {
|
||||
// MARK: Data Methods
|
||||
|
||||
func addToken(_ token: Token) async throws {
|
||||
tokens.append(token)
|
||||
}
|
||||
|
||||
func deleteToken(_ id: String) async throws {
|
||||
tokens.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
func fetchToken(withId id: String) async throws -> Token? {
|
||||
tokens.first { $0.id == id }
|
||||
}
|
||||
|
||||
func refreshTotpCode(for key: TOTPKeyModel) async throws -> TOTPCodeModel {
|
||||
try await clientVault.generateTOTPCode(
|
||||
for: key.rawAuthenticatorKey,
|
||||
date: timeProvider.presentTime
|
||||
)
|
||||
}
|
||||
|
||||
func tokenPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[Token], Never>> {
|
||||
tokens.publisher
|
||||
.collect()
|
||||
.eraseToAnyPublisher()
|
||||
.values
|
||||
}
|
||||
|
||||
func updateToken(_ token: Token) async throws {
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard let tokenIndex = tokens.firstIndex(where: { $0.id == token.id })
|
||||
else { return }
|
||||
tokens[tokenIndex] = token
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,103 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
// MARK: - AuthenticatorItemService
|
||||
|
||||
/// A protocol for an `AuthenticatorItemService` which is the service layer
|
||||
/// for managing a user's items
|
||||
///
|
||||
protocol AuthenticatorItemService {
|
||||
/// Add an item for the current user to local storage
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - authenticatorItem: The item to add
|
||||
///
|
||||
func addAuthenticatorItem(_ authenticatorItem: AuthenticatorItem) async throws
|
||||
|
||||
/// Deletes an item for the current user from local storage
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - id: The ID of the item to delete
|
||||
///
|
||||
func deleteAuthenticatorItem(id: String) async throws
|
||||
|
||||
/// Attempt to fetch an item for the current user
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - id: The ID of the item to find
|
||||
/// - Returns: The item if it was found and `nil` if not
|
||||
///
|
||||
func fetchAuthenticatorItem(withId id: String) async throws -> AuthenticatorItem?
|
||||
|
||||
/// Fetches all items for the current user
|
||||
///
|
||||
/// - Returns: The items belonging to the current user
|
||||
///
|
||||
func fetchAllAuthenticatorItems() async throws -> [AuthenticatorItem]
|
||||
|
||||
/// Updates an item for the current user
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - authenticatorItem: The item to update
|
||||
///
|
||||
func updateAuthenticatorItem(_ authenticatorItem: AuthenticatorItem) async throws
|
||||
|
||||
// MARK: Publishers
|
||||
|
||||
/// A publisher for the list of items for the current user
|
||||
///
|
||||
/// - Returns: The list of items
|
||||
///
|
||||
func authenticatorItemsPublisher() async throws -> AnyPublisher<[AuthenticatorItem], Error>
|
||||
}
|
||||
|
||||
// MARK: - DefaultAuthenticatorItemService
|
||||
|
||||
class DefaultAuthenticatorItemService {
|
||||
// MARK: Properties
|
||||
|
||||
// TODO: Generate this user ID and store it in the keychain?
|
||||
private let defaultUserId = "local"
|
||||
|
||||
/// The data store for persisted items
|
||||
private let authenticatorItemDataStore: AuthenticatorItemDataStore
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Initializes a `DefaultAuthenticatorItemService`
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - authenticatorItemDataStore: The data store for persisted items
|
||||
///
|
||||
init(
|
||||
authenticatorItemDataStore: AuthenticatorItemDataStore
|
||||
) {
|
||||
self.authenticatorItemDataStore = authenticatorItemDataStore
|
||||
}
|
||||
}
|
||||
|
||||
extension DefaultAuthenticatorItemService: AuthenticatorItemService {
|
||||
func addAuthenticatorItem(_ authenticatorItem: AuthenticatorItem) async throws {
|
||||
try await authenticatorItemDataStore.upsertAuthenticatorItem(authenticatorItem, userId: defaultUserId)
|
||||
}
|
||||
|
||||
func deleteAuthenticatorItem(id: String) async throws {
|
||||
try await authenticatorItemDataStore.deleteAuthenticatorItem(id: id, userId: defaultUserId)
|
||||
}
|
||||
|
||||
func fetchAuthenticatorItem(withId id: String) async throws -> AuthenticatorItem? {
|
||||
try await authenticatorItemDataStore.fetchAuthenticatorItem(withId: id, userId: defaultUserId)
|
||||
}
|
||||
|
||||
func fetchAllAuthenticatorItems() async throws -> [AuthenticatorItem] {
|
||||
try await authenticatorItemDataStore.fetchAllAuthenticatorItems(userId: defaultUserId)
|
||||
}
|
||||
|
||||
func updateAuthenticatorItem(_ authenticatorItem: AuthenticatorItem) async throws {
|
||||
try await authenticatorItemDataStore.upsertAuthenticatorItem(authenticatorItem, userId: defaultUserId)
|
||||
}
|
||||
|
||||
func authenticatorItemsPublisher() async throws -> AnyPublisher<[AuthenticatorItem], Error> {
|
||||
authenticatorItemDataStore.authenticatorItemPublisher(userId: defaultUserId)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
import XCTest
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class AuthenticatorItemServiceTests: AuthenticatorTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var authenticatorItemDataStore: MockAuthenticatorItemDataStore!
|
||||
var subject: AuthenticatorItemService!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
authenticatorItemDataStore = MockAuthenticatorItemDataStore()
|
||||
|
||||
subject = DefaultAuthenticatorItemService(
|
||||
authenticatorItemDataStore: authenticatorItemDataStore
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
|
||||
authenticatorItemDataStore = nil
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// `addAuthenticatorItem(_:)` adds the item to local storage
|
||||
func test_addAuthenticatorItem() async throws {
|
||||
try await subject.addAuthenticatorItem(.fixture())
|
||||
|
||||
XCTAssertEqual(authenticatorItemDataStore.upsertAuthenticatorItemValue?.id, "ID")
|
||||
}
|
||||
|
||||
/// `authenticatorItemsPublisher()` returns a publisher that emits data as the data store changes
|
||||
func test_authenticatorItemsPublisher() async throws {
|
||||
var iterator = try await subject.authenticatorItemsPublisher()
|
||||
.values
|
||||
.makeAsyncIterator()
|
||||
_ = try await iterator.next()
|
||||
|
||||
let item = AuthenticatorItem.fixture()
|
||||
authenticatorItemDataStore.authenticatorItemSubject.value = [item]
|
||||
let publisherValue = try await iterator.next()
|
||||
try XCTAssertEqual(XCTUnwrap(publisherValue), [item])
|
||||
}
|
||||
|
||||
/// `deleteAuthenticatorItem(id:)` deletes the item from local storage
|
||||
func test_deleteAuthenticatorItem() async throws {
|
||||
try await subject.deleteAuthenticatorItem(id: "Test")
|
||||
|
||||
XCTAssertEqual(authenticatorItemDataStore.deleteAuthenticatorItemId, "Test")
|
||||
XCTAssertEqual(authenticatorItemDataStore.deleteAuthenticatorItemUserId, "local")
|
||||
}
|
||||
|
||||
/// `fetchAuthenticatorItem(withId:)` returns the item if it exists and nil otherwise
|
||||
func test_fetchAuthenticatorItem() async throws {
|
||||
var item = try await subject.fetchAuthenticatorItem(withId: "1")
|
||||
XCTAssertNil(item)
|
||||
XCTAssertEqual(authenticatorItemDataStore.fetchAuthenticatorItemId, "1")
|
||||
|
||||
let testItem = AuthenticatorItem.fixture(id: "2")
|
||||
authenticatorItemDataStore.fetchAuthenticatorItemResult = testItem
|
||||
|
||||
item = try await subject.fetchAuthenticatorItem(withId: "2")
|
||||
XCTAssertEqual(item, testItem)
|
||||
XCTAssertEqual(authenticatorItemDataStore.fetchAuthenticatorItemId, "2")
|
||||
}
|
||||
|
||||
/// `fetchAllAuthenticatorItems()` returns all items
|
||||
func test_fetchAllAuthenticatorItems() async throws {
|
||||
authenticatorItemDataStore.fetchAllAuthenticatorItemsResult = .success([
|
||||
.fixture(id: "1"),
|
||||
.fixture(id: "2"),
|
||||
])
|
||||
|
||||
let items = try await subject.fetchAllAuthenticatorItems()
|
||||
XCTAssertEqual(items.count, 2)
|
||||
XCTAssertEqual(items[0].id, "1")
|
||||
XCTAssertEqual(items[1].id, "2")
|
||||
}
|
||||
|
||||
/// `updateAuthenticatorItemWithLocalStorage(_:)` updates the item in the local storage.
|
||||
func test_updateAuthenticatorItemWithLocalStorage() async throws {
|
||||
try await subject.updateAuthenticatorItem(.fixture(id: "id"))
|
||||
|
||||
XCTAssertEqual(authenticatorItemDataStore.upsertAuthenticatorItemValue?.id, "id")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - CryptographyService
|
||||
|
||||
/// A protocol for a `CryptographyService` which manages encrypting and decrypting `AuthenticationItem` objects
|
||||
///
|
||||
protocol CryptographyService {
|
||||
func encrypt(_ authenticatorItemView: AuthenticatorItemView) async throws -> AuthenticatorItem
|
||||
|
||||
func decrypt(_ authenticatorItem: AuthenticatorItem) async throws -> AuthenticatorItemView
|
||||
}
|
||||
|
||||
// TODO: Actually encrypt/decrypt the item
|
||||
class DefaultCryptographyService: CryptographyService {
|
||||
func encrypt(_ authenticatorItemView: AuthenticatorItemView) async throws -> AuthenticatorItem {
|
||||
AuthenticatorItem(
|
||||
id: authenticatorItemView.id,
|
||||
name: authenticatorItemView.name,
|
||||
totpKey: authenticatorItemView.totpKey
|
||||
)
|
||||
}
|
||||
|
||||
func decrypt(_ authenticatorItem: AuthenticatorItem) async throws -> AuthenticatorItemView {
|
||||
AuthenticatorItemView(
|
||||
id: authenticatorItem.id,
|
||||
name: authenticatorItem.name,
|
||||
totpKey: authenticatorItem.totpKey
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
import Combine
|
||||
import CoreData
|
||||
|
||||
// MARK: - AuthenticatorItemDataStore
|
||||
|
||||
/// A protocol for a data store that handles performing data requests for authenticator items.
|
||||
///
|
||||
protocol AuthenticatorItemDataStore: AnyObject {
|
||||
/// Deletes all `AuthenticatorItem` objects for a specific user.
|
||||
///
|
||||
/// - Parameter userId: The user ID of the user associated with the objects to delete.
|
||||
///
|
||||
func deleteAllAuthenticatorItems(userId: String) async throws
|
||||
|
||||
/// Deletes a `AuthenticatorItem` by ID for a user.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - id: The ID of the `AuthenticatorItem` to delete.
|
||||
/// - userId: The user ID of the user associated with the object to delete.
|
||||
///
|
||||
func deleteAuthenticatorItem(id: String, userId: String) async throws
|
||||
|
||||
/// Attempt to fetch an authenticator item with the given id.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - id: The id of the authenticator item to find.
|
||||
/// - userId: The user ID of the user associated with the authenticator items.
|
||||
/// - Returns: The authenticator item if it was found and `nil` if not.
|
||||
///
|
||||
func fetchAuthenticatorItem(withId id: String, userId: String) async throws -> AuthenticatorItem?
|
||||
|
||||
/// Fetches all the authenticator items belonging to the specified user id.
|
||||
///
|
||||
/// - Parameter userId: The id of the user associated with the authenticator items.
|
||||
/// - Returns: The authenticator items associated with the user id.
|
||||
///
|
||||
func fetchAllAuthenticatorItems(userId: String) async throws -> [AuthenticatorItem]
|
||||
|
||||
/// A publisher for a user's authenticator item objects.
|
||||
///
|
||||
/// - Parameter userId: The user ID of the user to associated with the objects to fetch.
|
||||
/// - Returns: A publisher for the user's authenticator items.
|
||||
///
|
||||
func authenticatorItemPublisher(userId: String) -> AnyPublisher<[AuthenticatorItem], Error>
|
||||
|
||||
/// Replaces a list of `AuthenticatorItem` objects for a user.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - authenticatorItems: The list of authenticator items to replace any existing authenticator items.
|
||||
/// - userId: The user ID of the user associated with the authenticator items.
|
||||
///
|
||||
func replaceAuthenticatorItems(_ authenticatorItems: [AuthenticatorItem], userId: String) async throws
|
||||
|
||||
/// Inserts or updates an authenticator item for a user.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - authenticatorItem: The authenticator item to insert or update.
|
||||
/// - userId: The user ID of the user associated with the authenticator item.
|
||||
///
|
||||
func upsertAuthenticatorItem(_ authenticatorItem: AuthenticatorItem, userId: String) async throws
|
||||
}
|
||||
|
||||
extension DataStore: AuthenticatorItemDataStore {
|
||||
func deleteAllAuthenticatorItems(userId: String) async throws {
|
||||
try await executeBatchDelete(AuthenticatorItemData.deleteByUserIdRequest(userId: userId))
|
||||
}
|
||||
|
||||
func deleteAuthenticatorItem(id: String, userId: String) async throws {
|
||||
try await backgroundContext.performAndSave {
|
||||
let results = try self.backgroundContext.fetch(
|
||||
AuthenticatorItemData.fetchByIdRequest(id: id, userId: userId)
|
||||
)
|
||||
for result in results {
|
||||
self.backgroundContext.delete(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAuthenticatorItem(withId id: String, userId: String) async throws -> AuthenticatorItem? {
|
||||
try await backgroundContext.perform {
|
||||
try self.backgroundContext.fetch(AuthenticatorItemData.fetchByIdRequest(id: id, userId: userId))
|
||||
.compactMap(AuthenticatorItem.init)
|
||||
.first
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAllAuthenticatorItems(userId: String) async throws -> [AuthenticatorItem] {
|
||||
try await backgroundContext.perform {
|
||||
let fetchRequest = AuthenticatorItemData.fetchByUserIdRequest(userId: userId)
|
||||
return try self.backgroundContext.fetch(fetchRequest).map(AuthenticatorItem.init)
|
||||
}
|
||||
}
|
||||
|
||||
func authenticatorItemPublisher(userId: String) -> AnyPublisher<[AuthenticatorItem], Error> {
|
||||
let fetchRequest = AuthenticatorItemData.fetchByUserIdRequest(userId: userId)
|
||||
// A sort descriptor is needed by `NSFetchedResultsController`.
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \AuthenticatorItemData.id, ascending: true)]
|
||||
return FetchedResultsPublisher(
|
||||
context: persistentContainer.viewContext,
|
||||
request: fetchRequest
|
||||
)
|
||||
.tryMap { try $0.map(AuthenticatorItem.init) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func replaceAuthenticatorItems(_ authenticatorItems: [AuthenticatorItem], userId: String) async throws {
|
||||
let deleteRequest = AuthenticatorItemData.deleteByUserIdRequest(userId: userId)
|
||||
let insertRequest = try AuthenticatorItemData.batchInsertRequest(objects: authenticatorItems, userId: userId)
|
||||
try await executeBatchReplace(
|
||||
deleteRequest: deleteRequest,
|
||||
insertRequest: insertRequest
|
||||
)
|
||||
}
|
||||
|
||||
func upsertAuthenticatorItem(_ authenticatorItem: AuthenticatorItem, userId: String) async throws {
|
||||
try await backgroundContext.performAndSave {
|
||||
_ = try AuthenticatorItemData(
|
||||
context: self.backgroundContext,
|
||||
userId: userId,
|
||||
authenticatorItem: authenticatorItem
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,147 @@
|
||||
import CoreData
|
||||
import XCTest
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class AuthenticatorItemDataStoreTests: AuthenticatorTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var subject: DataStore!
|
||||
|
||||
let authenticatorItems = [
|
||||
AuthenticatorItem.fixture(id: "1", name: "item1"),
|
||||
AuthenticatorItem.fixture(id: "2", name: "item2"),
|
||||
AuthenticatorItem.fixture(id: "3", name: "item3"),
|
||||
]
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
subject = DataStore(errorReporter: MockErrorReporter(), storeType: .memory)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// `authenticatorItemPublisher(userId:)` returns a publisher for a user's authenticatorItem objects.
|
||||
func test_authenticatorItemPublisher() async throws {
|
||||
var publishedValues = [[AuthenticatorItem]]()
|
||||
let publisher = subject.authenticatorItemPublisher(userId: "1")
|
||||
.sink(
|
||||
receiveCompletion: { _ in },
|
||||
receiveValue: { values in
|
||||
publishedValues.append(values)
|
||||
}
|
||||
)
|
||||
defer { publisher.cancel() }
|
||||
|
||||
try await subject.replaceAuthenticatorItems(authenticatorItems, userId: "1")
|
||||
|
||||
waitFor { publishedValues.count == 2 }
|
||||
XCTAssertTrue(publishedValues[0].isEmpty)
|
||||
XCTAssertEqual(publishedValues[1], authenticatorItems)
|
||||
}
|
||||
|
||||
/// `deleteAllAuthenticatorItems(user:)` removes all objects for the user.
|
||||
func test_deleteAllAuthenticatorItems() async throws {
|
||||
try await insertAuthenticatorItems(authenticatorItems, userId: "1")
|
||||
try await insertAuthenticatorItems(authenticatorItems, userId: "2")
|
||||
|
||||
try await subject.deleteAllAuthenticatorItems(userId: "1")
|
||||
|
||||
try XCTAssertTrue(fetchAuthenticatorItems(userId: "1").isEmpty)
|
||||
try XCTAssertEqual(fetchAuthenticatorItems(userId: "2").count, 3)
|
||||
}
|
||||
|
||||
/// `deleteAuthenticatorItem(id:userId:)` removes the authenticatorItem with the given ID for the user.
|
||||
func test_deleteAuthenticatorItem() async throws {
|
||||
try await insertAuthenticatorItems(authenticatorItems, userId: "1")
|
||||
|
||||
try await subject.deleteAuthenticatorItem(id: "2", userId: "1")
|
||||
|
||||
try XCTAssertEqual(
|
||||
fetchAuthenticatorItems(userId: "1"),
|
||||
authenticatorItems.filter { $0.id != "2" }
|
||||
)
|
||||
}
|
||||
|
||||
/// `fetchAuthenticatorItem(withId:)` returns the specified authenticatorItem if it exists and `nil` otherwise.
|
||||
func test_fetchAuthenticatorItem() async throws {
|
||||
try await insertAuthenticatorItems(authenticatorItems, userId: "1")
|
||||
|
||||
let authenticatorItem1 = try await subject.fetchAuthenticatorItem(withId: "1", userId: "1")
|
||||
XCTAssertEqual(authenticatorItem1, authenticatorItems.first)
|
||||
|
||||
let authenticatorItem42 = try await subject.fetchAuthenticatorItem(withId: "42", userId: "1")
|
||||
XCTAssertNil(authenticatorItem42)
|
||||
}
|
||||
|
||||
/// `replaceAuthenticatorItems(_:userId)` replaces the list of authenticatorItems for the user.
|
||||
func test_replaceAuthenticatorItems() async throws {
|
||||
try await insertAuthenticatorItems(authenticatorItems, userId: "1")
|
||||
|
||||
let newAuthenticatorItems = [
|
||||
AuthenticatorItem.fixture(id: "3", name: "item3"),
|
||||
AuthenticatorItem.fixture(id: "4", name: "item4"),
|
||||
AuthenticatorItem.fixture(id: "5", name: "item5"),
|
||||
]
|
||||
try await subject.replaceAuthenticatorItems(newAuthenticatorItems, userId: "1")
|
||||
|
||||
XCTAssertEqual(try fetchAuthenticatorItems(userId: "1"), newAuthenticatorItems)
|
||||
}
|
||||
|
||||
/// `upsertAuthenticatorItem(_:userId:)` inserts a authenticatorItem for a user.
|
||||
func test_upsertAuthenticatorItem_insert() async throws {
|
||||
let authenticatorItem = AuthenticatorItem.fixture(id: "1")
|
||||
try await subject.upsertAuthenticatorItem(authenticatorItem, userId: "1")
|
||||
|
||||
try XCTAssertEqual(fetchAuthenticatorItems(userId: "1"), [authenticatorItem])
|
||||
|
||||
let authenticatorItem2 = AuthenticatorItem.fixture(id: "2")
|
||||
try await subject.upsertAuthenticatorItem(authenticatorItem2, userId: "1")
|
||||
|
||||
try XCTAssertEqual(fetchAuthenticatorItems(userId: "1"), [authenticatorItem, authenticatorItem2])
|
||||
}
|
||||
|
||||
/// `upsertAuthenticatorItem(_:userId:)` updates an existing authenticatorItem for a user.
|
||||
func test_upsertAuthenticatorItem_update() async throws {
|
||||
try await insertAuthenticatorItems(authenticatorItems, userId: "1")
|
||||
|
||||
let updatedAuthenticatorItem = AuthenticatorItem.fixture(id: "2", name: "UPDATED CIPHER2")
|
||||
try await subject.upsertAuthenticatorItem(updatedAuthenticatorItem, userId: "1")
|
||||
|
||||
var expectedAuthenticatorItems = authenticatorItems
|
||||
expectedAuthenticatorItems[1] = updatedAuthenticatorItem
|
||||
|
||||
try XCTAssertEqual(fetchAuthenticatorItems(userId: "1"), expectedAuthenticatorItems)
|
||||
}
|
||||
|
||||
// MARK: Test Helpers
|
||||
|
||||
/// A test helper to fetch all authenticatorItem's for a user.
|
||||
private func fetchAuthenticatorItems(userId: String) throws -> [AuthenticatorItem] {
|
||||
let fetchRequest = AuthenticatorItemData.fetchByUserIdRequest(userId: userId)
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \AuthenticatorItemData.id, ascending: true)]
|
||||
return try subject.backgroundContext.fetch(fetchRequest).map(AuthenticatorItem.init)
|
||||
}
|
||||
|
||||
/// A test helper for inserting a list of authenticatorItems for a user.
|
||||
private func insertAuthenticatorItems(_ authenticatorItems: [AuthenticatorItem], userId: String) async throws {
|
||||
try await subject.backgroundContext.performAndSave {
|
||||
for authenticatorItem in authenticatorItems {
|
||||
_ = try AuthenticatorItemData(
|
||||
context: self.subject.backgroundContext,
|
||||
userId: userId,
|
||||
authenticatorItem: authenticatorItem
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import Combine
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class MockAuthenticatorItemDataStore: AuthenticatorItemDataStore {
|
||||
var deleteAllAuthenticatorItemsUserId: String?
|
||||
|
||||
var deleteAuthenticatorItemId: String?
|
||||
var deleteAuthenticatorItemUserId: String?
|
||||
|
||||
var fetchAllAuthenticatorItemsUserId: String?
|
||||
var fetchAllAuthenticatorItemsResult: Result<[AuthenticatorItem], Error> = .success([])
|
||||
|
||||
var fetchAuthenticatorItemId: String?
|
||||
var fetchAuthenticatorItemResult: AuthenticatorItem?
|
||||
|
||||
var authenticatorItemSubject = CurrentValueSubject<[AuthenticatorItem], Error>([])
|
||||
|
||||
var replaceAuthenticatorItemsValue: [AuthenticatorItem]?
|
||||
var replaceAuthenticatorItemsUserId: String?
|
||||
|
||||
var upsertAuthenticatorItemValue: AuthenticatorItem?
|
||||
var upsertAuthenticatorItemUserId: String?
|
||||
|
||||
func deleteAllAuthenticatorItems(userId: String) async throws {
|
||||
deleteAllAuthenticatorItemsUserId = userId
|
||||
}
|
||||
|
||||
func deleteAuthenticatorItem(id: String, userId: String) async throws {
|
||||
deleteAuthenticatorItemId = id
|
||||
deleteAuthenticatorItemUserId = userId
|
||||
}
|
||||
|
||||
func fetchAllAuthenticatorItems(userId: String) async throws -> [AuthenticatorItem] {
|
||||
fetchAllAuthenticatorItemsUserId = userId
|
||||
return try fetchAllAuthenticatorItemsResult.get()
|
||||
}
|
||||
|
||||
func fetchAuthenticatorItem(withId id: String, userId _: String) async -> AuthenticatorItem? {
|
||||
fetchAuthenticatorItemId = id
|
||||
return fetchAuthenticatorItemResult
|
||||
}
|
||||
|
||||
func authenticatorItemPublisher(userId _: String) -> AnyPublisher<[AuthenticatorItem], Error> {
|
||||
authenticatorItemSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func replaceAuthenticatorItems(_ authenticatorItems: [AuthenticatorItem], userId: String) async throws {
|
||||
replaceAuthenticatorItemsValue = authenticatorItems
|
||||
replaceAuthenticatorItemsUserId = userId
|
||||
}
|
||||
|
||||
func upsertAuthenticatorItem(_ authenticatorItem: AuthenticatorItem, userId: String) async throws {
|
||||
upsertAuthenticatorItemValue = authenticatorItem
|
||||
upsertAuthenticatorItemUserId = userId
|
||||
}
|
||||
}
|
||||
@ -47,7 +47,8 @@ enum TOTPExpirationCalculator {
|
||||
timeProvider: any TimeProvider
|
||||
) -> [Bool: [ItemListItem]] {
|
||||
let sortedItems: [Bool: [ItemListItem]] = Dictionary(grouping: items, by: { item in
|
||||
hasCodeExpired(item.totpCode, timeProvider: timeProvider)
|
||||
guard case let .totp(model) = item.itemType else { return false }
|
||||
return hasCodeExpired(model.totpCode, timeProvider: timeProvider)
|
||||
})
|
||||
return sortedItems
|
||||
}
|
||||
|
||||
@ -66,17 +66,21 @@ final class TOTPExpirationCalculatorTests: AuthenticatorTestCase {
|
||||
|
||||
func test_listItemsByExpiration() {
|
||||
let expired = ItemListItem.fixture(
|
||||
totpCode: .init(
|
||||
code: "",
|
||||
codeGenerationDate: Date(year: 2024, month: 1, day: 1, second: 29),
|
||||
period: 30
|
||||
totp: ItemListTotpItem.fixture(
|
||||
totpCode: TOTPCodeModel(
|
||||
code: "",
|
||||
codeGenerationDate: Date(year: 2024, month: 1, day: 1, second: 29),
|
||||
period: 30
|
||||
)
|
||||
)
|
||||
)
|
||||
let current = ItemListItem.fixture(
|
||||
totpCode: .init(
|
||||
code: "",
|
||||
codeGenerationDate: Date(year: 2024, month: 1, day: 1, second: 31),
|
||||
period: 30
|
||||
totp: ItemListTotpItem.fixture(
|
||||
totpCode: TOTPCodeModel(
|
||||
code: "",
|
||||
codeGenerationDate: Date(year: 2024, month: 1, day: 1, second: 31),
|
||||
period: 30
|
||||
)
|
||||
)
|
||||
)
|
||||
let expectation = [
|
||||
|
||||
@ -1,7 +1,15 @@
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
/// Protocol defining the functionality of a TOTP (Time-based One-Time Password) service.
|
||||
protocol TOTPService {
|
||||
/// Calculates the TOTP code for a given key
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - key: The `TOTPKeyModel` to generate a code for
|
||||
///
|
||||
func getTotpCode(for key: TOTPKeyModel) async throws -> TOTPCodeModel
|
||||
|
||||
/// Retrieves the TOTP configuration for a given key.
|
||||
///
|
||||
/// - Parameter key: A string representing the TOTP key.
|
||||
@ -11,12 +19,46 @@ protocol TOTPService {
|
||||
}
|
||||
|
||||
/// Default implementation of the `TOTPService`.
|
||||
struct DefaultTOTPService: TOTPService {
|
||||
/// Retrieves the TOTP configuration for a given key.
|
||||
class DefaultTOTPService {
|
||||
// MARK: Properties
|
||||
|
||||
/// The service to communicate with the SDK for encryption/decryption tasks.
|
||||
private let clientVault: ClientVaultService
|
||||
|
||||
/// The service used by the application to report non-fatal errors.
|
||||
private let errorReporter: ErrorReporter
|
||||
|
||||
/// The service used to get the present time.
|
||||
private let timeProvider: TimeProvider
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Initialize a `DefaultTOTPService`.
|
||||
///
|
||||
/// - Parameter key: A string representing the TOTP key.
|
||||
/// - Throws: `TOTPServiceError.invalidKeyFormat` if the key format is invalid.
|
||||
/// - Returns: A `TOTPKeyModel` containing the configuration details.
|
||||
/// - Parameters:
|
||||
/// - clientVault: The service to communicate with the SDK for encryption/decryption tasks.
|
||||
/// - errorReporter: The service used by the application to report non-fatal errors.
|
||||
/// - timeProvider: The service used to get the present time.
|
||||
///
|
||||
init(
|
||||
clientVault: ClientVaultService,
|
||||
errorReporter: ErrorReporter,
|
||||
timeProvider: TimeProvider
|
||||
) {
|
||||
self.clientVault = clientVault
|
||||
self.errorReporter = errorReporter
|
||||
self.timeProvider = timeProvider
|
||||
}
|
||||
}
|
||||
|
||||
extension DefaultTOTPService: TOTPService {
|
||||
func getTotpCode(for key: TOTPKeyModel) async throws -> TOTPCodeModel {
|
||||
try await clientVault.generateTOTPCode(
|
||||
for: key.rawAuthenticatorKey,
|
||||
date: timeProvider.presentTime
|
||||
)
|
||||
}
|
||||
|
||||
func getTOTPConfiguration(key: String?) throws -> TOTPKeyModel {
|
||||
guard let key,
|
||||
let config = TOTPKeyModel(authenticatorKey: key) else {
|
||||
|
||||
@ -5,29 +5,61 @@ import XCTest
|
||||
// MARK: - TOTPServiceTests
|
||||
|
||||
final class TOTPServiceTests: AuthenticatorTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var clientVaultService: MockClientVaultService!
|
||||
var errorReporter: MockErrorReporter!
|
||||
var timeProvider: MockTimeProvider!
|
||||
var subject: DefaultTOTPService!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
clientVaultService = MockClientVaultService()
|
||||
errorReporter = MockErrorReporter()
|
||||
timeProvider = MockTimeProvider(.currentTime)
|
||||
|
||||
subject = DefaultTOTPService(
|
||||
clientVault: clientVaultService,
|
||||
errorReporter: errorReporter,
|
||||
timeProvider: timeProvider
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
|
||||
clientVaultService = nil
|
||||
errorReporter = nil
|
||||
timeProvider = nil
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
func test_default_getTOTPConfiguration_base32() throws {
|
||||
let config = try DefaultTOTPService()
|
||||
let config = try subject
|
||||
.getTOTPConfiguration(key: .base32Key)
|
||||
XCTAssertNotNil(config)
|
||||
}
|
||||
|
||||
func test_default_getTOTPConfiguration_otp() throws {
|
||||
let config = try DefaultTOTPService()
|
||||
let config = try subject
|
||||
.getTOTPConfiguration(key: .otpAuthUriKeyComplete)
|
||||
XCTAssertNotNil(config)
|
||||
}
|
||||
|
||||
func test_default_getTOTPConfiguration_steam() throws {
|
||||
let config = try DefaultTOTPService()
|
||||
let config = try subject
|
||||
.getTOTPConfiguration(key: .steamUriKey)
|
||||
XCTAssertNotNil(config)
|
||||
}
|
||||
|
||||
func test_default_getTOTPConfiguration_failure() {
|
||||
XCTAssertThrowsError(
|
||||
try DefaultTOTPService().getTOTPConfiguration(key: "1234")
|
||||
try subject.getTOTPConfiguration(key: "1234")
|
||||
) { error in
|
||||
XCTAssertEqual(
|
||||
error as? TOTPServiceError,
|
||||
|
||||
@ -1,10 +1,20 @@
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class MockTOTPService: TOTPService {
|
||||
var refreshTOTPCodeResult: Result<TOTPCodeModel, Error> = .success(
|
||||
TOTPCodeModel(code: .base32Key, codeGenerationDate: .now, period: 30)
|
||||
)
|
||||
var refreshedTOTPKeyConfig: TOTPKeyModel?
|
||||
|
||||
var capturedKey: String?
|
||||
var getTOTPConfigResult: Result<TOTPKeyModel, Error> = .failure(TOTPServiceError.invalidKeyFormat)
|
||||
|
||||
func getTOTPConfiguration(key: String?) throws -> AuthenticatorShared.TOTPKeyModel {
|
||||
func getTotpCode(for key: TOTPKeyModel) async throws -> TOTPCodeModel {
|
||||
refreshedTOTPKeyConfig = key
|
||||
return try refreshTOTPCodeResult.get()
|
||||
}
|
||||
|
||||
func getTOTPConfiguration(key: String?) throws -> TOTPKeyModel {
|
||||
capturedKey = key
|
||||
return try getTOTPConfigResult.get()
|
||||
}
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class MockAuthenticatorItemService: AuthenticatorItemService {
|
||||
var addAuthenticatorItemAuthenticatorItems = [AuthenticatorItem]()
|
||||
var addAuthenticatorItemResult: Result<Void, Error> = .success(())
|
||||
|
||||
var deleteAuthenticatorItemId: String?
|
||||
var deleteAuthenticatorItemResult: Result<Void, Error> = .success(())
|
||||
|
||||
var fetchAuthenticatorItemId: String?
|
||||
var fetchAuthenticatorItemResult: Result<AuthenticatorItem?, Error> = .success(nil)
|
||||
|
||||
var fetchAllAuthenticatorItemsResult: Result<[AuthenticatorItem], Error> = .success([])
|
||||
|
||||
var updateAuthenticatorItemAuthenticatorItem: AuthenticatorItem?
|
||||
var updateAuthenticatorItemResult: Result<Void, Error> = .success(())
|
||||
|
||||
var authenticatorItemsSubject = CurrentValueSubject<[AuthenticatorItem], Error>([])
|
||||
|
||||
func addAuthenticatorItem(_ authenticatorItem: AuthenticatorShared.AuthenticatorItem) async throws {
|
||||
addAuthenticatorItemAuthenticatorItems.append(authenticatorItem)
|
||||
try addAuthenticatorItemResult.get()
|
||||
}
|
||||
|
||||
func deleteAuthenticatorItem(id: String) async throws {
|
||||
deleteAuthenticatorItemId = id
|
||||
try deleteAuthenticatorItemResult.get()
|
||||
}
|
||||
|
||||
func fetchAuthenticatorItem(withId id: String) async throws -> AuthenticatorShared.AuthenticatorItem? {
|
||||
fetchAuthenticatorItemId = id
|
||||
return try fetchAuthenticatorItemResult.get()
|
||||
}
|
||||
|
||||
func fetchAllAuthenticatorItems() async throws -> [AuthenticatorShared.AuthenticatorItem] {
|
||||
try fetchAllAuthenticatorItemsResult.get()
|
||||
}
|
||||
|
||||
func updateAuthenticatorItem(_ authenticatorItem: AuthenticatorShared.AuthenticatorItem) async throws {
|
||||
updateAuthenticatorItemAuthenticatorItem = authenticatorItem
|
||||
try updateAuthenticatorItemResult.get()
|
||||
}
|
||||
|
||||
func authenticatorItemsPublisher() async throws -> AnyPublisher<[AuthenticatorShared.AuthenticatorItem], Error> {
|
||||
authenticatorItemsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class MockCryptographyService: CryptographyService {
|
||||
var encryptError: Error?
|
||||
var encryptedAuthenticatorItems = [AuthenticatorItemView]()
|
||||
|
||||
func decrypt(_ authenticatorItem: AuthenticatorItem) async throws -> AuthenticatorItemView {
|
||||
AuthenticatorItemView(authenticatorItem: authenticatorItem)
|
||||
}
|
||||
|
||||
func encrypt(_ authenticatorItemView: AuthenticatorItemView) async throws -> AuthenticatorItem {
|
||||
encryptedAuthenticatorItems.append(authenticatorItemView)
|
||||
if let encryptError {
|
||||
throw encryptError
|
||||
}
|
||||
return AuthenticatorItem(authenticatorItemView: authenticatorItemView)
|
||||
}
|
||||
}
|
||||
@ -77,9 +77,9 @@ class AppCoordinator: Coordinator, HasRootNavigator {
|
||||
|
||||
// MARK: Private Methods
|
||||
|
||||
/// Shows the Token List screen.
|
||||
/// Shows the Item List screen.
|
||||
///
|
||||
/// - Parameter route: The token list route to show.
|
||||
/// - Parameter route: The item list route to show.
|
||||
///
|
||||
private func showItemList(route: ItemListRoute) {
|
||||
if let coordinator = childCoordinator as? AnyCoordinator<ItemListRoute, ItemListEvent> {
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import BitwardenSdk
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - TokenCoordinator
|
||||
// MARK: - AuthenticatorItemCoordinator
|
||||
|
||||
/// A coordinator that manages navigation for displaying, editing, and adding individual tokens.
|
||||
///
|
||||
class TokenCoordinator: NSObject, Coordinator, HasStackNavigator {
|
||||
class AuthenticatorItemCoordinator: NSObject, Coordinator, HasStackNavigator {
|
||||
// MARK: Types
|
||||
|
||||
typealias Module = TokenModule
|
||||
typealias Module = AuthenticatorItemModule
|
||||
|
||||
typealias Services = HasErrorReporter
|
||||
& HasTimeProvider
|
||||
@ -29,7 +29,7 @@ class TokenCoordinator: NSObject, Coordinator, HasStackNavigator {
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Creates a new `TokenCoordinator`.
|
||||
/// Creates a new `AuthenticatorItemCoordinator`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - module: The module used by this coordinator to create child coordinators.
|
||||
@ -48,9 +48,9 @@ class TokenCoordinator: NSObject, Coordinator, HasStackNavigator {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func handleEvent(_ event: TokenEvent, context: AnyObject?) async {}
|
||||
func handleEvent(_ event: AuthenticatorItemEvent, context: AnyObject?) async {}
|
||||
|
||||
func navigate(to route: TokenRoute, context: AnyObject?) {
|
||||
func navigate(to route: AuthenticatorItemRoute, context: AnyObject?) {
|
||||
switch route {
|
||||
case let .alert(alert):
|
||||
stackNavigator?.present(alert)
|
||||
@ -58,9 +58,11 @@ class TokenCoordinator: NSObject, Coordinator, HasStackNavigator {
|
||||
stackNavigator?.dismiss(animated: true, completion: {
|
||||
onDismiss?.action()
|
||||
})
|
||||
case let .editToken(token):
|
||||
showEditToken(for: token)
|
||||
case let .editAuthenticatorItem(authenticatorItemView):
|
||||
Logger.application.log("Edit item \(authenticatorItemView.id)")
|
||||
showEditAuthenticatorItem(for: authenticatorItemView)
|
||||
case let .viewToken(id):
|
||||
Logger.application.log("View token \(id)")
|
||||
showViewToken(id: id)
|
||||
}
|
||||
}
|
||||
@ -69,7 +71,7 @@ class TokenCoordinator: NSObject, Coordinator, HasStackNavigator {
|
||||
|
||||
// MARK: Private Methods
|
||||
|
||||
/// Present a child `TokenCoordinator` on top of the existing coordinator.
|
||||
/// Present a child `AuthenticatorItemCoordinator` on top of the existing coordinator.
|
||||
///
|
||||
/// Presenting a view on top of an already presented view within the same coordinator causes
|
||||
/// problems when dismissing only the top view. So instead, present a new coordinator and
|
||||
@ -77,9 +79,9 @@ class TokenCoordinator: NSObject, Coordinator, HasStackNavigator {
|
||||
///
|
||||
/// - Parameter route: The route to navigate to in the presented coordinator.
|
||||
///
|
||||
private func presentChildTokenCoordinator(route: TokenRoute, context: AnyObject?) {
|
||||
private func presentChildAuthenticatorItemCoordinator(route: AuthenticatorItemRoute, context: AnyObject?) {
|
||||
let navigationController = UINavigationController()
|
||||
let coordinator = module.makeTokenCoordinator(stackNavigator: navigationController)
|
||||
let coordinator = module.makeAuthenticatorItemCoordinator(stackNavigator: navigationController)
|
||||
coordinator.navigate(to: route, context: context)
|
||||
coordinator.start()
|
||||
stackNavigator?.present(navigationController)
|
||||
@ -90,22 +92,25 @@ class TokenCoordinator: NSObject, Coordinator, HasStackNavigator {
|
||||
/// - Parameters:
|
||||
/// - token: The `Token` to edit.
|
||||
///
|
||||
private func showEditToken(for token: Token) {
|
||||
private func showEditAuthenticatorItem(for authenticatorItemView: AuthenticatorItemView) {
|
||||
guard let stackNavigator else { return }
|
||||
if stackNavigator.isEmpty {
|
||||
guard let state = TokenItemState(existing: token)
|
||||
guard let state = AuthenticatorItemState(existing: authenticatorItemView)
|
||||
else { return }
|
||||
|
||||
let processor = EditTokenProcessor(
|
||||
let processor = EditAuthenticatorItemProcessor(
|
||||
coordinator: asAnyCoordinator(),
|
||||
services: services,
|
||||
state: state
|
||||
)
|
||||
let store = Store(processor: processor)
|
||||
let view = EditTokenView(store: store)
|
||||
let view = EditAuthenticatorItemView(store: store)
|
||||
stackNavigator.replace(view)
|
||||
} else {
|
||||
presentChildTokenCoordinator(route: .editToken(token), context: self)
|
||||
presentChildAuthenticatorItemCoordinator(
|
||||
route: .editAuthenticatorItem(authenticatorItemView),
|
||||
context: self
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - AuthenticatorItemModule
|
||||
|
||||
/// An object that builds coordinators for the token views.
|
||||
@MainActor
|
||||
protocol AuthenticatorItemModule {
|
||||
/// Initializes a coordinator for navigating between `AuthenticatorItemRoute` objects.
|
||||
///
|
||||
/// - Parameter stackNavigator: The stack navigator that will be used to navigate between routes.
|
||||
/// - Returns: A coordinator that can navigate to a `AuthenticatorItemRoute`.
|
||||
///
|
||||
func makeAuthenticatorItemCoordinator(
|
||||
stackNavigator: StackNavigator
|
||||
) -> AnyCoordinator<AuthenticatorItemRoute, AuthenticatorItemEvent>
|
||||
}
|
||||
|
||||
extension DefaultAppModule: AuthenticatorItemModule {
|
||||
func makeAuthenticatorItemCoordinator(
|
||||
stackNavigator: StackNavigator
|
||||
) -> AnyCoordinator<AuthenticatorItemRoute, AuthenticatorItemEvent> {
|
||||
AuthenticatorItemCoordinator(
|
||||
module: self,
|
||||
services: services,
|
||||
stackNavigator: stackNavigator
|
||||
).asAnyCoordinator()
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
import BitwardenSdk
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - TokenRoute
|
||||
// MARK: - AuthenticatorItemRoute
|
||||
|
||||
/// A route to a screen for a specific token.
|
||||
enum TokenRoute: Equatable {
|
||||
/// A route to a screen for a specific item.
|
||||
enum AuthenticatorItemRoute: Equatable {
|
||||
/// A route to display the specified alert.
|
||||
///
|
||||
/// - Parameter alert: The alert to display.
|
||||
@ -19,8 +19,8 @@ enum TokenRoute: Equatable {
|
||||
|
||||
/// A route to edit a token.
|
||||
///
|
||||
/// - Parameter token: the `Token` to edit
|
||||
case editToken(_ token: Token)
|
||||
/// - Parameter authenticatorItemView: the `AuthenticatorItemView` to edit
|
||||
case editAuthenticatorItem(_ authenticatorItemView: AuthenticatorItemView)
|
||||
|
||||
/// A route to the view token screen.
|
||||
///
|
||||
@ -29,4 +29,4 @@ enum TokenRoute: Equatable {
|
||||
case viewToken(id: String)
|
||||
}
|
||||
|
||||
enum TokenEvent {}
|
||||
enum AuthenticatorItemEvent {}
|
||||
@ -0,0 +1,108 @@
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
// MARK: - AuthenticatorItemState
|
||||
|
||||
/// An object that defines the current state of any view interacting with an authenticator item.
|
||||
///
|
||||
struct AuthenticatorItemState: Equatable {
|
||||
// MARK: Types
|
||||
|
||||
/// An enum defining if the state is a new or existing item.
|
||||
enum Configuration: Equatable {
|
||||
/// We are creating a new item.
|
||||
case add
|
||||
|
||||
/// We are viewing or editing an existing item.
|
||||
case existing(authenticatorItemView: AuthenticatorItemView)
|
||||
|
||||
/// The existing `AuthenticatorItemView` if the configuration is `existing`.
|
||||
var existingToken: AuthenticatorItemView? {
|
||||
guard case let .existing(authenticatorItemView) = self else { return nil }
|
||||
return authenticatorItemView
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The account of the item
|
||||
var account: String
|
||||
|
||||
/// The Add or Existing Configuration.
|
||||
let configuration: Configuration
|
||||
|
||||
/// A flag indicating if the key field is visible
|
||||
var isKeyVisible: Bool = false
|
||||
|
||||
/// The issuer of the item
|
||||
var issuer: String
|
||||
|
||||
/// The name of this item.
|
||||
var name: String
|
||||
|
||||
/// A toast for views
|
||||
var toast: Toast?
|
||||
|
||||
/// The TOTP key/code state.
|
||||
var totpState: LoginTOTPState
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
init(
|
||||
configuration: Configuration,
|
||||
name: String,
|
||||
totpState: LoginTOTPState
|
||||
) {
|
||||
self.configuration = configuration
|
||||
self.name = name
|
||||
self.totpState = totpState
|
||||
account = "Fixme"
|
||||
issuer = "Fixme"
|
||||
}
|
||||
|
||||
init?(existing authenticatorItemView: AuthenticatorItemView) {
|
||||
self.init(
|
||||
configuration: .existing(authenticatorItemView: authenticatorItemView),
|
||||
name: authenticatorItemView.name,
|
||||
totpState: LoginTOTPState(authenticatorItemView.totpKey)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension AuthenticatorItemState: EditAuthenticatorItemState {
|
||||
var editState: EditAuthenticatorItemState {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
extension AuthenticatorItemState: ViewAuthenticatorItemState {
|
||||
var authenticatorKey: String? {
|
||||
totpState.rawAuthenticatorKeyString
|
||||
}
|
||||
|
||||
var authenticatorItemView: AuthenticatorItemView {
|
||||
switch configuration {
|
||||
case let .existing(authenticatorItemView):
|
||||
return authenticatorItemView
|
||||
case .add:
|
||||
return newAuthenticatorItemView()
|
||||
}
|
||||
}
|
||||
|
||||
var totpCode: TOTPCodeModel? {
|
||||
totpState.codeModel
|
||||
}
|
||||
}
|
||||
|
||||
extension AuthenticatorItemState {
|
||||
/// Returns an `AuthenticatorItemView` based on the
|
||||
/// properties of the `AuthenticatorItemState`.
|
||||
///
|
||||
func newAuthenticatorItemView() -> AuthenticatorItemView {
|
||||
AuthenticatorItemView(
|
||||
id: UUID().uuidString,
|
||||
name: name,
|
||||
totpKey: totpState.rawAuthenticatorKeyString
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import BitwardenSdk
|
||||
|
||||
/// Synchronous actions that can be processed by an `EditItemProcessor`.
|
||||
enum EditTokenAction: Equatable {
|
||||
enum EditAuthenticatorItemAction: Equatable {
|
||||
/// The account field was changed.
|
||||
case accountChanged(String)
|
||||
|
||||
@ -11,7 +11,7 @@ enum EditTokenAction: Equatable {
|
||||
/// The key field was changed.
|
||||
case keyChanged(String)
|
||||
|
||||
/// The token's name was changed
|
||||
/// The item's name was changed
|
||||
case nameChanged(String)
|
||||
|
||||
/// The toast was shown or hidden.
|
||||
@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
/// Asynchronous effects that can be processed by an `EditTokenProcessor`
|
||||
enum EditTokenEffect {
|
||||
/// Asynchronous effects that can be processed by an `EditAuthenticatorItemProcessor`
|
||||
enum EditAuthenticatorItemEffect {
|
||||
/// The view appeared.
|
||||
case appeared
|
||||
|
||||
@ -1,28 +1,28 @@
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
/// The processor used to manage state and handle actions/effects for the edit token screen
|
||||
final class EditTokenProcessor: StateProcessor<
|
||||
EditTokenState,
|
||||
EditTokenAction,
|
||||
EditTokenEffect
|
||||
/// The processor used to manage state and handle actions/effects for the edit item screen
|
||||
final class EditAuthenticatorItemProcessor: StateProcessor<
|
||||
EditAuthenticatorItemState,
|
||||
EditAuthenticatorItemAction,
|
||||
EditAuthenticatorItemEffect
|
||||
> {
|
||||
// MARK: Types
|
||||
|
||||
typealias Services = HasErrorReporter
|
||||
& HasTokenRepository
|
||||
typealias Services = HasAuthenticatorItemRepository
|
||||
& HasErrorReporter
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The `Coordinator` that handles navigation.
|
||||
private var coordinator: AnyCoordinator<TokenRoute, TokenEvent>
|
||||
private var coordinator: AnyCoordinator<AuthenticatorItemRoute, AuthenticatorItemEvent>
|
||||
|
||||
/// The services required by this processor.
|
||||
private let services: Services
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Creates a new `EditTokenProcessor`.
|
||||
/// Creates a new `EditAuthenticatorItemProcessor`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - coordinator: The `Coordinator` that handles navigation.
|
||||
@ -30,9 +30,9 @@ final class EditTokenProcessor: StateProcessor<
|
||||
/// - state: The initial state for the processor.
|
||||
///
|
||||
init(
|
||||
coordinator: AnyCoordinator<TokenRoute, TokenEvent>,
|
||||
coordinator: AnyCoordinator<AuthenticatorItemRoute, AuthenticatorItemEvent>,
|
||||
services: Services,
|
||||
state: EditTokenState
|
||||
state: EditAuthenticatorItemState
|
||||
) {
|
||||
self.coordinator = coordinator
|
||||
self.services = services
|
||||
@ -42,7 +42,7 @@ final class EditTokenProcessor: StateProcessor<
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
override func perform(_ effect: EditTokenEffect) async {
|
||||
override func perform(_ effect: EditAuthenticatorItemEffect) async {
|
||||
switch effect {
|
||||
case .appeared:
|
||||
break
|
||||
@ -51,7 +51,7 @@ final class EditTokenProcessor: StateProcessor<
|
||||
}
|
||||
}
|
||||
|
||||
override func receive(_ action: EditTokenAction) {
|
||||
override func receive(_ action: EditAuthenticatorItemAction) {
|
||||
switch action {
|
||||
case let .accountChanged(account):
|
||||
state.account = account
|
||||
@ -90,13 +90,13 @@ final class EditTokenProcessor: StateProcessor<
|
||||
switch state.configuration {
|
||||
case .add:
|
||||
return
|
||||
case let .existing(token: token):
|
||||
let newToken = Token(
|
||||
id: token.id,
|
||||
name: token.name,
|
||||
authenticatorKey: state.totpState.rawAuthenticatorKeyString!
|
||||
)!
|
||||
try await updateToken(token: newToken)
|
||||
case let .existing(authenticatorItemView: authenticatorItemView):
|
||||
let newAuthenticatorItemView = AuthenticatorItemView(
|
||||
id: authenticatorItemView.id,
|
||||
name: authenticatorItemView.name,
|
||||
totpKey: state.totpState.rawAuthenticatorKeyString
|
||||
)
|
||||
try await updateAuthenticatorItem(authenticatorItem: newAuthenticatorItemView)
|
||||
}
|
||||
} catch let error as InputValidationError {
|
||||
coordinator.showAlert(Alert.inputValidationAlert(error: error))
|
||||
@ -109,8 +109,8 @@ final class EditTokenProcessor: StateProcessor<
|
||||
|
||||
/// Updates the item currently in `state`.
|
||||
///
|
||||
private func updateToken(token: Token) async throws {
|
||||
try await services.tokenRepository.updateToken(token)
|
||||
private func updateAuthenticatorItem(authenticatorItem: AuthenticatorItemView) async throws {
|
||||
try await services.authenticatorItemRepository.updateAuthenticatorItem(authenticatorItem)
|
||||
coordinator.hideLoadingOverlay()
|
||||
coordinator.navigate(to: .dismiss())
|
||||
}
|
||||
@ -1,20 +1,20 @@
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
/// The state of an `EditTokenView`
|
||||
protocol EditTokenState: Sendable {
|
||||
/// The state of an `EditAuthenticatorItemView`
|
||||
protocol EditAuthenticatorItemState: Sendable {
|
||||
// MARK: Properties
|
||||
|
||||
/// The account of the token
|
||||
/// The account of the item
|
||||
var account: String { get set }
|
||||
|
||||
/// The Add or Existing Configuration.
|
||||
var configuration: TokenItemState.Configuration { get }
|
||||
var configuration: AuthenticatorItemState.Configuration { get }
|
||||
|
||||
/// A flag indicating if the key is visible.
|
||||
var isKeyVisible: Bool { get set }
|
||||
|
||||
/// The issuer of the token
|
||||
/// The issuer of the item
|
||||
var issuer: String { get set }
|
||||
|
||||
/// The name of this item.
|
||||
@ -1,11 +1,15 @@
|
||||
import BitwardenSdk
|
||||
import SwiftUI
|
||||
|
||||
/// A view for editing a token
|
||||
struct EditTokenView: View {
|
||||
/// A view for editing an item
|
||||
struct EditAuthenticatorItemView: View {
|
||||
// MARK: Properties
|
||||
|
||||
@ObservedObject var store: Store<EditTokenState, EditTokenAction, EditTokenEffect>
|
||||
@ObservedObject var store: Store<
|
||||
EditAuthenticatorItemState,
|
||||
EditAuthenticatorItemAction,
|
||||
EditAuthenticatorItemEffect
|
||||
>
|
||||
|
||||
// MARK: View
|
||||
|
||||
@ -20,7 +24,7 @@ struct EditTokenView: View {
|
||||
.task { await store.perform(.appeared) }
|
||||
.toast(store.binding(
|
||||
get: \.toast,
|
||||
send: EditTokenAction.toastShown
|
||||
send: EditAuthenticatorItemAction.toastShown
|
||||
))
|
||||
}
|
||||
|
||||
@ -46,7 +50,7 @@ struct EditTokenView: View {
|
||||
title: Localizations.name,
|
||||
text: store.binding(
|
||||
get: \.name,
|
||||
send: EditTokenAction.nameChanged
|
||||
send: EditAuthenticatorItemAction.nameChanged
|
||||
)
|
||||
)
|
||||
|
||||
@ -54,11 +58,11 @@ struct EditTokenView: View {
|
||||
title: Localizations.authenticatorKey,
|
||||
text: store.binding(
|
||||
get: \.totpState.rawAuthenticatorKeyString!,
|
||||
send: EditTokenAction.keyChanged
|
||||
send: EditAuthenticatorItemAction.keyChanged
|
||||
),
|
||||
isPasswordVisible: store.binding(
|
||||
get: \.isKeyVisible,
|
||||
send: EditTokenAction.toggleKeyVisibilityChanged
|
||||
send: EditAuthenticatorItemAction.toggleKeyVisibilityChanged
|
||||
)
|
||||
)
|
||||
.textFieldConfiguration(.password)
|
||||
@ -76,15 +80,16 @@ struct EditTokenView: View {
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Edit") {
|
||||
EditTokenView(
|
||||
EditAuthenticatorItemView(
|
||||
store: Store(
|
||||
processor: StateProcessor(
|
||||
state: TokenItemState(
|
||||
state: AuthenticatorItemState(
|
||||
configuration: .existing(
|
||||
token: Token(
|
||||
authenticatorItemView: AuthenticatorItemView(
|
||||
id: "Example",
|
||||
name: "Example",
|
||||
authenticatorKey: "example"
|
||||
)!
|
||||
totpKey: "example"
|
||||
)
|
||||
),
|
||||
name: "Example",
|
||||
totpState: LoginTOTPState(
|
||||
@ -0,0 +1,15 @@
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
// MARK: - ViewAuthenticatorItemState
|
||||
|
||||
/// The state for viewing/adding/editing a totp item
|
||||
protocol ViewAuthenticatorItemState: Sendable {
|
||||
// MARK: Properties
|
||||
|
||||
/// The TOTP key.
|
||||
var authenticatorKey: String? { get }
|
||||
|
||||
/// The TOTP code model
|
||||
var totpCode: TOTPCodeModel? { get }
|
||||
}
|
||||
@ -10,7 +10,7 @@ struct ViewTokenItemView: View {
|
||||
// MARK: Properties
|
||||
|
||||
/// The `Store` for this view.
|
||||
@ObservedObject var store: Store<TokenItemState, ViewTokenAction, ViewTokenEffect>
|
||||
@ObservedObject var store: Store<AuthenticatorItemState, ViewTokenAction, ViewTokenEffect>
|
||||
|
||||
/// The `TimeProvider` used to calculate TOTP expiration.
|
||||
var timeProvider: any TimeProvider
|
||||
@ -59,7 +59,7 @@ struct ViewTokenItemView: View {
|
||||
store: Store(
|
||||
processor: StateProcessor(
|
||||
state:
|
||||
TokenItemState(
|
||||
AuthenticatorItemState(
|
||||
configuration: .add,
|
||||
name: "Example",
|
||||
totpState: LoginTOTPState(
|
||||
@ -11,16 +11,16 @@ final class ViewTokenProcessor: StateProcessor<
|
||||
> {
|
||||
// MARK: Types
|
||||
|
||||
typealias Services = HasErrorReporter
|
||||
typealias Services = HasAuthenticatorItemRepository
|
||||
& HasErrorReporter
|
||||
& HasPasteboardService
|
||||
& HasTOTPService
|
||||
& HasTimeProvider
|
||||
& HasTokenRepository
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The `Coordinator` that handles navigation, typically a `TokenCoordinator`.
|
||||
private let coordinator: AnyCoordinator<TokenRoute, TokenEvent>
|
||||
/// The `Coordinator` that handles navigation, typically a `AuthenticatorItemCoordinator`.
|
||||
private let coordinator: AnyCoordinator<AuthenticatorItemRoute, AuthenticatorItemEvent>
|
||||
|
||||
/// The ID of the item being viewed.
|
||||
private let itemId: String
|
||||
@ -39,7 +39,7 @@ final class ViewTokenProcessor: StateProcessor<
|
||||
/// - state: The initial state of this processor.
|
||||
///
|
||||
init(
|
||||
coordinator: AnyCoordinator<TokenRoute, TokenEvent>,
|
||||
coordinator: AnyCoordinator<AuthenticatorItemRoute, AuthenticatorItemEvent>,
|
||||
itemId: String,
|
||||
services: Services,
|
||||
state: ViewTokenState
|
||||
@ -55,7 +55,7 @@ final class ViewTokenProcessor: StateProcessor<
|
||||
override func perform(_ effect: ViewTokenEffect) async {
|
||||
switch effect {
|
||||
case .appeared:
|
||||
await streamTokenDetails()
|
||||
await streamItemDetails()
|
||||
case .totpCodeExpired:
|
||||
await updateTOTPCode()
|
||||
}
|
||||
@ -80,32 +80,36 @@ private extension ViewTokenProcessor {
|
||||
/// Triggers the edit state for the item currently stored in `state`.
|
||||
///
|
||||
private func editItem() {
|
||||
guard case let .data(tokenState) = state.loadingState,
|
||||
case let .existing(token) = tokenState.configuration else {
|
||||
guard case let .data(authenticatorItemState) = state.loadingState,
|
||||
case let .existing(authenticatorItemView) = authenticatorItemState.configuration else {
|
||||
return
|
||||
}
|
||||
Task {
|
||||
coordinator.navigate(to: .editToken(token), context: self)
|
||||
coordinator.navigate(to: .editAuthenticatorItem(authenticatorItemView), context: self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream the token details.
|
||||
private func streamTokenDetails() async {
|
||||
/// Stream the item details.
|
||||
private func streamItemDetails() async {
|
||||
do {
|
||||
guard let token = try await services.tokenRepository.fetchToken(withId: itemId)
|
||||
guard let authenticatorItemView = try await services.authenticatorItemRepository.fetchAuthenticatorItem(withId: itemId),
|
||||
let key = authenticatorItemView.totpKey,
|
||||
let model = TOTPKeyModel(authenticatorKey: key)
|
||||
else { return }
|
||||
|
||||
let code = try await services.tokenRepository.refreshTotpCode(for: token.key)
|
||||
guard var newTokenState = ViewTokenState(token: token) else { return }
|
||||
if case var .data(tokenState) = newTokenState.loadingState {
|
||||
let code = try await services.totpService.getTotpCode(for: model)
|
||||
guard var newAuthenticatorItemState = ViewTokenState(authenticatorItemView: authenticatorItemView)
|
||||
else { return }
|
||||
|
||||
if case var .data(authenticatorItemState) = newAuthenticatorItemState.loadingState {
|
||||
let totpState = LoginTOTPState(
|
||||
authKeyModel: token.key,
|
||||
authKeyModel: model,
|
||||
codeModel: code
|
||||
)
|
||||
tokenState.totpState = totpState
|
||||
newTokenState.loadingState = .data(tokenState)
|
||||
authenticatorItemState.totpState = totpState
|
||||
newAuthenticatorItemState.loadingState = .data(authenticatorItemState)
|
||||
}
|
||||
state = newTokenState
|
||||
state = newAuthenticatorItemState
|
||||
} catch {
|
||||
services.errorReporter.log(error: error)
|
||||
}
|
||||
@ -113,20 +117,20 @@ private extension ViewTokenProcessor {
|
||||
|
||||
/// Updates the TOTP code for the view.
|
||||
func updateTOTPCode() async {
|
||||
guard case let .data(tokenItemState) = state.loadingState,
|
||||
let calculationKey = tokenItemState.totpState.authKeyModel
|
||||
guard case let .data(authenticatorItemState) = state.loadingState,
|
||||
let calculationKey = authenticatorItemState.totpState.authKeyModel
|
||||
else { return }
|
||||
do {
|
||||
let code = try await services.tokenRepository.refreshTotpCode(for: calculationKey)
|
||||
let code = try await services.totpService.getTotpCode(for: calculationKey)
|
||||
|
||||
guard case let .data(tokenItemState) = state.loadingState else { return }
|
||||
guard case let .data(authenticatorItemState) = state.loadingState else { return }
|
||||
|
||||
let newTotpState = LoginTOTPState(
|
||||
authKeyModel: calculationKey,
|
||||
codeModel: code
|
||||
)
|
||||
|
||||
var newState = tokenItemState
|
||||
var newState = authenticatorItemState
|
||||
newState.totpState = newTotpState
|
||||
state.loadingState = .data(newState)
|
||||
} catch {
|
||||
@ -9,7 +9,7 @@ struct ViewTokenState: Sendable {
|
||||
|
||||
/// The current state. If this state is not `.loading`, this value will contain an associated value with the
|
||||
/// appropriate internal state.
|
||||
var loadingState: LoadingState<TokenItemState> = .loading(nil)
|
||||
var loadingState: LoadingState<AuthenticatorItemState> = .loading(nil)
|
||||
|
||||
/// A toast message to show in the view.
|
||||
var toast: Toast?
|
||||
@ -18,15 +18,15 @@ struct ViewTokenState: Sendable {
|
||||
extension ViewTokenState {
|
||||
// MARK: Initialization
|
||||
|
||||
/// Creates a new `ViewTokenState` from a provided `CipherView`.
|
||||
/// Creates a new `ViewTokenState` from a provided `AuthenticatorItemView`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - cipherView: The `CipherView` to create this state with.
|
||||
///
|
||||
init?(token: Token) {
|
||||
guard let tokenItemState = TokenItemState(
|
||||
existing: token
|
||||
init?(authenticatorItemView: AuthenticatorItemView) {
|
||||
guard let authenticatorItemState = AuthenticatorItemState(
|
||||
existing: authenticatorItemView
|
||||
) else { return nil }
|
||||
self.init(loadingState: .data(tokenItemState))
|
||||
self.init(loadingState: .data(authenticatorItemState))
|
||||
}
|
||||
}
|
||||
@ -40,9 +40,9 @@ struct ViewTokenView: View {
|
||||
|
||||
// MARK: Private Views
|
||||
|
||||
/// The details of the token.
|
||||
/// The details of the item.
|
||||
@ViewBuilder
|
||||
private func details(for state: TokenItemState) -> some View {
|
||||
private func details(for state: AuthenticatorItemState) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 16) {
|
||||
BitwardenTextValueField(title: Localizations.name, value: state.name)
|
||||
@ -95,7 +95,7 @@ struct ViewTokenView: View {
|
||||
processor: StateProcessor(
|
||||
state: ViewTokenState(
|
||||
loadingState: .data(
|
||||
TokenItemState(
|
||||
AuthenticatorItemState(
|
||||
configuration: .add,
|
||||
name: "Example",
|
||||
totpState: LoginTOTPState(
|
||||
@ -4,15 +4,46 @@ import Foundation
|
||||
/// Data model for an item displayed in the item list.
|
||||
///
|
||||
public struct ItemListItem: Equatable, Identifiable {
|
||||
// MARK: Types
|
||||
|
||||
/// The type of item being displayed by this item
|
||||
public enum ItemType: Equatable {
|
||||
/// A TOTP code item
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - model: The TOTP model
|
||||
case totp(model: ItemListTotpItem)
|
||||
}
|
||||
|
||||
/// The identifier for the item.
|
||||
public let id: String
|
||||
|
||||
/// The name to display for the item.
|
||||
public let name: String
|
||||
|
||||
/// The token used to generate the code.
|
||||
public let token: Token
|
||||
|
||||
/// The current TOTP code for the ciper.
|
||||
public var totpCode: TOTPCodeModel
|
||||
/// The type of item being displayed by this item
|
||||
public let itemType: ItemType
|
||||
}
|
||||
|
||||
extension ItemListItem {
|
||||
/// Initialize an `ItemListItem` from an `AuthenticatorItemView`
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - authenticatorItemView: The `AuthenticatorItemView` used to initialize the `ItemListItem`
|
||||
///
|
||||
init?(authenticatorItemView: AuthenticatorItemView) {
|
||||
let totpCode = TOTPCodeModel(code: "123456", codeGenerationDate: .now, period: 30)
|
||||
let totpModel = ItemListTotpItem(itemView: authenticatorItemView, totpCode: totpCode)
|
||||
self.init(id: authenticatorItemView.id,
|
||||
name: authenticatorItemView.name,
|
||||
itemType: .totp(model: totpModel))
|
||||
}
|
||||
}
|
||||
|
||||
public struct ItemListTotpItem: Equatable {
|
||||
/// The `AuthenticatorItemView` used to populate the view
|
||||
let itemView: AuthenticatorItemView
|
||||
|
||||
/// The current TOTP code for the item
|
||||
var totpCode: TOTPCodeModel
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
// MARK: - ItemListProcessor
|
||||
@ -7,12 +6,12 @@ import Foundation
|
||||
final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, ItemListEffect> {
|
||||
// MARK: Types
|
||||
|
||||
typealias Services = HasCameraService
|
||||
typealias Services = HasAuthenticatorItemRepository
|
||||
& HasCameraService
|
||||
& HasErrorReporter
|
||||
& HasPasteboardService
|
||||
& HasTOTPService
|
||||
& HasTimeProvider
|
||||
& HasTokenRepository
|
||||
|
||||
// MARK: Private Properties
|
||||
|
||||
@ -77,7 +76,7 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
|
||||
services.pasteboardService.copy(code)
|
||||
state.toast = Toast(text: Localizations.valueHasBeenCopied(Localizations.verificationCode))
|
||||
case let .itemPressed(item):
|
||||
coordinator.navigate(to: .viewToken(id: item.id))
|
||||
coordinator.navigate(to: .viewItem(id: item.id))
|
||||
case .morePressed:
|
||||
break
|
||||
case let .toastShown(newValue):
|
||||
@ -92,19 +91,22 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
|
||||
private func refreshTOTPCodes(for items: [ItemListItem]) async {
|
||||
guard case .data = state.loadingState else { return }
|
||||
let refreshedItems = await items.asyncMap { item in
|
||||
do {
|
||||
let refreshedCode = try await services.tokenRepository.refreshTotpCode(for: item.token.key)
|
||||
return ItemListItem(
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
token: item.token,
|
||||
totpCode: refreshedCode
|
||||
)
|
||||
} catch {
|
||||
guard case let .totp(model) = item.itemType,
|
||||
let key = model.itemView.totpKey,
|
||||
let keyModel = TOTPKeyModel(authenticatorKey: key),
|
||||
let code = try? await services.totpService.getTotpCode(for: keyModel)
|
||||
else {
|
||||
services.errorReporter.log(error: TOTPServiceError
|
||||
.unableToGenerateCode("Unable to refresh TOTP code for list view item: \(item.id)"))
|
||||
return item
|
||||
}
|
||||
var updatedModel = model
|
||||
updatedModel.totpCode = code
|
||||
return ItemListItem(
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
itemType: .totp(model: updatedModel)
|
||||
)
|
||||
}
|
||||
groupTotpExpirationManager?.configureTOTPRefreshScheduling(for: refreshedItems)
|
||||
state.loadingState = .data(refreshedItems)
|
||||
@ -128,10 +130,17 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
|
||||
/// Stream the items list.
|
||||
private func streamItemList() async {
|
||||
do {
|
||||
for try await tokenList in try await services.tokenRepository.tokenPublisher() {
|
||||
let itemList = try await tokenList.asyncMap { token in
|
||||
let code = try await services.tokenRepository.refreshTotpCode(for: token.key)
|
||||
return ItemListItem(id: token.id, name: token.name, token: token, totpCode: code)
|
||||
for try await value in try await services.authenticatorItemRepository.itemListPublisher() {
|
||||
guard let items = value.first?.items else { return }
|
||||
let itemList = try await items.asyncMap { item in
|
||||
guard case let .totp(model) = item.itemType,
|
||||
let key = model.itemView.totpKey,
|
||||
let keyModel = TOTPKeyModel(authenticatorKey: key)
|
||||
else { return item }
|
||||
let code = try await services.totpService.getTotpCode(for: keyModel)
|
||||
var updatedModel = model
|
||||
updatedModel.totpCode = code
|
||||
return ItemListItem(id: item.id, name: item.name, itemType: .totp(model: updatedModel))
|
||||
}
|
||||
groupTotpExpirationManager?.configureTOTPRefreshScheduling(for: itemList)
|
||||
state.loadingState = .data(itemList)
|
||||
@ -201,7 +210,8 @@ private class TOTPExpirationManager {
|
||||
func configureTOTPRefreshScheduling(for items: [ItemListItem]) {
|
||||
var newItemsByInterval = [UInt32: [ItemListItem]]()
|
||||
items.forEach { item in
|
||||
newItemsByInterval[item.totpCode.period, default: []].append(item)
|
||||
guard case let .totp(model) = item.itemType else { return }
|
||||
newItemsByInterval[model.totpCode.period, default: []].append(item)
|
||||
}
|
||||
itemsByInterval = newItemsByInterval
|
||||
}
|
||||
@ -245,11 +255,11 @@ extension ItemListProcessor: AuthenticatorKeyCaptureDelegate {
|
||||
do {
|
||||
let authKeyModel = try services.totpService.getTOTPConfiguration(key: key)
|
||||
let loginTotpState = LoginTOTPState(authKeyModel: authKeyModel)
|
||||
guard let key = loginTotpState.rawAuthenticatorKeyString,
|
||||
let newToken = Token(name: "Example", authenticatorKey: key)
|
||||
guard let key = loginTotpState.rawAuthenticatorKeyString
|
||||
else { return }
|
||||
Task {
|
||||
try await services.tokenRepository.addToken(newToken)
|
||||
let newItem = AuthenticatorItemView(id: UUID().uuidString, name: "Example", totpKey: key)
|
||||
try await services.authenticatorItemRepository.addAuthenticatorItem(newItem)
|
||||
await perform(.refresh)
|
||||
}
|
||||
state.toast = Toast(text: Localizations.authenticatorKeyAdded)
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
/// Data model for a section of items in the item list
|
||||
///
|
||||
public struct ItemListSection: Equatable, Identifiable {
|
||||
// MARK: Properties
|
||||
|
||||
/// The identifier for the section
|
||||
public let id: String
|
||||
|
||||
/// The list of items in the section
|
||||
public let items: [ItemListItem]
|
||||
|
||||
/// The name of the section, displayed as a section header
|
||||
public let name: String
|
||||
}
|
||||
@ -162,27 +162,29 @@ struct ItemListView: View {
|
||||
ItemListItem(
|
||||
id: "One",
|
||||
name: "One",
|
||||
token: Token(
|
||||
name: "One",
|
||||
authenticatorKey: "One"
|
||||
)!,
|
||||
totpCode: TOTPCodeModel(
|
||||
code: "123456",
|
||||
codeGenerationDate: Date(),
|
||||
period: 30
|
||||
itemType: .totp(
|
||||
model: ItemListTotpItem(
|
||||
itemView: AuthenticatorItemView.fixture(),
|
||||
totpCode: TOTPCodeModel(
|
||||
code: "123456",
|
||||
codeGenerationDate: Date(),
|
||||
period: 30
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
ItemListItem(
|
||||
id: "Two",
|
||||
name: "Two",
|
||||
token: Token(
|
||||
name: "Two",
|
||||
authenticatorKey: "Two"
|
||||
)!,
|
||||
totpCode: TOTPCodeModel(
|
||||
code: "123456",
|
||||
codeGenerationDate: Date(),
|
||||
period: 30
|
||||
itemType: .totp(
|
||||
model: ItemListTotpItem(
|
||||
itemView: AuthenticatorItemView.fixture(),
|
||||
totpCode: TOTPCodeModel(
|
||||
code: "123456",
|
||||
codeGenerationDate: Date(),
|
||||
period: 30
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import BitwardenSdk
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - ItemListCoordinator
|
||||
@ -8,8 +8,8 @@ import SwiftUI
|
||||
final class ItemListCoordinator: Coordinator, HasStackNavigator {
|
||||
// MARK: - Types
|
||||
|
||||
typealias Module = ItemListModule
|
||||
& TokenModule
|
||||
typealias Module = AuthenticatorItemModule
|
||||
& ItemListModule
|
||||
|
||||
typealias Services = HasTimeProvider
|
||||
& ItemListProcessor.Services
|
||||
@ -65,7 +65,8 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
|
||||
case .setupTotpManual:
|
||||
guard let delegate = context as? AuthenticatorKeyCaptureDelegate else { return }
|
||||
showManualTotp(delegate: delegate)
|
||||
case let .viewToken(id):
|
||||
case let .viewItem(id):
|
||||
Logger.application.log("View token \(id)")
|
||||
showToken(route: .viewToken(id: id))
|
||||
}
|
||||
}
|
||||
@ -121,9 +122,9 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
|
||||
///
|
||||
/// - Parameter route: The route to navigate to in the coordinator.
|
||||
///
|
||||
private func showToken(route: TokenRoute) {
|
||||
private func showToken(route: AuthenticatorItemRoute) {
|
||||
let navigationController = UINavigationController()
|
||||
let coordinator = module.makeTokenCoordinator(stackNavigator: navigationController)
|
||||
let coordinator = module.makeAuthenticatorItemCoordinator(stackNavigator: navigationController)
|
||||
coordinator.start()
|
||||
coordinator.navigate(to: route, context: self)
|
||||
|
||||
|
||||
@ -3,22 +3,22 @@ import Foundation
|
||||
|
||||
// MARK: - ItemListRoute
|
||||
|
||||
/// A route to a specific screen or subscreen of the Token List
|
||||
/// A route to a specific screen or subscreen of the Item List
|
||||
public enum ItemListRoute: Equatable, Hashable {
|
||||
/// A route to the add item screen.
|
||||
case addItem
|
||||
|
||||
/// A route to the base token list screen.
|
||||
/// A route to the base item list screen.
|
||||
case list
|
||||
|
||||
/// A route to the manual totp screen for setting up TOTP.
|
||||
case setupTotpManual
|
||||
|
||||
/// A route to the view token screen.
|
||||
/// A route to the view item screen.
|
||||
///
|
||||
/// - Parameter id: The id of the token to display.
|
||||
///
|
||||
case viewToken(id: String)
|
||||
case viewItem(id: String)
|
||||
}
|
||||
|
||||
enum ItemListEvent {
|
||||
|
||||
@ -1,103 +0,0 @@
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
// MARK: - TokenState
|
||||
|
||||
/// An object that defines the current state of any view interacting with a token.
|
||||
///
|
||||
struct TokenItemState: Equatable {
|
||||
// MARK: Types
|
||||
|
||||
/// An enum defining if the state is a new or existing token.
|
||||
enum Configuration: Equatable {
|
||||
/// We are creating a new token.
|
||||
case add
|
||||
|
||||
/// We are viewing or editing an existing token.
|
||||
case existing(token: Token)
|
||||
|
||||
/// The existing `CipherView` if the configuration is `existing`.
|
||||
var existingToken: Token? {
|
||||
guard case let .existing(token) = self else { return nil }
|
||||
return token
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The account of the token
|
||||
var account: String
|
||||
|
||||
/// The Add or Existing Configuration.
|
||||
let configuration: Configuration
|
||||
|
||||
/// A flag indicating if the key field is visible
|
||||
var isKeyVisible: Bool = false
|
||||
|
||||
/// The issuer of the token
|
||||
var issuer: String
|
||||
|
||||
/// The name of this item.
|
||||
var name: String
|
||||
|
||||
/// A toast for views
|
||||
var toast: Toast?
|
||||
|
||||
/// The TOTP key/code state.
|
||||
var totpState: LoginTOTPState
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
init(
|
||||
configuration: Configuration,
|
||||
name: String,
|
||||
totpState: LoginTOTPState
|
||||
) {
|
||||
self.configuration = configuration
|
||||
self.name = name
|
||||
self.totpState = totpState
|
||||
account = "Fixme"
|
||||
issuer = "Fixme"
|
||||
}
|
||||
|
||||
init?(existing token: Token) {
|
||||
self.init(
|
||||
configuration: .existing(token: token),
|
||||
name: token.name,
|
||||
totpState: LoginTOTPState(token.key.base32Key)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension TokenItemState: EditTokenState {
|
||||
var editState: EditTokenState {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
extension TokenItemState: ViewTokenItemState {
|
||||
var authenticatorKey: String? {
|
||||
totpState.rawAuthenticatorKeyString
|
||||
}
|
||||
|
||||
var token: Token {
|
||||
switch configuration {
|
||||
case let .existing(token):
|
||||
return token
|
||||
case .add:
|
||||
return newToken()
|
||||
}
|
||||
}
|
||||
|
||||
var totpCode: TOTPCodeModel? {
|
||||
totpState.codeModel
|
||||
}
|
||||
}
|
||||
|
||||
extension TokenItemState {
|
||||
/// Returns a `Token` based on the properties of the `TokenItemState`.
|
||||
///
|
||||
func newToken() -> Token {
|
||||
Token(name: name, authenticatorKey: totpState.rawAuthenticatorKeyString!)!
|
||||
}
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - TokenModule
|
||||
|
||||
/// An object that builds coordinators for the token views.
|
||||
@MainActor
|
||||
protocol TokenModule {
|
||||
/// Initializes a coordinator for navigating between `TokenRoute` objects.
|
||||
///
|
||||
/// - Parameter stackNavigator: The stack navigator that will be used to navigate between routes.
|
||||
/// - Returns: A coordinator that can navigate to a `TokenRoute`.
|
||||
///
|
||||
func makeTokenCoordinator(
|
||||
stackNavigator: StackNavigator
|
||||
) -> AnyCoordinator<TokenRoute, TokenEvent>
|
||||
}
|
||||
|
||||
extension DefaultAppModule: TokenModule {
|
||||
func makeTokenCoordinator(
|
||||
stackNavigator: StackNavigator
|
||||
) -> AnyCoordinator<TokenRoute, TokenEvent> {
|
||||
TokenCoordinator(
|
||||
module: self,
|
||||
services: services,
|
||||
stackNavigator: stackNavigator
|
||||
).asAnyCoordinator()
|
||||
}
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
// MARK: - ViewTokenItemState
|
||||
|
||||
// The state for viewing/adding/editing a token item
|
||||
protocol ViewTokenItemState: Sendable {
|
||||
// MARK: Properties
|
||||
|
||||
/// The TOTP key.
|
||||
var authenticatorKey: String? { get }
|
||||
|
||||
/// The TOTP key/code state.
|
||||
// var totpState: LoginTOTPState
|
||||
|
||||
/// The TOTP code model
|
||||
var totpCode: TOTPCodeModel? { get }
|
||||
}
|
||||
|
||||
//extension ViewTokenItemState {
|
||||
// var totpCode: TOTPCodeModel? {
|
||||
// totpState.codeModel
|
||||
// }
|
||||
//}
|
||||
@ -27,7 +27,10 @@ struct ItemListItemRowView: View {
|
||||
.accessibilityHidden(true)
|
||||
|
||||
HStack {
|
||||
totpCodeRow(store.state.item.name, store.state.item.totpCode)
|
||||
switch store.state.item.itemType {
|
||||
case let .totp(model):
|
||||
totpCodeRow(store.state.item.name, model.totpCode)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 9)
|
||||
}
|
||||
@ -91,6 +94,7 @@ struct ItemListItemRowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview {
|
||||
ItemListItemRowView(
|
||||
store: Store(
|
||||
@ -99,14 +103,15 @@ struct ItemListItemRowView: View {
|
||||
item: ItemListItem(
|
||||
id: UUID().uuidString,
|
||||
name: "Example",
|
||||
token: Token(
|
||||
name: "Example",
|
||||
authenticatorKey: "Example"
|
||||
)!,
|
||||
totpCode: TOTPCodeModel(
|
||||
code: "123456",
|
||||
codeGenerationDate: Date(),
|
||||
period: 30
|
||||
itemType: .totp(
|
||||
model: ItemListTotpItem(
|
||||
itemView: AuthenticatorItemView.fixture(),
|
||||
totpCode: TOTPCodeModel(
|
||||
code: "123456",
|
||||
codeGenerationDate: Date(),
|
||||
period: 30
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
hasDivider: true,
|
||||
@ -117,3 +122,4 @@ struct ItemListItemRowView: View {
|
||||
timeProvider: PreviewTimeProvider()
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user