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:
chahinebrini 2026-06-03 10:39:51 +02:00
parent 87d6395ed2
commit d54bd06727
6 changed files with 425 additions and 21 deletions

View File

@ -51,6 +51,7 @@ final class WizardModel {
// Auth + Magic State // Auth + Magic State
var authSession: AuthSession? var authSession: AuthSession?
var showingLogin: Bool = false var showingLogin: Bool = false
var showingHub: Bool = false
var showingManageBindings: Bool = false var showingManageBindings: Bool = false
var magicRegistration: MagicRegistration? var magicRegistration: MagicRegistration?
var registrationError: String? var registrationError: String?
@ -59,6 +60,8 @@ final class WizardModel {
// Load existing session from keychain // Load existing session from keychain
authSession = AuthService.shared.currentSession() authSession = AuthService.shared.currentSession()
showingLogin = (authSession == nil) showingLogin = (authSession == nil)
// Nach Login direkt zum Hub statt Mac-Auto-Registrierung
showingHub = (authSession != nil)
} }
func advance() { func advance() {
@ -106,14 +109,36 @@ final class WizardModel {
func handleLogin(session: AuthSession) { func handleLogin(session: AuthSession) {
authSession = session authSession = session
showingLogin = false showingLogin = false
showingHub = true
} }
func handleLogout() async { func handleLogout() async {
await AuthService.shared.signOut() await AuthService.shared.signOut()
authSession = nil authSession = nil
showingLogin = true showingLogin = true
showingHub = false
reset() 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() { func reset() {
step = .macRegistration step = .macRegistration

View File

@ -11,6 +11,8 @@ struct ContentView: View {
LoginView { session in LoginView { session in
model.handleLogin(session: session) model.handleLogin(session: session)
} }
} else if model.showingHub {
DeviceHubView()
} else { } else {
mainWizardView mainWizardView
} }
@ -41,6 +43,16 @@ struct ContentView: View {
} }
Spacer() 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 // Help-Button
Button(action: { showingHelp = true }) { Button(action: { showingHelp = true }) {
Image(systemName: "questionmark.circle") Image(systemName: "questionmark.circle")
@ -51,8 +63,8 @@ struct ContentView: View {
.help("Hilfe & FAQ (⌘?)") .help("Hilfe & FAQ (⌘?)")
.keyboardShortcut("?", modifiers: .command) .keyboardShortcut("?", modifiers: .command)
if model.step != .done { if model.step != .done && model.step != .macRegistration {
Text("Schritt \(model.step.stepNumber) von \(WizardStep.total)") Text("Schritt \(model.step.stepNumber - 1) von \(WizardStep.total - 1)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.leading, 12) .padding(.leading, 12)

View 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())
}

View File

@ -30,8 +30,8 @@ struct DoneView: View {
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.controlSize(.large) .controlSize(.large)
Button("Wizard schließen / Neuer Bind") { Button("Zurück zur Geräte-Übersicht") {
model.reset() model.returnToHub()
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)

View File

@ -17,17 +17,11 @@ struct MacRegistrationView: View {
.font(.system(size: 80)) .font(.system(size: 80))
.foregroundStyle(.blue) .foregroundStyle(.blue)
Text("Mac für ReBreak Magic registrieren") Text("Mac mit DNS-Schutz registrieren")
.font(.title) .font(.title)
.bold() .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.") 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)
Text("Optional: Du kannst diesen Mac zusätzlich selbst mit dem DNS-Filter schützen.")
.font(.caption)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.horizontal, 40) .padding(.horizontal, 40)
@ -74,19 +68,21 @@ struct MacRegistrationView: View {
} }
HStack(spacing: 12) { HStack(spacing: 12) {
Button("← Abbrechen") { model.returnToHub() }
.buttonStyle(.bordered)
.disabled(isRegistering || isInstallingProfile)
if model.magicRegistration == nil { if model.magicRegistration == nil {
Button("Mac registrieren") { handleRegistration() } Button("Mac registrieren") { handleRegistration() }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(isRegistering || macInfo == nil || isInstallingProfile) .disabled(isRegistering || macInfo == nil || isInstallingProfile)
} else { } else if !profileInstalled {
if !profileInstalled { Button("DNS-Schutz installieren") { handleProfileInstall() }
Button("DNS-Schutz installieren (optional)") { handleProfileInstall() }
.buttonStyle(.bordered)
.disabled(isInstallingProfile)
}
Button("Weiter → iPhone-Setup") { model.advance() }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(isInstallingProfile) .disabled(isInstallingProfile)
} else {
Button("✓ Fertig — zurück zur Übersicht") { model.returnToHub() }
.buttonStyle(.borderedProminent)
} }
if isRegistering || isInstallingProfile { if isRegistering || isInstallingProfile {

View File

@ -413,8 +413,8 @@ export async function deleteUserDevice(
// RebreakMagic DNS-Device-Binding // RebreakMagic DNS-Device-Binding
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/** Hard-Limit für Magic-Bindings pro User (Plan-unabhängig für MVP). */ /** Hard-Limit für Magic-Bindings pro User (5 für Legend-Plan / Staging-Testing). */
export const MAGIC_DEVICE_LIMIT = 3; export const MAGIC_DEVICE_LIMIT = 5;
export interface MagicDeviceRecord { export interface MagicDeviceRecord {
deviceId: string; deviceId: string;