diff --git a/apps/mobile-app/ios/.swiftlint.yml b/apps/mobile-app/ios/.swiftlint.yml index 82debeb8e..c3eec9928 100644 --- a/apps/mobile-app/ios/.swiftlint.yml +++ b/apps/mobile-app/ios/.swiftlint.yml @@ -31,6 +31,10 @@ line_length: warning: 200 error: 250 +function_body_length: + warning: 150 + error: 200 + comment_spacing: # fixed invalid configuration severity: error diff --git a/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Passkey.swift b/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Passkey.swift index 904f99bfe..fe4e09ae0 100644 --- a/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Passkey.swift +++ b/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Passkey.swift @@ -233,6 +233,26 @@ extension CredentialProviderViewController: PasskeyProviderDelegate { let capturedEnablePrf = enablePrf let capturedPrfInputs = prfInputs + // Query for existing passkeys with this rpId and userName + var existingPasskeys: [PasskeyWithCredentialInfo] = [] + do { + let results = try vaultStore.getPasskeysWithCredentialInfo(forRpId: rpId, userName: userName, userId: userId) + existingPasskeys = results.map { result in + PasskeyWithCredentialInfo( + id: result.passkey.id, + displayName: result.passkey.displayName, + serviceName: result.serviceName, + username: result.username, + rpId: result.passkey.rpId, + userId: result.passkey.userHandle + ) + } + print("PasskeyRegistration: Found \(existingPasskeys.count) existing passkeys for rpId: \(rpId)") + } catch { + print("PasskeyRegistration: Failed to query existing passkeys: \(error)") + // Continue with empty list + } + // Create view model with handlers // Use lazy initialization to avoid capturing viewModel before it's assigned var viewModel: PasskeyRegistrationViewModel! @@ -242,6 +262,7 @@ extension CredentialProviderViewController: PasskeyProviderDelegate { origin: "https://\(rpId)", userName: userName, userDisplayName: userDisplayName, + existingPasskeys: existingPasskeys, completionHandler: { [weak self] success in guard let self = self else { return } @@ -319,7 +340,6 @@ extension CredentialProviderViewController: PasskeyProviderDelegate { try _ = await vaultStore.syncVault(using: webApiService) // Step 2: Extract favicon from service URL - viewModel.setLoading(true, message: NSLocalizedString("creating_passkey", comment: "Syncing vault...")) var logo: Data? do { logo = try await webApiService.extractFavicon(url: "https://\(rpId)") @@ -328,8 +348,6 @@ extension CredentialProviderViewController: PasskeyProviderDelegate { } // Step 3: Create passkey credentials - viewModel.setLoading(true, message: NSLocalizedString("creating_passkey", comment: "Creating passkey...")) - // Generate new credential ID (UUID that will be used as the passkey ID) let passkeyId = UUID() let credentialId = try PasskeyHelper.guidToBytes(passkeyId.uuidString) @@ -369,15 +387,26 @@ extension CredentialProviderViewController: PasskeyProviderDelegate { // Begin transaction try vaultStore.beginTransaction() - // Store credential with passkey and logo in database - // Use viewModel.displayName as the title (Service.name) - _ = try vaultStore.createCredentialWithPasskey( - rpId: rpId, - userName: userName, - displayName: viewModel.displayName, - passkey: passkey, - logo: logo - ) + // Check if we're replacing an existing passkey + if let oldPasskeyId = viewModel.selectedPasskeyToReplace { + // Replace existing passkey + try vaultStore.replacePasskey( + oldPasskeyId: oldPasskeyId, + newPasskey: passkey, + displayName: viewModel.displayName, + logo: logo + ) + } else { + // Store credential with passkey and logo in database + // Use viewModel.displayName as the title (Service.name) + _ = try vaultStore.createCredentialWithPasskey( + rpId: rpId, + userName: userName, + displayName: viewModel.displayName, + passkey: passkey, + logo: logo + ) + } // Commit transaction to persist the data try vaultStore.commitTransaction() diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Passkey.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Passkey.swift index f47785e1e..e162d013e 100644 --- a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Passkey.swift +++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Passkey.swift @@ -108,6 +108,61 @@ extension VaultStore { return passkeys } + /** + * Get passkeys with credential info for a specific rpId and optionally username + * Used for finding existing passkeys that might be replaced during registration + */ + public func getPasskeysWithCredentialInfo(forRpId rpId: String, userName: String? = nil, userId: Data? = nil) throws -> [(passkey: Passkey, serviceName: String?, username: String?)] { + guard let dbConn = dbConnection else { + throw VaultStoreError.vaultNotUnlocked + } + + print("VaultStore+Passkey: Looking up passkeys with credential info for rpId: \(rpId), userName: \(userName ?? "nil")") + + // Join passkeys with credentials and services to get display info + let passkeysTable = Table("Passkeys") + let credentialsTable = Table("Credentials") + let servicesTable = Table("Services") + + let query = passkeysTable + .select(passkeysTable[*], credentialsTable[Expression("Username")], servicesTable[Expression("Name")]) + .join(credentialsTable, on: passkeysTable[Expression("CredentialId")] == credentialsTable[Expression("Id")]) + .join(servicesTable, on: credentialsTable[Expression("ServiceId")] == servicesTable[Expression("Id")]) + .filter(passkeysTable[Expression("RpId")] == rpId) + .filter(passkeysTable[Expression("IsDeleted")] == 0) + .filter(credentialsTable[Expression("IsDeleted")] == 0) + .order(passkeysTable[Expression("CreatedAt")].desc) + + var results: [(passkey: Passkey, serviceName: String?, username: String?)] = [] + + for row in try dbConn.prepare(query) { + if let passkey = try parsePasskeyRow(row) { + let credUsername = try? row.get(Expression("Username")) + let serviceName = try? row.get(Expression("Name")) + + // Filter by username or userId if provided + var matches = true + if let userName = userName { + if credUsername != userName { + matches = false + } + } + if let userId = userId { + if passkey.userHandle != userId { + matches = false + } + } + + if matches { + results.append((passkey: passkey, serviceName: serviceName, username: credUsername)) + } + } + } + + print("VaultStore+Passkey: Found \(results.count) matching passkeys with credential info") + return results + } + /** * Get all credentials that have passkeys attached */ @@ -495,6 +550,91 @@ extension VaultStore { print("VaultStore+Passkey: Successfully inserted passkey") } + /** + * Replace an existing passkey with a new one + * This deletes the old passkey and creates a new one with the same credential + */ + public func replacePasskey(oldPasskeyId: UUID, newPasskey: Passkey, displayName: String, logo: Data? = nil) throws { + guard let dbConn = dbConnection else { + throw VaultStoreError.vaultNotUnlocked + } + + print("VaultStore+Passkey: Replacing passkey \(oldPasskeyId.uuidString) with new passkey \(newPasskey.id.uuidString)") + + // Get the old passkey to find its credential + let oldPasskeyQuery = Self.passkeysTable + .filter(Self.colId == oldPasskeyId.uuidString) + .filter(Self.colIsDeleted == 0) + .limit(1) + + guard let oldPasskeyRow = try dbConn.pluck(oldPasskeyQuery), + let oldPasskey = try parsePasskeyRow(oldPasskeyRow) else { + throw VaultStoreError.databaseError("Passkey not found") + } + + let credentialId = oldPasskey.parentCredentialId + let now = Date() + let timestamp = formatDateForDatabase(now) + + // Update the credential's service with new logo if provided + if let logo = logo { + let logoBlob = Blob(bytes: [UInt8](logo)) + let credentialsTable = Table("Credentials") + let servicesTable = Table("Services") + + // Get the service ID from the credential + let credQuery = credentialsTable + .filter(Expression("Id") == credentialId.uuidString) + .limit(1) + + if let credRow = try dbConn.pluck(credQuery) { + let serviceId = try credRow.get(Expression("ServiceId")) + + // Update the service with new logo and displayName + let serviceUpdate = servicesTable + .filter(Expression("Id") == serviceId) + .update( + Expression("Logo") <- logoBlob, + Expression("Name") <- displayName, + Expression("UpdatedAt") <- timestamp + ) + + try dbConn.run(serviceUpdate) + } + } + + // Delete the old passkey + let deleteQuery = Self.passkeysTable + .filter(Self.colId == oldPasskeyId.uuidString) + .update( + Self.colIsDeleted <- 1, + Self.colUpdatedAt <- timestamp + ) + + try dbConn.run(deleteQuery) + + // Create the new passkey with the same credential ID + var updatedPasskey = newPasskey + updatedPasskey = Passkey( + id: newPasskey.id, + parentCredentialId: credentialId, // Use the old credential ID + rpId: newPasskey.rpId, + userHandle: newPasskey.userHandle, + userName: newPasskey.userName, + publicKey: newPasskey.publicKey, + privateKey: newPasskey.privateKey, + prfKey: newPasskey.prfKey, + displayName: displayName, + createdAt: now, + updatedAt: now, + isDeleted: false + ) + + try insertPasskey(updatedPasskey) + + print("VaultStore+Passkey: Successfully replaced passkey") + } + /** * Parse a date string to a Date object for use in queries. */ diff --git a/apps/mobile-app/ios/VaultUI/Views/Passkey/Components/CompactInfoRow.swift b/apps/mobile-app/ios/VaultUI/Views/Passkey/Components/CompactInfoRow.swift new file mode 100644 index 000000000..e193398e1 --- /dev/null +++ b/apps/mobile-app/ios/VaultUI/Views/Passkey/Components/CompactInfoRow.swift @@ -0,0 +1,31 @@ +import SwiftUI + +/// Compact info row component for displaying read-only information +struct CompactInfoRow: View { + let label: String + let value: String + let icon: String + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(spacing: 8) { + Image(systemName: icon) + .foregroundColor(ColorConstants.Light.primary) + .font(.caption) + .frame(width: 16) + + Text(label + ":") + .font(.caption) + .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted) + + Text(value) + .font(.caption) + .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text) + + Spacer() + } + .padding(.vertical, 4) + .padding(.horizontal, 8) + } +} diff --git a/apps/mobile-app/ios/VaultUI/Views/Passkey/Components/ExistingPasskeyRow.swift b/apps/mobile-app/ios/VaultUI/Views/Passkey/Components/ExistingPasskeyRow.swift new file mode 100644 index 000000000..3fe1f7ec1 --- /dev/null +++ b/apps/mobile-app/ios/VaultUI/Views/Passkey/Components/ExistingPasskeyRow.swift @@ -0,0 +1,67 @@ +import SwiftUI +import VaultModels + +/// Row displaying an existing passkey that can be selected for replacement +struct ExistingPasskeyRow: View { + let passkey: PasskeyWithCredentialInfo + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(spacing: 12) { + // Passkey icon + Image(systemName: "key.fill") + .foregroundColor(ColorConstants.Light.primary) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 4) { + Text(passkey.displayName) + .font(.body) + .fontWeight(.medium) + .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text) + + if let serviceName = passkey.serviceName { + Text(serviceName) + .font(.caption) + .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted) + } + + if let username = passkey.username { + Text(username) + .font(.caption) + .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted) + } + } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted) + .font(.caption) + } + .padding() + .background( + (colorScheme == .dark ? ColorConstants.Dark.accentBackground : ColorConstants.Light.accentBackground) + ) + .cornerRadius(8) + } +} + +/// Helper struct to pass passkey data with credential info +public struct PasskeyWithCredentialInfo: Identifiable { + public let id: UUID + public let displayName: String + public let serviceName: String? + public let username: String? + public let rpId: String + public let userId: Data? + + public init(id: UUID, displayName: String, serviceName: String?, username: String?, rpId: String, userId: Data?) { + self.id = id + self.displayName = displayName + self.serviceName = serviceName + self.username = username + self.rpId = rpId + self.userId = userId + } +} diff --git a/apps/mobile-app/ios/VaultUI/Views/Passkey/Components/PasskeyRegistrationHeader.swift b/apps/mobile-app/ios/VaultUI/Views/Passkey/Components/PasskeyRegistrationHeader.swift new file mode 100644 index 000000000..4bdb93a2a --- /dev/null +++ b/apps/mobile-app/ios/VaultUI/Views/Passkey/Components/PasskeyRegistrationHeader.swift @@ -0,0 +1,31 @@ +import SwiftUI + +private let locBundle = Bundle.vaultUI + +/// Header component for passkey registration view +struct PasskeyRegistrationHeader: View { + let rpId: String + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(spacing: 12) { + Image("Logo", bundle: Bundle(for: PasskeyRegistrationViewModel.self)) + .resizable() + .scaledToFit() + .frame(width: 80, height: 80) + .padding(.top, 20) + + Text(String(localized: "create_passkey_title", bundle: locBundle)) + .font(.title) + .fontWeight(.bold) + .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text) + + Text(String(localized: "create_passkey_subtitle", bundle: locBundle)) + .font(.subheadline) + .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .padding(.bottom, 20) + } +} diff --git a/apps/mobile-app/ios/VaultUI/Views/Passkey/Components/PasskeyTitleInput.swift b/apps/mobile-app/ios/VaultUI/Views/Passkey/Components/PasskeyTitleInput.swift new file mode 100644 index 000000000..3fa252188 --- /dev/null +++ b/apps/mobile-app/ios/VaultUI/Views/Passkey/Components/PasskeyTitleInput.swift @@ -0,0 +1,32 @@ +import SwiftUI + +private let locBundle = Bundle.vaultUI + +/// Editable title input field for passkey registration +struct PasskeyTitleInput: View { + @Binding var title: String + let focusState: FocusState.Binding + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "title", bundle: locBundle)) + .font(.caption) + .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted) + .padding(.horizontal) + + TextField("", text: $title) + .textFieldStyle(PlainTextFieldStyle()) + .font(.body) + .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text) + .padding() + .background( + (colorScheme == .dark ? ColorConstants.Dark.accentBackground : ColorConstants.Light.accentBackground) + ) + .cornerRadius(8) + .padding(.horizontal) + .focused(focusState) + } + .padding(.bottom, 8) + } +} diff --git a/apps/mobile-app/ios/VaultUI/Views/Passkey/PasskeyFormView.swift b/apps/mobile-app/ios/VaultUI/Views/Passkey/PasskeyFormView.swift new file mode 100644 index 000000000..1cb11a798 --- /dev/null +++ b/apps/mobile-app/ios/VaultUI/Views/Passkey/PasskeyFormView.swift @@ -0,0 +1,179 @@ +import SwiftUI + +private let locBundle = Bundle.vaultUI + +/// Form view for creating or replacing a passkey +struct PasskeyFormView: View { + @ObservedObject var viewModel: PasskeyRegistrationViewModel + let isReplaceMode: Bool + let replacingPasskeyId: UUID? + + @Environment(\.colorScheme) private var colorScheme + @FocusState private var isTitleFocused: Bool + + var replacingPasskey: PasskeyWithCredentialInfo? { + guard let id = replacingPasskeyId else { return nil } + return viewModel.existingPasskeys.first(where: { $0.id == id }) + } + + var body: some View { + ZStack { + VStack(spacing: 16) { + ScrollView { + VStack(spacing: 16) { + // Warning and explanation if replacing + if isReplaceMode, let passkey = replacingPasskey { + VStack(spacing: 12) { + // Explanation text + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "replace_passkey_explanation", bundle: locBundle)) + .font(.subheadline) + .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 4) + } + .padding(.horizontal) + .padding(.top, 8) + } + + // Editable title field + PasskeyTitleInput(title: $viewModel.displayName, focusState: $isTitleFocused) + .padding(.top, isReplaceMode ? 8 : 8) + + // Request details (compact, read-only) + VStack(spacing: 8) { + CompactInfoRow( + label: String(localized: "website", bundle: locBundle), + value: viewModel.rpId, + icon: "globe" + ) + + if let userName = viewModel.userName { + CompactInfoRow( + label: String(localized: "username", bundle: locBundle), + value: userName, + icon: "person.fill" + ) + } + } + .padding(.horizontal) + } + } + + // Action button + Button(action: { + viewModel.createPasskey() + }, label: { + HStack { + Image(systemName: "key.fill") + Text(isReplaceMode + ? String(localized: "confirm_replace", bundle: locBundle) + : String(localized: "create_passkey_button_confirm", bundle: locBundle)) + } + .padding() + .frame(maxWidth: .infinity) + .background(ColorConstants.Light.primary) + .foregroundColor(.white) + .cornerRadius(8) + }) + .padding(.horizontal) + .padding(.bottom, 20) + } + .opacity(viewModel.isLoading ? 0.3 : 1.0) + .disabled(viewModel.isLoading) + + // Loading overlay + if viewModel.isLoading { + LoadingOverlayView(message: viewModel.loadingMessage) + } + } + .navigationTitle(isReplaceMode + ? String(localized: "replace_passkey_title", bundle: locBundle) + : String(localized: "create_passkey_title", bundle: locBundle)) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + // Auto-focus the title field + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + isTitleFocused = true + } + } + } +} + +/// Loading overlay component with AliasVault branding +private struct LoadingOverlayView: View { + let message: String + @Environment(\.colorScheme) private var colorScheme + @State private var animatingDots: [Bool] = [false, false, false, false] + @State private var textDots = "" + @State private var timer: Timer? + + var body: some View { + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 0) { + // AliasVault logo animation - four pulsing dots + HStack(spacing: 10) { + ForEach(0..<4) { index in + Circle() + .fill(ColorConstants.Light.tertiary) + .frame(width: 8, height: 8) + .opacity(animatingDots[index] ? 1.0 : 0.3) + .animation( + Animation.easeInOut(duration: 0.7) + .repeatForever(autoreverses: true) + .delay(Double(index) * 0.2), + value: animatingDots[index] + ) + } + } + .padding(12) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(colorScheme == .dark ? Color.clear : Color.white) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(ColorConstants.Light.tertiary, lineWidth: 5) + ) + .shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1) + ) + + // Loading message with animated dots + if !message.isEmpty { + Text(message + textDots) + .font(.body) + .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text) + .multilineTextAlignment(.center) + .padding(.horizontal) + .padding(.top, 16) + } + } + .padding(20) + + Spacer() + } + .onAppear { + // Start dot animations + for index in 0..<4 { + animatingDots[index] = true + } + + // Start text dots animation + let textTimer = Timer.scheduledTimer(withTimeInterval: 0.4, repeats: true) { _ in + if textDots.count >= 3 { + textDots = "" + } else { + textDots += "." + } + } + timer = textTimer + } + .onDisappear { + timer?.invalidate() + timer = nil + } + } +} diff --git a/apps/mobile-app/ios/VaultUI/Views/Passkey/PasskeyNavigationDestination.swift b/apps/mobile-app/ios/VaultUI/Views/Passkey/PasskeyNavigationDestination.swift new file mode 100644 index 000000000..02111e4b5 --- /dev/null +++ b/apps/mobile-app/ios/VaultUI/Views/Passkey/PasskeyNavigationDestination.swift @@ -0,0 +1,7 @@ +import Foundation + +/// Navigation destinations for passkey registration flow +public enum PasskeyNavigationDestination: Hashable { + case createNew + case replace(UUID) +} diff --git a/apps/mobile-app/ios/VaultUI/Views/Passkey/PasskeyRegistrationView.swift b/apps/mobile-app/ios/VaultUI/Views/Passkey/PasskeyRegistrationView.swift new file mode 100644 index 000000000..f8bfff3cb --- /dev/null +++ b/apps/mobile-app/ios/VaultUI/Views/Passkey/PasskeyRegistrationView.swift @@ -0,0 +1,440 @@ +import SwiftUI +import AuthenticationServices +import VaultModels + +private let locBundle = Bundle.vaultUI + +/// Passkey registration view for the autofill extension +public struct PasskeyRegistrationView: View { + @ObservedObject public var viewModel: PasskeyRegistrationViewModel + @State private var navigationPath = NavigationPath() + + @Environment(\.colorScheme) private var colorScheme + + public init(viewModel: PasskeyRegistrationViewModel) { + self._viewModel = ObservedObject(wrappedValue: viewModel) + } + + public var body: some View { + NavigationStack(path: $navigationPath) { + ZStack { + (colorScheme == .dark ? ColorConstants.Dark.background : ColorConstants.Light.background) + .ignoresSafeArea() + + // Main content + ScrollView { + VStack(spacing: 24) { + // Header + PasskeyRegistrationHeader(rpId: viewModel.rpId) + + // Show selection or form based on existing passkeys + if viewModel.existingPasskeys.isEmpty { + // Go directly to create form + createFormContent + } else { + // Show selection view + selectionContent + } + } + } + .opacity(viewModel.isLoading ? 0.3 : 1.0) + .disabled(viewModel.isLoading) + + // Loading overlay + if viewModel.isLoading { + LoadingOverlayView(message: viewModel.loadingMessage) + } + } + .navigationBarHidden(viewModel.existingPasskeys.isEmpty) + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(for: PasskeyNavigationDestination.self) { destination in + destinationView(for: destination) + } + } + } + + // MARK: - Selection Content (Inline) + + private var selectionContent: some View { + VStack(spacing: 16) { + // Create new button + Button(action: { + viewModel.handleCreateNew() + navigationPath.append(PasskeyNavigationDestination.createNew) + }, label: { + HStack { + Image(systemName: "key.fill") + Text(String(localized: "create_new_passkey", bundle: locBundle)) + } + .padding() + .frame(maxWidth: .infinity) + .background(ColorConstants.Light.primary) + .foregroundColor(.white) + .cornerRadius(8) + }) + + // Divider + HStack { + Rectangle() + .fill(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted) + .frame(height: 1) + Text(String(localized: "or", bundle: locBundle)) + .font(.caption) + .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted) + Rectangle() + .fill(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted) + .frame(height: 1) + } + .padding(.horizontal) + + // Existing passkeys list + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "select_passkey_to_replace", bundle: locBundle)) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text) + .padding(.horizontal) + + ScrollView { + VStack(spacing: 8) { + ForEach(viewModel.existingPasskeys) { passkeyInfo in + Button(action: { + viewModel.handleSelectReplace(passkeyId: passkeyInfo.id) + navigationPath.append(PasskeyNavigationDestination.replace(passkeyInfo.id)) + }, label: { + ExistingPasskeyRow(passkey: passkeyInfo) + }) + .buttonStyle(PlainButtonStyle()) + } + } + } + .frame(maxHeight: 200) + } + + Spacer() + + // Cancel button + Button(action: { + viewModel.cancel() + }, label: { + Text(String(localized: "cancel", bundle: locBundle)) + .padding() + .frame(maxWidth: .infinity) + .foregroundColor(ColorConstants.Light.primary) + }) + } + .padding(.horizontal) + .padding(.bottom, 20) + } + + // MARK: - Create Form Content (Inline for no existing passkeys) + + @FocusState private var isTitleFocused: Bool + + private var createFormContent: some View { + VStack(spacing: 16) { + // Editable title field + PasskeyTitleInput(title: $viewModel.displayName, focusState: $isTitleFocused) + + // Request details (compact, read-only) + VStack(spacing: 8) { + CompactInfoRow( + label: String(localized: "website", bundle: locBundle), + value: viewModel.rpId, + icon: "globe" + ) + + if let userName = viewModel.userName { + CompactInfoRow( + label: String(localized: "username", bundle: locBundle), + value: userName, + icon: "person.fill" + ) + } + } + .padding(.horizontal) + + Spacer() + + // Action buttons + VStack(spacing: 12) { + Button(action: { + viewModel.createPasskey() + }, label: { + HStack { + Image(systemName: "key.fill") + Text(String(localized: "create_passkey_button_confirm", bundle: locBundle)) + } + .padding() + .frame(maxWidth: .infinity) + .background(ColorConstants.Light.primary) + .foregroundColor(.white) + .cornerRadius(8) + }) + + Button(action: { + viewModel.cancel() + }, label: { + Text(String(localized: "cancel", bundle: locBundle)) + .padding() + .frame(maxWidth: .infinity) + .foregroundColor(ColorConstants.Light.primary) + }) + } + .padding(.horizontal) + .padding(.bottom, 20) + } + .onAppear { + // Auto-focus when appearing + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + isTitleFocused = true + } + } + } + + // MARK: - Navigation Destination + + @ViewBuilder + private func destinationView(for destination: PasskeyNavigationDestination) -> some View { + switch destination { + case .createNew: + PasskeyFormView( + viewModel: viewModel, + isReplaceMode: false, + replacingPasskeyId: nil + ) + case .replace(let passkeyId): + PasskeyFormView( + viewModel: viewModel, + isReplaceMode: true, + replacingPasskeyId: passkeyId + ) + } + } +} + +/// Loading overlay component with AliasVault branding +private struct LoadingOverlayView: View { + let message: String + @Environment(\.colorScheme) private var colorScheme + @State private var animatingDots: [Bool] = [false, false, false, false] + @State private var textDots = "" + @State private var timer: Timer? + + var body: some View { + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 0) { + // AliasVault logo animation - four pulsing dots + HStack(spacing: 10) { + ForEach(0..<4) { index in + Circle() + .fill(ColorConstants.Light.tertiary) + .frame(width: 8, height: 8) + .opacity(animatingDots[index] ? 1.0 : 0.3) + .animation( + Animation.easeInOut(duration: 0.7) + .repeatForever(autoreverses: true) + .delay(Double(index) * 0.2), + value: animatingDots[index] + ) + } + } + .padding(12) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(colorScheme == .dark ? Color.clear : Color.white) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(ColorConstants.Light.tertiary, lineWidth: 5) + ) + .shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1) + ) + + // Loading message with animated dots + if !message.isEmpty { + Text(message + textDots) + .font(.body) + .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text) + .multilineTextAlignment(.center) + .padding(.horizontal) + .padding(.top, 16) + } + } + .padding(20) + + Spacer() + } + .onAppear { + // Start dot animations + for index in 0..<4 { + animatingDots[index] = true + } + + // Start text dots animation + let textTimer = Timer.scheduledTimer(withTimeInterval: 0.4, repeats: true) { _ in + if textDots.count >= 3 { + textDots = "" + } else { + textDots += "." + } + } + timer = textTimer + } + .onDisappear { + timer?.invalidate() + timer = nil + } + } +} + +/// View model for passkey registration +public class PasskeyRegistrationViewModel: ObservableObject { + @Published public var requestId: String + @Published public var rpId: String + @Published public var origin: String + @Published public var userName: String? + @Published public var userDisplayName: String? + @Published public var displayName: String // Editable title that defaults to rpId + @Published public var isLoading: Bool = false + @Published public var loadingMessage: String = "" + @Published public var existingPasskeys: [PasskeyWithCredentialInfo] = [] + @Published public var selectedPasskeyToReplace: UUID? + + private let completionHandler: (Bool) -> Void + private let cancelHandler: () -> Void + + public init( + requestId: String, + rpId: String, + origin: String, + userName: String? = nil, + userDisplayName: String? = nil, + existingPasskeys: [PasskeyWithCredentialInfo] = [], + completionHandler: @escaping (Bool) -> Void, + cancelHandler: @escaping () -> Void + ) { + self.requestId = requestId + self.rpId = rpId + self.origin = origin + self.userName = userName + self.userDisplayName = userDisplayName + // Initialize displayName to rpId by default + self.displayName = rpId + self.existingPasskeys = existingPasskeys + self.completionHandler = completionHandler + self.cancelHandler = cancelHandler + } + + /// Update loading state (called from main thread) + @MainActor + public func setLoading(_ loading: Bool, message: String = "") { + self.isLoading = loading + self.loadingMessage = message + } + + public func handleCreateNew() { + selectedPasskeyToReplace = nil + displayName = rpId + } + + public func handleSelectReplace(passkeyId: UUID) { + selectedPasskeyToReplace = passkeyId + // Pre-fill display name with the existing passkey's name + if let passkey = existingPasskeys.first(where: { $0.id == passkeyId }) { + displayName = passkey.displayName + } + } + + public func createPasskey() { + // Trigger passkey creation in Swift + print("PasskeyRegistration: Create passkey button clicked, replace mode: \(selectedPasskeyToReplace != nil)") + completionHandler(true) + } + + public func cancel() { + cancelHandler() + } +} + +// MARK: - Previews +#if DEBUG +#Preview("Light Mode - No Existing Passkeys") { + PasskeyRegistrationView( + viewModel: PasskeyRegistrationViewModel( + requestId: "12345678-1234-1234-1234-123456789012", + rpId: "example.com", + origin: "https://example.com", + userName: "user@example.com", + userDisplayName: "John Doe", + existingPasskeys: [], + completionHandler: { success in + print("Create completed with success: \(success)") + }, + cancelHandler: { + print("Cancel tapped") + } + ) + ) + .preferredColorScheme(.light) + .environment(\.locale, Locale(identifier: "en")) +} + +#Preview("Dark Mode - With Existing Passkeys") { + PasskeyRegistrationView( + viewModel: PasskeyRegistrationViewModel( + requestId: "12345678-1234-1234-1234-123456789012", + rpId: "example.com", + origin: "https://example.com", + userName: "user@example.com", + userDisplayName: "John Doe", + existingPasskeys: [ + PasskeyWithCredentialInfo( + id: UUID(), + displayName: "My Example Passkey", + serviceName: "Example Service", + username: "user@example.com", + rpId: "example.com", + userId: nil + ), + PasskeyWithCredentialInfo( + id: UUID(), + displayName: "Work Account", + serviceName: "Example Service", + username: "user@example.com", + rpId: "example.com", + userId: nil + ) + ], + completionHandler: { success in + print("Create completed with success: \(success)") + }, + cancelHandler: { + print("Cancel tapped") + } + ) + ) + .preferredColorScheme(.dark) + .environment(\.locale, Locale(identifier: "en")) +} + +#Preview("Loading State") { + let viewModel = PasskeyRegistrationViewModel( + requestId: "12345678-1234-1234-1234-123456789012", + rpId: "example.com", + origin: "https://example.com", + userName: "user@example.com", + userDisplayName: "John Doe", + existingPasskeys: [], + completionHandler: { _ in }, + cancelHandler: { } + ) + viewModel.isLoading = true + viewModel.loadingMessage = "Creating passkey" + return PasskeyRegistrationView(viewModel: viewModel) + .preferredColorScheme(.light) + .environment(\.locale, Locale(identifier: "en")) +} +#endif diff --git a/apps/mobile-app/ios/VaultUI/Views/Passkey/PasskeySelectionView.swift b/apps/mobile-app/ios/VaultUI/Views/Passkey/PasskeySelectionView.swift new file mode 100644 index 000000000..57de2eefe --- /dev/null +++ b/apps/mobile-app/ios/VaultUI/Views/Passkey/PasskeySelectionView.swift @@ -0,0 +1,78 @@ +import SwiftUI + +private let locBundle = Bundle.vaultUI + +/// Selection view for choosing between creating new or replacing existing passkey +struct PasskeySelectionView: View { + @ObservedObject var viewModel: PasskeyRegistrationViewModel + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(spacing: 24) { + // Create new button + NavigationLink(value: PasskeyNavigationDestination.createNew) { + HStack { + Image(systemName: "key.fill") + Text(String(localized: "create_new_passkey", bundle: locBundle)) + } + .padding() + .frame(maxWidth: .infinity) + .background(ColorConstants.Light.primary) + .foregroundColor(.white) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + + // Divider + HStack { + Rectangle() + .fill(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted) + .frame(height: 1) + Text(String(localized: "or", bundle: locBundle)) + .font(.caption) + .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted) + Rectangle() + .fill(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted) + .frame(height: 1) + } + .padding(.horizontal) + + // Existing passkeys list + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "select_passkey_to_replace", bundle: locBundle)) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text) + .padding(.horizontal) + + ScrollView { + VStack(spacing: 8) { + ForEach(viewModel.existingPasskeys) { passkeyInfo in + NavigationLink(value: PasskeyNavigationDestination.replace(passkeyInfo.id)) { + ExistingPasskeyRow(passkey: passkeyInfo) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + .frame(maxHeight: 200) + } + + Spacer() + + // Cancel button + Button(action: { + viewModel.cancel() + }, label: { + Text(String(localized: "cancel", bundle: locBundle)) + .padding() + .frame(maxWidth: .infinity) + .foregroundColor(ColorConstants.Light.primary) + }) + } + .padding(.horizontal) + .padding(.bottom, 20) + .navigationTitle(String(localized: "create_passkey_title", bundle: locBundle)) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/apps/mobile-app/ios/VaultUI/Views/PasskeyRegistrationView.swift b/apps/mobile-app/ios/VaultUI/Views/PasskeyRegistrationView.swift deleted file mode 100644 index 126515b5a..000000000 --- a/apps/mobile-app/ios/VaultUI/Views/PasskeyRegistrationView.swift +++ /dev/null @@ -1,402 +0,0 @@ -import SwiftUI -import AuthenticationServices - -private let locBundle = Bundle.vaultUI - -/// Passkey registration view for the autofill extension -public struct PasskeyRegistrationView: View { - @ObservedObject public var viewModel: PasskeyRegistrationViewModel - - @Environment(\.colorScheme) private var colorScheme - @FocusState private var isTitleFocused: Bool - - public init(viewModel: PasskeyRegistrationViewModel) { - self._viewModel = ObservedObject(wrappedValue: viewModel) - } - - public var body: some View { - NavigationView { - ZStack { - (colorScheme == .dark ? ColorConstants.Dark.background : ColorConstants.Light.background) - .ignoresSafeArea() - - // Main content - ScrollView { - VStack(spacing: 24) { - // Header - VStack(spacing: 12) { - Image("Logo", bundle: Bundle(for: PasskeyRegistrationViewModel.self)) - .resizable() - .scaledToFit() - .frame(width: 80, height: 80) - .padding(.top, 20) - - Text(String(localized: "create_passkey_title", bundle: locBundle)) - .font(.title) - .fontWeight(.bold) - .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text) - - Text(String(localized: "create_passkey_subtitle", bundle: locBundle)) - .font(.subheadline) - .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - .padding(.bottom, 20) - - // Editable title field - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "title", bundle: locBundle)) - .font(.caption) - .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted) - .padding(.horizontal) - - TextField("", text: $viewModel.displayName) - .textFieldStyle(PlainTextFieldStyle()) - .font(.body) - .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text) - .padding() - .background( - (colorScheme == .dark ? ColorConstants.Dark.accentBackground : ColorConstants.Light.accentBackground) - ) - .cornerRadius(8) - .padding(.horizontal) - .focused($isTitleFocused) - } - .padding(.bottom, 8) - - // Request details (compact, read-only) - VStack(spacing: 8) { - CompactInfoRow( - label: String(localized: "website", bundle: locBundle), - value: viewModel.rpId, - icon: "globe" - ) - - if let userName = viewModel.userName { - CompactInfoRow( - label: String(localized: "username", bundle: locBundle), - value: userName, - icon: "person.fill" - ) - } - } - .padding(.horizontal) - - Spacer() - - // Action buttons - VStack(spacing: 12) { - Button(action: { - viewModel.createPasskey() - }, label: { - HStack { - Image(systemName: "key.fill") - Text(String(localized: "create_passkey_button_confirm", bundle: locBundle)) - } - .padding() - .frame(maxWidth: .infinity) - .background(ColorConstants.Light.primary) - .foregroundColor(.white) - .cornerRadius(8) - }) - - Button(action: { - viewModel.cancel() - }, label: { - Text(String(localized: "cancel", bundle: locBundle)) - .padding() - .frame(maxWidth: .infinity) - .foregroundColor(ColorConstants.Light.primary) - }) - } - .padding(.horizontal) - .padding(.bottom, 20) - } - } - .opacity(viewModel.isLoading ? 0.3 : 1.0) - .disabled(viewModel.isLoading) - - // Loading overlay - if viewModel.isLoading { - LoadingOverlayView(message: viewModel.loadingMessage) - } - } - .navigationBarHidden(true) - .onAppear { - // Auto-focus the title field - isTitleFocused = true - } - } - } -} - -/// AliasVault logo view component - loads from xcassets -private struct AliasVaultLogoView: View { - var body: some View { - Image("Logo", bundle: Bundle(for: PasskeyRegistrationViewModel.self)) - .resizable() - .scaledToFit() - } -} - -/// Loading overlay component with AliasVault branding -private struct LoadingOverlayView: View { - let message: String - @Environment(\.colorScheme) private var colorScheme - @State private var animatingDots: [Bool] = [false, false, false, false] - @State private var textDots = "" - @State private var timer: Timer? - - var body: some View { - VStack(spacing: 0) { - Spacer() - - VStack(spacing: 0) { - // AliasVault logo animation - four pulsing dots - HStack(spacing: 10) { - ForEach(0..<4) { index in - Circle() - .fill(ColorConstants.Light.tertiary) - .frame(width: 8, height: 8) - .opacity(animatingDots[index] ? 1.0 : 0.3) - .animation( - Animation.easeInOut(duration: 0.7) - .repeatForever(autoreverses: true) - .delay(Double(index) * 0.2), - value: animatingDots[index] - ) - } - } - .padding(12) - .padding(.horizontal, 12) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(colorScheme == .dark ? Color.clear : Color.white) - .overlay( - RoundedRectangle(cornerRadius: 20) - .stroke(ColorConstants.Light.tertiary, lineWidth: 5) - ) - .shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1) - ) - - // Loading message with animated dots - if !message.isEmpty { - Text(message + textDots) - .font(.body) - .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text) - .multilineTextAlignment(.center) - .padding(.horizontal) - .padding(.top, 16) - } - } - .padding(20) - - Spacer() - } - .onAppear { - // Start dot animations - for index in 0..<4 { - animatingDots[index] = true - } - - // Start text dots animation - let textTimer = Timer.scheduledTimer(withTimeInterval: 0.4, repeats: true) { _ in - if textDots.count >= 3 { - textDots = "" - } else { - textDots += "." - } - } - timer = textTimer - } - .onDisappear { - timer?.invalidate() - timer = nil - } - } -} - -/// Compact info row component for displaying read-only passkey details -private struct CompactInfoRow: View { - let label: String - let value: String - let icon: String - - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - HStack(spacing: 8) { - Image(systemName: icon) - .foregroundColor(ColorConstants.Light.primary) - .frame(width: 24) - - Text(label + ":") - .font(.subheadline) - .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted) - - Text(value) - .font(.subheadline) - .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text) - - Spacer() - } - .padding(.vertical, 4) - .padding(.horizontal, 8) - } -} - -/// View model for passkey registration -public class PasskeyRegistrationViewModel: ObservableObject { - @Published public var requestId: String - @Published public var rpId: String - @Published public var origin: String - @Published public var userName: String? - @Published public var userDisplayName: String? - @Published public var displayName: String // Editable title that defaults to rpId - @Published public var isLoading: Bool = false - @Published public var loadingMessage: String = "" - - private let completionHandler: (Bool) -> Void - private let cancelHandler: () -> Void - - public init( - requestId: String, - rpId: String, - origin: String, - userName: String? = nil, - userDisplayName: String? = nil, - completionHandler: @escaping (Bool) -> Void, - cancelHandler: @escaping () -> Void - ) { - self.requestId = requestId - self.rpId = rpId - self.origin = origin - self.userName = userName - self.userDisplayName = userDisplayName - // Initialize displayName to rpId by default - self.displayName = rpId - self.completionHandler = completionHandler - self.cancelHandler = cancelHandler - } - - /// Update loading state (called from main thread) - @MainActor - public func setLoading(_ loading: Bool, message: String = "") { - self.isLoading = loading - self.loadingMessage = message - } - - public func createPasskey() { - // Trigger passkey creation in Swift - print("PasskeyRegistration: Create passkey button clicked") - completionHandler(true) - } - - public func openMainApp() { - // Build the deep link URL - var urlString = "net.aliasvault.app://credentials/passkey-create" - let encodedRequestId = requestId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" - urlString += "?requestId=\(encodedRequestId)" - - guard let url = URL(string: urlString) else { - print("PasskeyRegistration: Invalid URL string: \(urlString)") - completionHandler(false) - return - } - - print("PasskeyRegistration: Opening main app with URL: \(url.absoluteString)") - - // Use UIApplication.shared.open from the view model (works in button tap context) - UIApplication.shared.open(url, options: [:]) { [weak self] success in - print("PasskeyRegistration: UIApplication.shared.open completed with success=\(success)") - - if success { - print("PasskeyRegistration: App opened successfully") - } else { - print("PasskeyRegistration: Failed to open app") - } - - self?.completionHandler(success) - } - } - - public func cancel() { - cancelHandler() - } -} - -// MARK: - Previews -#if DEBUG -#Preview("Light Mode - With Username") { - PasskeyRegistrationView( - viewModel: PasskeyRegistrationViewModel( - requestId: "12345678-1234-1234-1234-123456789012", - rpId: "example.com", - origin: "https://example.com", - userName: "user@example.com", - userDisplayName: "John Doe", - completionHandler: { success in - print("Open app completed with success: \(success)") - }, - cancelHandler: { - print("Cancel tapped") - } - ) - ) - .preferredColorScheme(.light) - .environment(\.locale, Locale(identifier: "en")) -} - -#Preview("Dark Mode - No Username") { - PasskeyRegistrationView( - viewModel: PasskeyRegistrationViewModel( - requestId: "12345678-1234-1234-1234-123456789012", - rpId: "example.com", - origin: "https://example.com", - userName: nil, - userDisplayName: nil, - completionHandler: { success in - print("Open app completed with success: \(success)") - }, - cancelHandler: { - print("Cancel tapped") - } - ) - ) - .preferredColorScheme(.dark) - .environment(\.locale, Locale(identifier: "en")) -} - -#Preview("Loading State") { - let viewModel = PasskeyRegistrationViewModel( - requestId: "12345678-1234-1234-1234-123456789012", - rpId: "example.com", - origin: "https://example.com", - userName: "user@example.com", - userDisplayName: "John Doe", - completionHandler: { _ in }, - cancelHandler: { } - ) - viewModel.isLoading = true - viewModel.loadingMessage = "Creating passkey" - return PasskeyRegistrationView(viewModel: viewModel) - .preferredColorScheme(.light) - .environment(\.locale, Locale(identifier: "en")) -} - -#Preview("Logo Only") { - VStack(spacing: 40) { - AliasVaultLogoView() - .frame(width: 80, height: 80) - - AliasVaultLogoView() - .frame(width: 120, height: 120) - - AliasVaultLogoView() - .frame(width: 60, height: 60) - } - .padding() - .preferredColorScheme(.light) -} -#endif diff --git a/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings b/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings index 2a23e9d10..61f678957 100644 Binary files a/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings and b/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings differ