From d54bd06727bc35a81fb958900e86205192af8900 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Wed, 3 Jun 2026 10:39:51 +0200 Subject: [PATCH] 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. --- .../Sources/Models/WizardModel.swift | 25 ++ .../Sources/Views/ContentView.swift | 16 +- .../Sources/Views/DeviceHubView.swift | 371 ++++++++++++++++++ .../Sources/Views/DoneView.swift | 4 +- .../Sources/Views/MacRegistrationView.swift | 26 +- backend/server/db/devices.ts | 4 +- 6 files changed, 425 insertions(+), 21 deletions(-) create mode 100644 apps/rebreak-magic-mac/Sources/Views/DeviceHubView.swift diff --git a/apps/rebreak-magic-mac/Sources/Models/WizardModel.swift b/apps/rebreak-magic-mac/Sources/Models/WizardModel.swift index ba34d60..487d6d7 100644 --- a/apps/rebreak-magic-mac/Sources/Models/WizardModel.swift +++ b/apps/rebreak-magic-mac/Sources/Models/WizardModel.swift @@ -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 diff --git a/apps/rebreak-magic-mac/Sources/Views/ContentView.swift b/apps/rebreak-magic-mac/Sources/Views/ContentView.swift index 0726e0e..ecc6d04 100644 --- a/apps/rebreak-magic-mac/Sources/Views/ContentView.swift +++ b/apps/rebreak-magic-mac/Sources/Views/ContentView.swift @@ -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) diff --git a/apps/rebreak-magic-mac/Sources/Views/DeviceHubView.swift b/apps/rebreak-magic-mac/Sources/Views/DeviceHubView.swift new file mode 100644 index 0000000..37ac58a --- /dev/null +++ b/apps/rebreak-magic-mac/Sources/Views/DeviceHubView.swift @@ -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()) +} diff --git a/apps/rebreak-magic-mac/Sources/Views/DoneView.swift b/apps/rebreak-magic-mac/Sources/Views/DoneView.swift index 7f9f9b5..d6fe08d 100644 --- a/apps/rebreak-magic-mac/Sources/Views/DoneView.swift +++ b/apps/rebreak-magic-mac/Sources/Views/DoneView.swift @@ -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) diff --git a/apps/rebreak-magic-mac/Sources/Views/MacRegistrationView.swift b/apps/rebreak-magic-mac/Sources/Views/MacRegistrationView.swift index d876601..6ac62e5 100644 --- a/apps/rebreak-magic-mac/Sources/Views/MacRegistrationView.swift +++ b/apps/rebreak-magic-mac/Sources/Views/MacRegistrationView.swift @@ -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 { diff --git a/backend/server/db/devices.ts b/backend/server/db/devices.ts index 8486abf..c26ccaf 100644 --- a/backend/server/db/devices.ts +++ b/backend/server/db/devices.ts @@ -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;