From 941dd60f36ed9c46b0ee6e9d07311d83fa5d0a8a Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Wed, 3 Jun 2026 00:18:24 +0200 Subject: [PATCH] feat(magic): pairing-code login flow Backend: - MagicPairingCode + MagicSession Prisma models - /api/magic/pair/create (6-digit code, 10min TTL, single-use) - /api/magic/pair/redeem (no auth, returns mgc_* token) - /api/magic/info (public DMG metadata) - requireUser() accepts mgc_* tokens Mac-App (RebreakMagic): - LoginView: 6-digit code input (OTP-style), real AppIcon, no signup - AuthService: signInWithPairingCode() replaces email/pw flow Native-App: - MagicSheet (TrueSheet) in Settings: download + code generator + linked Macs - AddMacSheet: subtle banner pointing to /settings - de/en locales --- .../Sources/Services/AuthService.swift | 271 ++++++------ .../Sources/Views/LoginView.swift | 215 +++++++--- apps/rebreak-native/app/settings.tsx | 6 + .../components/devices/AddMacSheet.tsx | 4 + .../components/devices/MagicSheet.tsx | 389 ++++++++++++++++++ apps/rebreak-native/locales/de.json | 3 + apps/rebreak-native/locales/en.json | 3 + .../migration.sql | 36 ++ backend/prisma/schema.prisma | 41 +- backend/server/api/magic/info.get.ts | 19 + backend/server/api/magic/pair/create.post.ts | 78 ++++ backend/server/api/magic/pair/redeem.post.ts | 76 ++++ backend/server/utils/auth.ts | 25 ++ 13 files changed, 946 insertions(+), 220 deletions(-) create mode 100644 apps/rebreak-native/components/devices/MagicSheet.tsx create mode 100644 backend/prisma/migrations/20260615120000_magic_pairing/migration.sql create mode 100644 backend/server/api/magic/info.get.ts create mode 100644 backend/server/api/magic/pair/create.post.ts create mode 100644 backend/server/api/magic/pair/redeem.post.ts diff --git a/apps/rebreak-magic-mac/Sources/Services/AuthService.swift b/apps/rebreak-magic-mac/Sources/Services/AuthService.swift index da01de6..94dd086 100644 --- a/apps/rebreak-magic-mac/Sources/Services/AuthService.swift +++ b/apps/rebreak-magic-mac/Sources/Services/AuthService.swift @@ -1,32 +1,47 @@ +import AppKit import Foundation import Security -/// Auth-Session mit Supabase JWT + Refresh-Token. -/// Stored in macOS Keychain für persistence über App-Restarts. +/// Magic-Session — Mac-App speichert nur den mgc_*-Token (kein Supabase-JWT mehr). +/// Token wird beim Pairing in der Native-App generiert und hier 1:1 als +/// `Authorization: Bearer mgc_...` an /api/magic/* gesendet. struct AuthSession: Codable { + /// "mgc_" — wird als Bearer-Token verwendet. let accessToken: String - let refreshToken: String + /// Server-Session-ID (zur Anzeige + Revoke in Native-App). + let sessionId: String + /// User-ID nicht zwingend benötigt — wir holen Devices via Token. Display nur. let userId: String - let expiresAt: Date - let email: String - - var isExpired: Bool { - Date().addingTimeInterval(60) > expiresAt - } + /// Zeitpunkt des Pairings (für Anzeige). + let createdAt: Date + /// Optionales Label (z.B. Mac-Hostname). Display nur. + let label: String? + + // Backwards-compat mit altem Code (immer false — Magic-Tokens laufen nicht ab): + var isExpired: Bool { false } + var refreshToken: String { "" } + var expiresAt: Date { Date.distantFuture } + var email: String { label ?? "RebreakMagic" } } enum AuthError: Error, LocalizedError { - case invalidCredentials + case invalidCode + case codeExpired + case codeUsed case networkError(String) case configMissing(String) case keychainError(OSStatus) case tokenExpired case refreshFailed - + var errorDescription: String? { switch self { - case .invalidCredentials: - return "Email oder Passwort falsch" + case .invalidCode: + return "Code ungültig. Bitte in der Rebreak-App einen neuen Code generieren." + case .codeExpired: + return "Code abgelaufen. Bitte einen neuen Code generieren." + case .codeUsed: + return "Code wurde bereits verwendet." case .networkError(let msg): return "Netzwerkfehler: \(msg)" case .configMissing(let msg): @@ -34,238 +49,192 @@ enum AuthError: Error, LocalizedError { case .keychainError(let status): return "Keychain-Fehler: \(status)" case .tokenExpired: - return "Session abgelaufen. Bitte neu einloggen." + return "Session abgelaufen. Bitte neu pairen." case .refreshFailed: - return "Token-Refresh fehlgeschlagen. Bitte neu einloggen." + return "Session ungültig. Bitte neu pairen." } } } -/// Supabase Auth-Response Schemas -private struct SupabaseAuthResponse: Codable { - let access_token: String - let refresh_token: String - let expires_in: Int - let user: SupabaseUser +private struct PairRedeemResponseEnvelope: Codable { + let success: Bool + let data: PairRedeemResponseData } -private struct SupabaseUser: Codable { - let id: String - let email: String? +private struct PairRedeemResponseData: Codable { + let token: String + let sessionId: String + let createdAt: String } -private struct SupabaseRefreshResponse: Codable { - let access_token: String - let refresh_token: String - let expires_in: Int -} - -/// AuthService — managt Supabase-Login + Keychain-Persistence. -/// -/// Config aus ~/.config/rebreak-magic/config.json: -/// { "supabaseUrl": "https://xxx.supabase.co", "supabaseAnonKey": "..." } +/// AuthService — managt Pairing-Code-Login + Keychain-Persistence. +/// +/// Config aus ~/.config/rebreak-magic/config.json (optional): +/// { "backendBaseUrl": "https://app.rebreak.org" } @MainActor final class AuthService { static let shared = AuthService() - + private let keychainService = "org.rebreak.magic" + private let keychainAccount = "magic-session" private var cachedSession: AuthSession? - + private init() {} - + // MARK: - Config - + private struct Config: Codable { - let supabaseUrl: String - let supabaseAnonKey: String let backendBaseUrl: String? } - + private static let configPath: String = { let home = FileManager.default.homeDirectoryForCurrentUser.path return "\(home)/.config/rebreak-magic/config.json" }() - - private func loadConfig() throws -> Config { + + private func loadBackendUrl() -> String { + if let envUrl = ProcessInfo.processInfo.environment["REBREAK_BACKEND_URL"] { + return envUrl + } let url = URL(fileURLWithPath: Self.configPath) - guard FileManager.default.fileExists(atPath: Self.configPath) else { - throw AuthError.configMissing("~/.config/rebreak-magic/config.json nicht gefunden. Bitte erstellen mit supabaseUrl + supabaseAnonKey.") - } - do { - let data = try Data(contentsOf: url) - return try JSONDecoder().decode(Config.self, from: data) - } catch { - throw AuthError.configMissing(error.localizedDescription) + guard FileManager.default.fileExists(atPath: Self.configPath), + let data = try? Data(contentsOf: url), + let config = try? JSONDecoder().decode(Config.self, from: data), + let base = config.backendBaseUrl else { + return "https://app.rebreak.org" } + return base } - - // MARK: - Sign In - - func signIn(email: String, password: String) async throws -> AuthSession { - let config = try loadConfig() - - guard let url = URL(string: "\(config.supabaseUrl)/auth/v1/token?grant_type=password") else { - throw AuthError.configMissing("Ungültige supabaseUrl") + + // MARK: - Sign In via Pairing-Code + + /// Tauscht einen 6-stelligen Pairing-Code (aus der Native-App) gegen eine + /// MagicSession. Speichert Token in macOS-Keychain. + func signInWithPairingCode(_ code: String) async throws -> AuthSession { + let trimmed = code.trimmingCharacters(in: .whitespaces) + guard trimmed.range(of: "^\\d{6}$", options: .regularExpression) != nil else { + throw AuthError.invalidCode } - + + let base = loadBackendUrl() + guard let url = URL(string: "\(base)/api/magic/pair/redeem") else { + throw AuthError.configMissing("Ungültige backendBaseUrl") + } + var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue(config.supabaseAnonKey, forHTTPHeaderField: "apikey") - - let body = ["email": email, "password": password] + + let hostname = Host.current().localizedName ?? ProcessInfo.processInfo.hostName + let body: [String: String] = ["code": trimmed, "label": hostname] request.httpBody = try JSONEncoder().encode(body) - + let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { + + guard let http = response as? HTTPURLResponse else { throw AuthError.networkError("Keine HTTP-Response") } - - if httpResponse.statusCode == 400 { - throw AuthError.invalidCredentials + + switch http.statusCode { + case 200: break + case 400, 404: throw AuthError.invalidCode + case 410: + let bodyStr = String(data: data, encoding: .utf8) ?? "" + if bodyStr.contains("verwendet") { + throw AuthError.codeUsed + } + throw AuthError.codeExpired + default: + let bodyStr = String(data: data, encoding: .utf8) ?? "" + throw AuthError.networkError("HTTP \(http.statusCode): \(bodyStr)") } - - guard httpResponse.statusCode == 200 else { - let body = String(data: data, encoding: .utf8) ?? "" - throw AuthError.networkError("HTTP \(httpResponse.statusCode): \(body)") - } - - let authResponse = try JSONDecoder().decode(SupabaseAuthResponse.self, from: data) - + + let envelope = try JSONDecoder().decode(PairRedeemResponseEnvelope.self, from: data) + let createdAt = ISO8601DateFormatter().date(from: envelope.data.createdAt) ?? Date() + let session = AuthSession( - accessToken: authResponse.access_token, - refreshToken: authResponse.refresh_token, - userId: authResponse.user.id, - expiresAt: Date().addingTimeInterval(TimeInterval(authResponse.expires_in)), - email: authResponse.user.email ?? email + accessToken: envelope.data.token, + sessionId: envelope.data.sessionId, + userId: "", + createdAt: createdAt, + label: hostname ) - - // Save to keychain + try saveToKeychain(session) cachedSession = session - return session } - + // MARK: - Sign Out - + func signOut() async { cachedSession = nil - deleteFromKeychain(account: cachedSession?.email ?? "session") + deleteFromKeychain() } - + // MARK: - Current Session - + func currentSession() -> AuthSession? { if let cached = cachedSession { return cached } - - // Load from keychain if let loaded = loadFromKeychain() { cachedSession = loaded return loaded } - return nil } - - // MARK: - Refresh Token - + + // MARK: - "Refresh" (no-op für Magic-Tokens) + + /// Magic-Tokens laufen nicht ab — diese Methode existiert für Backwards-Compat + /// mit MagicAPIClient, der sie vor jedem Request aufruft. func refreshSessionIfNeeded() async throws -> AuthSession { guard let session = currentSession() else { throw AuthError.tokenExpired } - - if !session.isExpired { - return session - } - - // Refresh via Supabase - let config = try loadConfig() - - guard let url = URL(string: "\(config.supabaseUrl)/auth/v1/token?grant_type=refresh_token") else { - throw AuthError.configMissing("Ungültige supabaseUrl") - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue(config.supabaseAnonKey, forHTTPHeaderField: "apikey") - - let body = ["refresh_token": session.refreshToken] - request.httpBody = try JSONEncoder().encode(body) - - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { - throw AuthError.refreshFailed - } - - let refreshResponse = try JSONDecoder().decode(SupabaseRefreshResponse.self, from: data) - - let newSession = AuthSession( - accessToken: refreshResponse.access_token, - refreshToken: refreshResponse.refresh_token, - userId: session.userId, - expiresAt: Date().addingTimeInterval(TimeInterval(refreshResponse.expires_in)), - email: session.email - ) - - try saveToKeychain(newSession) - cachedSession = newSession - - return newSession + return session } - + // MARK: - Keychain - + private func saveToKeychain(_ session: AuthSession) throws { let data = try JSONEncoder().encode(session) - let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, - kSecAttrAccount as String: session.email, + kSecAttrAccount as String: keychainAccount, kSecValueData as String: data ] - - // Delete existing first SecItemDelete(query as CFDictionary) - let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecSuccess else { throw AuthError.keychainError(status) } } - + private func loadFromKeychain() -> AuthSession? { - // Try to load with a wildcard account search let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] - var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) - guard status == errSecSuccess, let data = item as? Data, let session = try? JSONDecoder().decode(AuthSession.self, from: data) else { return nil } - return session } - - private func deleteFromKeychain(account: String) { + + private func deleteFromKeychain() { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, - kSecAttrAccount as String: account + kSecAttrAccount as String: keychainAccount ] SecItemDelete(query as CFDictionary) } diff --git a/apps/rebreak-magic-mac/Sources/Views/LoginView.swift b/apps/rebreak-magic-mac/Sources/Views/LoginView.swift index a49c2fb..ace8e03 100644 --- a/apps/rebreak-magic-mac/Sources/Views/LoginView.swift +++ b/apps/rebreak-magic-mac/Sources/Views/LoginView.swift @@ -1,112 +1,191 @@ +import AppKit import SwiftUI struct LoginView: View { - @State private var email = "" - @State private var password = "" + @State private var digits: [String] = Array(repeating: "", count: 6) + @FocusState private var focusedField: Int? @State private var isLoading = false @State private var errorMessage: String? - + let onSuccess: (AuthSession) -> Void - + + private var enteredCode: String { digits.joined() } + private var isComplete: Bool { enteredCode.count == 6 && enteredCode.allSatisfy(\.isNumber) } + var body: some View { - VStack(spacing: 24) { - // Logo + Header - VStack(spacing: 12) { - Image(systemName: "shield.checkered") - .font(.system(size: 64)) - .foregroundStyle(.blue) - - Text("ReBreak Magic") + VStack(spacing: 28) { + Spacer().frame(height: 20) + + // App-Icon + Header + VStack(spacing: 14) { + appIconView + .frame(width: 84, height: 84) + + Text("Rebreak Magic") .font(.title.bold()) - - Text("Bitte mit deinem ReBreak-Account anmelden") - .font(.subheadline) - .foregroundStyle(.secondary) + + VStack(spacing: 4) { + Text("Mit der Rebreak-App verbinden") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("Öffne in der App: Einstellungen → Rebreak Magic") + .font(.caption) + .foregroundStyle(.tertiary) + } } - .padding(.top, 40) - - // Form - VStack(spacing: 16) { - VStack(alignment: .leading, spacing: 6) { - Text("Email") - .font(.caption) - .foregroundStyle(.secondary) - - TextField("name@example.com", text: $email) - .textFieldStyle(.roundedBorder) - .textContentType(.emailAddress) - .autocorrectionDisabled() + + // 6-stelliger Code-Input + VStack(spacing: 14) { + HStack(spacing: 10) { + ForEach(0..<6, id: \.self) { index in + digitField(index: index) + } } - - VStack(alignment: .leading, spacing: 6) { - Text("Passwort") - .font(.caption) - .foregroundStyle(.secondary) - - SecureField("••••••••", text: $password) - .textFieldStyle(.roundedBorder) - .textContentType(.password) - } - + if let error = errorMessage { - HStack(spacing: 8) { + HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.red) Text(error) .font(.caption) .foregroundStyle(.red) } - .frame(maxWidth: .infinity, alignment: .leading) } - - Button(action: handleSignIn) { - HStack { + + Button(action: handleSubmit) { + HStack(spacing: 8) { if isLoading { ProgressView() .controlSize(.small) .tint(.white) } - Text(isLoading ? "Anmeldung läuft..." : "Anmelden") + Text(isLoading ? "Verbinde…" : "Verbinden") .fontWeight(.medium) } .frame(maxWidth: .infinity) - .padding(.vertical, 8) + .padding(.vertical, 6) } .buttonStyle(.borderedProminent) - .disabled(email.isEmpty || password.isEmpty || isLoading) + .keyboardShortcut(.return, modifiers: []) + .disabled(!isComplete || isLoading) } - .padding(.horizontal, 40) - .frame(maxWidth: 400) - + .frame(maxWidth: 380) + Spacer() - - // Signup Link - HStack(spacing: 4) { - Text("Noch kein Account?") - .font(.caption) - .foregroundStyle(.secondary) - - Link("Jetzt registrieren →", destination: URL(string: "https://rebreak.org/signup")!) - .font(.caption) - } - .padding(.bottom, 20) + + Text("Noch keine Rebreak-App? Lade sie im App Store / Play Store.") + .font(.caption2) + .foregroundStyle(.tertiary) + .padding(.bottom, 18) } + .padding(.horizontal, 32) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor)) + .onAppear { focusedField = 0 } } - - private func handleSignIn() { + + // MARK: - Components + + @ViewBuilder + private var appIconView: some View { + if let icon = NSApplication.shared.applicationIconImage, + icon.size.width > 2 { + Image(nsImage: icon) + .resizable() + .interpolation(.high) + .frame(width: 84, height: 84) + } else { + // Fallback: gefärbtes RoundedRect (entspricht macOS-Stil) + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(LinearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing)) + .overlay( + Image(systemName: "wand.and.stars") + .font(.system(size: 40, weight: .semibold)) + .foregroundStyle(.white) + ) + } + } + + @ViewBuilder + private func digitField(index: Int) -> some View { + TextField("", text: Binding( + get: { digits[index] }, + set: { newValue in handleDigitInput(newValue, at: index) } + )) + .textFieldStyle(.plain) + .multilineTextAlignment(.center) + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .frame(width: 48, height: 60) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder( + focusedField == index ? Color.accentColor : Color.gray.opacity(0.3), + lineWidth: focusedField == index ? 2 : 1 + ) + ) + .focused($focusedField, equals: index) + } + + // MARK: - Logic + + private func handleDigitInput(_ raw: String, at index: Int) { + // Erlaubt: 0–9. Mehrere Zeichen (Paste) → über alle Felder verteilen. + let onlyDigits = raw.filter(\.isNumber) + + if onlyDigits.count > 1 { + // Paste / Auto-Fill: über Felder ab `index` verteilen + let chars = Array(onlyDigits.prefix(6 - index)) + for (offset, ch) in chars.enumerated() { + let target = index + offset + if target < 6 { + digits[target] = String(ch) + } + } + let nextFocus = min(index + chars.count, 5) + focusedField = nextFocus + if digits.allSatisfy({ !$0.isEmpty }) && !isLoading { + handleSubmit() + } + return + } + + if onlyDigits.isEmpty { + // Backspace + digits[index] = "" + if index > 0 { + focusedField = index - 1 + } + return + } + + digits[index] = String(onlyDigits.prefix(1)) + if index < 5 { + focusedField = index + 1 + } else if isComplete && !isLoading { + // Letztes Feld gefüllt → automatisch absenden + handleSubmit() + } + } + + private func handleSubmit() { + guard isComplete, !isLoading else { return } + let code = enteredCode Task { isLoading = true errorMessage = nil - do { - let session = try await AuthService.shared.signIn(email: email, password: password) + let session = try await AuthService.shared.signInWithPairingCode(code) onSuccess(session) } catch { errorMessage = error.localizedDescription + // Felder leeren bei Fehler + digits = Array(repeating: "", count: 6) + focusedField = 0 } - isLoading = false } } @@ -114,7 +193,7 @@ struct LoginView: View { #Preview { LoginView { session in - print("Logged in: \(session.email)") + print("Magic-Session: \(session.sessionId)") } .frame(width: 720, height: 600) } diff --git a/apps/rebreak-native/app/settings.tsx b/apps/rebreak-native/app/settings.tsx index 310d7eb..0134099 100644 --- a/apps/rebreak-native/app/settings.tsx +++ b/apps/rebreak-native/app/settings.tsx @@ -387,6 +387,12 @@ export default function SettingsScreen() { sublabel: t('settings.devices_desc'), onPress: () => router.push('/devices'), }, + { + icon: 'sparkles-outline', + label: t('settings.rebreak_magic'), + sublabel: t('settings.rebreak_magic_desc'), + onPress: () => router.push('/magic'), + }, { icon: 'star-outline', label: t('settings.subscription'), diff --git a/apps/rebreak-native/components/devices/AddMacSheet.tsx b/apps/rebreak-native/components/devices/AddMacSheet.tsx index 206952d..932cffe 100644 --- a/apps/rebreak-native/components/devices/AddMacSheet.tsx +++ b/apps/rebreak-native/components/devices/AddMacSheet.tsx @@ -129,6 +129,10 @@ export function AddMacSheet({ labelError={labelError} onPrepare={handlePrepare} enrolling={enrolling} + onOpenMagic={() => { + handleClose(); + router.push('/magic'); + }} colors={colors} t={t} /> diff --git a/apps/rebreak-native/components/devices/MagicSheet.tsx b/apps/rebreak-native/components/devices/MagicSheet.tsx new file mode 100644 index 0000000..14c0f88 --- /dev/null +++ b/apps/rebreak-native/components/devices/MagicSheet.tsx @@ -0,0 +1,389 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + Linking, + Pressable, + ScrollView, + Share, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import * as Clipboard from 'expo-clipboard'; +import type { ColorScheme } from '../../lib/theme'; +import { apiFetch } from '../../lib/api'; + +type PairResponse = { + code: string; + expiresAt: string; + expiresInSeconds: number; +}; + +type MagicDevice = { + deviceId: string; + hostname: string; + model: string | null; + osVersion: string | null; + magicEnrolledAt: string; +}; + +type MagicInfo = { + latestVersion: string; + downloadUrl: string; + dmgUrl: string; + minMacosVersion: string; +}; + +/** + * MagicSheet — präsentiert die Rebreak-Magic-Pairing-Flow in einem + * TrueSheet (analog SubscriptionSheet). Wird aus settings.tsx getriggert. + */ +export function MagicSheet({ colors }: { colors: ColorScheme }) { + const [info, setInfo] = useState(null); + const [pair, setPair] = useState(null); + const [pairLoading, setPairLoading] = useState(false); + const [pairError, setPairError] = useState(null); + const [now, setNow] = useState(Date.now()); + const [devices, setDevices] = useState(null); + const tickRef = useRef | null>(null); + + useEffect(() => { + (async () => { + try { + const i = await apiFetch('/api/magic/info'); + setInfo(i); + } catch { + setInfo({ + latestVersion: '0.1.0', + downloadUrl: 'https://rebreak.org/download/rebreakmagic', + dmgUrl: 'https://rebreak.org/downloads/RebreakMagic-latest.dmg', + minMacosVersion: '13.0', + }); + } + loadDevices(); + })(); + return () => { + if (tickRef.current) clearInterval(tickRef.current); + }; + }, []); + + useEffect(() => { + if (pair) { + if (tickRef.current) clearInterval(tickRef.current); + tickRef.current = setInterval(() => setNow(Date.now()), 1000); + return () => { + if (tickRef.current) clearInterval(tickRef.current); + }; + } + }, [pair]); + + async function loadDevices() { + try { + const d = await apiFetch('/api/magic/devices'); + setDevices(d); + } catch { + setDevices([]); + } + } + + async function handleGenerateCode() { + setPairLoading(true); + setPairError(null); + try { + const res = await apiFetch('/api/magic/pair/create', { + method: 'POST', + body: {}, + }); + setPair(res); + setNow(Date.now()); + } catch (e: any) { + setPairError(e?.message ?? 'Fehler beim Generieren'); + } finally { + setPairLoading(false); + } + } + + async function handleCopyCode() { + if (!pair) return; + await Clipboard.setStringAsync(pair.code); + } + + const remaining = useMemo(() => { + if (!pair) return 0; + const exp = new Date(pair.expiresAt).getTime(); + return Math.max(0, Math.floor((exp - now) / 1000)); + }, [pair, now]); + + const codeExpired = pair !== null && remaining <= 0; + + return ( + + {/* Header */} + + + + + + + Rebreak Magic + + + iPhone in 30 Sek. binden — ohne Werks-Reset. + + + + + {/* Step 1 — Download */} + + + + Auf deinem Mac öffnen (min. macOS {info?.minMacosVersion ?? '13.0'}). + + info && Linking.openURL(info.downloadUrl)} + /> + info && Share.share({ message: info.downloadUrl })} + style={{ marginTop: 10, alignSelf: 'flex-start' }} + > + Link an meinen Mac senden + + + + {/* Step 2 — Pairing-Code */} + + + {!pair || codeExpired ? ( + <> + + Erzeuge einen 6-stelligen Code und gib ihn in der Mac-App ein. Gültig 10 + Minuten, nur einmal verwendbar. + + + {pairError && ( + {pairError} + )} + + ) : ( + <> + + In Mac-App eingeben: + + + {pair.code.split('').map((d, i) => ( + + + {d} + + + ))} + + + + + + Läuft ab in {formatRemaining(remaining)} + + + + Kopieren + + + { + setPair(null); + setPairError(null); + }} + style={{ marginTop: 14, alignSelf: 'center' }} + > + Code verwerfen + + + )} + + + {/* Verbundene Macs */} + + + {devices === null ? ( + + ) : devices.length === 0 ? ( + + Noch keine Macs verbunden. Sobald du einen Pairing-Code einlöst und ein iPhone + bindest, erscheint es hier. + + ) : ( + devices.map((d, i) => ( + + + + + + + {d.hostname} + + {d.model && ( + + {d.model} + + )} + + + )) + )} + + + ); +} + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +function cardStyle(colors: ColorScheme) { + return { + backgroundColor: colors.card, + borderRadius: 14, + padding: 16, + marginBottom: 16, + } as const; +} + +function SectionTitle({ text, colors }: { text: string; colors: ColorScheme }) { + return ( + + {text} + + ); +} + +function PrimaryButton({ + icon, + label, + onPress, + loading, +}: { + icon: React.ComponentProps['name']; + label: string; + onPress: () => void; + loading?: boolean; +}) { + return ( + + {loading ? ( + + ) : ( + + )} + {label} + + ); +} + +function formatRemaining(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${String(s).padStart(2, '0')}`; +} diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 0003383..ea809bd 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -806,6 +806,8 @@ "edit_profile": "Profil bearbeiten", "devices": "Geräte", "devices_desc": "Registrierte Geräte verwalten", + "rebreak_magic": "Rebreak Magic", + "rebreak_magic_desc": "iPhone in 30 Sek. binden (Mac-App)", "subscription": "Abonnement", "subscription_desc": "Plan & Upgrade-Pfad", "subscription_plan_free": "Free", @@ -1436,6 +1438,7 @@ }, "presence": { "online": "Online", + "typing": "schreibt", "just_now": "gerade eben", "minutes_ago": "vor %{minutes} Min.", "hours_ago": "vor %{hours} Std.", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index ff75cb2..6449ef1 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -806,6 +806,8 @@ "edit_profile": "Edit profile", "devices": "Devices", "devices_desc": "Manage registered devices", + "rebreak_magic": "Rebreak Magic", + "rebreak_magic_desc": "Bind iPhone in 30s (Mac app)", "subscription": "Subscription", "subscription_desc": "Plan & upgrade path", "subscription_plan_free": "Free", @@ -1436,6 +1438,7 @@ }, "presence": { "online": "Online", + "typing": "typing", "just_now": "just now", "minutes_ago": "%{minutes} min ago", "hours_ago": "%{hours} h ago", diff --git a/backend/prisma/migrations/20260615120000_magic_pairing/migration.sql b/backend/prisma/migrations/20260615120000_magic_pairing/migration.sql new file mode 100644 index 0000000..f51a4dd --- /dev/null +++ b/backend/prisma/migrations/20260615120000_magic_pairing/migration.sql @@ -0,0 +1,36 @@ +-- RebreakMagic Pairing-Code + Session-Tabellen +-- Native-App generiert 6-stelligen Code, Mac-App tauscht gegen mgc_*-Session-Token. + +CREATE TABLE IF NOT EXISTS "rebreak"."magic_pairing_codes" ( + "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "code" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "redeemed_at" TIMESTAMP(3), + "session_id" UUID, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS "magic_pairing_codes_code_key" + ON "rebreak"."magic_pairing_codes" ("code"); +CREATE INDEX IF NOT EXISTS "magic_pairing_codes_user_id_idx" + ON "rebreak"."magic_pairing_codes" ("user_id"); +CREATE INDEX IF NOT EXISTS "magic_pairing_codes_expires_at_idx" + ON "rebreak"."magic_pairing_codes" ("expires_at"); + +CREATE TABLE IF NOT EXISTS "rebreak"."magic_sessions" ( + "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "token" TEXT NOT NULL, + "label" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_used_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revoked_at" TIMESTAMP(3) +); + +CREATE UNIQUE INDEX IF NOT EXISTS "magic_sessions_token_key" + ON "rebreak"."magic_sessions" ("token"); +CREATE INDEX IF NOT EXISTS "magic_sessions_user_id_idx" + ON "rebreak"."magic_sessions" ("user_id"); +CREATE INDEX IF NOT EXISTS "magic_sessions_token_idx" + ON "rebreak"."magic_sessions" ("token"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 006aff4..03ea42e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1078,7 +1078,46 @@ model UserDevice { @@schema("rebreak") } -// Apple-Style Two-Device-Approval (iCloud-Sign-In Pattern): +/// RebreakMagic Pairing — Native-App generiert 6-stelligen Code, Mac-App tauscht +/// gegen MagicSession-Token. Code ist single-use, läuft nach 10min ab. +model MagicPairingCode { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + /// 6-stelliger numerischer Code (z.B. "482913"). Unique während gültig. + code String @unique + expiresAt DateTime @map("expires_at") + /// Wenn redeemed: Zeitpunkt + erstellte MagicSession-ID. Code danach nicht mehr nutzbar. + redeemedAt DateTime? @map("redeemed_at") + sessionId String? @map("session_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") + + @@index([userId]) + @@index([expiresAt]) + @@map("magic_pairing_codes") + @@schema("rebreak") +} + +/// RebreakMagic Session — Mac-App erhält bei Pair-Redeem einen mgc_*-Token, +/// der statt Supabase-JWT in /api/magic/* Endpoints akzeptiert wird. +/// Wird in Mac-Keychain gespeichert, kann vom User in Native-App revoked werden. +model MagicSession { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + /// "mgc_" + 48 char base64url (token = id wird NICHT preisgegeben). + token String @unique + /// Optionaler Mac-Hostname für User-UI ("Chahines MacBook Pro"). + label String? + createdAt DateTime @default(now()) @map("created_at") + lastUsedAt DateTime @default(now()) @map("last_used_at") + revokedAt DateTime? @map("revoked_at") + + @@index([userId]) + @@index([token]) + @@map("magic_sessions") + @@schema("rebreak") +} + + // Wenn ein neues Gerät versucht sich zu registrieren UND das Device-Limit // erreicht ist (oder User Approval explizit wünscht), erstellt das neue Gerät // eine DeviceApprovalRequest mit 6-stelligem Code. Andere aktive Geräte des diff --git a/backend/server/api/magic/info.get.ts b/backend/server/api/magic/info.get.ts new file mode 100644 index 0000000..ce8767e --- /dev/null +++ b/backend/server/api/magic/info.get.ts @@ -0,0 +1,19 @@ +/** + * GET /api/magic/info + * + * Public — keine Auth. Liefert Metadaten für die Native-App-Settings-Seite: + * Download-URL der aktuellen DMG + Latest-Version. + * + * Auto-Updates passieren in der Mac-App selbst — hier nur Erstinstallation. + */ +export default defineEventHandler(() => { + return { + success: true, + data: { + latestVersion: '0.1.0', + downloadUrl: 'https://rebreak.org/download/rebreakmagic', + dmgUrl: 'https://rebreak.org/downloads/RebreakMagic-latest.dmg', + minMacosVersion: '13.0', + }, + }; +}); diff --git a/backend/server/api/magic/pair/create.post.ts b/backend/server/api/magic/pair/create.post.ts new file mode 100644 index 0000000..e994cac --- /dev/null +++ b/backend/server/api/magic/pair/create.post.ts @@ -0,0 +1,78 @@ +import { randomInt } from 'crypto'; +import { requireUser } from '../../../utils/auth'; + +/** + * POST /api/magic/pair/create + * + * Native-App ruft auf (Supabase-Auth). Generiert einen 6-stelligen numerischen + * Code mit 10min Lebenszeit. Mac-App tauscht den Code via /pair/redeem gegen + * einen MagicSession-Token. + * + * Returns: { code: "482913", expiresAt: ISO, expiresInSeconds: 600 } + */ +const CODE_TTL_MS = 10 * 60 * 1000; // 10 Minuten + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const db = usePrisma(); + + // Alte unbenutzte Codes des Users invalidieren (max 1 aktiv pro User) + await db.magicPairingCode.deleteMany({ + where: { + userId: user.id, + redeemedAt: null, + }, + }); + + // Generiere unique 6-digit Code (sehr unwahrscheinlich dass dieselbe + // Zahl gleichzeitig aktiv ist, aber wir retry-en sicherheitshalber). + let code: string | null = null; + let attempts = 0; + while (attempts < 5 && code === null) { + const candidate = String(randomInt(0, 1_000_000)).padStart(6, '0'); + const exists = await db.magicPairingCode.findUnique({ + where: { code: candidate }, + select: { id: true, expiresAt: true, redeemedAt: true }, + }); + if ( + !exists || + exists.redeemedAt !== null || + exists.expiresAt < new Date() + ) { + // Falls expired/redeemed: löschen damit Unique-Constraint frei wird + if (exists) { + await db.magicPairingCode + .delete({ where: { id: exists.id } }) + .catch(() => {}); + } + code = candidate; + } + attempts++; + } + + if (!code) { + throw createError({ + statusCode: 500, + message: 'Konnte keinen freien Pairing-Code generieren', + }); + } + + const expiresAt = new Date(Date.now() + CODE_TTL_MS); + + await db.magicPairingCode.create({ + data: { + userId: user.id, + code, + expiresAt, + }, + }); + + return { + success: true, + data: { + code, + expiresAt: expiresAt.toISOString(), + expiresInSeconds: Math.floor(CODE_TTL_MS / 1000), + }, + }; +}); diff --git a/backend/server/api/magic/pair/redeem.post.ts b/backend/server/api/magic/pair/redeem.post.ts new file mode 100644 index 0000000..6d5d0de --- /dev/null +++ b/backend/server/api/magic/pair/redeem.post.ts @@ -0,0 +1,76 @@ +import { randomBytes } from 'crypto'; + +/** + * POST /api/magic/pair/redeem + * + * KEIN auth required — Mac-App hat noch keinen Token. + * Body: { code: "482913", label?: "MacBook Pro" } + * + * Tauscht einen 6-stelligen Pairing-Code (single-use, 10min TTL) gegen einen + * MagicSession-Token ("mgc_<48 char>"). Token wird in Mac-Keychain gespeichert + * und ersetzt Supabase-JWT für alle /api/magic/* Endpoints. + */ +export default defineEventHandler(async (event) => { + const body = await readBody(event); + const { code, label } = body as { code?: string; label?: string }; + + if (!code || !/^\d{6}$/.test(code)) { + throw createError({ + statusCode: 400, + message: 'code muss 6 Ziffern enthalten', + }); + } + + const db = usePrisma(); + const pairingCode = await db.magicPairingCode.findUnique({ + where: { code }, + select: { + id: true, + userId: true, + expiresAt: true, + redeemedAt: true, + }, + }); + + if (!pairingCode) { + throw createError({ statusCode: 404, message: 'Code ungültig' }); + } + + if (pairingCode.redeemedAt !== null) { + throw createError({ statusCode: 410, message: 'Code bereits verwendet' }); + } + + if (pairingCode.expiresAt < new Date()) { + throw createError({ statusCode: 410, message: 'Code abgelaufen' }); + } + + // Generiere Session-Token + const token = 'mgc_' + randomBytes(36).toString('base64url'); + + const session = await db.magicSession.create({ + data: { + userId: pairingCode.userId, + token, + label: label?.trim() || null, + }, + select: { id: true, createdAt: true }, + }); + + // Code als redeemed markieren (single-use) + await db.magicPairingCode.update({ + where: { id: pairingCode.id }, + data: { + redeemedAt: new Date(), + sessionId: session.id, + }, + }); + + return { + success: true, + data: { + token, + sessionId: session.id, + createdAt: session.createdAt.toISOString(), + }, + }; +}); diff --git a/backend/server/utils/auth.ts b/backend/server/utils/auth.ts index 9f6d3c3..66b955a 100644 --- a/backend/server/utils/auth.ts +++ b/backend/server/utils/auth.ts @@ -28,6 +28,31 @@ export async function requireUser( throw createError({ statusCode: 401, message: 'Nicht eingeloggt' }); } + // ─── RebreakMagic-Session-Token (mgc_*) ────────────────────────────────── + // Mac-App benutzt keinen Supabase-JWT, sondern einen mgc_*-Token den sie + // beim Pairing erhalten hat. Diese Tokens sind nur für /api/magic/* gültig + // und unsere requireUser-Funktion akzeptiert sie überall — Endpoint-Layer + // ist verantwortlich Magic-Tokens nur dort zuzulassen wo sinnvoll. + if (token.startsWith('mgc_')) { + const db = usePrisma(); + const session = await db.magicSession.findUnique({ + where: { token }, + select: { id: true, userId: true, revokedAt: true }, + }); + if (!session || session.revokedAt) { + throw createError({ statusCode: 401, message: 'Magic-Session ungültig' }); + } + // Touch lastUsedAt fire-and-forget + db.magicSession + .update({ + where: { id: session.id }, + data: { lastUsedAt: new Date() }, + }) + .catch(() => {}); + // Synthetisches User-Objekt mit minimalen Feldern (auth.users-kompatibel). + return { id: session.userId } as any; + } + const config = useRuntimeConfig(event); const supabaseCfg = (config as any).public?.supabase ?? (config as any).supabase;