741 lines
25 KiB
Swift
741 lines
25 KiB
Swift
// ── Auth UI Kit ─────────────────────────────────────────────
|
|
// Reusable SwiftUI auth views for all ByteLyst iOS/macOS apps.
|
|
// BLLoginView, BLMfaChallengeView, BLPasskeyView, BLStepUpSheet.
|
|
// Themed via @Environment injection — always matches host product.
|
|
|
|
import SwiftUI
|
|
import os
|
|
|
|
#if canImport(AuthenticationServices)
|
|
import AuthenticationServices
|
|
#endif
|
|
|
|
private let logger = Logger(subsystem: "com.bytelyst.platform", category: "BLAuthUI")
|
|
|
|
// MARK: - Auth UI Configuration
|
|
|
|
/// Configuration for BLAuthUI views — passed via Environment.
|
|
public struct BLAuthUIConfig {
|
|
public let productName: String
|
|
public let accentColor: Color
|
|
public let backgroundColor: Color
|
|
public let textColor: Color
|
|
public let secondaryTextColor: Color
|
|
public let cardColor: Color
|
|
public let enabledProviders: [BLAuthUIProvider]
|
|
|
|
public init(
|
|
productName: String = "ByteLyst",
|
|
accentColor: Color = .blue,
|
|
backgroundColor: Color = Color(.systemBackground),
|
|
textColor: Color = .primary,
|
|
secondaryTextColor: Color = .secondary,
|
|
cardColor: Color = Color(.secondarySystemBackground),
|
|
enabledProviders: [BLAuthUIProvider] = [.google, .apple]
|
|
) {
|
|
self.productName = productName
|
|
self.accentColor = accentColor
|
|
self.backgroundColor = backgroundColor
|
|
self.textColor = textColor
|
|
self.secondaryTextColor = secondaryTextColor
|
|
self.cardColor = cardColor
|
|
self.enabledProviders = enabledProviders
|
|
}
|
|
}
|
|
|
|
/// OAuth providers supported by BLAuthUI.
|
|
public enum BLAuthUIProvider: String, CaseIterable, Sendable {
|
|
case google
|
|
case microsoft
|
|
case apple
|
|
}
|
|
|
|
// MARK: - Environment Key
|
|
|
|
private struct BLAuthUIConfigKey: EnvironmentKey {
|
|
static let defaultValue = BLAuthUIConfig()
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
public var blAuthUIConfig: BLAuthUIConfig {
|
|
get { self[BLAuthUIConfigKey.self] }
|
|
set { self[BLAuthUIConfigKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
// MARK: - BLLoginView
|
|
|
|
/// Full login view with email/password + social buttons + passkey option.
|
|
/// Host product injects theme via `.environment(\.blAuthUIConfig, config)`.
|
|
public struct BLLoginView: View {
|
|
@Environment(\.blAuthUIConfig) private var config
|
|
|
|
@State private var email = ""
|
|
@State private var password = ""
|
|
@State private var isLoading = false
|
|
@State private var errorMessage: String?
|
|
|
|
/// Called with email + password when user taps Sign In.
|
|
public var onLogin: (String, String) async throws -> Void
|
|
/// Called with provider name when user taps a social button.
|
|
public var onSocialLogin: (BLAuthUIProvider) -> Void
|
|
/// Called when user taps "Use Passkey".
|
|
public var onPasskeyLogin: (() -> Void)?
|
|
/// Called when user taps "Forgot Password?".
|
|
public var onForgotPassword: (() -> Void)?
|
|
/// Called when user taps "Create Account".
|
|
public var onCreateAccount: (() -> Void)?
|
|
|
|
public init(
|
|
onLogin: @escaping (String, String) async throws -> Void,
|
|
onSocialLogin: @escaping (BLAuthUIProvider) -> Void,
|
|
onPasskeyLogin: (() -> Void)? = nil,
|
|
onForgotPassword: (() -> Void)? = nil,
|
|
onCreateAccount: (() -> Void)? = nil
|
|
) {
|
|
self.onLogin = onLogin
|
|
self.onSocialLogin = onSocialLogin
|
|
self.onPasskeyLogin = onPasskeyLogin
|
|
self.onForgotPassword = onForgotPassword
|
|
self.onCreateAccount = onCreateAccount
|
|
}
|
|
|
|
public var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 24) {
|
|
// Header
|
|
VStack(spacing: 8) {
|
|
Text("Sign in to \(config.productName)")
|
|
.font(.title2.bold())
|
|
.foregroundColor(config.textColor)
|
|
Text("Welcome back")
|
|
.font(.subheadline)
|
|
.foregroundColor(config.secondaryTextColor)
|
|
}
|
|
.padding(.top, 40)
|
|
|
|
// Social Buttons
|
|
if !config.enabledProviders.isEmpty {
|
|
VStack(spacing: 12) {
|
|
ForEach(config.enabledProviders, id: \.self) { provider in
|
|
socialButton(for: provider)
|
|
}
|
|
}
|
|
|
|
dividerRow
|
|
}
|
|
|
|
// Email / Password
|
|
VStack(spacing: 16) {
|
|
TextField("Email", text: $email)
|
|
.textFieldStyle(.roundedBorder)
|
|
.textContentType(.emailAddress)
|
|
.keyboardType(.emailAddress)
|
|
.autocapitalization(.none)
|
|
.disableAutocorrection(true)
|
|
|
|
SecureField("Password", text: $password)
|
|
.textFieldStyle(.roundedBorder)
|
|
.textContentType(.password)
|
|
}
|
|
|
|
// Error
|
|
if let errorMessage {
|
|
Text(errorMessage)
|
|
.font(.caption)
|
|
.foregroundColor(.red)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
|
|
// Sign In Button
|
|
Button {
|
|
Task { await performLogin() }
|
|
} label: {
|
|
HStack {
|
|
if isLoading {
|
|
ProgressView()
|
|
.tint(.white)
|
|
}
|
|
Text("Sign In")
|
|
.fontWeight(.semibold)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
.background(config.accentColor)
|
|
.foregroundColor(.white)
|
|
.cornerRadius(10)
|
|
}
|
|
.disabled(email.isEmpty || password.isEmpty || isLoading)
|
|
|
|
// Passkey
|
|
if let onPasskeyLogin {
|
|
Button {
|
|
logger.debug("Passkey login tapped")
|
|
onPasskeyLogin()
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "person.badge.key.fill")
|
|
Text("Sign in with Passkey")
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
.background(config.cardColor)
|
|
.foregroundColor(config.textColor)
|
|
.cornerRadius(10)
|
|
}
|
|
}
|
|
|
|
// Forgot Password / Create Account
|
|
VStack(spacing: 12) {
|
|
if let onForgotPassword {
|
|
Button("Forgot Password?") {
|
|
onForgotPassword()
|
|
}
|
|
.font(.subheadline)
|
|
.foregroundColor(config.accentColor)
|
|
}
|
|
|
|
if let onCreateAccount {
|
|
HStack {
|
|
Text("Don't have an account?")
|
|
.foregroundColor(config.secondaryTextColor)
|
|
Button("Create Account") {
|
|
onCreateAccount()
|
|
}
|
|
.foregroundColor(config.accentColor)
|
|
}
|
|
.font(.subheadline)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 24)
|
|
}
|
|
.background(config.backgroundColor.ignoresSafeArea())
|
|
}
|
|
|
|
private func performLogin() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
do {
|
|
logger.debug("Attempting email/password login for \(email)")
|
|
try await onLogin(email, password)
|
|
} catch let error as BLAuthError {
|
|
// MFA required is not an error for the user — handled upstream
|
|
logger.info("MFA required for \(email)")
|
|
_ = error // Suppress unused warning
|
|
} catch {
|
|
logger.error("Login failed: \(error.localizedDescription)")
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
isLoading = false
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func socialButton(for provider: BLAuthUIProvider) -> some View {
|
|
Button {
|
|
logger.debug("Social login: \(provider.rawValue)")
|
|
onSocialLogin(provider)
|
|
} label: {
|
|
HStack {
|
|
providerIcon(for: provider)
|
|
Text("Continue with \(providerDisplayName(provider))")
|
|
.fontWeight(.medium)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
.background(config.cardColor)
|
|
.foregroundColor(config.textColor)
|
|
.cornerRadius(10)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func providerIcon(for provider: BLAuthUIProvider) -> some View {
|
|
switch provider {
|
|
case .google:
|
|
Image(systemName: "globe")
|
|
case .microsoft:
|
|
Image(systemName: "building.2")
|
|
case .apple:
|
|
Image(systemName: "applelogo")
|
|
}
|
|
}
|
|
|
|
private func providerDisplayName(_ provider: BLAuthUIProvider) -> String {
|
|
switch provider {
|
|
case .google: return "Google"
|
|
case .microsoft: return "Microsoft"
|
|
case .apple: return "Apple"
|
|
}
|
|
}
|
|
|
|
private var dividerRow: some View {
|
|
HStack {
|
|
Rectangle().fill(Color.gray.opacity(0.3)).frame(height: 1)
|
|
Text("or")
|
|
.font(.caption)
|
|
.foregroundColor(config.secondaryTextColor)
|
|
Rectangle().fill(Color.gray.opacity(0.3)).frame(height: 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - BLMfaChallengeView
|
|
|
|
/// 6-digit TOTP code entry with countdown and recovery code fallback.
|
|
public struct BLMfaChallengeView: View {
|
|
@Environment(\.blAuthUIConfig) private var config
|
|
|
|
@State private var code = ""
|
|
@State private var isLoading = false
|
|
@State private var errorMessage: String?
|
|
@State private var showRecovery = false
|
|
|
|
public let challenge: BLMfaChallenge
|
|
public var onVerify: (String, String, String) async throws -> Void
|
|
public var onCancel: (() -> Void)?
|
|
|
|
public init(
|
|
challenge: BLMfaChallenge,
|
|
onVerify: @escaping (String, String, String) async throws -> Void,
|
|
onCancel: (() -> Void)? = nil
|
|
) {
|
|
self.challenge = challenge
|
|
self.onVerify = onVerify
|
|
self.onCancel = onCancel
|
|
}
|
|
|
|
public var body: some View {
|
|
VStack(spacing: 24) {
|
|
// Header
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "lock.shield.fill")
|
|
.font(.system(size: 48))
|
|
.foregroundColor(config.accentColor)
|
|
|
|
Text("Two-Factor Authentication")
|
|
.font(.title3.bold())
|
|
.foregroundColor(config.textColor)
|
|
|
|
Text(showRecovery
|
|
? "Enter a recovery code"
|
|
: "Enter the 6-digit code from your authenticator app")
|
|
.font(.subheadline)
|
|
.foregroundColor(config.secondaryTextColor)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
|
|
// Code Input
|
|
TextField(showRecovery ? "Recovery Code" : "000000", text: $code)
|
|
.textFieldStyle(.roundedBorder)
|
|
.keyboardType(showRecovery ? .default : .numberPad)
|
|
.multilineTextAlignment(.center)
|
|
.font(.title2.monospaced())
|
|
.frame(maxWidth: 200)
|
|
|
|
// Error
|
|
if let errorMessage {
|
|
Text(errorMessage)
|
|
.font(.caption)
|
|
.foregroundColor(.red)
|
|
}
|
|
|
|
// Verify Button
|
|
Button {
|
|
Task { await verify() }
|
|
} label: {
|
|
HStack {
|
|
if isLoading { ProgressView().tint(.white) }
|
|
Text("Verify")
|
|
.fontWeight(.semibold)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
.background(config.accentColor)
|
|
.foregroundColor(.white)
|
|
.cornerRadius(10)
|
|
}
|
|
.disabled(code.isEmpty || isLoading)
|
|
|
|
// Toggle recovery / cancel
|
|
VStack(spacing: 12) {
|
|
Button(showRecovery ? "Use authenticator code" : "Use recovery code") {
|
|
showRecovery.toggle()
|
|
code = ""
|
|
errorMessage = nil
|
|
}
|
|
.font(.subheadline)
|
|
.foregroundColor(config.accentColor)
|
|
|
|
if let onCancel {
|
|
Button("Cancel") { onCancel() }
|
|
.font(.subheadline)
|
|
.foregroundColor(config.secondaryTextColor)
|
|
}
|
|
}
|
|
}
|
|
.padding(24)
|
|
.background(config.backgroundColor)
|
|
}
|
|
|
|
private func verify() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
let method = showRecovery ? "recovery" : "totp"
|
|
do {
|
|
logger.debug("Verifying MFA: method=\(method)")
|
|
try await onVerify(challenge.mfaChallenge, code, method)
|
|
} catch {
|
|
logger.error("MFA verify failed: \(error.localizedDescription)")
|
|
errorMessage = error.localizedDescription
|
|
code = ""
|
|
}
|
|
isLoading = false
|
|
}
|
|
}
|
|
|
|
// MARK: - BLPasskeyView
|
|
|
|
/// Passkey prompt with biometric hint text.
|
|
/// Triggers ASAuthorizationController for platform passkey authentication.
|
|
public struct BLPasskeyView: View {
|
|
@Environment(\.blAuthUIConfig) private var config
|
|
|
|
@State private var isLoading = false
|
|
@State private var errorMessage: String?
|
|
|
|
public var onAuthenticate: () async throws -> Void
|
|
public var onCancel: (() -> Void)?
|
|
|
|
public init(
|
|
onAuthenticate: @escaping () async throws -> Void,
|
|
onCancel: (() -> Void)? = nil
|
|
) {
|
|
self.onAuthenticate = onAuthenticate
|
|
self.onCancel = onCancel
|
|
}
|
|
|
|
public var body: some View {
|
|
VStack(spacing: 24) {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "person.badge.key.fill")
|
|
.font(.system(size: 48))
|
|
.foregroundColor(config.accentColor)
|
|
|
|
Text("Sign in with Passkey")
|
|
.font(.title3.bold())
|
|
.foregroundColor(config.textColor)
|
|
|
|
Text("Use Face ID, Touch ID, or your security key to sign in")
|
|
.font(.subheadline)
|
|
.foregroundColor(config.secondaryTextColor)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
|
|
if let errorMessage {
|
|
Text(errorMessage)
|
|
.font(.caption)
|
|
.foregroundColor(.red)
|
|
}
|
|
|
|
Button {
|
|
Task { await authenticate() }
|
|
} label: {
|
|
HStack {
|
|
if isLoading { ProgressView().tint(.white) }
|
|
Image(systemName: "faceid")
|
|
Text("Continue")
|
|
.fontWeight(.semibold)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
.background(config.accentColor)
|
|
.foregroundColor(.white)
|
|
.cornerRadius(10)
|
|
}
|
|
.disabled(isLoading)
|
|
|
|
if let onCancel {
|
|
Button("Use another method") { onCancel() }
|
|
.font(.subheadline)
|
|
.foregroundColor(config.accentColor)
|
|
}
|
|
}
|
|
.padding(24)
|
|
.background(config.backgroundColor)
|
|
}
|
|
|
|
private func authenticate() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
do {
|
|
logger.debug("Starting passkey authentication")
|
|
try await onAuthenticate()
|
|
} catch {
|
|
logger.error("Passkey auth failed: \(error.localizedDescription)")
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
isLoading = false
|
|
}
|
|
}
|
|
|
|
// MARK: - BLStepUpSheet
|
|
|
|
/// Re-authentication sheet for sensitive operations.
|
|
/// Supports password re-entry or biometric confirmation.
|
|
public struct BLStepUpSheet: View {
|
|
@Environment(\.blAuthUIConfig) private var config
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var password = ""
|
|
@State private var isLoading = false
|
|
@State private var errorMessage: String?
|
|
|
|
public let reason: String
|
|
public var onStepUp: (String, String) async throws -> String
|
|
public var onComplete: (String) -> Void
|
|
|
|
public init(
|
|
reason: String = "This action requires re-authentication",
|
|
onStepUp: @escaping (String, String) async throws -> String,
|
|
onComplete: @escaping (String) -> Void
|
|
) {
|
|
self.reason = reason
|
|
self.onStepUp = onStepUp
|
|
self.onComplete = onComplete
|
|
}
|
|
|
|
public var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 24) {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "lock.fill")
|
|
.font(.system(size: 36))
|
|
.foregroundColor(config.accentColor)
|
|
|
|
Text("Confirm Your Identity")
|
|
.font(.title3.bold())
|
|
.foregroundColor(config.textColor)
|
|
|
|
Text(reason)
|
|
.font(.subheadline)
|
|
.foregroundColor(config.secondaryTextColor)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
|
|
SecureField("Password", text: $password)
|
|
.textFieldStyle(.roundedBorder)
|
|
.textContentType(.password)
|
|
|
|
if let errorMessage {
|
|
Text(errorMessage)
|
|
.font(.caption)
|
|
.foregroundColor(.red)
|
|
}
|
|
|
|
#if canImport(LocalAuthentication)
|
|
Button {
|
|
Task { await biometricStepUp() }
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "faceid")
|
|
Text("Use Biometrics")
|
|
.fontWeight(.medium)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
.background(config.cardColor)
|
|
.foregroundColor(config.textColor)
|
|
.cornerRadius(10)
|
|
}
|
|
#endif
|
|
|
|
Button {
|
|
Task { await passwordStepUp() }
|
|
} label: {
|
|
HStack {
|
|
if isLoading { ProgressView().tint(.white) }
|
|
Text("Confirm")
|
|
.fontWeight(.semibold)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
.background(config.accentColor)
|
|
.foregroundColor(.white)
|
|
.cornerRadius(10)
|
|
}
|
|
.disabled(password.isEmpty || isLoading)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(24)
|
|
.background(config.backgroundColor)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func passwordStepUp() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
do {
|
|
logger.debug("Step-up: password method")
|
|
let token = try await onStepUp("password", password)
|
|
onComplete(token)
|
|
dismiss()
|
|
} catch {
|
|
logger.error("Step-up failed: \(error.localizedDescription)")
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
isLoading = false
|
|
}
|
|
|
|
#if canImport(LocalAuthentication)
|
|
private func biometricStepUp() async {
|
|
let success = await BLBiometricAuth.authenticate(reason: "Confirm your identity")
|
|
if success {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
do {
|
|
logger.debug("Step-up: biometric method")
|
|
let token = try await onStepUp("biometric", "biometric_verified")
|
|
onComplete(token)
|
|
dismiss()
|
|
} catch {
|
|
logger.error("Biometric step-up failed: \(error.localizedDescription)")
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
isLoading = false
|
|
} else {
|
|
errorMessage = "Biometric authentication failed"
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// ── BLDeviceListView ────────────────────────────────────────
|
|
|
|
/// Device management view — list trusted/remembered devices, revoke trust.
|
|
/// Mirrors the Kotlin `BLDeviceListScreen` for platform parity.
|
|
///
|
|
/// - Parameters:
|
|
/// - devices: List of devices from `BLAuthClient.listDevices()`.
|
|
/// - onRevokeDevice: Called with device ID when user revokes a device.
|
|
/// - onRevokeAll: Called when user revokes all devices. `nil` hides the button.
|
|
/// - isLoading: Whether data is loading.
|
|
public struct BLDeviceListView: View {
|
|
public let devices: [BLDevice]
|
|
public let onRevokeDevice: (String) -> Void
|
|
public var onRevokeAll: (() -> Void)?
|
|
public var isLoading: Bool = false
|
|
|
|
public init(
|
|
devices: [BLDevice],
|
|
onRevokeDevice: @escaping (String) -> Void,
|
|
onRevokeAll: (() -> Void)? = nil,
|
|
isLoading: Bool = false
|
|
) {
|
|
self.devices = devices
|
|
self.onRevokeDevice = onRevokeDevice
|
|
self.onRevokeAll = onRevokeAll
|
|
self.isLoading = isLoading
|
|
}
|
|
|
|
public var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
HStack {
|
|
Text("Your Devices")
|
|
.font(.title2.bold())
|
|
Spacer()
|
|
if let onRevokeAll, !devices.isEmpty {
|
|
Button(role: .destructive, action: onRevokeAll) {
|
|
Text("Revoke All")
|
|
}
|
|
}
|
|
}
|
|
|
|
if isLoading {
|
|
HStack {
|
|
Spacer()
|
|
ProgressView()
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 32)
|
|
} else if devices.isEmpty {
|
|
Text("No devices found")
|
|
.foregroundStyle(.secondary)
|
|
.padding(.vertical, 16)
|
|
} else {
|
|
ForEach(devices, id: \.id) { device in
|
|
DeviceCardView(device: device) {
|
|
onRevokeDevice(device.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Individual device card within BLDeviceListView.
|
|
private struct DeviceCardView: View {
|
|
let device: BLDevice
|
|
let onRevoke: () -> Void
|
|
|
|
private var platformIcon: String {
|
|
switch device.platform {
|
|
case "ios": return "iphone"
|
|
case "android": return "apps.iphone"
|
|
case "macos": return "laptopcomputer"
|
|
case "windows", "linux": return "desktopcomputer"
|
|
default: return "display"
|
|
}
|
|
}
|
|
|
|
private var trustColor: Color {
|
|
switch device.trustLevel {
|
|
case "trusted": return .blue
|
|
case "remembered": return .green
|
|
default: return .secondary
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: platformIcon)
|
|
.font(.title2)
|
|
.foregroundStyle(trustColor)
|
|
.frame(width: 32)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(device.name)
|
|
.font(.body)
|
|
Text("\(device.trustLevel) · \(device.platform)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if device.trustLevel == "trusted" || device.trustLevel == "remembered" {
|
|
Button(role: .destructive, action: onRevoke) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(.red)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
}
|