diff --git a/Chronos.xcodeproj/project.pbxproj b/Chronos.xcodeproj/project.pbxproj index 434a123..a961591 100644 --- a/Chronos.xcodeproj/project.pbxproj +++ b/Chronos.xcodeproj/project.pbxproj @@ -34,6 +34,8 @@ 6B3F92AB2C1C7987004125A8 /* VaultService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B3F92AA2C1C7987004125A8 /* VaultService.swift */; }; 6B4B48F32BD7BB3C007D357D /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B4B48F22BD7BB3C007D357D /* Token.swift */; }; 6B5E41CE2BD790F80045DBC6 /* EncryptedToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B5E41CD2BD790F80045DBC6 /* EncryptedToken.swift */; }; + 6B65F8082C21C17200AC8606 /* VaultSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B65F8072C21C17200AC8606 /* VaultSelectionView.swift */; }; + 6B65F80B2C21C2EA00AC8606 /* VaultSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B65F80A2C21C2EA00AC8606 /* VaultSetupView.swift */; }; 6B66D5E72B526315006DB79D /* AddTokenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B66D5E62B526315006DB79D /* AddTokenView.swift */; }; 6B66D5ED2B526648006DB79D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 6B66D5EC2B526648006DB79D /* CodeScanner */; }; 6B66D5EF2B52BAC2006DB79D /* HOTPRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B66D5EE2B52BAC2006DB79D /* HOTPRowView.swift */; }; @@ -79,6 +81,8 @@ 6B3F92AA2C1C7987004125A8 /* VaultService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultService.swift; sourceTree = ""; }; 6B4B48F22BD7BB3C007D357D /* Token.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Token.swift; sourceTree = ""; }; 6B5E41CD2BD790F80045DBC6 /* EncryptedToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptedToken.swift; sourceTree = ""; }; + 6B65F8072C21C17200AC8606 /* VaultSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultSelectionView.swift; sourceTree = ""; }; + 6B65F80A2C21C2EA00AC8606 /* VaultSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultSetupView.swift; sourceTree = ""; }; 6B66D5E62B526315006DB79D /* AddTokenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTokenView.swift; sourceTree = ""; }; 6B66D5EE2B52BAC2006DB79D /* HOTPRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HOTPRowView.swift; sourceTree = ""; }; 6B7383E42B9C4230008E8867 /* Container.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Container.swift; sourceTree = ""; }; @@ -117,11 +121,12 @@ 6B19CA6E2B7A695200690390 /* Onboarding */ = { isa = PBXGroup; children = ( + 6B65F8062C21C15E00AC8606 /* Restore */, 6B19CA712B7A70B800690390 /* WelcomeView.swift */, 6B19CA732B7A769900690390 /* PasswordSetupView.swift */, - 6B842DD42BE33E2E00056F0F /* RestoreBackupView.swift */, 6B3C7A372BE764E70043FEBD /* StorageSetupView.swift */, 6BC3C3B42BA6B91E00B181B9 /* BiometricsSetupView.swift */, + 6B65F80A2C21C2EA00AC8606 /* VaultSetupView.swift */, ); path = Onboarding; sourceTree = ""; @@ -228,6 +233,15 @@ path = Database; sourceTree = ""; }; + 6B65F8062C21C15E00AC8606 /* Restore */ = { + isa = PBXGroup; + children = ( + 6B842DD42BE33E2E00056F0F /* RestoreBackupView.swift */, + 6B65F8072C21C17200AC8606 /* VaultSelectionView.swift */, + ); + path = Restore; + sourceTree = ""; + }; 6B66D5E52B525D1B006DB79D /* AddToken */ = { isa = PBXGroup; children = ( @@ -437,6 +451,7 @@ 6B3BB0E22B4ED19300DCEF0B /* TokensTab.swift in Sources */, 6B12B0A02C19DB7800E9ED2D /* ExportService.swift in Sources */, 6B3BB0E02B4ECE6F00DCEF0B /* TOTPRowView.swift in Sources */, + 6B65F80B2C21C2EA00AC8606 /* VaultSetupView.swift in Sources */, 6B5E41CE2BD790F80045DBC6 /* EncryptedToken.swift in Sources */, 6B4B48F32BD7BB3C007D357D /* Token.swift in Sources */, 6B842DD52BE33E2E00056F0F /* RestoreBackupView.swift in Sources */, @@ -445,6 +460,7 @@ 6B39629E2BF63F27000410B0 /* SwiftDataService.swift in Sources */, 6BD6D2012C11FEB4004512BF /* OTPService.swift in Sources */, 6B39629A2BF5E935000410B0 /* MainAppView.swift in Sources */, + 6B65F8082C21C17200AC8606 /* VaultSelectionView.swift in Sources */, 6B3962A02BF6423B000410B0 /* CryptoService.swift in Sources */, 6B27317A2B53E23800F30621 /* UpdateTokenView.swift in Sources */, 6B27317C2B53F0B200F30621 /* TokenRowView.swift in Sources */, @@ -607,7 +623,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = com.joeldavidw.Chronos; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -653,7 +669,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = com.joeldavidw.Chronos; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Chronos/App/AuthenticationView.swift b/Chronos/App/AuthenticationView.swift index 9c89fb4..aca938f 100644 --- a/Chronos/App/AuthenticationView.swift +++ b/Chronos/App/AuthenticationView.swift @@ -10,10 +10,11 @@ struct AuthenticationView: View { Group { if !stateOnboardingCompleted { WelcomeView() + // Defaults to a cloud container for SwiftUI on the first load. Allows SwiftData in SwiftUI to retrieve vaults from CloudKit. + .modelContainer(swiftDataService.getCloudModelContainer()) } else { PasswordLoginView() } } - .modelContainer(swiftDataService.getCloudModelContainer()) } } diff --git a/Chronos/App/Login/PasswordLoginView.swift b/Chronos/App/Login/PasswordLoginView.swift index ce1fc1e..c01ae41 100644 --- a/Chronos/App/Login/PasswordLoginView.swift +++ b/Chronos/App/Login/PasswordLoginView.swift @@ -1,6 +1,5 @@ import AlertKit import Factory -import SwiftData import SwiftUI struct PasswordLoginView: View { diff --git a/Chronos/App/Onboarding/PasswordSetupView.swift b/Chronos/App/Onboarding/PasswordSetupView.swift index 9bd794a..c180b93 100644 --- a/Chronos/App/Onboarding/PasswordSetupView.swift +++ b/Chronos/App/Onboarding/PasswordSetupView.swift @@ -13,6 +13,8 @@ struct PasswordSetupView: View { @FocusState private var focusedField: FocusedField? + let vaultName: String + let cryptoService = Container.shared.cryptoService() let stateService = Container.shared.stateService() let vaultService = Container.shared.vaultService() @@ -24,7 +26,7 @@ struct PasswordSetupView: View { .font(.system(size: 44)) .padding(.bottom, 16) - Text("Your master password is used to encrypt your data securely. Choose a memorable, random, and unique password with at least 10 characters.") + Text("Your master password is used to securely encrypt your tokens in a vault. Choose a memorable, random, and unique password with at least 10 characters.") .fixedSize(horizontal: false, vertical: true) .multilineTextAlignment(.center) @@ -100,10 +102,7 @@ extension PasswordSetupView { let chronosCrypto = await cryptoService.wrapMasterKeyWithUserPassword(password: Array(password.utf8)) - let vault = vaultService.createVault(chronosCrypto: chronosCrypto)! - stateService.setVaultId(vaultId: vault.vaultId!) - - nextBtnPressed = true + nextBtnPressed = vaultService.createVaultCrypto(vaultName: vaultName, chronosCrypto: chronosCrypto) } } diff --git a/Chronos/App/Onboarding/RestoreBackupView.swift b/Chronos/App/Onboarding/Restore/RestoreBackupView.swift similarity index 95% rename from Chronos/App/Onboarding/RestoreBackupView.swift rename to Chronos/App/Onboarding/Restore/RestoreBackupView.swift index 97e1141..f8ba0ee 100644 --- a/Chronos/App/Onboarding/RestoreBackupView.swift +++ b/Chronos/App/Onboarding/Restore/RestoreBackupView.swift @@ -60,9 +60,6 @@ struct RestoreBackupView: View { restoreBtnPressed = false if passwordVerified { - let vault = vaultService.getFirstVault(isRestore: true) - stateService.setVaultId(vaultId: vault!.vaultId!) - isICloudEnabled = true } else { passwordInvalid = true diff --git a/Chronos/App/Onboarding/Restore/VaultSelectionView.swift b/Chronos/App/Onboarding/Restore/VaultSelectionView.swift new file mode 100644 index 0000000..8e377be --- /dev/null +++ b/Chronos/App/Onboarding/Restore/VaultSelectionView.swift @@ -0,0 +1,67 @@ +import AlertKit +import Factory +import SwiftData +import SwiftUI + +struct VaultSelectionView: View { + @Query(sort: \Vault.createdAt) var vaults: [Vault] + + @State private var moveToNextScreen: Bool = false + + let cryptoService = Container.shared.cryptoService() + let vaultService = Container.shared.vaultService() + let stateService = Container.shared.stateService() + + let formatter = RelativeDateTimeFormatter() + + var body: some View { + VStack { + ScrollViewReader { _ in + List(vaults) { vault in + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(vault.name) + .fontWeight(.semibold) + + Text("Created \(formatter.localizedString(for: Date(timeIntervalSince1970: vault.createdAt!.timeIntervalSince1970), relativeTo: Date.now))") + .foregroundStyle(.gray) + .font(.subheadline) + } + + Spacer() + + VStack(alignment: .trailing) { + Text("\(vault.encryptedTokens?.count ?? 0)") + .font(.system(size: 20)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(.gray).opacity(0.6)) + .cornerRadius(8) + } + } + .contentShape(Rectangle()) + .onTapGesture { + guard let vaultId = vault.vaultId else { + AlertKitAPI.present( + title: "Unable to access vault", + icon: .error, + style: .iOS17AppleMusic, + haptic: .error + ) + return + } + + stateService.setVaultId(vaultId: vaultId) + moveToNextScreen = true + } + .padding(CGFloat(4)) + } + .listStyle(.plain) + } + } + .navigationDestination(isPresented: $moveToNextScreen) { + RestoreBackupView() + } + .navigationTitle("Vaults") + } +} diff --git a/Chronos/App/Onboarding/StorageSetupView.swift b/Chronos/App/Onboarding/StorageSetupView.swift index 742e011..331c4e6 100644 --- a/Chronos/App/Onboarding/StorageSetupView.swift +++ b/Chronos/App/Onboarding/StorageSetupView.swift @@ -1,4 +1,5 @@ import Factory +import SwiftData import SwiftUI struct StorageSetupView: View { @@ -57,7 +58,7 @@ struct StorageSetupView: View { .navigationTitle("Storage") .navigationBarTitleDisplayMode(.inline) .navigationDestination(isPresented: $nextBtnPressed) { - PasswordSetupView() + VaultSetupView() } .confirmationDialog("Vault Exists", isPresented: $showICloudOverwriteConfirmation, titleVisibility: .visible) { Button("Continue", role: .destructive, action: { diff --git a/Chronos/App/Onboarding/VaultSetupView.swift b/Chronos/App/Onboarding/VaultSetupView.swift new file mode 100644 index 0000000..cfae893 --- /dev/null +++ b/Chronos/App/Onboarding/VaultSetupView.swift @@ -0,0 +1,63 @@ +import Factory +import SwiftUI + +struct VaultSetupView: View { + let vaultService = Container.shared.vaultService() + + @State private var vaultName: String = "My Vault" + @State private var isCreatingVault: Bool = false + @State private var nextBtnPressed: Bool = false + + var body: some View { + VStack { + Image(systemName: "lock.shield") + .font(.system(size: 44)) + .padding(.bottom, 16) + + Text("A vault contains all of your Two-Factor Authentication (2FA) tokens and is secured with your own password.") + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.center) + + Text("Your vault name") + .padding(.top, 32) + + Group { + TextField("", text: $vaultName) + .multilineTextAlignment(.center) + .background(Color.clear) + } + .frame(height: 48) + .background(Color(.systemGray6)) + .cornerRadius(8) + + Spacer() + + Button { + nextBtnPressed = true + } label: { + if !isCreatingVault { + Text("Next") + .foregroundStyle(Color(red: 0.04, green: 0, blue: 0.11)) + .bold() + .frame(minWidth: 0, maxWidth: .infinity) + .frame(height: 32) + } else { + ProgressView() + .tint(Color(red: 0.04, green: 0, blue: 0.11)) + .frame(minWidth: 0, maxWidth: .infinity) + .frame(height: 32) + } + } + .padding(.top, 64) + .buttonStyle(.borderedProminent) + } + .padding(.vertical, 32) + .padding(.horizontal, 24) + .background(Color(red: 0.04, green: 0, blue: 0.11).ignoresSafeArea()) + .navigationTitle("Vault") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(isPresented: $nextBtnPressed) { + PasswordSetupView(vaultName: vaultName) + } + } +} diff --git a/Chronos/App/Onboarding/WelcomeView.swift b/Chronos/App/Onboarding/WelcomeView.swift index 650e9db..2981974 100644 --- a/Chronos/App/Onboarding/WelcomeView.swift +++ b/Chronos/App/Onboarding/WelcomeView.swift @@ -5,7 +5,7 @@ import SwiftUI struct WelcomeView: View { let swiftDataService = Container.shared.swiftDataService() - @Query var chronosCryptos: [ChronosCrypto] + @Query var vaults: [Vault] @State private var getStartedPressed: Bool = false @State private var restorePressed: Bool = false @@ -40,7 +40,7 @@ struct WelcomeView: View { .frame(height: 32) } .padding(.top, 4) - .disabled(chronosCryptos.isEmpty) + .disabled(vaults.isEmpty) .buttonStyle(.borderless) .padding(.bottom, 32) } @@ -52,7 +52,11 @@ struct WelcomeView: View { StorageSetupView() } .navigationDestination(isPresented: $restorePressed) { - RestoreBackupView() + if vaults.count == 1 { + RestoreBackupView() + } else if vaults.count > 1 { + VaultSelectionView() + } } } .onAppear(perform: { diff --git a/Chronos/Database/Vault.swift b/Chronos/Database/Vault.swift index 631e92d..22ceadb 100644 --- a/Chronos/Database/Vault.swift +++ b/Chronos/Database/Vault.swift @@ -5,6 +5,7 @@ import SwiftData class Vault { var vaultId: UUID? var createdAt: Date? + var name: String = "My Vault" @Relationship(deleteRule: .cascade) var chronosCryptos: [ChronosCrypto]? = [] @@ -12,8 +13,9 @@ class Vault { @Relationship(deleteRule: .cascade) var encryptedTokens: [EncryptedToken]? = [] - init(vaultId: UUID, createdAt: Date) { + init(vaultId: UUID, name: String, createdAt: Date) { self.vaultId = vaultId + self.name = name self.createdAt = createdAt } } diff --git a/Chronos/Services/CryptoService.swift b/Chronos/Services/CryptoService.swift index c9f88bc..3487f83 100644 --- a/Chronos/Services/CryptoService.swift +++ b/Chronos/Services/CryptoService.swift @@ -41,8 +41,8 @@ public class CryptoService { } } - func unwrapMasterKeyWithUserPassword(password: [UInt8], isRestore: Bool = false) async -> Bool { - guard let vault = vaultService.getFirstVault(isRestore: isRestore) else { + func unwrapMasterKeyWithUserPassword(password: [UInt8], isRestore _: Bool = false) async -> Bool { + guard let vault = vaultService.getVaultFromCloudContainer() else { return false } diff --git a/Chronos/Services/SwiftDataService.swift b/Chronos/Services/SwiftDataService.swift index 6128872..733c7f7 100644 --- a/Chronos/Services/SwiftDataService.swift +++ b/Chronos/Services/SwiftDataService.swift @@ -65,9 +65,9 @@ extension SwiftDataService { func doesICloudBackupExist() -> Bool { let container = getCloudModelContainer() let context = ModelContext(container) - let cryptoArr = try! context.fetch(FetchDescriptor()) + let vaults = try! context.fetch(FetchDescriptor()) - return !cryptoArr.isEmpty + return !vaults.isEmpty } func deleteLocallyPersistedChronosData() { diff --git a/Chronos/Services/VaultService.swift b/Chronos/Services/VaultService.swift index ccd6289..6a784f7 100644 --- a/Chronos/Services/VaultService.swift +++ b/Chronos/Services/VaultService.swift @@ -9,67 +9,77 @@ public class VaultService { private let stateService = Container.shared.stateService() private let swiftDataService = Container.shared.swiftDataService() - func createVault(chronosCrypto: ChronosCrypto) -> Vault? { + func createVaultCrypto(vaultName: String, chronosCrypto: ChronosCrypto) -> Bool { let context = ModelContext(swiftDataService.getModelContainer()) - - let vault = Vault(vaultId: UUID(), createdAt: Date()) - + let vault = Vault(vaultId: UUID(), name: vaultName, createdAt: Date()) vault.chronosCryptos = [chronosCrypto] - context.insert(vault) do { try context.save() - logger.info("Successfully saved vault") + logger.info("Successfully saved vault with chronosCrypto") - return vault + stateService.setVaultId(vaultId: vault.vaultId!) + return true } catch { logger.error("Failed to save context: \(error.localizedDescription)") - return nil + return false } } - // TODO(joeldavidw): Selects first vault for now. Selection page should be shown if there are more than one vault. - func getFirstVault(isRestore: Bool) -> Vault? { - let context = ModelContext(swiftDataService.getModelContainer(isRestore: isRestore)) - - guard let vaultArr = try? context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)])) else { - logger.error("No vaults found") + func getVaultFromCloudContainer() -> Vault? { + guard let vaultId: UUID = stateService.getVaultId() else { + logger.error("vaultId not found in AppStorage") return nil } - guard let vault = vaultArr.first else { - logger.error("Empty vaultArr") + let predicate = #Predicate { $0.vaultId == vaultId } + let context = ModelContext(swiftDataService.getCloudModelContainer()) + + do { + let vaultArr = try context.fetch(FetchDescriptor(predicate: predicate)) + if let vault = vaultArr.first { + logger.info("Returning vault \(vault.name)") + return vault + } else { + logger.error("No vaults found with the given vaultId") + return nil + } + } catch { + logger.error("Failed to fetch vaults: \(error.localizedDescription)") return nil } - - return vault } +} - func getVault() -> Vault? { +extension VaultService { + private func getVault(context: ModelContext) -> Vault? { guard let vaultId: UUID = stateService.getVaultId() else { logger.error("vaultId not found in AppStorage") return nil } - let context = ModelContext(swiftDataService.getModelContainer()) - let predicate = #Predicate { $0.vaultId == vaultId } - guard let vaultArr = try? context.fetch(FetchDescriptor(predicate: predicate)) else { - logger.error("No vaults found") + do { + let vaultArr = try context.fetch(FetchDescriptor(predicate: predicate)) + if let vault = vaultArr.first { + logger.info("Returning vault \(vault.name)") + return vault + } else { + logger.error("No vaults found with the given vaultId") + return nil + } + } catch { + logger.error("Failed to fetch vaults: \(error.localizedDescription)") return nil } - - return vaultArr.first } -} -extension VaultService { func insertEncryptedToken(_ encryptedToken: EncryptedToken) { let context = ModelContext(swiftDataService.getModelContainer()) - let vault = getVault()! + let vault = getVault(context: context)! vault.encryptedTokens?.append(encryptedToken) @@ -86,7 +96,7 @@ extension VaultService { func deleteEncryptedToken(_ encryptedToken: EncryptedToken) { let context = ModelContext(swiftDataService.getModelContainer()) - let vault = getVault()! + let vault = getVault(context: context)! vault.encryptedTokens?.append(encryptedToken)