mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-13 18:05:28 -04:00
Add passkey replace flow (#520)
This commit is contained in:
@@ -31,6 +31,10 @@ line_length:
|
||||
warning: 200
|
||||
error: 250
|
||||
|
||||
function_body_length:
|
||||
warning: 150
|
||||
error: 200
|
||||
|
||||
comment_spacing: # fixed invalid configuration
|
||||
severity: error
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
179
apps/mobile-app/ios/VaultUI/Views/Passkey/PasskeyFormView.swift
Normal file
179
apps/mobile-app/ios/VaultUI/Views/Passkey/PasskeyFormView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
/// Navigation destinations for passkey registration flow
|
||||
public enum PasskeyNavigationDestination: Hashable {
|
||||
case createNew
|
||||
case replace(UUID)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Binary file not shown.
Reference in New Issue
Block a user