- 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).
387 lines
13 KiB
Swift
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())
|
|
}
|