feat(magic): post-login Device-Hub als zentraler Einstieg + Limit 3->5
Redesign:
- Nach Login landet User direkt im neuen DeviceHubView statt
Auto-Mac-Registrierung. Hub zeigt: User-Email, X/5-Slot-Counter,
Liste aller registrierten Geraete + 'Geraet hinzufuegen' mit
iPhone/iPad vs Mac Wahl.
- Mac wird NUR registriert wenn User aktiv 'Mac' im Hub waehlt
(frueher: auto on app-start, frass Slot).
- iOS-Pfad: Hub -> Welcome/Preflight/Supervise/Enroll/Configure
-> Done -> 'Zurueck zur Geraete-Uebersicht'.
- Mac-Pfad: Hub -> MacRegistrationView (Register+DNS-Install)
-> 'Fertig -> Hub'.
- Wizard-Header hat jetzt Grid-Icon 'Zur Geraete-Uebersicht' als
Escape-Hatch jederzeit.
- Per-Device-Loeschung im Hub: Trash-Icon -> Confirm-Dialog
('Auf X muss Freigabe bestaetigt werden, 24h Cooldown') ->
request-release-Endpoint (existing infra).
- Device-Limit 3 -> 5 in backend (Staging-Testing + Legend-Wert
fuer spaeter).
- StepIndicator/Step-Counter: macRegistration zaehlt nicht im
iOS-Flow.
This commit is contained in:
parent
87d6395ed2
commit
d54bd06727
@ -51,6 +51,7 @@ final class WizardModel {
|
||||
// Auth + Magic State
|
||||
var authSession: AuthSession?
|
||||
var showingLogin: Bool = false
|
||||
var showingHub: Bool = false
|
||||
var showingManageBindings: Bool = false
|
||||
var magicRegistration: MagicRegistration?
|
||||
var registrationError: String?
|
||||
@ -59,6 +60,8 @@ final class WizardModel {
|
||||
// Load existing session from keychain
|
||||
authSession = AuthService.shared.currentSession()
|
||||
showingLogin = (authSession == nil)
|
||||
// Nach Login direkt zum Hub statt Mac-Auto-Registrierung
|
||||
showingHub = (authSession != nil)
|
||||
}
|
||||
|
||||
func advance() {
|
||||
@ -106,14 +109,36 @@ final class WizardModel {
|
||||
func handleLogin(session: AuthSession) {
|
||||
authSession = session
|
||||
showingLogin = false
|
||||
showingHub = true
|
||||
}
|
||||
|
||||
func handleLogout() async {
|
||||
await AuthService.shared.signOut()
|
||||
authSession = nil
|
||||
showingLogin = true
|
||||
showingHub = false
|
||||
reset()
|
||||
}
|
||||
|
||||
// MARK: - Hub Navigation
|
||||
|
||||
/// User wählt 'iOS-Gerät hinzufügen' im Hub.
|
||||
func startIOSFlow() {
|
||||
showingHub = false
|
||||
step = .welcome
|
||||
}
|
||||
|
||||
/// User wählt 'Mac schützen' im Hub.
|
||||
func startMacFlow() {
|
||||
showingHub = false
|
||||
step = .macRegistration
|
||||
}
|
||||
|
||||
/// Zurück zur Geräte-Übersicht.
|
||||
func returnToHub() {
|
||||
reset()
|
||||
showingHub = true
|
||||
}
|
||||
|
||||
func reset() {
|
||||
step = .macRegistration
|
||||
|
||||
@ -11,6 +11,8 @@ struct ContentView: View {
|
||||
LoginView { session in
|
||||
model.handleLogin(session: session)
|
||||
}
|
||||
} else if model.showingHub {
|
||||
DeviceHubView()
|
||||
} else {
|
||||
mainWizardView
|
||||
}
|
||||
@ -41,6 +43,16 @@ struct ContentView: View {
|
||||
}
|
||||
Spacer()
|
||||
|
||||
// Back to Hub
|
||||
Button(action: { model.returnToHub() }) {
|
||||
Image(systemName: "rectangle.grid.2x2")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Zur Geräte-Übersicht")
|
||||
.padding(.trailing, 4)
|
||||
|
||||
// Help-Button
|
||||
Button(action: { showingHelp = true }) {
|
||||
Image(systemName: "questionmark.circle")
|
||||
@ -51,8 +63,8 @@ struct ContentView: View {
|
||||
.help("Hilfe & FAQ (⌘?)")
|
||||
.keyboardShortcut("?", modifiers: .command)
|
||||
|
||||
if model.step != .done {
|
||||
Text("Schritt \(model.step.stepNumber) von \(WizardStep.total)")
|
||||
if model.step != .done && model.step != .macRegistration {
|
||||
Text("Schritt \(model.step.stepNumber - 1) von \(WizardStep.total - 1)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, 12)
|
||||
|
||||
371
apps/rebreak-magic-mac/Sources/Views/DeviceHubView.swift
Normal file
371
apps/rebreak-magic-mac/Sources/Views/DeviceHubView.swift
Normal file
@ -0,0 +1,371 @@
|
||||
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) {
|
||||
Text(device.hostname)
|
||||
.font(.callout.bold())
|
||||
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.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())
|
||||
}
|
||||
@ -30,8 +30,8 @@ struct DoneView: View {
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
|
||||
Button("Wizard schließen / Neuer Bind") {
|
||||
model.reset()
|
||||
Button("Zurück zur Geräte-Übersicht") {
|
||||
model.returnToHub()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@ -17,17 +17,11 @@ struct MacRegistrationView: View {
|
||||
.font(.system(size: 80))
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
Text("Mac für ReBreak Magic registrieren")
|
||||
Text("Mac mit DNS-Schutz registrieren")
|
||||
.font(.title)
|
||||
.bold()
|
||||
|
||||
Text("ReBreak Magic richtet dein iPhone/iPad ein: Supervised-Mode, MDM-Enrollment und automatische Installation der ReBreak-App. Dieser Mac dient als Setup-Brücke.")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
Text("Optional: Du kannst diesen Mac zusätzlich selbst mit dem DNS-Filter schützen.")
|
||||
.font(.caption)
|
||||
Text("Dieser Mac wird als geschütztes Gerät registriert. ReBreak installiert ein DNS-Filter-Profil — Glücksspiel-Domains werden auf System-Ebene blockiert.")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 40)
|
||||
@ -74,19 +68,21 @@ struct MacRegistrationView: View {
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("← Abbrechen") { model.returnToHub() }
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(isRegistering || isInstallingProfile)
|
||||
|
||||
if model.magicRegistration == nil {
|
||||
Button("Mac registrieren") { handleRegistration() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(isRegistering || macInfo == nil || isInstallingProfile)
|
||||
} else {
|
||||
if !profileInstalled {
|
||||
Button("DNS-Schutz installieren (optional)") { handleProfileInstall() }
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(isInstallingProfile)
|
||||
}
|
||||
Button("Weiter → iPhone-Setup") { model.advance() }
|
||||
} else if !profileInstalled {
|
||||
Button("DNS-Schutz installieren") { handleProfileInstall() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(isInstallingProfile)
|
||||
} else {
|
||||
Button("✓ Fertig — zurück zur Übersicht") { model.returnToHub() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
|
||||
if isRegistering || isInstallingProfile {
|
||||
|
||||
@ -413,8 +413,8 @@ export async function deleteUserDevice(
|
||||
// RebreakMagic DNS-Device-Binding
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Hard-Limit für Magic-Bindings pro User (Plan-unabhängig für MVP). */
|
||||
export const MAGIC_DEVICE_LIMIT = 3;
|
||||
/** Hard-Limit für Magic-Bindings pro User (5 für Legend-Plan / Staging-Testing). */
|
||||
export const MAGIC_DEVICE_LIMIT = 5;
|
||||
|
||||
export interface MagicDeviceRecord {
|
||||
deviceId: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user