chahinebrini c1edef8abd feat(magic): RebreakMagic device-binding + DNS profile
- backend: /api/magic/{register,devices,profile,release} + AdGuard provisioning + 24h cooldown
- prisma: magic_binding_fields migration (additive on UserDevice)
- mac-app: Phase 2 - Login + MacRegistration + Profile install
- marketing: landing section + /download/rebreakmagic + DMG
- lyra: forbidden phrases + RebreakMagic coach guidance
2026-06-02 09:15:19 +02:00

248 lines
8.1 KiB
Swift

import SwiftUI
struct ManageBindingsView: View {
@State private var devices: [MagicDevice] = []
@State private var isLoading = false
@State private var errorMessage: String?
let onDismiss: () -> Void
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Gebundene Geräte verwalten")
.font(.title2.bold())
Text("Hier kannst du bestehende Magic-Bindings freigeben.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Schließen", action: onDismiss)
.keyboardShortcut(.escape)
}
.padding()
.background(Color(nsColor: .windowBackgroundColor))
Divider()
// Content
if isLoading && devices.isEmpty {
VStack(spacing: 12) {
ProgressView()
Text("Lade Geräte...")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = errorMessage {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundStyle(.red)
Text(error)
.font(.caption)
.multilineTextAlignment(.center)
Button("Erneut versuchen", action: loadDevices)
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else if devices.isEmpty {
VStack(spacing: 12) {
Image(systemName: "laptopcomputer.slash")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("Keine gebundenen Geräte")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
VStack(spacing: 12) {
ForEach(devices) { device in
DeviceRow(device: device, onAction: handleDeviceAction)
}
}
.padding()
}
}
}
.frame(minWidth: 600, minHeight: 400)
.onAppear(perform: loadDevices)
}
private func loadDevices() {
Task {
isLoading = true
errorMessage = nil
do {
devices = try await MagicAPIClient.shared.listDevices()
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
private func handleDeviceAction(_ action: DeviceAction, for device: MagicDevice) {
Task {
do {
switch action {
case .requestRelease:
_ = try await MagicAPIClient.shared.requestRelease(deviceId: device.deviceId)
case .cancelRelease:
try await MagicAPIClient.shared.cancelRelease(deviceId: device.deviceId)
}
// Reload list
try await Task.sleep(for: .milliseconds(500))
devices = try await MagicAPIClient.shared.listDevices()
} catch {
errorMessage = error.localizedDescription
}
}
}
}
enum DeviceAction {
case requestRelease
case cancelRelease
}
private struct DeviceRow: View {
let device: MagicDevice
let onAction: (DeviceAction, MagicDevice) -> Void
@State private var timeRemaining: String = ""
@State private var timer: Timer?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "laptopcomputer")
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 2) {
Text(device.hostname)
.font(.headline)
if let model = device.model {
Text(model)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if device.isReleasing {
VStack(alignment: .trailing, spacing: 2) {
Text("Freigabe läuft")
.font(.caption.bold())
.foregroundStyle(.orange)
if !timeRemaining.isEmpty {
Text(timeRemaining)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
} else {
Text("Aktiv")
.font(.caption)
.foregroundStyle(.green)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.green.opacity(0.1))
.clipShape(Capsule())
}
}
HStack(spacing: 16) {
Label(formatDate(device.enrolledDate), systemImage: "calendar")
.font(.caption)
.foregroundStyle(.secondary)
if let os = device.osVersion {
Label(os, systemImage: "info.circle")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if device.isReleasing {
Button("Freigabe abbrechen") {
onAction(.cancelRelease, device)
}
.buttonStyle(.borderless)
.foregroundStyle(.blue)
.font(.caption)
} else {
Button("Freigabe anfordern") {
onAction(.requestRelease, device)
}
.buttonStyle(.borderless)
.foregroundStyle(.red)
.font(.caption)
}
}
}
.padding()
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 8))
.onAppear {
updateTimeRemaining()
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
updateTimeRemaining()
}
}
.onDisappear {
timer?.invalidate()
}
}
private func formatDate(_ date: Date?) -> String {
guard let date = date else { return "" }
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter.string(from: date)
}
private func updateTimeRemaining() {
guard let releaseDate = device.releaseDate else {
timeRemaining = ""
return
}
let remaining = releaseDate.timeIntervalSince(Date())
if remaining <= 0 {
timeRemaining = "Läuft ab..."
return
}
let hours = Int(remaining) / 3600
let minutes = (Int(remaining) % 3600) / 60
if hours > 0 {
timeRemaining = "noch \(hours)h \(minutes)m"
} else {
timeRemaining = "noch \(minutes)m"
}
}
}
#Preview {
ManageBindingsView {
print("Dismissed")
}
}