mirror of
https://github.com/maxgoedjen/secretive.git
synced 2026-03-06 01:37:22 +01:00
New setup (#657)
* WIP * WIP * WIP * Tweaks. * WIP * WIP * WIP * WIP * WIP * Cleanup * WIP * WIP * WIP * WIP * WIP * WIP * WIP * REmove setup menu item * WIP * . * . * . * Cleaup.
This commit is contained in:
151
Sources/Secretive/Views/Secrets/CreateSecretView.swift
Normal file
151
Sources/Secretive/Views/Secrets/CreateSecretView.swift
Normal file
@@ -0,0 +1,151 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
|
||||
struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
||||
|
||||
@State var store: StoreType
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var createdSecret: (AnySecret?) -> Void
|
||||
|
||||
@State private var name = ""
|
||||
@State private var keyAttribution = ""
|
||||
@State private var authenticationRequirement: AuthenticationRequirement = .presenceRequired
|
||||
@State private var keyType: KeyType?
|
||||
@State var advanced = false
|
||||
@State var errorText: String?
|
||||
|
||||
private var authenticationOptions: [AuthenticationRequirement] {
|
||||
if advanced || authenticationRequirement == .biometryCurrent {
|
||||
[.presenceRequired, .notRequired, .biometryCurrent]
|
||||
} else {
|
||||
[.presenceRequired, .notRequired]
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .trailing) {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: .createSecretNameLabel), text: $name, prompt: Text(.createSecretNamePlaceholder))
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Picker(.createSecretRequireAuthenticationTitle, selection: $authenticationRequirement) {
|
||||
ForEach(authenticationOptions) { option in
|
||||
HStack {
|
||||
switch option {
|
||||
case .notRequired:
|
||||
Image(systemName: "bell")
|
||||
Text(.createSecretNotifyTitle)
|
||||
case .presenceRequired:
|
||||
Image(systemName: "lock")
|
||||
Text(.createSecretRequireAuthenticationTitle)
|
||||
case .biometryCurrent:
|
||||
Image(systemName: "lock.trianglebadge.exclamationmark.fill")
|
||||
Text(.createSecretRequireAuthenticationBiometricCurrentTitle)
|
||||
case .unknown:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.tag(option)
|
||||
}
|
||||
}
|
||||
Group {
|
||||
switch authenticationRequirement {
|
||||
case .notRequired:
|
||||
Text(.createSecretNotifyDescription)
|
||||
case .presenceRequired:
|
||||
Text(.createSecretRequireAuthenticationDescription)
|
||||
case .biometryCurrent:
|
||||
Text(.createSecretRequireAuthenticationBiometricCurrentDescription)
|
||||
case .unknown:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
if authenticationRequirement == .biometryCurrent {
|
||||
Text(.createSecretBiometryCurrentWarning)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 3)
|
||||
.background(.red.opacity(0.5), in: RoundedRectangle(cornerRadius: 5))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
if advanced {
|
||||
Section {
|
||||
VStack {
|
||||
Picker(.createSecretKeyTypeLabel, selection: $keyType) {
|
||||
ForEach(store.supportedKeyTypes, id: \.self) { option in
|
||||
Text(String(describing: option))
|
||||
.tag(option)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
if keyType?.algorithm == .mldsa {
|
||||
Text(.createSecretMldsaWarning)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 3)
|
||||
.background(.red.opacity(0.5), in: RoundedRectangle(cornerRadius: 5))
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
TextField(.createSecretKeyAttributionLabel, text: $keyAttribution, prompt: Text(verbatim: "test@example.com"))
|
||||
Text(.createSecretKeyAttributionDescription)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let errorText {
|
||||
Section {
|
||||
} footer: {
|
||||
Text(verbatim: errorText)
|
||||
.errorStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Toggle(.createSecretAdvancedLabel, isOn: $advanced)
|
||||
.toggleStyle(.button)
|
||||
Spacer()
|
||||
Button(.createSecretCancelButton, role: .cancel) {
|
||||
dismiss()
|
||||
}
|
||||
Button(.createSecretCreateButton, action: save)
|
||||
.keyboardShortcut(.return)
|
||||
.primaryButton()
|
||||
.disabled(name.isEmpty)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onAppear {
|
||||
keyType = store.supportedKeyTypes.first
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
func save() {
|
||||
let attribution = keyAttribution.isEmpty ? nil : keyAttribution
|
||||
Task {
|
||||
do {
|
||||
let new = try await store.create(
|
||||
name: name,
|
||||
attributes: .init(
|
||||
keyType: keyType!,
|
||||
authentication: authenticationRequirement,
|
||||
publicKeyAttribution: attribution
|
||||
)
|
||||
)
|
||||
createdSecret(AnySecret(new))
|
||||
dismiss()
|
||||
} catch {
|
||||
errorText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CreateSecretView(store: Preview.StoreModifiable()) { _ in }
|
||||
}
|
||||
60
Sources/Secretive/Views/Secrets/DeleteSecretView.swift
Normal file
60
Sources/Secretive/Views/Secrets/DeleteSecretView.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
|
||||
extension View {
|
||||
|
||||
func showingDeleteConfirmation(isPresented: Binding<Bool>, _ secret: AnySecret, _ store: AnySecretStoreModifiable?, dismissalBlock: @escaping (Bool) -> ()) -> some View {
|
||||
modifier(DeleteSecretConfirmationModifier(isPresented: isPresented, secret: secret, store: store, dismissalBlock: dismissalBlock))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct DeleteSecretConfirmationModifier: ViewModifier {
|
||||
|
||||
var isPresented: Binding<Bool>
|
||||
var secret: AnySecret
|
||||
var store: AnySecretStoreModifiable?
|
||||
var dismissalBlock: (Bool) -> ()
|
||||
@State var confirmedSecretName = ""
|
||||
@State private var errorText: String?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.confirmationDialog(
|
||||
.deleteConfirmationTitle(secretName: secret.name),
|
||||
isPresented: isPresented,
|
||||
titleVisibility: .visible,
|
||||
actions: {
|
||||
TextField(secret.name, text: $confirmedSecretName)
|
||||
if let errorText {
|
||||
Text(verbatim: errorText)
|
||||
.errorStyle()
|
||||
}
|
||||
Button(.deleteConfirmationDeleteButton, action: delete)
|
||||
.disabled(confirmedSecretName != secret.name)
|
||||
Button(.deleteConfirmationCancelButton, role: .cancel) {
|
||||
dismissalBlock(false)
|
||||
}
|
||||
},
|
||||
message: {
|
||||
Text(.deleteConfirmationDescription(secretName: secret.name, confirmSecretName: secret.name))
|
||||
}
|
||||
)
|
||||
.dialogIcon(Image(systemName: "lock.trianglebadge.exclamationmark.fill"))
|
||||
.onExitCommand {
|
||||
dismissalBlock(false)
|
||||
}
|
||||
}
|
||||
|
||||
func delete() {
|
||||
Task {
|
||||
do {
|
||||
try await store!.delete(secret: secret)
|
||||
dismissalBlock(true)
|
||||
} catch {
|
||||
errorText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
67
Sources/Secretive/Views/Secrets/EditSecretView.swift
Normal file
67
Sources/Secretive/Views/Secrets/EditSecretView.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
|
||||
struct EditSecretView<StoreType: SecretStoreModifiable>: View {
|
||||
|
||||
let store: StoreType
|
||||
let secret: StoreType.SecretType
|
||||
let dismissalBlock: (_ renamed: Bool) -> ()
|
||||
|
||||
@State private var name: String
|
||||
@State private var publicKeyAttribution: String
|
||||
@State var errorText: String?
|
||||
|
||||
init(store: StoreType, secret: StoreType.SecretType, dismissalBlock: @escaping (Bool) -> ()) {
|
||||
self.store = store
|
||||
self.secret = secret
|
||||
self.dismissalBlock = dismissalBlock
|
||||
name = secret.name
|
||||
publicKeyAttribution = secret.publicKeyAttribution ?? ""
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .trailing) {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: .createSecretNameLabel), text: $name, prompt: Text(.createSecretNamePlaceholder))
|
||||
VStack(alignment: .leading) {
|
||||
TextField(.createSecretKeyAttributionLabel, text: $publicKeyAttribution, prompt: Text(verbatim: "test@example.com"))
|
||||
Text(.createSecretKeyAttributionDescription)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} footer: {
|
||||
if let errorText {
|
||||
Text(verbatim: errorText)
|
||||
.errorStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Button(.editCancelButton) {
|
||||
dismissalBlock(false)
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button(.editSaveButton, action: rename)
|
||||
.disabled(name.isEmpty)
|
||||
.keyboardShortcut(.return)
|
||||
.primaryButton()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
func rename() {
|
||||
var attributes = secret.attributes
|
||||
attributes.publicKeyAttribution = publicKeyAttribution.isEmpty ? nil : publicKeyAttribution
|
||||
Task {
|
||||
do {
|
||||
try await store.update(secret: secret, name: name, attributes: attributes)
|
||||
dismissalBlock(true)
|
||||
} catch {
|
||||
errorText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
Sources/Secretive/Views/Secrets/EmptyStoreView.swift
Normal file
71
Sources/Secretive/Views/Secrets/EmptyStoreView.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
|
||||
struct EmptyStoreView: View {
|
||||
|
||||
@State var store: AnySecretStore?
|
||||
|
||||
var body: some View {
|
||||
if store is AnySecretStoreModifiable {
|
||||
EmptyStoreModifiableView()
|
||||
} else {
|
||||
EmptyStoreImmutableView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyStoreImmutableView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text(.emptyStoreNonmodifiableTitle).bold()
|
||||
Text(.emptyStoreNonmodifiableDescription)
|
||||
Text(.emptyStoreNonmodifiableSupportedKeyTypes)
|
||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct EmptyStoreModifiableView: View {
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { windowGeometry in
|
||||
VStack {
|
||||
GeometryReader { g in
|
||||
Path { path in
|
||||
path.move(to: CGPoint(x: g.size.width / 2, y: g.size.height))
|
||||
path.addCurve(to:
|
||||
CGPoint(x: g.size.width * (3/4), y: g.size.height * (1/2)), control1:
|
||||
CGPoint(x: g.size.width / 2, y: g.size.height * (1/2)), control2:
|
||||
CGPoint(x: g.size.width * (3/4), y: g.size.height * (1/2)))
|
||||
path.addCurve(to:
|
||||
CGPoint(x: g.size.width - 13, y: 0), control1:
|
||||
CGPoint(x: g.size.width - 13 , y: g.size.height * (1/2)), control2:
|
||||
CGPoint(x: g.size.width - 13, y: 0))
|
||||
}.stroke(style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||
Path { path in
|
||||
path.move(to: CGPoint(x: g.size.width - 23, y: 0))
|
||||
path.addLine(to: CGPoint(x: g.size.width - 13, y: -10))
|
||||
path.addLine(to: CGPoint(x: g.size.width - 3, y: 0))
|
||||
}.fill()
|
||||
}.frame(height: (windowGeometry.size.height/2) - 20).padding()
|
||||
Text(.emptyStoreModifiableClickHereTitle).bold()
|
||||
Text(.emptyStoreModifiableClickHereDescription)
|
||||
Spacer()
|
||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
struct EmptyStoreModifiableView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
EmptyStoreImmutableView()
|
||||
EmptyStoreModifiableView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
24
Sources/Secretive/Views/Secrets/NoStoresView.swift
Normal file
24
Sources/Secretive/Views/Secrets/NoStoresView.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NoStoresView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text(.noSecureStorageTitle)
|
||||
.bold()
|
||||
Text(.noSecureStorageDescription)
|
||||
Link(.noSecureStorageYubicoLink, destination: URL(string: "https://www.yubico.com/products/compare-yubikey-5-series/")!)
|
||||
}.padding()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
struct NoStoresView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NoStoresView()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
42
Sources/Secretive/Views/Secrets/SecretDetailView.swift
Normal file
42
Sources/Secretive/Views/Secrets/SecretDetailView.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
|
||||
struct SecretDetailView<SecretType: Secret>: View {
|
||||
|
||||
let secret: SecretType
|
||||
|
||||
private let keyWriter = OpenSSHPublicKeyWriter()
|
||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.agentHomeURL)
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
Form {
|
||||
Section {
|
||||
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))
|
||||
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: publicKeyFileStoreController.publicKeyPath(for: secret))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(minHeight: 200, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
|
||||
var keyString: String {
|
||||
keyWriter.openSSHString(secret: secret)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SecretDetailView(secret: Preview.Secret(name: "Demonstration Secret"))
|
||||
}
|
||||
55
Sources/Secretive/Views/Secrets/SecretListItemView.swift
Normal file
55
Sources/Secretive/Views/Secrets/SecretListItemView.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
|
||||
struct SecretListItemView: View {
|
||||
|
||||
@State var store: AnySecretStore
|
||||
var secret: AnySecret
|
||||
|
||||
@State var isDeleting: Bool = false
|
||||
@State var isRenaming: Bool = false
|
||||
|
||||
var deletedSecret: (AnySecret) -> Void
|
||||
var renamedSecret: (AnySecret) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(value: secret) {
|
||||
if secret.authenticationRequirement.required {
|
||||
HStack {
|
||||
Text(secret.name)
|
||||
Spacer()
|
||||
Image(systemName: "lock")
|
||||
}
|
||||
} else {
|
||||
Text(secret.name)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
if store is AnySecretStoreModifiable {
|
||||
Button(action: { isRenaming = true }) {
|
||||
Image(systemName: "pencil")
|
||||
Text(.secretListEditButton)
|
||||
}
|
||||
Button(action: { isDeleting = true }) {
|
||||
Image(systemName: "trash")
|
||||
Text(.secretListDeleteButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
.showingDeleteConfirmation(isPresented: $isDeleting, secret, store as? AnySecretStoreModifiable) { deleted in
|
||||
if deleted {
|
||||
deletedSecret(secret)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isRenaming) {
|
||||
if let modifiable = store as? AnySecretStoreModifiable {
|
||||
EditSecretView(store: modifiable, secret: secret) { renamed in
|
||||
isRenaming = false
|
||||
if renamed {
|
||||
renamedSecret(secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
Sources/Secretive/Views/Secrets/StoreListView.swift
Normal file
64
Sources/Secretive/Views/Secrets/StoreListView.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
|
||||
struct StoreListView: View {
|
||||
|
||||
@Binding var activeSecret: AnySecret?
|
||||
|
||||
@Environment(\.secretStoreList) private var storeList
|
||||
|
||||
private func secretDeleted(secret: AnySecret) {
|
||||
activeSecret = nextDefaultSecret
|
||||
}
|
||||
|
||||
private func secretRenamed(secret: AnySecret) {
|
||||
// Toggle so name updates in list.
|
||||
activeSecret = nil
|
||||
activeSecret = secret
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(selection: $activeSecret) {
|
||||
ForEach(storeList.stores) { store in
|
||||
if store.isAvailable {
|
||||
Section(header: Text(store.name)) {
|
||||
ForEach(store.secrets) { secret in
|
||||
SecretListItemView(
|
||||
store: store,
|
||||
secret: secret,
|
||||
deletedSecret: secretDeleted,
|
||||
renamedSecret: secretRenamed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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 {
|
||||
EmptyStoreView(store: storeList.modifiableStore ?? storeList.stores.first)
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.onAppear {
|
||||
activeSecret = nextDefaultSecret
|
||||
}
|
||||
.frame(minWidth: 100, idealWidth: 240)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
extension StoreListView {
|
||||
|
||||
private var nextDefaultSecret: AnySecret? {
|
||||
return storeList.allSecrets.first
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user