Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 96 additions & 45 deletions Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import SwiftUI
import AskKeyBroker

/// The frozen content of a create, modify or delete approval: the before and
/// after summary and the separately authenticated reveal of the values.
/// The operation's frozen summary and separately authenticated value reveal.
struct FrozenWriteApprovalContent: View {
var writeSummary: BrokerCredentialWriteSummary?
var revealMaterial: (@MainActor () async throws -> FrozenApprovalMaterial)?
Expand All @@ -14,61 +13,113 @@ struct FrozenWriteApprovalContent: View {
var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
if let writeSummary {
let content = FrozenWriteSummaryContent(summary: writeSummary)
ScrollView {
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text(appLocalized("Before"))
ForEach(Array(writeSummary.before.enumerated()), id: \.offset) { _, item in
Text("\(item.name) · \(item.byteCount) B · \(item.delivery.environmentVariable ?? "App")")
}
Text(appLocalized("After"))
ForEach(Array(writeSummary.after.enumerated()), id: \.offset) { _, item in
Text("\(item.name) · \(item.byteCount) B · \(item.delivery.environmentVariable ?? "App")")
}
}.frame(maxWidth: .infinity, alignment: .leading)
summaryRows(content.components)
}
.font(Theme.Fonts.caption)
.foregroundStyle(Theme.textSecondary)
.frame(maxHeight: 70)
.accessibilityIdentifier("approval-components")
metadataContent(content)
}
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text(appLocalized("Frozen Content to Write"))
.font(Theme.Fonts.caption.weight(.semibold))
if let revealedMaterial {
HStack {
Text(revealedMaterial.title).lineLimit(1)
Spacer()
Text(revealedMaterial.encoding).foregroundStyle(Theme.textSecondary)
Button(appLocalized("Hide")) { self.revealedMaterial = nil }
}.font(Theme.Fonts.caption)
ScrollView {
Text(verbatim: revealedMaterial.content)
.font(Theme.Fonts.mono)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}.frame(height: 85)
} else {
HStack {
Text("••••••••").foregroundStyle(Theme.textSecondary)
Spacer()
Button(appLocalized("Authenticate and View"), action: reveal)
.disabled(revealMaterial == nil || revealing)
.accessibilityIdentifier("approval-reveal-frozen-material")
}
Text(appLocalized("Viewing requires separate authentication and does not approve this request."))
.font(Theme.Fonts.caption).foregroundStyle(Theme.textSecondary)
}
if revealFailed {
Text(appLocalized("Unable to view: authentication was not completed or the request is no longer valid."))
.font(Theme.Fonts.caption).foregroundStyle(Theme.warning)
}
if writeSummary?.operation != .delete {
frozenContent
}
.padding(Theme.Spacing.md)
.background(Theme.neutralSubtle, in: .rect(cornerRadius: Theme.Radius.group))
}
.font(Theme.Fonts.secondary)
.onDisappear { revealTask?.cancel(); revealTask = nil; revealedMaterial = nil }
}

private var frozenContent: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text(appLocalized("Frozen Content to Write"))
.font(Theme.Fonts.caption.weight(.semibold))
if let revealedMaterial {
HStack {
Text(revealedMaterial.title).lineLimit(1)
Spacer()
Text(revealedMaterial.encoding).foregroundStyle(Theme.textSecondary)
Button(appLocalized("Hide")) { self.revealedMaterial = nil }
}.font(Theme.Fonts.caption)
ScrollView {
Text(verbatim: revealedMaterial.content)
.font(Theme.Fonts.mono)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}.frame(height: 85)
} else {
HStack {
Text("••••••••").foregroundStyle(Theme.textSecondary)
Spacer()
Button(appLocalized("Authenticate and View"), action: reveal)
.disabled(revealMaterial == nil || revealing)
.accessibilityIdentifier("approval-reveal-frozen-material")
}
Text(appLocalized("Viewing requires separate authentication and does not approve this request."))
.font(Theme.Fonts.caption).foregroundStyle(Theme.textSecondary)
}
if revealFailed {
Text(appLocalized("Unable to view: authentication was not completed or the request is no longer valid."))
.font(Theme.Fonts.caption).foregroundStyle(Theme.warning)
}
}
.padding(Theme.Spacing.md)
.background(Theme.neutralSubtle, in: .rect(cornerRadius: Theme.Radius.group))
}

private func metadataContent(_ content: FrozenWriteSummaryContent) -> some View {
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text(appLocalized("Usage instructions"))
.font(Theme.Fonts.caption.weight(.semibold))
ScrollView {
summaryRows(content.instructions)
}
.frame(maxHeight: 60)
.accessibilityIdentifier("approval-usage-instructions")
Text(appLocalized("Group"))
.font(Theme.Fonts.caption.weight(.semibold))
ScrollView {
summaryRows(content.group)
}
.frame(maxHeight: 30)
.accessibilityIdentifier("approval-group-changes")
if content.createsGroup {
Text(appLocalized("New group — created when approved"))
.foregroundStyle(Theme.warning)
.accessibilityIdentifier("approval-new-group")
}
}
.font(Theme.Fonts.caption)
.accessibilityIdentifier("approval-credential-metadata")
}

