mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-03-20 15:41:40 -04:00
Auto filter native credentials list based on provided URL (#771)
This commit is contained in:
@@ -20,6 +20,8 @@ import VaultModels
|
||||
* logins in the keyboard).
|
||||
*/
|
||||
class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
private var viewModel: CredentialProviderViewModel?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
@@ -46,6 +48,8 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
}
|
||||
)
|
||||
|
||||
self.viewModel = viewModel
|
||||
|
||||
let hostingController = UIHostingController(
|
||||
rootView: CredentialProviderView(viewModel: viewModel)
|
||||
)
|
||||
@@ -65,7 +69,13 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
}
|
||||
|
||||
override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) {
|
||||
// This is handled in the SwiftUI view's onAppear
|
||||
guard let viewModel = self.viewModel else { return }
|
||||
|
||||
// Instead of directly filtering credentials, just set the search text
|
||||
let matchedDomains = serviceIdentifiers.map { $0.identifier.lowercased() }
|
||||
if let firstDomain = matchedDomains.first {
|
||||
viewModel.setSearchFilter(firstDomain)
|
||||
}
|
||||
}
|
||||
|
||||
override func prepareInterfaceForUserChoosingTextToInsert() {
|
||||
@@ -73,11 +83,6 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
}
|
||||
|
||||
override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) {
|
||||
// Get credentials and return the first one that matches the identity
|
||||
// TODO: how do we handle authentication here? We need Face ID access before we can access credentials..
|
||||
// so we probably actually need to have a .shared instance in the autofill extension where after one unlock
|
||||
// it stays unlocked? Or should we cache usernames locally and still require faceid as soon as user tries to
|
||||
// autofill the username? Check how this should work functionally.
|
||||
do {
|
||||
let credentials = try VaultStore.shared.getAllCredentials()
|
||||
if let matchingCredential = credentials.first(where: { $0.service.name ?? "" == credentialIdentity.serviceIdentifier.identifier }) {
|
||||
|
||||
@@ -20,11 +20,11 @@ struct CredentialCard: View {
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(credential.service.name ?? "Unknown Service")
|
||||
Text(truncateText(credential.service.name ?? "Unknown", limit: 26))
|
||||
.font(.headline)
|
||||
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text)
|
||||
|
||||
Text(credential.username ?? "No username")
|
||||
Text(truncateText(usernameOrEmail(credential: credential), limit: 26))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted)
|
||||
}
|
||||
@@ -47,6 +47,25 @@ struct CredentialCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
func usernameOrEmail(credential: Credential) -> String {
|
||||
if let username = credential.username, !username.isEmpty {
|
||||
return username
|
||||
}
|
||||
if let email = credential.alias?.email, !email.isEmpty {
|
||||
return email
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncateText(_ text: String?, limit: Int) -> String {
|
||||
guard let text = text else { return "" }
|
||||
if text.count > limit {
|
||||
let index = text.index(text.startIndex, offsetBy: limit)
|
||||
return String(text[..<index]) + "..."
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CredentialCard(
|
||||
@@ -66,14 +85,14 @@ struct CredentialCard: View {
|
||||
),
|
||||
service: Service(
|
||||
id: UUID(),
|
||||
name: "Example Service",
|
||||
name: "Example Service with a very long name bla bla bla",
|
||||
url: "https://example.com",
|
||||
logo: nil,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date(),
|
||||
isDeleted: false
|
||||
),
|
||||
username: "johndoe",
|
||||
username: "usernameverylongverylongtextindeed",
|
||||
notes: "Sample notes",
|
||||
password: Password(
|
||||
id: UUID(),
|
||||
|
||||
@@ -7,7 +7,7 @@ public struct CredentialProviderView: View {
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
|
||||
public init(viewModel: CredentialProviderViewModel) {
|
||||
self._viewModel = ObservedObject(wrappedValue: viewModel)
|
||||
}
|
||||
@@ -33,15 +33,50 @@ public struct CredentialProviderView: View {
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 8) {
|
||||
ForEach(viewModel.filteredCredentials, id: \.service) { credential in
|
||||
CredentialCard(credential: credential) {
|
||||
viewModel.selectCredential(credential)
|
||||
if viewModel.filteredCredentials.isEmpty {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text)
|
||||
|
||||
Text("No credentials found")
|
||||
.font(.headline)
|
||||
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text)
|
||||
|
||||
Text("No existing credentials match your search")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button(action: {
|
||||
viewModel.showAddCredential = true
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
Text("Create New Credential")
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(ColorConstants.Light.primary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.padding(.top, 60)
|
||||
} else {
|
||||
LazyVStack(spacing: 8) {
|
||||
ForEach(viewModel.filteredCredentials, id: \.service) { credential in
|
||||
CredentialCard(credential: credential) {
|
||||
viewModel.selectCredential(credential)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.loadCredentials()
|
||||
@@ -61,13 +96,6 @@ public struct CredentialProviderView: View {
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack {
|
||||
Button {
|
||||
Task { await viewModel.loadCredentials() }
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.icon : ColorConstants.Light.icon)
|
||||
}
|
||||
|
||||
Button("Add") {
|
||||
viewModel.showAddCredential = true
|
||||
}
|
||||
@@ -125,6 +153,12 @@ public class CredentialProviderViewModel: ObservableObject {
|
||||
self.cancelHandler = cancelHandler
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func setSearchFilter(_ text: String) {
|
||||
self.searchText = text
|
||||
self.filterCredentials()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadCredentials() async {
|
||||
isLoading = true
|
||||
@@ -140,7 +174,69 @@ public class CredentialProviderViewModel: ObservableObject {
|
||||
func filterCredentials() {
|
||||
if searchText.isEmpty {
|
||||
filteredCredentials = credentials
|
||||
return
|
||||
}
|
||||
|
||||
func extractRootDomain(from urlString: String) -> String? {
|
||||
guard let url = URL(string: urlString), let host = url.host else { return nil }
|
||||
let parts = host.components(separatedBy: ".")
|
||||
return parts.count >= 2 ? parts.suffix(2).joined(separator: ".") : host
|
||||
}
|
||||
|
||||
func extractDomainWithoutExtension(from domain: String) -> String {
|
||||
return domain.components(separatedBy: ".").first ?? domain
|
||||
}
|
||||
|
||||
if let searchUrl = URL(string: searchText), let hostname = searchUrl.host, !hostname.isEmpty {
|
||||
let baseUrl = "\(searchUrl.scheme ?? "https")://\(hostname)"
|
||||
let rootDomain = extractRootDomain(from: searchUrl.absoluteString) ?? hostname
|
||||
let domainWithoutExtension = extractDomainWithoutExtension(from: rootDomain)
|
||||
|
||||
// 1. Exact URL match
|
||||
var matches = credentials.filter { credential in
|
||||
if let serviceUrl = credential.service.url,
|
||||
let url = URL(string: serviceUrl) {
|
||||
return url.absoluteString.lowercased() == searchUrl.absoluteString.lowercased()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. Base URL match (excluding query/path)
|
||||
if matches.isEmpty {
|
||||
matches = credentials.filter { credential in
|
||||
if let serviceUrl = credential.service.url,
|
||||
let url = URL(string: serviceUrl) {
|
||||
return url.absoluteString.lowercased().hasPrefix(baseUrl.lowercased())
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Root domain match (e.g., coolblue.nl)
|
||||
if matches.isEmpty {
|
||||
matches = credentials.filter { credential in
|
||||
if let serviceUrl = credential.service.url,
|
||||
let credRootDomain = extractRootDomain(from: serviceUrl) {
|
||||
return credRootDomain.lowercased() == rootDomain.lowercased()
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Domain name part match (e.g., "coolblue" in service name)
|
||||
if matches.isEmpty {
|
||||
matches = credentials.filter { credential in
|
||||
if let serviceName = credential.service.name?.lowercased() {
|
||||
return serviceName.contains(domainWithoutExtension.lowercased()) ||
|
||||
domainWithoutExtension.lowercased().contains(serviceName)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
filteredCredentials = matches
|
||||
} else {
|
||||
// Non-URL fallback: simple text search in service name or username
|
||||
filteredCredentials = credentials.filter { credential in
|
||||
(credential.service.name?.localizedCaseInsensitiveContains(searchText) ?? false) ||
|
||||
(credential.username?.localizedCaseInsensitiveContains(searchText) ?? false)
|
||||
@@ -341,6 +437,25 @@ class PreviewCredentialProviderViewModel: CredentialProviderViewModel {
|
||||
createdAt: Date(),
|
||||
updatedAt: Date(),
|
||||
isDeleted: false
|
||||
),
|
||||
Credential(
|
||||
id: UUID(),
|
||||
alias: .preview,
|
||||
service: Service(
|
||||
id: UUID(),
|
||||
name: "Long name service with a lot of characters",
|
||||
url: "https://another.com",
|
||||
logo: nil,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date(),
|
||||
isDeleted: false
|
||||
),
|
||||
username: "usernameisalsoprettylongjusttoseewhathappens",
|
||||
notes: "Another sample credential",
|
||||
password: .preview,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date(),
|
||||
isDeleted: false
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user