chahinebrini ac72fabc34 feat(magic): Hub vereinigt Magic-Bindings + alte ProtectedDevices
- GET /api/magic/devices fetcht jetzt parallel listMagicDevices()
  + listProtectedDevices() und merged beide Quellen in eine
  Response. Items haben neues 'source' Feld (magic|protected).
- ProtectedDevice (alter Native-DNS-Flow) wird auf gleiche
  Shape gemappt: label->hostname, platform->model.
- Mac-App MagicDevice: source-Feld optional + resolvedSource
  Fallback fuer Backwards-Compat. id mit source-Prefix gegen
  Collisions zwischen Tabellen.
- DeviceHubView Row: protected-Geraete bekommen graues
  'Native-App' Badge und Hinweis 'Verwaltung in der
  ReBreak-App' statt Trash-Button (Release laeuft dort).
2026-06-03 11:05:15 +02:00

387 lines
13 KiB
Swift

import SwiftUI
/// Post-Login Hub: zeigt User-Info + gebundene Geräte + Add-Device-Picker.
/// Limit erreicht Add-Buttons disabled, User muss erst Release anfordern.
struct DeviceHubView: View {
@Environment(WizardModel.self) private var model
@State private var devices: [MagicDevice] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var actionInFlight = false
/// Hard-Limit muss synchron zum Backend (MAGIC_DEVICE_LIMIT) sein.
private let deviceLimit = 5
private var atLimit: Bool { devices.count >= deviceLimit }
var body: some View {
VStack(spacing: 0) {
header
Divider()
content
}
.frame(minWidth: 720, minHeight: 540)
.task { await loadDevices() }
}
@ViewBuilder
private var header: some View {
HStack(spacing: 12) {
Image(systemName: "shield.lefthalf.filled")
.font(.title)
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 2) {
Text("ReBreak Magic")
.font(.title2.bold())
if let email = model.authSession?.email {
Text(email)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Text("\(devices.count) / \(deviceLimit) Geräte")
.font(.caption.monospacedDigit())
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(atLimit ? Color.orange.opacity(0.15) : Color.blue.opacity(0.1))
.foregroundStyle(atLimit ? Color.orange : Color.blue)
.clipShape(Capsule())
Button {
Task { await model.handleLogout() }
} label: {
Image(systemName: "rectangle.portrait.and.arrow.right")
.help("Abmelden")
}
.buttonStyle(.borderless)
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
@ViewBuilder
private var content: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
addDeviceSection
devicesSection
}
.padding(24)
}
}
@ViewBuilder
private var addDeviceSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Neues Gerät hinzufügen")
.font(.headline)
Spacer()
if atLimit {
Label("Limit erreicht — bitte unten ein Gerät freigeben", systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
}
}
HStack(spacing: 12) {
addDeviceButton(
title: "iPhone / iPad",
subtitle: "Supervised + MDM + ReBreak-App",
icon: "iphone",
primary: true
) {
model.startIOSFlow()
}
addDeviceButton(
title: "Mac",
subtitle: "DNS-Filter-Profil installieren",
icon: "desktopcomputer",
primary: false
) {
model.startMacFlow()
}
}
.disabled(atLimit)
.opacity(atLimit ? 0.5 : 1.0)
}
}
@ViewBuilder
private func addDeviceButton(
title: String,
subtitle: String,
icon: String,
primary: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.title2)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.headline)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(14)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(primary ? Color.blue.opacity(0.08) : Color(nsColor: .controlBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(primary ? Color.blue.opacity(0.3) : Color.gray.opacity(0.2), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
@ViewBuilder
private var devicesSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Registrierte Geräte")
.font(.headline)
Spacer()
Button {
Task { await loadDevices() }
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
.help("Aktualisieren")
.disabled(isLoading)
}
if let error = errorMessage {
Label(error, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.red)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.red.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
if isLoading && devices.isEmpty {
HStack {
ProgressView().controlSize(.small)
Text("Lade Geräte…").font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, minHeight: 120)
} else if devices.isEmpty {
emptyState
} else {
VStack(spacing: 8) {
ForEach(devices) { device in
HubDeviceRow(device: device) { action in
await handleAction(action, device: device)
}
}
}
}
}
}
@ViewBuilder
private var emptyState: some View {
VStack(spacing: 10) {
Image(systemName: "checkmark.shield")
.font(.system(size: 42))
.foregroundStyle(.secondary)
Text("Noch keine Geräte registriert")
.font(.callout.bold())
Text("Wähle oben „iPhone / iPad“ oder „Mac“ um anzufangen.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, minHeight: 160)
.padding()
.background(Color(nsColor: .controlBackgroundColor).opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
private func loadDevices() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
devices = try await MagicAPIClient.shared.listDevices()
} catch {
errorMessage = error.localizedDescription
}
}
private func handleAction(_ action: HubDeviceAction, device: MagicDevice) async {
guard !actionInFlight else { return }
actionInFlight = true
defer { actionInFlight = false }
do {
switch action {
case .requestRelease:
_ = try await MagicAPIClient.shared.requestRelease(deviceId: device.deviceId)
case .cancelRelease:
try await MagicAPIClient.shared.cancelRelease(deviceId: device.deviceId)
}
await loadDevices()
} catch {
errorMessage = error.localizedDescription
}
}
}
// MARK: - Row
private enum HubDeviceAction {
case requestRelease
case cancelRelease
}
private struct HubDeviceRow: View {
let device: MagicDevice
let onAction: (HubDeviceAction) async -> Void
@State private var timeRemaining: String = ""
@State private var timer: Timer?
@State private var confirmingRelease = false
private var platformIcon: String {
let m = (device.model ?? "").lowercased()
let h = device.hostname.lowercased()
if m.contains("iphone") || h.contains("iphone") { return "iphone" }
if m.contains("ipad") || h.contains("ipad") { return "ipad" }
if m.contains("mac") || h.contains("mac") || h.contains("book") { return "desktopcomputer" }
return "shield"
}
var body: some View {
HStack(spacing: 14) {
Image(systemName: platformIcon)
.font(.title2)
.foregroundStyle(.blue)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(device.hostname)
.font(.callout.bold())
if device.resolvedSource == .protected {
Text("Native-App")
.font(.caption2.bold())
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.gray.opacity(0.15))
.foregroundStyle(.secondary)
.clipShape(Capsule())
}
}
HStack(spacing: 8) {
if let model = device.model {
Text(model).font(.caption2).foregroundStyle(.secondary)
}
if let os = device.osVersion {
Text("·").font(.caption2).foregroundStyle(.tertiary)
Text(os).font(.caption2).foregroundStyle(.secondary)
}
}
}
Spacer()
if device.resolvedSource == .protected {
Text("Verwaltung in der ReBreak-App")
.font(.caption2)
.foregroundStyle(.tertiary)
} else if device.isReleasing {
VStack(alignment: .trailing, spacing: 2) {
Label("Freigabe läuft", systemImage: "hourglass")
.font(.caption2.bold())
.foregroundStyle(.orange)
if !timeRemaining.isEmpty {
Text(timeRemaining).font(.caption2.monospacedDigit()).foregroundStyle(.secondary)
}
}
Button("Abbrechen") {
Task { await onAction(.cancelRelease) }
}
.buttonStyle(.borderless)
.font(.caption)
} else {
Button(role: .destructive) {
confirmingRelease = true
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Gerät freigeben")
.confirmationDialog(
"Gerät freigeben?",
isPresented: $confirmingRelease,
titleVisibility: .visible
) {
Button("Freigabe anfordern", role: .destructive) {
Task { await onAction(.requestRelease) }
}
Button("Abbrechen", role: .cancel) { }
} message: {
Text("Auf „\(device.hostname)“ muss die Freigabe bestätigt werden. Anschließend startet ein 24h-Cooldown bevor der Slot frei wird.")
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 8))
.onAppear {
updateTimeRemaining()
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
updateTimeRemaining()
}
}
.onDisappear { timer?.invalidate() }
}
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 mins = (Int(remaining) % 3600) / 60
let secs = Int(remaining) % 60
if hours > 0 {
timeRemaining = String(format: "%dh %02dm", hours, mins)
} else {
timeRemaining = String(format: "%02d:%02d", mins, secs)
}
}
}
#Preview {
DeviceHubView()
.environment(WizardModel())
}