private func summaryRows(_ rows: [FrozenWriteSummaryContent.Row]) -> some View {
Grid(alignment: .topLeading, horizontalSpacing: Theme.Spacing.sm, verticalSpacing: Theme.Spacing.xs) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
GridRow {
if let label = row.label {
Text(verbatim: label).foregroundStyle(Theme.textSecondary).fixedSize()
}
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
ForEach(Array(row.values.enumerated()), id: \.offset) { _, value in
summaryText(value)
}
}
.gridCellColumns(row.label == nil ? 2 : 1)
}
}
}.frame(maxWidth: .infinity, alignment: .leading)
}

private func summaryText(_ value: String) -> some View {
Text(verbatim: value)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}

private func reveal() {
guard let revealMaterial, !revealing else { return }
revealing = true
Expand Down
61 changes: 61 additions & 0 deletions Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import AskKeyBroker

/// Presentation only: the frozen summary remains the approval's source of truth.
struct FrozenWriteSummaryContent {
struct Row: Equatable {
let label: String?
let values: [String]
}

let components: [Row]
let instructions: [Row]
let group: [Row]
let createsGroup: Bool

init(summary: BrokerCredentialWriteSummary) {
let beforeComponents = summary.before.map(Self.componentText)
let afterComponents = summary.after.map(Self.componentText)
if beforeComponents.isEmpty && afterComponents.isEmpty {
components = []
} else {
// Equal byte counts and delivery descriptors do not prove equal values.
let unchanged = summary.before == summary.after && summary.beforeDigest != nil
&& summary.beforeDigest == summary.afterDigest
components = Self.rows(operation: summary.operation, before: beforeComponents,
after: afterComponents, unchanged: unchanged)
}
instructions = Self.rows(operation: summary.operation,
before: [Self.instructionsText(summary.beforeUsageInstructions)],
after: [Self.instructionsText(summary.afterUsageInstructions)],
unchanged: (summary.beforeUsageInstructions ?? "") == (summary.afterUsageInstructions ?? ""))
group = Self.rows(operation: summary.operation,
before: [summary.beforeGroup ?? appLocalized("Ungrouped")],
after: [summary.afterGroup ?? appLocalized("Ungrouped")],
unchanged: summary.beforeGroup == summary.afterGroup)
createsGroup = summary.createsGroup && (summary.operation == .create || summary.operation == .modify)
}

private static func rows(operation: BrokerApprovalOperation, before: [String],
after: [String], unchanged: Bool) -> [Row] {
switch operation {
case .create:
return after.isEmpty ? [] : [.init(label: nil, values: after)]
case .delete, .read:
return before.isEmpty ? [] : [.init(label: nil, values: before)]
case .modify:
if unchanged {
return [.init(label: appLocalized("Unchanged"), values: before)]
}
return [.init(label: appLocalized("Before"), values: before),
.init(label: appLocalized("After"), values: after)]
}
}

private static func instructionsText(_ value: String?) -> String {
value.flatMap { $0.isEmpty ? nil : $0 } ?? appLocalized("None")
}

private static func componentText(_ item: BrokerCredentialComponentSummary) -> String {
"\(item.name) · \(item.byteCount) B · \(item.delivery.environmentVariable ?? "App")"
}
}
24 changes: 24 additions & 0 deletions Sources/AskKeyAppKit/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
@@ -1,6 +1,30 @@
{
"sourceLanguage": "en",
"strings": {
"Unchanged": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Unchanged" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "不变" } },
"qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Unchanged!!]" } }
}
},
"New group — created when approved": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "New group — created when approved" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "新分组 · 批准后创建" } },
"qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!New group — created when approved!!]" } }
}
},
"None": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "None" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "无" } },
"qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!None!!]" } }
}
},
"Requested by": {
"extractionState": "manual",
"localizations": {
Expand Down
4 changes: 2 additions & 2 deletions Sources/AskKeyBroker/AgentTextWriteAction.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import Foundation

public enum AgentTextWriteAction: Codable, Equatable, Sendable {
case createBundle(name: String, components: [BrokerCredentialComponentInput])
case modifyBundle(name: String, changes: [BrokerCredentialComponentChange])
case createBundle(name: String, components: [BrokerCredentialComponentInput], usageInstructions: String? = nil, group: String? = nil)
case modifyBundle(name: String, changes: [BrokerCredentialComponentChange] = [], usageInstructions: String? = nil, group: BrokerCredentialGroupChange? = nil)
case create(name: String, value: String)
case modify(name: String, value: String)
case delete(name: String)
Expand Down
20 changes: 19 additions & 1 deletion Sources/AskKeyBroker/BrokerCatalogItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,34 @@ public struct BrokerCatalogItem: Codable, Equatable, Sendable {
public let name: String
public let payloadKind: BrokerCatalogPayloadKind
public let usageInstructions: String
public let group: String?
public let environmentVariable: String?
public let expired: Bool

public init(credentialID: String? = nil, name: String, payloadKind: BrokerCatalogPayloadKind, usageInstructions: String, environmentVariable: String?, expired: Bool, components: [BrokerCatalogComponent]? = nil) {
public init(credentialID: String? = nil, name: String, payloadKind: BrokerCatalogPayloadKind, usageInstructions: String, environmentVariable: String?, expired: Bool, components: [BrokerCatalogComponent]? = nil, group: String? = nil) {
self.credentialID = credentialID
self.components = components
self.name = name
self.payloadKind = payloadKind
self.usageInstructions = usageInstructions
self.group = group
self.environmentVariable = environmentVariable
self.expired = expired
}

private enum CodingKeys: String, CodingKey {
case credentialID, components, name, payloadKind, usageInstructions, group, environmentVariable, expired
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(credentialID, forKey: .credentialID)
try container.encodeIfPresent(components, forKey: .components)
try container.encode(name, forKey: .name)
try container.encode(payloadKind, forKey: .payloadKind)
try container.encode(usageInstructions, forKey: .usageInstructions)
try container.encode(group, forKey: .group)
try container.encodeIfPresent(environmentVariable, forKey: .environmentVariable)
try container.encode(expired, forKey: .expired)
}
}
37 changes: 29 additions & 8 deletions Sources/AskKeyBroker/CredentialComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ public enum BrokerCredentialComponentChange: Codable, Equatable, Sendable {
case remove(String)
}

/// Nil on the action preserves the group; Ungrouped explicitly clears it.
public enum BrokerCredentialGroupChange: Codable, Equatable, Sendable {
case named(String)
case ungrouped
}

public struct BrokerCredentialComponentSummary: Equatable, Sendable {
public let name: String
public let payloadKind: BrokerCatalogPayloadKind
Expand All @@ -104,24 +110,36 @@ public struct BrokerCredentialComponentSummary: Equatable, Sendable {
}
}

/// App-only approval projection; no values, upload capabilities or plaintext.
/// App-only approval projection; no credential values or upload capabilities.
public struct BrokerCredentialWriteSummary: Equatable, Sendable {
public let credentialName: String
public let operation: BrokerApprovalOperation
public let before: [BrokerCredentialComponentSummary]
public let after: [BrokerCredentialComponentSummary]
public let beforeDigest: String?
public let afterDigest: String?
public let beforeUsageInstructions: String?
public let afterUsageInstructions: String?
public let beforeGroup: String?
public let afterGroup: String?
public let createsGroup: Bool

public init(credentialName: String, operation: BrokerApprovalOperation,
before: [BrokerCredentialComponentSummary], after: [BrokerCredentialComponentSummary],
beforeDigest: String?, afterDigest: String?) {
beforeDigest: String?, afterDigest: String?,
beforeUsageInstructions: String? = nil, afterUsageInstructions: String? = nil,
beforeGroup: String? = nil, afterGroup: String? = nil, createsGroup: Bool = false) {
self.credentialName = credentialName
self.operation = operation
self.before = before
self.after = after
self.beforeDigest = beforeDigest
self.afterDigest = afterDigest
self.beforeUsageInstructions = beforeUsageInstructions
self.afterUsageInstructions = afterUsageInstructions
self.beforeGroup = beforeGroup
self.afterGroup = afterGroup
self.createsGroup = createsGroup
}
}

Expand All @@ -137,7 +155,7 @@ public extension AgentTextWriteAction {
var credentialName: String {
switch self {
case .create(let name, _), .modify(let name, _), .delete(let name),
.createBundle(let name, _), .modifyBundle(let name, _): return name
.createBundle(let name, _, _, _), .modifyBundle(let name, _, _, _): return name
}
}
}
Expand All @@ -146,8 +164,8 @@ public extension AgentTextWriteRequest {
var componentFileReferences: [BrokerComponentFileReference] {
let components: [BrokerCredentialComponentInput]
switch action {
case .createBundle(_, let inputs): components = inputs
case .modifyBundle(_, let changes):
case .createBundle(_, let inputs, _, _): components = inputs
case .modifyBundle(_, let changes, _, _):
components = changes.compactMap { if case .upsert(let input) = $0 { return input }; return nil }
default: components = []
}
Expand All @@ -174,10 +192,13 @@ public extension AgentTextWriteRequest {
switch action {
case .create(_, let value), .modify(_, let value): fields.append(value)
case .delete: break
case .createBundle(_, let components):
case .createBundle(_, let components, let instructions, let group):
guard !components.isEmpty, components.count <= 64, components.allSatisfy(add) else { return false }
case .modifyBundle(_, let changes):
guard !changes.isEmpty, changes.count <= 64 else { return false }
fields += [instructions, group].compactMap { $0 }
case .modifyBundle(_, let changes, let instructions, let group):
guard (!changes.isEmpty || instructions != nil || group != nil), changes.count <= 64 else { return false }
if let instructions { fields.append(instructions) }
if case .named(let name) = group { fields.append(name) }
for change in changes {
switch change {
case .upsert(let component): guard add(component) else { return false }
Expand Down
Loading
Loading