Certificate UI/Import (#798)

* Sketching out.

* WIP

* WIP

* Dump

* Apply stash

* Merge + WIP

* UI

* More WIP

* Agent config

* UI cleanup

* Restore dirty files

* XPC

* Edit/delete

* UI fixes

* Cleanup

* Change id for OpenSSHCertificate to hex of md5

* Fix runtime warning for confirmation dialog

* Mark strings as reviewed

* Cleanup

* Fix agent tests
This commit is contained in:
Max Goedjen
2026-05-06 01:03:21 -07:00
committed by GitHub
parent 2f4d10d70d
commit b337b24641
35 changed files with 1516 additions and 225 deletions

View File

@@ -0,0 +1,68 @@
import SwiftUI
import SecretKit
import Common
import SSHProtocolKit
struct CertificateDetailView: View {
let certificate: OpenSSHCertificate
var body: some View {
ScrollView {
Form {
Section {
CopyableView(
title: .certificateDetailKeyIdLabel,
image: Image(systemName: "person.text.rectangle"),
text: certificate.keyID
)
Spacer()
.frame(height: 20)
CopyableView(
title: .certificateDetailSerialLabel,
image: Image(systemName: "number.circle"),
text: certificate.serial.formatted()
)
Spacer()
.frame(height: 20)
if let validityRange = certificate.validityRange {
let epoch = Date(timeIntervalSince1970: 0)
let end = Date(timeIntervalSince1970: TimeInterval(UInt64.max))
switch (validityRange.lowerBound, validityRange.upperBound) {
case (epoch, end):
EmptyView()
case (epoch, let otherEnd):
MultilineInfoView(title: .certificateDetailValidUntilLabel, image: Image(systemName: "calendar.badge.clock"), items: [otherEnd.formatted()])
Spacer()
.frame(height: 20)
case (let otherStart, end):
MultilineInfoView(title: .certificateDetailValidAfterLabel, image: Image(systemName: "calendar.badge.clock"), items: [otherStart.formatted()])
Spacer()
.frame(height: 20)
default:
MultilineInfoView(title: .certificateDetailValidityRangeLabel, image: Image(systemName: "calendar.badge.clock"), items: [validityRange.formatted()])
Spacer()
.frame(height: 20)
}
}
if !certificate.principals.isEmpty {
MultilineInfoView(title: .certificateDetailPrincipalsLabel, image: Image(systemName: "person.2"), items: certificate.principals)
Spacer()
.frame(height: 20)
}
CopyableView(
title: .certificateDetailPathLabel,
image: Image(systemName: "checkmark.seal.text.page"),
text: URL.certificatePath(for: certificate, in: URL.certificatesDirectory),
showRevealInFinder: true
)
Spacer()
}
}
.padding()
}
.frame(minHeight: 200, maxHeight: .infinity)
}
}

View File

@@ -0,0 +1,42 @@
import SwiftUI
import CertificateKit
import SSHProtocolKit
struct CertificateListItemView: View {
@Environment(\.certificateStore) private var store
var certificate: OpenSSHCertificate
@State var isDeleting: Bool = false
@State var isRenaming: Bool = false
var deletedCertificate: (OpenSSHCertificate) -> Void
var renamedCertificate: (OpenSSHCertificate) -> Void
var body: some View {
NavigationLink(value: certificate) {
Text(certificate.name)
}
.sheet(isPresented: $isRenaming, onDismiss: {
renamedCertificate(certificate)
}, content: {
EditCertificateView(store: store, certificate: certificate)
})
.showingDeleteConfirmation(isPresented: $isDeleting, certificate, store) { deleted in
if deleted {
deletedCertificate(certificate)
}
}
.contextMenu {
Button(action: { isRenaming = true }) {
Image(systemName: "pencil")
Text(.secretListEditButton)
}
Button(action: { isDeleting = true }) {
Image(systemName: "trash")
Text(.secretListDeleteButton)
}
}
}
}

View File

@@ -0,0 +1,52 @@
import SwiftUI
import CertificateKit
import SSHProtocolKit
extension View {
func showingDeleteConfirmation(isPresented: Binding<Bool>, _ certificate: OpenSSHCertificate, _ store: CertificateStore, dismissalBlock: @escaping (Bool) -> ()) -> some View {
modifier(DeleteCertificateConfirmationModifier(isPresented: isPresented, certificate: certificate, store: store, dismissalBlock: dismissalBlock))
}
}
struct DeleteCertificateConfirmationModifier: ViewModifier {
var isPresented: Binding<Bool>
var certificate: OpenSSHCertificate
var store: CertificateStore
var dismissalBlock: (Bool) -> ()
@State var confirmedSecretName = ""
@State private var errorText: String?
func body(content: Content) -> some View {
content
.confirmationDialog(
String(localized: .deleteConfirmationTitle(name: certificate.name)),
isPresented: isPresented,
titleVisibility: .visible,
actions: {
Button(.deleteConfirmationDeleteButton, action: delete)
Button(.deleteConfirmationCancelButton, role: .cancel) {
dismissalBlock(false)
}
},
)
.dialogIcon(Image(systemName: "lock.trianglebadge.exclamationmark.fill"))
.onExitCommand {
dismissalBlock(false)
}
}
func delete() {
Task {
do {
try store.delete(certificate: certificate)
dismissalBlock(true)
} catch {
errorText = error.localizedDescription
}
}
}
}

View File

@@ -21,7 +21,7 @@ struct DeleteSecretConfirmationModifier: ViewModifier {
func body(content: Content) -> some View {
content
.confirmationDialog(
.deleteConfirmationTitle(secretName: secret.name),
.deleteConfirmationTitle(name: secret.name),
isPresented: isPresented,
titleVisibility: .visible,
actions: {

View File

@@ -0,0 +1,60 @@
import SwiftUI
import SSHProtocolKit
import CertificateKit
struct EditCertificateView: View {
let store: CertificateStore
let certificate: OpenSSHCertificate
@State private var name: String
@State var errorText: String?
@Environment(\.dismiss) var dismiss
init(store: CertificateStore, certificate: OpenSSHCertificate) {
self.store = store
self.certificate = certificate
name = certificate.name
}
var body: some View {
VStack(alignment: .trailing) {
Form {
Section {
TextField(String(localized: .renameCertificateLabel), text: $name, prompt: Text(.renameCertificateNamePlaceholder))
} footer: {
if let errorText {
Text(verbatim: errorText)
.errorStyle()
}
}
}
HStack {
Button(.editCancelButton) {
dismiss()
}
.keyboardShortcut(.cancelAction)
Button(.editSaveButton, action: rename)
.disabled(name.isEmpty)
.keyboardShortcut(.return)
.primaryButton()
}
.padding()
}
.formStyle(.grouped)
}
func rename() {
Task {
do {
var updated = certificate
updated.name = name
try store.update(certificate: updated)
dismiss()
} catch {
errorText = error.localizedDescription
}
}
}
}

View File

@@ -6,6 +6,8 @@ import SSHProtocolKit
struct SecretDetailView<SecretType: Secret>: View {
let secret: SecretType
let certificates: [OpenSSHCertificate]
let navigateToCertificate: ((OpenSSHCertificate) -> Void)?
private let keyWriter = OpenSSHPublicKeyWriter()
@@ -13,16 +15,42 @@ struct SecretDetailView<SecretType: Secret>: View {
ScrollView {
Form {
Section {
CopyableView(title: .secretDetailSha256FingerprintLabel, image: Image(systemName: "touchid"), text: keyWriter.openSSHSHA256Fingerprint(secret: secret))
CopyableView(
title: .secretDetailSha256FingerprintLabel,
image: Image(systemName: "touchid"),
text: keyWriter.openSSHSHA256Fingerprint(secret: secret)
)
Spacer()
.frame(height: 20)
CopyableView(title: .secretDetailMd5FingerprintLabel, image: Image(systemName: "touchid"), text: keyWriter.openSSHMD5Fingerprint(secret: secret))
CopyableView(
title: .secretDetailMd5FingerprintLabel,
image: Image(systemName: "touchid"),
text: keyWriter.openSSHMD5Fingerprint(secret: secret)
)
Spacer()
.frame(height: 20)
CopyableView(title: .secretDetailPublicKeyLabel, image: Image(systemName: "key"), text: keyString)
Spacer()
.frame(height: 20)
CopyableView(title: .secretDetailPublicKeyPathLabel, image: Image(systemName: "lock.doc"), text: URL.publicKeyPath(for: secret, in: URL.publicKeyDirectory), showRevealInFinder: true)
CopyableView(
title: .secretDetailPublicKeyPathLabel,
image: Image(systemName: "lock.doc"),
text: URL.publicKeyPath(for: secret, in: URL.publicKeyDirectory),
showRevealInFinder: true
)
if !certificates.isEmpty {
Spacer()
.frame(height: 20)
MultilineInfoView(
title: .secretDetailCertificatePathLabel,
image: Image(
systemName: "checkmark.seal.text.page"
),
items: certificates.map({ certificate in
MultilineInfoView.Item(
text: certificate.name,
action: (Image(systemName: "chevron.forward"), { navigateToCertificate?(certificate) })
)
})
)
}
Spacer()
}
}
@@ -32,10 +60,6 @@ struct SecretDetailView<SecretType: Secret>: View {
}
var keyString: String {
keyWriter.openSSHString(secret: secret)
}
}
//#Preview {

View File

@@ -1,25 +1,32 @@
import SwiftUI
import SecretKit
import SSHProtocolKit
struct StoreListView: View {
@Binding var activeSecret: AnySecret?
enum StoreListSelection: Hashable {
case secret(AnySecret)
case certificate(OpenSSHCertificate)
}
@Binding var selection: StoreListSelection?
@Environment(\.secretStoreList) private var storeList
@Environment(\.certificateStore) private var certificateStore
private func secretDeleted(secret: AnySecret) {
activeSecret = nextDefaultSecret
selection = nextDefaultSecret.map(StoreListSelection.secret)
}
private func secretRenamed(secret: AnySecret) {
// Pull new version from store, so we get all updated attributes
activeSecret = nil
activeSecret = storeList.allSecrets.first(where: { $0.id == secret.id })
selection = nil
selection = storeList.allSecrets.first(where: { $0.id == secret.id }).map(StoreListSelection.secret)
}
var body: some View {
NavigationSplitView {
List(selection: $activeSecret) {
List(selection: $selection) {
ForEach(storeList.stores) { store in
if store.isAvailable {
Section(header: Text(store.name)) {
@@ -30,29 +37,51 @@ struct StoreListView: View {
deletedSecret: secretDeleted,
renamedSecret: secretRenamed,
)
.tag(StoreListSelection.secret(secret))
}
}
}
}
if !certificateStore.certificates.isEmpty {
Section("Certificates") {
ForEach(certificateStore.certificates) { certificate in
CertificateListItemView(
certificate: certificate,
deletedCertificate: { _ in },
renamedCertificate: { _ in }
)
.tag(StoreListSelection.certificate(certificate))
}
}
}
}
} detail: {
if let activeSecret {
SecretDetailView(secret: activeSecret)
} else if let nextDefaultSecret {
// This just means onAppear hasn't executed yet.
// Do this to avoid a blip.
SecretDetailView(secret: nextDefaultSecret)
} else {
if let modifiable = storeList.modifiableStore, modifiable.isAvailable {
EmptyStoreView(store: modifiable)
switch selection {
case .secret(let secret):
SecretDetailView(secret: secret, certificates: certificateStore.certificates(for: secret)) {
selection = .certificate($0)
}
case .certificate(let certificate):
CertificateDetailView(certificate: certificate)
case nil:
if let nextDefaultSecret {
// This just means onAppear hasn't executed yet.
// Do this to avoid a blip.
SecretDetailView(secret: nextDefaultSecret, certificates: certificateStore.certificates(for: nextDefaultSecret)) {
selection = .certificate($0)
}
} else {
EmptyStoreView(store: storeList.stores.first(where: \.isAvailable))
if let modifiable = storeList.modifiableStore, modifiable.isAvailable {
EmptyStoreView(store: modifiable)
} else {
EmptyStoreView(store: storeList.stores.first(where: \.isAvailable))
}
}
}
}
.navigationSplitViewStyle(.balanced)
.onAppear {
activeSecret = nextDefaultSecret
selection = nextDefaultSecret.map(StoreListSelection.secret)
}
.frame(minWidth: 100, idealWidth: 240)