// // KeysView.swift // Zyquo Router // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Two tabs: Provider Keys (masked vault fields with per-provider Test — // identical UX to Zyquo Cloud) and Local API Keys (create/reveal-once/ // revoke zyquo-sk tokens). // import SwiftUI struct KeysView: View { @State private var tab: Tab = .provider enum Tab: String, CaseIterable, Identifiable { case provider = "Provider Keys" case local = "Local API Keys" var id: String { rawValue } } var body: some View { VStack(alignment: .leading, spacing: 0) { HStack { SectionHeader(title: "Keys") Spacer() Picker("", selection: $tab) { ForEach(Tab.allCases) { tab in Text(tab.rawValue).tag(tab) } } .labelsHidden() .pickerStyle(.segmented) .frame(width: 280) } .padding(ZyquoMetrics.contentInset) ZyquoHairline() switch tab { case .provider: ProviderKeysTab() case .local: LocalKeysTab() } } } } // MARK: - Provider keys private struct ProviderKeysTab: View { @EnvironmentObject private var vault: KeyVaultStore @EnvironmentObject private var catalog: ModelCatalog var body: some View { ScrollView { VStack(spacing: 0) { ForEach(ProviderID.builtIn) { provider in ProviderKeyRow(provider: provider) ZyquoHairline() .padding(.leading, ZyquoMetrics.contentInset) } } } } } private struct ProviderKeyRow: View { let provider: ProviderID @EnvironmentObject private var vault: KeyVaultStore @EnvironmentObject private var catalog: ModelCatalog @State private var draft = "" @State private var editing = false var body: some View { HStack(spacing: ZyquoSpacing.md) { HStack(spacing: ZyquoSpacing.xs) { Circle() .fill(ZyquoColor.providerHue(provider)) .frame(width: 8, height: 8) Text(provider.displayName) .font(ZyquoFont.bodyEmphasis()) .foregroundStyle(ZyquoColor.textPrimary) } .frame(width: 150, alignment: .leading) if editing || !vault.hasKey(for: provider) { SecureField("Paste API key…", text: $draft) .textFieldStyle(.roundedBorder) .font(ZyquoFont.mono(size: 12)) .frame(maxWidth: 320) Button("Save") { vault.setKey(draft, for: provider) draft = "" editing = false } .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty) if editing { Button("Cancel") { draft = "" editing = false } } } else { Text(vault.redactedKeys[provider] ?? "••••") .font(ZyquoFont.mono(size: 12)) .foregroundStyle(ZyquoColor.textSecondary) .frame(maxWidth: 320, alignment: .leading) Button("Replace") { editing = true } Button("Remove") { vault.deleteKey(for: provider) } Button("Test") { Task { await vault.testKey(for: provider, catalog: catalog) } } } Spacer() statusView } .padding(.horizontal, ZyquoMetrics.contentInset) .padding(.vertical, ZyquoSpacing.xs) } @ViewBuilder private var statusView: some View { switch vault.statuses[provider] ?? .unset { case .unset: Text("No key") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) case .saved: statusDot(ZyquoColor.textTertiary, "Saved") case .testing: ProgressView() .controlSize(.small) case .verified(let latency): statusDot(ZyquoColor.success, String(format: "OK · %.0f ms", latency * 1000)) case .failed(let message): statusDot(ZyquoColor.danger, message) .help(message) } } private func statusDot(_ color: Color, _ text: String) -> some View { HStack(spacing: 5) { Circle().fill(color).frame(width: 7, height: 7) Text(text) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) .lineLimit(1) .frame(maxWidth: 220, alignment: .trailing) } } } // MARK: - Local keys private struct LocalKeysTab: View { @EnvironmentObject private var localKeys: LocalKeysStore @State private var newName = "" @State private var revealedToken: String? var body: some View { ScrollView { VStack(alignment: .leading, spacing: ZyquoSpacing.md) { // Create HStack(spacing: ZyquoSpacing.xs) { TextField("Key name (e.g. \"CLI\", \"VS Code\")", text: $newName) .textFieldStyle(.roundedBorder) .frame(maxWidth: 280) Button("Create key") { revealedToken = localKeys.create(name: newName) newName = "" } } if let token = revealedToken { VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { Label("Copy this token now — it is shown only once.", systemImage: "exclamationmark.triangle") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.warning) HStack { CopyField(value: token, size: 12.5) Button("Done") { revealedToken = nil } } } .padding(ZyquoSpacing.sm) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(ZyquoColor.accentSubtle) ) } if localKeys.keys.isEmpty { Text("No local keys. Without keys, the router is open on localhost; LAN mode requires at least one enabled key.") .font(ZyquoFont.body()) .foregroundStyle(ZyquoColor.textSecondary) } else { VStack(spacing: 0) { ForEach(localKeys.keys) { key in LocalKeyRow(record: key) ZyquoHairline() } } .zyquoCard() } } .padding(ZyquoMetrics.contentInset) } } } private struct LocalKeyRow: View { let record: APIKeyRecord @EnvironmentObject private var localKeys: LocalKeysStore var body: some View { HStack(spacing: ZyquoSpacing.md) { VStack(alignment: .leading, spacing: 2) { Text(record.name) .font(ZyquoFont.bodyEmphasis()) .foregroundStyle(ZyquoColor.textPrimary) Text("\(record.tokenPrefix)… · created \(record.createdAt.formatted(date: .abbreviated, time: .omitted))") .font(ZyquoFont.mono(size: 11)) .foregroundStyle(ZyquoColor.textTertiary) } Spacer() Toggle("", isOn: Binding( get: { record.enabled }, set: { localKeys.setEnabled($0, for: record.id) } )) .toggleStyle(.switch) .controlSize(.small) .labelsHidden() Button("Revoke", role: .destructive) { localKeys.revoke(record.id) } } .padding(ZyquoSpacing.sm) } }