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
This commit is contained in:
chahinebrini 2026-06-03 00:18:24 +02:00
parent 138e45fe0a
commit 941dd60f36
13 changed files with 946 additions and 220 deletions

View File

@ -1,22 +1,33 @@
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_<base64url>" 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
/// Zeitpunkt des Pairings (für Anzeige).
let createdAt: Date
/// Optionales Label (z.B. Mac-Hostname). Display nur.
let label: String?
var isExpired: Bool {
Date().addingTimeInterval(60) > expiresAt
}
// 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)
@ -25,8 +36,12 @@ enum AuthError: Error, LocalizedError {
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,41 +49,34 @@ 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.
/// AuthService managt Pairing-Code-Login + Keychain-Persistence.
///
/// Config aus ~/.config/rebreak-magic/config.json:
/// { "supabaseUrl": "https://xxx.supabase.co", "supabaseAnonKey": "..." }
/// 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() {}
@ -76,8 +84,6 @@ final class AuthService {
// MARK: - Config
private struct Config: Codable {
let supabaseUrl: String
let supabaseAnonKey: String
let backendBaseUrl: String?
}
@ -86,65 +92,76 @@ final class AuthService {
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
// MARK: - Sign In via Pairing-Code
func signIn(email: String, password: String) async throws -> AuthSession {
let config = try loadConfig()
/// 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
}
guard let url = URL(string: "\(config.supabaseUrl)/auth/v1/token?grant_type=password") else {
throw AuthError.configMissing("Ungültige supabaseUrl")
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
}
@ -152,7 +169,7 @@ final class AuthService {
func signOut() async {
cachedSession = nil
deleteFromKeychain(account: cachedSession?.email ?? "session")
deleteFromKeychain()
}
// MARK: - Current Session
@ -161,79 +178,35 @@ final class AuthService {
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
}
// 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)
@ -241,31 +214,27 @@ final class AuthService {
}
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)
}

View File

@ -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
var body: some View {
VStack(spacing: 24) {
// Logo + Header
VStack(spacing: 12) {
Image(systemName: "shield.checkered")
.font(.system(size: 64))
.foregroundStyle(.blue)
private var enteredCode: String { digits.joined() }
private var isComplete: Bool { enteredCode.count == 6 && enteredCode.allSatisfy(\.isNumber) }
Text("ReBreak Magic")
var body: some View {
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")
VStack(spacing: 4) {
Text("Mit der Rebreak-App verbinden")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.top, 40)
// Form
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text("Email")
Text("Öffne in der App: Einstellungen → Rebreak Magic")
.font(.caption)
.foregroundStyle(.secondary)
TextField("name@example.com", text: $email)
.textFieldStyle(.roundedBorder)
.textContentType(.emailAddress)
.autocorrectionDisabled()
.foregroundStyle(.tertiary)
}
}
VStack(alignment: .leading, spacing: 6) {
Text("Passwort")
.font(.caption)
.foregroundStyle(.secondary)
SecureField("••••••••", text: $password)
.textFieldStyle(.roundedBorder)
.textContentType(.password)
// 6-stelliger Code-Input
VStack(spacing: 14) {
HStack(spacing: 10) {
ForEach(0..<6, id: \.self) { index in
digitField(index: index)
}
}
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: 09. 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)
}

View File

@ -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'),

View File

@ -129,6 +129,10 @@ export function AddMacSheet({
labelError={labelError}
onPrepare={handlePrepare}
enrolling={enrolling}
onOpenMagic={() => {
handleClose();
router.push('/magic');
}}
colors={colors}
t={t}
/>

View File

@ -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<MagicInfo | null>(null);
const [pair, setPair] = useState<PairResponse | null>(null);
const [pairLoading, setPairLoading] = useState(false);
const [pairError, setPairError] = useState<string | null>(null);
const [now, setNow] = useState(Date.now());
const [devices, setDevices] = useState<MagicDevice[] | null>(null);
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
(async () => {
try {
const i = await apiFetch<MagicInfo>('/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<MagicDevice[]>('/api/magic/devices');
setDevices(d);
} catch {
setDevices([]);
}
}
async function handleGenerateCode() {
setPairLoading(true);
setPairError(null);
try {
const res = await apiFetch<PairResponse>('/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 (
<ScrollView
style={{ maxHeight: 640 }}
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 32 }}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<View
style={{
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: '#007AFF22',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="sparkles" size={22} color="#007AFF" />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 20, fontFamily: 'Nunito_700Bold', color: colors.text }}>
Rebreak Magic
</Text>
<Text style={{ fontSize: 13, color: colors.textMuted, marginTop: 1 }}>
iPhone in 30 Sek. binden ohne Werks-Reset.
</Text>
</View>
</View>
{/* Step 1 — Download */}
<SectionTitle text="1. Mac-App herunterladen" colors={colors} />
<View style={cardStyle(colors)}>
<Text style={{ fontSize: 14, color: colors.text, marginBottom: 12 }}>
Auf deinem Mac öffnen (min. macOS {info?.minMacosVersion ?? '13.0'}).
</Text>
<PrimaryButton
icon="cloud-download-outline"
label="Download öffnen"
onPress={() => info && Linking.openURL(info.downloadUrl)}
/>
<TouchableOpacity
onPress={() => info && Share.share({ message: info.downloadUrl })}
style={{ marginTop: 10, alignSelf: 'flex-start' }}
>
<Text style={{ fontSize: 13, color: '#007AFF' }}>Link an meinen Mac senden</Text>
</TouchableOpacity>
</View>
{/* Step 2 — Pairing-Code */}
<SectionTitle text="2. Pairing-Code generieren" colors={colors} />
<View style={cardStyle(colors)}>
{!pair || codeExpired ? (
<>
<Text style={{ fontSize: 14, color: colors.text, marginBottom: 14 }}>
Erzeuge einen 6-stelligen Code und gib ihn in der Mac-App ein. Gültig 10
Minuten, nur einmal verwendbar.
</Text>
<PrimaryButton
icon="key-outline"
label={
pairLoading
? 'Generiere…'
: codeExpired
? 'Neuen Code erzeugen'
: 'Code erzeugen'
}
onPress={handleGenerateCode}
loading={pairLoading}
/>
{pairError && (
<Text style={{ marginTop: 10, color: colors.error, fontSize: 13 }}>{pairError}</Text>
)}
</>
) : (
<>
<Text
style={{
fontSize: 13,
color: colors.textMuted,
textAlign: 'center',
marginBottom: 12,
}}
>
In Mac-App eingeben:
</Text>
<Pressable
onPress={handleCopyCode}
style={{
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
paddingVertical: 18,
borderRadius: 14,
backgroundColor: colors.groupedBg,
marginBottom: 12,
}}
>
{pair.code.split('').map((d, i) => (
<View
key={i}
style={{
width: 38,
height: 52,
borderRadius: 8,
backgroundColor: colors.card,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 28, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{d}
</Text>
</View>
))}
</Pressable>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Ionicons name="time-outline" size={14} color={colors.textMuted} />
<Text style={{ fontSize: 13, color: colors.textMuted }}>
Läuft ab in {formatRemaining(remaining)}
</Text>
</View>
<TouchableOpacity onPress={handleCopyCode}>
<Text style={{ fontSize: 13, color: '#007AFF', fontWeight: '600' }}>Kopieren</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
onPress={() => {
setPair(null);
setPairError(null);
}}
style={{ marginTop: 14, alignSelf: 'center' }}
>
<Text style={{ fontSize: 13, color: colors.textMuted }}>Code verwerfen</Text>
</TouchableOpacity>
</>
)}
</View>
{/* Verbundene Macs */}
<SectionTitle text="Verbundene Macs" colors={colors} />
<View style={cardStyle(colors)}>
{devices === null ? (
<ActivityIndicator />
) : devices.length === 0 ? (
<Text style={{ fontSize: 14, color: colors.textMuted }}>
Noch keine Macs verbunden. Sobald du einen Pairing-Code einlöst und ein iPhone
bindest, erscheint es hier.
</Text>
) : (
devices.map((d, i) => (
<View
key={d.deviceId}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
borderTopWidth: i === 0 ? 0 : 1,
borderTopColor: colors.border,
}}
>
<View
style={{
width: 36,
height: 36,
borderRadius: 9,
backgroundColor: colors.groupedBg,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
}}
>
<Ionicons name="laptop-outline" size={20} color={colors.text} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 15, color: colors.text, fontWeight: '600' }}>
{d.hostname}
</Text>
{d.model && (
<Text style={{ fontSize: 12, color: colors.textMuted, marginTop: 1 }}>
{d.model}
</Text>
)}
</View>
</View>
))
)}
</View>
</ScrollView>
);
}
// ─── 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
style={{
fontSize: 12,
textTransform: 'uppercase',
letterSpacing: 0.4,
color: colors.textMuted,
marginBottom: 8,
marginLeft: 4,
fontFamily: 'Nunito_700Bold',
}}
>
{text}
</Text>
);
}
function PrimaryButton({
icon,
label,
onPress,
loading,
}: {
icon: React.ComponentProps<typeof Ionicons>['name'];
label: string;
onPress: () => void;
loading?: boolean;
}) {
return (
<TouchableOpacity
onPress={onPress}
disabled={loading}
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
backgroundColor: '#007AFF',
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 12,
opacity: loading ? 0.6 : 1,
}}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Ionicons name={icon} size={18} color="#fff" />
)}
<Text style={{ color: '#fff', fontSize: 15, fontFamily: 'Nunito_700Bold' }}>{label}</Text>
</TouchableOpacity>
);
}
function formatRemaining(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}

View File

@ -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.",

View File

@ -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",

View File

@ -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");

View File

@ -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

View File

@ -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',
},
};
});

View File

@ -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),
},
};
});

View File

@ -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(),
},
};
});

View File

@ -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;