Files
Termind/TFI/Features/Keychain/MobileCredentialsView.swift
T

412 lines
16 KiB
Swift

import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
struct MobileCredentialsView: View {
let app: MobileAppModel
@State private var editing: Identity?
@State private var keyEditor: MobileKeyEditorModel?
@State private var deletingKey: SSHKeyRef?
@State private var deletingIdentity: Identity?
@State private var error: String?
@State private var query = ""
private var keys: [SSHKeyRef] { app.credentials.searchKeys(query) }
private var identities: [Identity] { app.credentials.searchIdentities(query) }
init(app: MobileAppModel, initialQuery: String = "") {
self.app = app
_query = State(initialValue: initialQuery)
}
var body: some View {
MobileCardPage {
if !query.isEmpty, keys.isEmpty, identities.isEmpty {
ContentUnavailableView.search(text: query)
}
if query.isEmpty || !keys.isEmpty {
MobileCardSection(title: "Keys", count: keys.count) {
if app.credentials.keys.isEmpty {
ContentUnavailableView("No Keys", systemImage: "key")
}
MobileCardGrid {
ForEach(keys) { key in
MobileActionCard {
keyEditor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: key)
} content: {
MobileKeyCredentialCard(key: key, credentials: app.credentials)
} actions: {
Button("Edit", systemImage: "pencil") {
keyEditor = MobileKeyEditorModel(
mode: .edit, credentials: app.credentials, key: key)
}
Divider()
Button("Delete", systemImage: "trash", role: .destructive) { deletingKey = key }
}
}
}
}
}
if query.isEmpty || !identities.isEmpty {
MobileCardSection(title: "Identities", count: identities.count) {
if app.credentials.identities.isEmpty {
ContentUnavailableView("No Identities", systemImage: "person.crop.square")
}
MobileCardGrid {
ForEach(identities) { identity in
MobileActionCard {
editing = identity
} content: {
MobileIdentityCredentialCard(identity: identity, credentials: app.credentials)
} actions: {
Button("Edit", systemImage: "pencil") { editing = identity }
Divider()
Button("Delete", systemImage: "trash", role: .destructive) {
deletingIdentity = identity
}
}
}
}
}
}
}
.searchable(text: $query.singleLineText)
.navigationTitle("Keychain")
.toolbar {
Menu {
Button("Generate Key", systemImage: "key") {
keyEditor = MobileKeyEditorModel(
mode: .generate, credentials: app.credentials,
defaultStorage: app.newVaultKeyStorage)
}
Button("Import Key", systemImage: "square.and.arrow.down") {
keyEditor = MobileKeyEditorModel(
mode: .importKey, credentials: app.credentials,
defaultStorage: app.newVaultKeyStorage)
}
Divider()
Button("New Identity", systemImage: "person.crop.circle.badge.plus") {
editing = Identity(
name: "", username: "",
passwordStorage: app.newVaultPasswordStorage)
}
} label: {
Label("Add", systemImage: "plus").labelStyle(.iconOnly)
}
}
.sheet(item: $editing) { MobileIdentityEditor(app: app, identity: $0) }
.sheet(item: $keyEditor) { MobileKeyEditor(model: $0) }
.confirmationDialog(
"Delete Identity?",
isPresented: Binding(get: { deletingIdentity != nil }, set: { if !$0 { deletingIdentity = nil } }),
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
if let identity = deletingIdentity, !app.credentials.delete(identityID: identity.id) {
error = app.credentials.storageError
}
deletingIdentity = nil
}
} message: {
Text("Hosts using this identity will ask for credentials again.")
}
.confirmationDialog(
"Delete Key?", isPresented: Binding(get: { deletingKey != nil }, set: { if !$0 { deletingKey = nil } }),
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
if let key = deletingKey {
do { try app.credentials.delete(keyID: key.id) } catch { self.error = error.localizedDescription }
}
deletingKey = nil
}
} message: {
Text("Identities using this key will no longer have access to it.")
}
.alert("Unable to Delete", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK", role: .cancel) { error = nil }
} message: {
Text(error ?? "")
}
}
}
private struct MobileKeyCredentialCard: View {
let key: SSHKeyRef
let credentials: CredentialLibraryModel
private var dependents: Int { credentials.identities(using: key.id).count }
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
MobileCardIcon(symbol: "key.fill", tint: key.colorTag.color ?? .accentColor)
VStack(alignment: .leading, spacing: 6) {
Text(key.label).font(.headline).lineLimit(1)
Text(key.fingerprint).font(.caption.monospaced()).foregroundStyle(.secondary)
.lineLimit(1).truncationMode(.middle)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack(spacing: 6) {
primaryChips
secondaryChips
Spacer(minLength: 6)
dependentLabel
}
.frame(maxWidth: .infinity)
.clipped()
}
.accessibilityElement(children: .combine)
}
@ViewBuilder private var primaryChips: some View {
MobileCardChip(text: keyDisplayName)
if key.hasPassphrase {
MobileCardChip(title: "Passphrase Required", symbol: "lock.fill")
}
}
@ViewBuilder private var secondaryChips: some View {
MobileCardChip(text: key.storage.mobileDisplayName, symbol: key.storage.mobileSymbol)
switch credentials.keyAvailability(for: key) {
case .missing:
MobileCardChip(title: "Private key missing", symbol: "exclamationmark.triangle.fill", tint: .orange)
case .failed:
MobileCardChip(title: "Keychain unavailable", symbol: "exclamationmark.triangle.fill", tint: .orange)
case .available, .notConfigured:
EmptyView()
}
}
@ViewBuilder private var dependentLabel: some View {
if dependents > 0 {
Text("\(dependents) identities")
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(1)
}
}
private var keyDisplayName: String {
if key.algorithm == .rsa, let bits = key.keySize { return "RSA \(bits)" }
return key.algorithm.displayName
}
}
private struct MobileIdentityCredentialCard: View {
let identity: Identity
let credentials: CredentialLibraryModel
private var key: SSHKeyRef? { identity.keyID.flatMap(credentials.key) }
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
MobileCardIcon(
symbol: "person.badge.key.fill", tint: identity.colorTag.color ?? .accentColor)
VStack(alignment: .leading, spacing: 6) {
Text(identity.name).font(.headline).lineLimit(1)
Text(identity.username).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack(spacing: 6) {
secretChips
Spacer(minLength: 6)
modifiedLabel
}
.frame(maxWidth: .infinity)
.clipped()
}
.accessibilityElement(children: .combine)
}
@ViewBuilder private var secretChips: some View {
if identity.usesPassword {
switch credentials.passwordAvailability(for: identity) {
case .available:
MobileCardChip(title: "Password", symbol: "lock.fill")
case .missing:
MobileCardChip(title: "Password missing", symbol: "exclamationmark.triangle.fill", tint: .orange)
case .failed:
MobileCardChip(
title: "Password unavailable", symbol: "exclamationmark.triangle.fill", tint: .orange)
case .notConfigured:
EmptyView()
}
MobileCardChip(
text: identity.passwordStorage.mobileDisplayName,
symbol: identity.passwordStorage.mobileSymbol)
}
if identity.usesKey {
if let key {
MobileCardChip(text: key.label, symbol: "key.fill", tint: key.colorTag.color ?? .accentColor)
} else {
MobileCardChip(title: "Key missing", symbol: "exclamationmark.triangle.fill", tint: .orange)
}
}
if !identity.hasSecret {
MobileCardChip(title: "No secret", symbol: "questionmark.circle")
}
}
private var modifiedLabel: some View {
Text(
modifiedAt?.formatted(.relative(presentation: .numeric))
?? String(localized: "Unknown")
)
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(1)
}
private var modifiedAt: Date? {
guard let date = credentials.identityModifiedAt(identity.id),
date > VaultVersion.baselineDate
else { return nil }
return date
}
}
extension PasswordStorage {
fileprivate var mobileDisplayName: String {
switch self {
case .thisDevice: String(localized: "This Device")
case .iCloud: String(localized: "iCloud")
}
}
fileprivate var mobileSymbol: String {
switch self {
case .thisDevice: "ipad.and.iphone"
case .iCloud: "icloud.fill"
}
}
}
extension KeyStorage {
fileprivate var mobileDisplayName: String {
switch self {
case .thisDevice: String(localized: "This Device")
case .iCloud: String(localized: "iCloud")
case .secureEnclave: String(localized: "Secure Enclave")
}
}
fileprivate var mobileSymbol: String {
switch self {
case .thisDevice: "ipad.and.iphone"
case .iCloud: "icloud.fill"
case .secureEnclave: "checkmark.shield.fill"
}
}
}
nonisolated struct MobileIdentityEditorConfiguration: Hashable, Sendable {
var showsKey: Bool
var requiresPassword: Bool
static let standard = MobileIdentityEditorConfiguration(showsKey: true, requiresPassword: false)
static let proxy = MobileIdentityEditorConfiguration(showsKey: false, requiresPassword: true)
func canSave(
identity: Identity, password: String, hasConfiguredPassword: Bool, removesPassword: Bool
) -> Bool {
guard !identity.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return false
}
guard requiresPassword else { return true }
return hasConfiguredPassword || !password.isEmpty
}
}
struct MobileIdentityEditor: View {
let app: MobileAppModel
let original: Identity
let isNew: Bool
let configuration: MobileIdentityEditorConfiguration
let saved: (Identity) -> Void
@State private var draft: Identity
@State private var password = ""
@State private var originalPassword = ""
@State private var didLoadPassword = false
init(
app: MobileAppModel, identity: Identity,
configuration: MobileIdentityEditorConfiguration = .standard,
saved: @escaping (Identity) -> Void = { _ in }
) {
self.app = app
original = identity
isNew = app.library.identity(identity.id) == nil
self.configuration = configuration
self.saved = saved
_draft = State(initialValue: identity)
}
var body: some View {
MobileEditorSheet(
title: title,
isDirty: draft != original || password != originalPassword,
canSave: configuration.canSave(
identity: draft, password: password,
hasConfiguredPassword: original.usesPassword,
removesPassword: canEditPassword && password.isEmpty && !originalPassword.isEmpty),
save: {
draft.username = draft.username.trimmingCharacters(in: .whitespacesAndNewlines)
draft.name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines)
try app.library.upsert(
draft,
passwordUpdate: !canEditPassword
? .keep
: password == originalPassword ? .keep : password.isEmpty ? .remove : .replace(password))
saved(draft)
},
content: {
IdentityFormSections(
name: $draft.name, username: $draft.username, password: $password,
passwordStorage: $draft.passwordStorage, keyID: $draft.keyID,
colorTag: $draft.colorTag, namePrompt: Text(verbatim: draft.defaultName),
keys: app.credentials.keys, showsKey: configuration.showsKey,
canEditPassword: canEditPassword,
canEditPasswordStorage: canEditPassword
&& !(original.usesPassword && original.passwordStorage == .iCloud)
)
}
)
.onAppear(perform: loadPasswordOnce)
.onDisappear {
password = ""
originalPassword = ""
}
}
var title: String {
String(localized: isNew ? "New Identity" : "Edit Identity")
}
private var hasAvailablePassword: Bool {
original.usesPassword && app.credentials.passwordAvailability(for: original) == .available
}
var canEditPassword: Bool {
!original.usesPassword || hasAvailablePassword
}
private func loadPasswordOnce() {
guard !didLoadPassword else { return }
didLoadPassword = true
guard hasAvailablePassword,
let loaded = try? app.credentials.editablePassword(for: original)
else { return }
password = loaded
originalPassword = loaded
}
}