Add passkey replace flow (#520)

This commit is contained in:
Leendert de Borst
2025-10-13 14:40:52 +02:00
parent d93ec10cc9
commit c41bf8a921
13 changed files with 1050 additions and 414 deletions

View File

@@ -31,6 +31,10 @@ line_length:
warning: 200
error: 250
function_body_length:
warning: 150
error: 200
comment_spacing: # fixed invalid configuration
severity: error

View File

@@ -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()

View File

@@ -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<String?>("Username")], servicesTable[Expression<String?>("Name")])
.join(credentialsTable, on: passkeysTable[Expression<String>("CredentialId")] == credentialsTable[Expression<String>("Id")])
.join(servicesTable, on: credentialsTable[Expression<String>("ServiceId")] == servicesTable[Expression<String>("Id")])
.filter(passkeysTable[Expression<String>("RpId")] == rpId)
.filter(passkeysTable[Expression<Int64>("IsDeleted")] == 0)
.filter(credentialsTable[Expression<Int64>("IsDeleted")] == 0)
.order(passkeysTable[Expression<String>("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<String?>("Username"))
let serviceName = try? row.get(Expression<String?>("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<String>("Id") == credentialId.uuidString)
.limit(1)
if let credRow = try dbConn.pluck(credQuery) {
let serviceId = try credRow.get(Expression<String>("ServiceId"))
// Update the service with new logo and displayName
let serviceUpdate = servicesTable
.filter(Expression<String>("Id") == serviceId)
.update(
Expression<SQLite.Blob?>("Logo") <- logoBlob,
Expression<String?>("Name") <- displayName,
Expression<String>("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.
*/

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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<Bool>.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)
}
}

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,7 @@
import Foundation
/// Navigation destinations for passkey registration flow
public enum PasskeyNavigationDestination: Hashable {
case createNew
case replace(UUID)
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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

View File

Binary file not shown.