import SwiftUI /// A repository's build secrets: their names, a way to set or replace /// one, and removal. Values are never shown, here or anywhere. struct BuildSecretsView: View { @State private var model: BuildSecretsViewModel @State private var adding = false @State private var removing: String? init(client: GitbayClient, repo: String) { _model = State(initialValue: BuildSecretsViewModel(client: client, repoPath: repo)) } var body: some View { List { if let error = model.actionError, !adding { Section { GBNotice(error, .gbWarn) } } Section { ForEach(model.state.value ?? [], id: \.self) { name in Text(name) .font(.gbMono(.subheadline)) .swipeActions { Button("Remove", role: .destructive) { removing = name } .disabled(model.working) } } } footer: { if model.state.value != nil { Text("Builds read each secret as an environment variable. Setting a name again replaces its value.") } } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("Build Secrets") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { adding = true } label: { Image(systemName: "plus") } .accessibilityLabel("Set Secret") .disabled(model.working) .accessibilityIdentifier("secret-add-button") } } .sheet(isPresented: $adding) { SecretSetSheet(model: model) { adding = false } } .confirmationDialog( "Remove \(removing ?? "")? Builds that read it will no longer find it.", isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), titleVisibility: .visible ) { Button("Remove", role: .destructive) { if let name = removing { Task { await model.remove(name) } } removing = nil } Button("Cancel", role: .cancel) {} } .task { await model.load() } .refreshable { await model.load() } } } private struct SecretSetSheet: View { let model: BuildSecretsViewModel let onDone: () -> Void @State private var name = "" @State private var value = "" @State private var multiLine = false /// Errors shown here are this sheet's own, not an earlier list action's. @State private var attempted = false var body: some View { NavigationStack { Form { Section { TextField("NAME", text: $name) .font(.gbMono(.body)) .autocorrectionDisabled() .textInputAutocapitalization(.characters) .accessibilityIdentifier("secret-name") if multiLine { TextEditor(text: $value) .frame(minHeight: 120) .font(.gbMono(.caption)) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("secret-value-multiline") } else { SecureField("Value", text: $value) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("secret-value") } Toggle("Multi-line value", isOn: $multiLine) } footer: { Text(multiLine ? "Shown while you type, for a value with line breaks such as a private key. It is sent once and cannot be read back." : "Uppercase letters, digits and underscores. The value is sent once and cannot be read back.") } if attempted, let error = model.actionError { Section { GBNotice(error, .gbWarn) } } } .navigationTitle("Set Secret") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { onDone() }.disabled(model.working) } ToolbarItem(placement: .confirmationAction) { if model.working { ProgressView() } else { Button("Set") { Task { attempted = true guard await model.set(name: name, value: value) else { return } // A secure field that still holds text as the sheet // goes makes iOS offer to save it as a password. value = "" await Task.yield() onDone() } } .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || value.isEmpty) .accessibilityIdentifier("secret-submit") } } } .interactiveDismissDisabled(model.working) } } } /// A repository's deploy keys: keys bound to it alone, read-only unless /// they may push, with when each was used and when it expires. struct DeployKeysView: View { @State private var model: DeployKeysViewModel @State private var adding = false @State private var removing: DeployKey? init(client: GitbayClient, repo: String) { _model = State(initialValue: DeployKeysViewModel(client: client, repoPath: repo)) } var body: some View { List { if let error = model.actionError, !adding { Section { GBNotice(error, .gbWarn) } } Section { ForEach(model.state.value ?? []) { key in VStack(alignment: .leading, spacing: 3) { if !key.label.isEmpty { Text(key.label).font(.gbSans(.subheadline)) } Text(key.fingerprint) .font(.gbMono(.caption)) .lineLimit(1) .truncationMode(.middle) HStack(spacing: 6) { GBChip(key.canPush ? "read-write" : "read-only", key.canPush ? Color.gbWarn : Color.secondary) dates(key) } } .padding(.vertical, 2) .accessibilityElement(children: .combine) .swipeActions { Button("Remove", role: .destructive) { removing = key } .disabled(model.working) } } } footer: { if model.state.value != nil { Text("A deploy key reaches this repository and nothing else. A read-write key can push.") } } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("Deploy Keys") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { adding = true } label: { Image(systemName: "plus") } .accessibilityLabel("Add Deploy Key") .disabled(model.working) .accessibilityIdentifier("deploy-key-add-button") } } .sheet(isPresented: $adding) { DeployKeyAddSheet(model: model) { adding = false } } .confirmationDialog( "Remove the deploy key \(removing.map { $0.label.isEmpty ? $0.fingerprint : $0.label } ?? "")?", isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), titleVisibility: .visible ) { Button("Remove", role: .destructive) { if let key = removing { Task { await model.remove(key) } } removing = nil } Button("Cancel", role: .cancel) {} } .task { await model.load() } .refreshable { await model.load() } } private func dates(_ key: DeployKey) -> some View { HStack(spacing: 4) { if let used = key.lastUsedAt { Text("used \(used.formatted(.relative(presentation: .named)))") } else { Text("never used") } if let expires = key.expiresAt { Text("ยท") if expires <= .now { Text("expired \(expires.formatted(date: .abbreviated, time: .omitted))") .foregroundStyle(Color.gbBad) } else { Text("expires \(expires.formatted(date: .abbreviated, time: .omitted))") } } } .font(.gbSans(.caption2)) .foregroundStyle(.secondary) } } private struct DeployKeyAddSheet: View { let model: DeployKeysViewModel let onDone: () -> Void @State private var text = "" @State private var canPush = false @State private var ttl: String? @State private var attempted = false var body: some View { NavigationStack { Form { Section { TextEditor(text: $text) .frame(minHeight: 120) .font(.gbMono(.caption)) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("deploy-key-text") } footer: { Text("Paste the public key, as in authorized_keys. Its comment becomes the label.") } Section { Toggle("Allow pushes", isOn: $canPush) .accessibilityIdentifier("deploy-key-rw") Picker("Expires", selection: $ttl) { Text("Never").tag(String?.none) Text("In 30 days").tag(String?.some("30d")) Text("In 90 days").tag(String?.some("90d")) Text("In a year").tag(String?.some("365d")) } } if attempted, let error = model.actionError { Section { GBNotice(error, .gbWarn) } } } .navigationTitle("Add Deploy Key") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { onDone() }.disabled(model.working) } ToolbarItem(placement: .confirmationAction) { if model.working { ProgressView() } else { Button("Add") { Task { attempted = true if await model.add(publicKey: text, canPush: canPush, ttl: ttl) { onDone() } } } .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .accessibilityIdentifier("deploy-key-submit") } } } .interactiveDismissDisabled(model.working) } } }