diff --git a/Sources/Clavis/State/AppState.swift b/Sources/Clavis/State/AppState.swift index c7bdff0..3ca0ac1 100644 --- a/Sources/Clavis/State/AppState.swift +++ b/Sources/Clavis/State/AppState.swift @@ -479,7 +479,8 @@ public class AppState: ObservableObject { algorithm: String = "ECDSA P-256", storageType: KeyStorageType = .secureEnclave, biometricPolicy: BiometricPolicy? = nil, - keyPurpose: KeyPurpose = .general + keyPurpose: KeyPurpose = .general, + accessProfile: KeyAccessProfile? = nil ) throws -> SSHKeyInfo { defer { self.keys = keyManagementViewModel.keys @@ -490,7 +491,8 @@ public class AppState: ObservableObject { algorithm: algorithm, storageType: storageType, biometricPolicy: biometricPolicy, - keyPurpose: keyPurpose + keyPurpose: keyPurpose, + accessProfile: accessProfile ) // Generating a key already authenticates the user; transition the panel out // of its initial locked state so leaving empty-vault bypass does not lock the window. @@ -529,6 +531,16 @@ public class AppState: ObservableObject { try changeKeyKind(label: label, to: newPurpose) } + public func changeAccessProfile(label: String, type: KeyType? = nil, capabilities: KeyCapabilities? = nil) throws { + defer { + self.keys = keyManagementViewModel.keys + self.errorMessage = keyManagementViewModel.errorMessage + refreshAgentSessions() + updateGitGraceState() + } + try keyManagementViewModel.changeAccessProfile(label: label, type: type, capabilities: capabilities) + } + public func lockKey(label: String) throws { defer { self.keys = keyManagementViewModel.keys diff --git a/Sources/Clavis/State/KeyManagementViewModel.swift b/Sources/Clavis/State/KeyManagementViewModel.swift index ae1a1f3..6f5ef2c 100644 --- a/Sources/Clavis/State/KeyManagementViewModel.swift +++ b/Sources/Clavis/State/KeyManagementViewModel.swift @@ -37,14 +37,16 @@ public class KeyManagementViewModel: ObservableObject { algorithm: String = "ECDSA P-256", storageType: KeyStorageType = .secureEnclave, biometricPolicy: BiometricPolicy? = nil, - keyPurpose: KeyPurpose = .general + keyPurpose: KeyPurpose = .general, + accessProfile: KeyAccessProfile? = nil ) throws -> SSHKeyInfo { let info = try keyManager.generateKey( label: label, algorithm: algorithm, storageType: storageType, biometricPolicy: biometricPolicy, - keyPurpose: keyPurpose + keyPurpose: keyPurpose, + accessProfile: accessProfile ) refresh() return info @@ -70,6 +72,11 @@ public class KeyManagementViewModel: ObservableObject { try changeKeyKind(label: label, to: newPurpose) } + public func changeAccessProfile(label: String, type: KeyType? = nil, capabilities: KeyCapabilities? = nil) throws { + defer { refresh() } + try keyManager.changeAccessProfile(label: label, type: type, capabilities: capabilities) + } + public func lockKey(label: String) throws { try keyManager.lockKey(label: label) refresh() diff --git a/Sources/Clavis/Views/KeyDetailInspectorView.swift b/Sources/Clavis/Views/KeyDetailInspectorView.swift index 88cf2d9..3b2bb93 100644 --- a/Sources/Clavis/Views/KeyDetailInspectorView.swift +++ b/Sources/Clavis/Views/KeyDetailInspectorView.swift @@ -167,8 +167,6 @@ public struct KeyDetailInspectorView: View { @State private var isRenaming: Bool = false @State private var draftKeyName: String = "" @State private var showingDeleteAlert: Bool = false - @State private var showingPurposeSheet: Bool = false - @State private var showingApprovalSheet: Bool = false @State private var changePurposeError: String? = nil @State private var showingChangePurposeError: Bool = false @State private var lastActivityDate: Date? = nil @@ -226,24 +224,6 @@ public struct KeyDetailInspectorView: View { : "Touch ID, Apple Watch, or your Mac password." } - private var currentAgentPolicy: AgentKeyPolicy? { - appState.getAgentPolicy(fingerprint: key.fingerprint) - } - - private var currentGracePeriod: KeyGracePeriod { - appState.gracePeriod(for: key) - } - - private var gracePeriodBinding: Binding { - Binding( - get: { appState.gracePeriod(for: key) }, - set: { newValue in - appState.setGracePeriod(newValue, for: key) - onShowToast?("Key grace period updated") - } - ) - } - private var activeGitGraceSession: (remainingSeconds: Int, remainingOperations: Int, formattedTime: String)? { if let grace = appState.activeGitGraces.first(where: { $0.keyLabel == key.label }) { return (grace.remainingSeconds, grace.remainingOperations, grace.formattedRemainingTime) @@ -260,10 +240,6 @@ public struct KeyDetailInspectorView: View { return nil } - private var activeAgentSessionForKey: AgentSessionSummary? { - appState.agentSessions.first(where: { $0.keyFingerprint == key.fingerprint }) - } - private var formattedDate: String { let formatter = DateFormatter() formatter.dateStyle = .medium @@ -302,8 +278,11 @@ public struct KeyDetailInspectorView: View { } publicIdentitySection - signingSection + activeSessionsSection + operationSection(.ssh) + operationSection(.signature) activitySection + deleteSection } .formStyle(.grouped) .scrollContentBackground(.hidden) @@ -315,30 +294,6 @@ public struct KeyDetailInspectorView: View { isRenaming = false lastActivityDate = appState.lastActivityDate(for: key) } - .sheet(isPresented: $showingPurposeSheet) { - KeyPurposeEditorSheet( - key: key, - appState: appState, - onSaved: { message in - onShowToast?(message) - } - ) - .dismissOnOutsideClick { - showingPurposeSheet = false - } - } - .sheet(isPresented: $showingApprovalSheet) { - KeyApprovalEditorSheet( - key: key, - appState: appState, - onSaved: { message in - onShowToast?(message) - } - ) - .dismissOnOutsideClick { - showingApprovalSheet = false - } - } .alert(Self.deleteConfirmationTitle(label: key.label), isPresented: $showingDeleteAlert) { Button(ClavisUIStrings.Common.delete, role: .destructive) { ClavisLogger.promptDebug("calvis-ui", "KeyDetailInspectorView: user confirmed deletion of key '\(key.label)'") @@ -362,7 +317,7 @@ public struct KeyDetailInspectorView: View { private var detailHeading: some View { HStack(alignment: .center, spacing: 12) { ZStack { - Image(systemName: "key.fill") + Image(systemName: key.effectiveAccessProfile.type == .bot ? "desktopcomputer" : "person") .font(.system(size: 20, weight: .medium)) .foregroundColor(DesignTokens.accentBlue) } @@ -407,16 +362,7 @@ public struct KeyDetailInspectorView: View { } .frame(maxWidth: .infinity, alignment: .leading) - CircularGlassIconButton( - icon: "trash", - size: 34, - iconSize: 15, - isDanger: true, - helpText: "Delete key", - accessibilityLabel: "Delete \(key.label)" - ) { - showingDeleteAlert = true - } + } .frame(minHeight: 43) } @@ -451,7 +397,14 @@ public struct KeyDetailInspectorView: View { ? key.fingerprint : "SHA256:\(key.fingerprint)" - return Section("Public identity") { + return Section("Identity") { + Picker("Type", selection: Binding( + get: { key.effectiveAccessProfile.type }, + set: { type in changeProfile(type: type) } + )) { + ForEach(KeyType.allCases, id: \.self) { Text($0.title).tag($0) } + } + .help("Changing key type requires fresh authentication and revokes approvals.") identityCopyRow( label: "Public key", fullValue: key.publicKeyOpenSSH, @@ -499,107 +452,75 @@ public struct KeyDetailInspectorView: View { } } - // MARK: - Section 2: Signing - - private var signingSection: some View { - let approvalText = Self.approvalSummaryText(for: key, agentPolicy: currentAgentPolicy) - let approvalHelp = Self.approvalDescriptionText(for: key, agentPolicy: currentAgentPolicy) + private func changeProfile(type: KeyType? = nil, capabilities: KeyCapabilities? = nil) { + do { + try appState.changeAccessProfile(label: key.label, type: type, capabilities: capabilities) + } catch { + changePurposeError = error.localizedDescription + showingChangePurposeError = true + } + } - return Section("Signing") { - LabeledContent("Purpose") { - Button(action: { showingPurposeSheet = true }) { - HStack(spacing: 6) { - Text(key.purpose.inspectorLabel) - .font(.system(size: 13)) - .foregroundColor(.primary) - Image(systemName: "chevron.right") - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(DesignTokens.textSecondary) - } + private func operationSection(_ operation: KeyOperation) -> some View { + let profile = key.effectiveAccessProfile + return Section(operation == .ssh ? "SSH" : "Signature") { + Toggle("Enabled", isOn: Binding( + get: { profile.capabilities.permits(operation) }, + set: { enabled in + changeProfile(capabilities: KeyCapabilities( + ssh: operation == .ssh ? enabled : profile.capabilities.ssh, + signature: operation == .signature ? enabled : profile.capabilities.signature)) } - .buttonStyle(.plain) - } - - LabeledContent("Approval") { - Button(action: { showingApprovalSheet = true }) { - HStack(spacing: 6) { - Text(approvalText) - .font(.system(size: 13)) - .foregroundColor(.primary) - Image(systemName: "chevron.right") - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(DesignTokens.textSecondary) - } + )) + .toggleStyle(.switch) + if profile.capabilities.permits(operation) { + LabeledContent("Approval") { + Text(Self.approvalSummaryText(for: key)).foregroundStyle(.secondary) } - .buttonStyle(.plain) - .help("Edit signing approval: \(approvalHelp)") } + } + } - Picker("Grace period", selection: gracePeriodBinding) { - ForEach(KeyGracePeriod.allCases) { option in - Text(option.title).tag(option) + private var activeSessionsSection: some View { + let sessions = appState.agentSessions.filter { $0.keyFingerprint == key.fingerprint } + return Section("Active Sessions") { + VStack(spacing: 12) { + if let grace = activeGitGraceSession { + HStack { + Label("Git signing", systemImage: "signature") + Spacer() + Text(grace.formattedTime).monospacedDigit().foregroundStyle(.secondary) + Button("Revoke") { appState.revokeGitGrace(keyLabel: key.label) } + } } - } - .help("How long this key stays available after approval. Per session lasts until Clavis quits; locking the key ends the window immediately.") - - if currentGracePeriod != .always || activeGitGraceSession != nil || activeAgentSessionForKey != nil { - activeWindowRow - } - - if let gitGrace = activeGitGraceSession, key.purpose != .agent { - LabeledContent("Git signing window") { - HStack(spacing: 8) { - Text("\(gitGrace.formattedTime) · \(gitGrace.remainingOperations) left") - .font(.system(size: 12).monospacedDigit()) - .foregroundColor(DesignTokens.accentGreen) - Button("Revoke") { - appState.revokeGitGrace(keyLabel: key.label) - onShowToast?("Signing window revoked") + ForEach(sessions, id: \.id) { session in + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(session.toolName).lineLimit(1) + Text("SSH · expires \(session.expiresAt.formatted(date: .omitted, time: .shortened))") + .font(.caption).foregroundStyle(.secondary) } - .liquidGlassButton() - .controlSize(.small) + Spacer() + Button("Revoke") { appState.endAgentSession(id: session.id) } } } + if sessions.isEmpty && activeGitGraceSession == nil { + Text("No active approvals. Clavis will ask on the next permitted request.") + .font(.callout).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } } + .frame(minHeight: 48) } } - private var activeWindowRow: some View { - let isUnlocked = appState.isKeyUnlocked(key) - let statusText: String = { - if let gitGrace = activeGitGraceSession { - return "\(gitGrace.formattedTime) remaining" - } - if let session = activeAgentSessionForKey { - let remaining = max(0, Int(session.expiresAt.timeIntervalSinceNow)) - let mins = remaining / 60 - let secs = remaining % 60 - return String(format: "%d:%02d remaining", mins, secs) - } - if currentGracePeriod == .session && isUnlocked { - return "This session" - } - return "Approval required" - }() - - return LabeledContent("Active window") { - HStack(spacing: 8) { - Text(statusText) - .font(.system(size: 12).monospacedDigit()) - .foregroundColor(isUnlocked ? DesignTokens.accentGreen : DesignTokens.textSecondary) - .lineLimit(1) - - if isUnlocked { - Button("Revoke") { - appState.revokeGitGrace(keyLabel: key.label) - if let session = activeAgentSessionForKey { - appState.endAgentSession(id: session.id) - } - onShowToast?("Grace window revoked · approval required") - } - .liquidGlassButton() - .controlSize(.small) - } + private var deleteSection: some View { + Section("Delete Key") { + HStack { + Text("Delete this key and revoke its approvals.") + .font(.callout).foregroundStyle(.secondary) + Spacer() + Button("Delete Key…", role: .destructive) { showingDeleteAlert = true } } } } @@ -650,327 +571,6 @@ public struct KeyDetailInspectorView: View { } } -// MARK: - Key Purpose Sheet - -private struct KeyPurposeEditorSheet: View { - let key: SSHKeyInfo - @ObservedObject var appState: AppState - var onSaved: (String) -> Void - - @Environment(\.dismiss) private var dismiss - @State private var selectedPurpose: KeyPurpose - @State private var errorMessage: String? - - init(key: SSHKeyInfo, appState: AppState, onSaved: @escaping (String) -> Void) { - self.key = key - self.appState = appState - self.onSaved = onSaved - self._selectedPurpose = State(initialValue: key.purpose) - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack { - Text("Key purpose") - .font(.system(size: 17, weight: .semibold)) - Spacer() - CircularGlassIconButton( - icon: "xmark", - size: 26, - iconSize: 11, - helpText: ClavisUIStrings.Common.cancel, - accessibilityLabel: ClavisUIStrings.Common.cancel - ) { - dismiss() - } - } - .padding(.horizontal, 23) - .padding(.top, 20) - .padding(.bottom, 16) - - Divider() - - VStack(alignment: .leading, spacing: 16) { - Text("Choose where you want to use \(key.label).") - .font(.system(size: 12)) - .foregroundColor(DesignTokens.textSecondary) - - if key.purpose == .gitSigningOnly { - HStack(alignment: .top, spacing: 10) { - Image(systemName: "lock.shield") - .foregroundColor(DesignTokens.accentBlue) - Text("Git-signing-only keys are permanently scoped to local Git commit and tag signing and cannot be converted into SSH or Agent keys.") - .font(.system(size: 12)) - .foregroundColor(DesignTokens.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(12) - .glassCard(cornerRadius: 9) - } else { - Form { - Section { - Picker("Purpose", selection: $selectedPurpose) { - Text(KeyPurpose.general.inspectorLabel).tag(KeyPurpose.general) - Text(KeyPurpose.agent.inspectorLabel).tag(KeyPurpose.agent) - } - } - } - .groupedEmbeddedForm(zeroMargins: true) - - HStack(alignment: .center, spacing: 10) { - Image(systemName: "checkmark.shield") - .foregroundColor(DesignTokens.accentBlue) - Text("Changing purpose resets approval to “Always ask”. You can edit the policy afterward.") - .font(.system(size: 12)) - .foregroundColor(DesignTokens.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(12) - .glassCard(cornerRadius: 9) - } - - if let errorMessage { - Text(errorMessage) - .font(.system(size: 11)) - .foregroundColor(.red) - } - } - .padding(.horizontal, 23) - .padding(.vertical, 20) - - Divider() - - HStack { - Spacer() - Button(ClavisUIStrings.Common.cancel) { - dismiss() - } - .liquidGlassButton(prominent: false) - .keyboardShortcut(.cancelAction) - - if key.purpose != .gitSigningOnly { - Button("Save purpose") { - savePurpose() - } - .liquidGlassButton(prominent: true) - .keyboardShortcut(.defaultAction) - .disabled(selectedPurpose == key.purpose) - } - } - .padding(.horizontal, 23) - .padding(.vertical, 14) - } - .frame(width: 440) - } - - private func savePurpose() { - do { - try appState.changeKeyKind(label: key.label, to: selectedPurpose) - dismiss() - onSaved("Key purpose updated") - } catch { - errorMessage = error.localizedDescription - } - } -} - -// MARK: - Signing Approval Sheet - -private struct KeyApprovalEditorSheet: View { - let key: SSHKeyInfo - @ObservedObject var appState: AppState - var onSaved: (String) -> Void - - @Environment(\.dismiss) private var dismiss - @State private var selectedMode: AgentKeyPolicy.Mode - @State private var maxRatePerMinute: Int - @State private var allowedHostsText: String - @State private var errorMessage: String? - - init(key: SSHKeyInfo, appState: AppState, onSaved: @escaping (String) -> Void) { - self.key = key - self.appState = appState - self.onSaved = onSaved - let policy = appState.getAgentPolicy(fingerprint: key.fingerprint) ?? AgentKeyPolicy() - self._selectedMode = State(initialValue: policy.mode) - self._maxRatePerMinute = State(initialValue: policy.refillPerMinute) - self._allowedHostsText = State( - initialValue: policy.allowedHosts.isEmpty - ? "github.com" - : policy.allowedHosts.map(\.name).joined(separator: ", ") - ) - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack { - Text("Signing approval") - .font(.system(size: 17, weight: .semibold)) - Spacer() - CircularGlassIconButton( - icon: "xmark", - size: 26, - iconSize: 11, - helpText: ClavisUIStrings.Common.cancel, - accessibilityLabel: ClavisUIStrings.Common.cancel - ) { - dismiss() - } - } - .padding(.horizontal, 23) - .padding(.top, 20) - .padding(.bottom, 16) - - Divider() - - VStack(alignment: .leading, spacing: 14) { - Text("Choose how \(key.label) authorizes signatures.") - .font(.system(size: 12)) - .foregroundColor(DesignTokens.textSecondary) - - VStack(alignment: .leading, spacing: 6) { - Text("Authentication") - .font(.system(size: 12, weight: .semibold)) - Text(key.biometricPolicy == .biometryCurrentSet ? "Strict biometrics (Touch ID only)" : "User presence (Touch ID, Apple Watch, or Mac password)") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.primary) - Text("Hardware access control is bound inside the Secure Enclave when the key is created.") - .font(.system(size: 11)) - .foregroundColor(DesignTokens.textSecondary) - } - - if key.purpose == .agent { - VStack(alignment: .leading, spacing: 8) { - approvalRadioRow( - mode: .ask, - title: "Always ask for approval", - subtitle: "Use the hardware authentication method when approval is required." - ) - approvalRadioRow( - mode: .notify, - title: "Notify only", - subtitle: "Show a notification for each signature. No approval required." - ) - approvalRadioRow( - mode: .none, - title: "Autonomous", - subtitle: "Sign silently within your host and rate limits." - ) - } - - if selectedMode != .ask { - Text("Signatures can be approved without your presence. Only allow hosts you trust.") - .font(.system(size: 12)) - .foregroundColor(DesignTokens.accentOrange) - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(DesignTokens.accentOrange.opacity(0.10)) - .cornerRadius(7) - } - - VStack(alignment: .leading, spacing: 6) { - Text("Maximum signatures per minute") - .font(.system(size: 12, weight: .semibold)) - TextField("20", value: $maxRatePerMinute, format: .number) - .textFieldStyle(.roundedBorder) - } - - VStack(alignment: .leading, spacing: 6) { - Text("Allowed hosts") - .font(.system(size: 12, weight: .semibold)) - TextField("github.com, *.internal", text: $allowedHostsText) - .textFieldStyle(.roundedBorder) - Text("Comma-separated host patterns, e.g. github.com, *.internal") - .font(.system(size: 11)) - .foregroundColor(DesignTokens.textSecondary) - } - } else { - Text("Automated signing options are available for keys with the Agent purpose.") - .font(.system(size: 11)) - .foregroundColor(DesignTokens.textSecondary) - } - - if let errorMessage { - Text(errorMessage) - .font(.system(size: 11)) - .foregroundColor(.red) - } - } - .padding(.horizontal, 23) - .padding(.vertical, 18) - - Divider() - - HStack { - Spacer() - Button(ClavisUIStrings.Common.cancel) { - dismiss() - } - .liquidGlassButton(prominent: false) - .keyboardShortcut(.cancelAction) - - if key.purpose == .agent { - Button("Save changes") { - saveApprovalPolicy() - } - .liquidGlassButton(prominent: true) - .keyboardShortcut(.defaultAction) - } else { - Button("Done") { - dismiss() - } - .liquidGlassButton(prominent: true) - .keyboardShortcut(.defaultAction) - } - } - .padding(.horizontal, 23) - .padding(.vertical, 14) - } - .frame(width: 460) - } - - private func approvalRadioRow( - mode: AgentKeyPolicy.Mode, - title: String, - subtitle: String - ) -> some View { - let isSelected = (selectedMode == mode) - return Button(action: { selectedMode = mode }) { - HStack(alignment: .top, spacing: 10) { - Image(systemName: isSelected ? "largecircle.fill.circle" : "circle") - .foregroundColor(isSelected ? DesignTokens.accentBlue : DesignTokens.textSecondary) - .font(.system(size: 13)) - .padding(.top, 2) - VStack(alignment: .leading, spacing: 3) { - Text(title) - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.primary) - Text(subtitle) - .font(.system(size: 11)) - .foregroundColor(DesignTokens.textSecondary) - } - Spacer() - } - .padding(10) - .liquidGlassControl(cornerRadius: 9, isSelected: isSelected) - } - .buttonStyle(.plain) - } - - private func saveApprovalPolicy() { - var policy = appState.getAgentPolicy(fingerprint: key.fingerprint) ?? AgentKeyPolicy() - policy.mode = selectedMode - policy.refillPerMinute = max(1, min(600, maxRatePerMinute)) - do { - try appState.setAgentPolicy(policy, fingerprint: key.fingerprint) - dismiss() - onSaved("Signing approval updated") - } catch { - errorMessage = error.localizedDescription - } - } -} - // MARK: - Dynamic Security Posture & Alert Helpers public extension KeyDetailInspectorView { diff --git a/Sources/Clavis/Views/Sheets/CreateKeySheet.swift b/Sources/Clavis/Views/Sheets/CreateKeySheet.swift index 39c2da2..d761964 100644 --- a/Sources/Clavis/Views/Sheets/CreateKeySheet.swift +++ b/Sources/Clavis/Views/Sheets/CreateKeySheet.swift @@ -46,7 +46,9 @@ public struct CreateKeySheet: View { @State private var keyName: String = "" @State private var selectedPreset: KeyTypePreset = .hardware @State private var selectedBiometricPolicy: BiometricPolicy = Self.defaultBiometricPolicy - @State private var selectedPurpose: KeyPurpose = Self.defaultPurpose + @State private var selectedType: KeyType = .personal + @State private var sshEnabled = true + @State private var signatureEnabled = true @State private var isCreating = false @State private var errorMessage: String? = nil @@ -159,11 +161,17 @@ public struct CreateKeySheet: View { } } - // Key Purpose & Scope - KeyPurposePicker( - selection: $selectedPurpose, - accent: .accentColor - ) + Form { + Picker("Type", selection: $selectedType) { + ForEach(KeyType.allCases, id: \.self) { Text($0.title).tag($0) } + } + Toggle("SSH", isOn: $sshEnabled) + Toggle("Signature", isOn: $signatureEnabled) + } + .formStyle(.grouped) + .frame(height: 145) + Text("Personal apps can use either key type. Bots can use Bot keys only.") + .font(.caption).foregroundStyle(.secondary) // Footer / Actions HStack { @@ -195,7 +203,7 @@ public struct CreateKeySheet: View { private func createKey() { let label = keyName.trimmingCharacters(in: .whitespacesAndNewlines) guard !label.isEmpty else { return } - ClavisLogger.promptDebug("calvis-ui", "CreateKeySheet: submitting create key for '\(label)' (preset: \(selectedPreset.rawValue), purpose: \(selectedPurpose.rawValue))") + ClavisLogger.promptDebug("calvis-ui", "CreateKeySheet: submitting create key for '\(label)' (preset: \(selectedPreset.rawValue), type: \(selectedType.rawValue))") isCreating = true errorMessage = nil @@ -206,7 +214,8 @@ public struct CreateKeySheet: View { algorithm: selectedPreset.algorithm.rawValue, storageType: selectedPreset.storageType, biometricPolicy: selectedPreset == .hardware ? selectedBiometricPolicy : nil, - keyPurpose: selectedPurpose + accessProfile: KeyAccessProfile(type: selectedType, + capabilities: KeyCapabilities(ssh: sshEnabled, signature: signatureEnabled)) ) dismiss() } catch { diff --git a/Tests/ClavisAppTests/KeyInspectorRenderingTests.swift b/Tests/ClavisAppTests/KeyInspectorRenderingTests.swift new file mode 100644 index 0000000..57538cf --- /dev/null +++ b/Tests/ClavisAppTests/KeyInspectorRenderingTests.swift @@ -0,0 +1,44 @@ +import AppKit +import SwiftUI +import Testing +@testable import Clavis +@testable import ClavisCore +@testable import ClavisTestSupport + +@Suite @MainActor struct KeyInspectorRenderingTests { + @Test(arguments: KeyType.allCases, [false, true]) + func inspectorRendersLongNamesAndIndependentCapabilities(type: KeyType, dark: Bool) async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let manager = fixture.makeKeyManager() + let agent = MockAgentController() + let state = AppState(keyManager: manager, sshAgentServer: SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager), agentLifecycle: agent) + let profile = KeyAccessProfile(type: type, capabilities: .init(ssh: true, signature: false)) + let key = try state.generateKey(label: "Long project key name that should remain on one line", accessProfile: profile) + #expect(state.keys.first?.effectiveAccessProfile == profile) + let root = KeyDetailInspectorView(key: key, appState: state, onDelete: {}) + .environment(\.colorScheme, dark ? .dark : .light) + .background(Color(nsColor: .windowBackgroundColor)) + let view = NSHostingView(rootView: root) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 650, height: 760), styleMask: [.borderless], backing: .buffered, defer: false) + window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + window.contentView = view + window.orderFront(nil) + defer { window.orderOut(nil) } + try await Task.sleep(nanoseconds: 150_000_000) + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + let png = try #require(bitmap.representation(using: .png, properties: [:])) + #expect(png.count > 5000) + if ProcessInfo.processInfo.environment["CLAVIS_RENDER_REVIEW"] == "1" { + let directory = URL(fileURLWithPath: "/private/tmp/clavis-inspector-review") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try png.write(to: directory.appendingPathComponent("\(type.rawValue)-\(dark ? "dark" : "light").png")) + } + try state.changeAccessProfile(label: key.label, type: type == .bot ? .personal : .bot) + #expect(state.keys.first?.effectiveAccessProfile.capabilities == profile.capabilities) + } + } +}