412 lines
16 KiB
Swift
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
|
|
}
|
|
}
|