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:
parent
138e45fe0a
commit
941dd60f36
@ -1,22 +1,33 @@
|
|||||||
|
import AppKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
/// Auth-Session mit Supabase JWT + Refresh-Token.
|
/// Magic-Session — Mac-App speichert nur den mgc_*-Token (kein Supabase-JWT mehr).
|
||||||
/// Stored in macOS Keychain für persistence über App-Restarts.
|
/// Token wird beim Pairing in der Native-App generiert und hier 1:1 als
|
||||||
|
/// `Authorization: Bearer mgc_...` an /api/magic/* gesendet.
|
||||||
struct AuthSession: Codable {
|
struct AuthSession: Codable {
|
||||||
|
/// "mgc_<base64url>" — wird als Bearer-Token verwendet.
|
||||||
let accessToken: String
|
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 userId: String
|
||||||
let expiresAt: Date
|
/// Zeitpunkt des Pairings (für Anzeige).
|
||||||
let email: String
|
let createdAt: Date
|
||||||
|
/// Optionales Label (z.B. Mac-Hostname). Display nur.
|
||||||
|
let label: String?
|
||||||
|
|
||||||
var isExpired: Bool {
|
// Backwards-compat mit altem Code (immer false — Magic-Tokens laufen nicht ab):
|
||||||
Date().addingTimeInterval(60) > expiresAt
|
var isExpired: Bool { false }
|
||||||
}
|
var refreshToken: String { "" }
|
||||||
|
var expiresAt: Date { Date.distantFuture }
|
||||||
|
var email: String { label ?? "RebreakMagic" }
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AuthError: Error, LocalizedError {
|
enum AuthError: Error, LocalizedError {
|
||||||
case invalidCredentials
|
case invalidCode
|
||||||
|
case codeExpired
|
||||||
|
case codeUsed
|
||||||
case networkError(String)
|
case networkError(String)
|
||||||
case configMissing(String)
|
case configMissing(String)
|
||||||
case keychainError(OSStatus)
|
case keychainError(OSStatus)
|
||||||
@ -25,8 +36,12 @@ enum AuthError: Error, LocalizedError {
|
|||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .invalidCredentials:
|
case .invalidCode:
|
||||||
return "Email oder Passwort falsch"
|
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):
|
case .networkError(let msg):
|
||||||
return "Netzwerkfehler: \(msg)"
|
return "Netzwerkfehler: \(msg)"
|
||||||
case .configMissing(let msg):
|
case .configMissing(let msg):
|
||||||
@ -34,41 +49,34 @@ enum AuthError: Error, LocalizedError {
|
|||||||
case .keychainError(let status):
|
case .keychainError(let status):
|
||||||
return "Keychain-Fehler: \(status)"
|
return "Keychain-Fehler: \(status)"
|
||||||
case .tokenExpired:
|
case .tokenExpired:
|
||||||
return "Session abgelaufen. Bitte neu einloggen."
|
return "Session abgelaufen. Bitte neu pairen."
|
||||||
case .refreshFailed:
|
case .refreshFailed:
|
||||||
return "Token-Refresh fehlgeschlagen. Bitte neu einloggen."
|
return "Session ungültig. Bitte neu pairen."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supabase Auth-Response Schemas
|
private struct PairRedeemResponseEnvelope: Codable {
|
||||||
private struct SupabaseAuthResponse: Codable {
|
let success: Bool
|
||||||
let access_token: String
|
let data: PairRedeemResponseData
|
||||||
let refresh_token: String
|
|
||||||
let expires_in: Int
|
|
||||||
let user: SupabaseUser
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SupabaseUser: Codable {
|
private struct PairRedeemResponseData: Codable {
|
||||||
let id: String
|
let token: String
|
||||||
let email: String?
|
let sessionId: String
|
||||||
|
let createdAt: String
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SupabaseRefreshResponse: Codable {
|
/// AuthService — managt Pairing-Code-Login + Keychain-Persistence.
|
||||||
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:
|
/// Config aus ~/.config/rebreak-magic/config.json (optional):
|
||||||
/// { "supabaseUrl": "https://xxx.supabase.co", "supabaseAnonKey": "..." }
|
/// { "backendBaseUrl": "https://app.rebreak.org" }
|
||||||
@MainActor
|
@MainActor
|
||||||
final class AuthService {
|
final class AuthService {
|
||||||
static let shared = AuthService()
|
static let shared = AuthService()
|
||||||
|
|
||||||
private let keychainService = "org.rebreak.magic"
|
private let keychainService = "org.rebreak.magic"
|
||||||
|
private let keychainAccount = "magic-session"
|
||||||
private var cachedSession: AuthSession?
|
private var cachedSession: AuthSession?
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
@ -76,8 +84,6 @@ final class AuthService {
|
|||||||
// MARK: - Config
|
// MARK: - Config
|
||||||
|
|
||||||
private struct Config: Codable {
|
private struct Config: Codable {
|
||||||
let supabaseUrl: String
|
|
||||||
let supabaseAnonKey: String
|
|
||||||
let backendBaseUrl: String?
|
let backendBaseUrl: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,65 +92,76 @@ final class AuthService {
|
|||||||
return "\(home)/.config/rebreak-magic/config.json"
|
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)
|
let url = URL(fileURLWithPath: Self.configPath)
|
||||||
guard FileManager.default.fileExists(atPath: Self.configPath) else {
|
guard FileManager.default.fileExists(atPath: Self.configPath),
|
||||||
throw AuthError.configMissing("~/.config/rebreak-magic/config.json nicht gefunden. Bitte erstellen mit supabaseUrl + supabaseAnonKey.")
|
let data = try? Data(contentsOf: url),
|
||||||
}
|
let config = try? JSONDecoder().decode(Config.self, from: data),
|
||||||
do {
|
let base = config.backendBaseUrl else {
|
||||||
let data = try Data(contentsOf: url)
|
return "https://app.rebreak.org"
|
||||||
return try JSONDecoder().decode(Config.self, from: data)
|
|
||||||
} catch {
|
|
||||||
throw AuthError.configMissing(error.localizedDescription)
|
|
||||||
}
|
}
|
||||||
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sign In
|
// MARK: - Sign In via Pairing-Code
|
||||||
|
|
||||||
func signIn(email: String, password: String) async throws -> AuthSession {
|
/// Tauscht einen 6-stelligen Pairing-Code (aus der Native-App) gegen eine
|
||||||
let config = try loadConfig()
|
/// 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 {
|
let base = loadBackendUrl()
|
||||||
throw AuthError.configMissing("Ungültige supabaseUrl")
|
guard let url = URL(string: "\(base)/api/magic/pair/redeem") else {
|
||||||
|
throw AuthError.configMissing("Ungültige backendBaseUrl")
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
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)
|
request.httpBody = try JSONEncoder().encode(body)
|
||||||
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
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")
|
throw AuthError.networkError("Keine HTTP-Response")
|
||||||
}
|
}
|
||||||
|
|
||||||
if httpResponse.statusCode == 400 {
|
switch http.statusCode {
|
||||||
throw AuthError.invalidCredentials
|
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 envelope = try JSONDecoder().decode(PairRedeemResponseEnvelope.self, from: data)
|
||||||
let body = String(data: data, encoding: .utf8) ?? ""
|
let createdAt = ISO8601DateFormatter().date(from: envelope.data.createdAt) ?? Date()
|
||||||
throw AuthError.networkError("HTTP \(httpResponse.statusCode): \(body)")
|
|
||||||
}
|
|
||||||
|
|
||||||
let authResponse = try JSONDecoder().decode(SupabaseAuthResponse.self, from: data)
|
|
||||||
|
|
||||||
let session = AuthSession(
|
let session = AuthSession(
|
||||||
accessToken: authResponse.access_token,
|
accessToken: envelope.data.token,
|
||||||
refreshToken: authResponse.refresh_token,
|
sessionId: envelope.data.sessionId,
|
||||||
userId: authResponse.user.id,
|
userId: "",
|
||||||
expiresAt: Date().addingTimeInterval(TimeInterval(authResponse.expires_in)),
|
createdAt: createdAt,
|
||||||
email: authResponse.user.email ?? email
|
label: hostname
|
||||||
)
|
)
|
||||||
|
|
||||||
// Save to keychain
|
|
||||||
try saveToKeychain(session)
|
try saveToKeychain(session)
|
||||||
cachedSession = session
|
cachedSession = session
|
||||||
|
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,7 +169,7 @@ final class AuthService {
|
|||||||
|
|
||||||
func signOut() async {
|
func signOut() async {
|
||||||
cachedSession = nil
|
cachedSession = nil
|
||||||
deleteFromKeychain(account: cachedSession?.email ?? "session")
|
deleteFromKeychain()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Current Session
|
// MARK: - Current Session
|
||||||
@ -161,79 +178,35 @@ final class AuthService {
|
|||||||
if let cached = cachedSession {
|
if let cached = cachedSession {
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load from keychain
|
|
||||||
if let loaded = loadFromKeychain() {
|
if let loaded = loadFromKeychain() {
|
||||||
cachedSession = loaded
|
cachedSession = loaded
|
||||||
return loaded
|
return loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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 {
|
func refreshSessionIfNeeded() async throws -> AuthSession {
|
||||||
guard let session = currentSession() else {
|
guard let session = currentSession() else {
|
||||||
throw AuthError.tokenExpired
|
throw AuthError.tokenExpired
|
||||||
}
|
}
|
||||||
|
return session
|
||||||
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
|
// MARK: - Keychain
|
||||||
|
|
||||||
private func saveToKeychain(_ session: AuthSession) throws {
|
private func saveToKeychain(_ session: AuthSession) throws {
|
||||||
let data = try JSONEncoder().encode(session)
|
let data = try JSONEncoder().encode(session)
|
||||||
|
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: keychainService,
|
kSecAttrService as String: keychainService,
|
||||||
kSecAttrAccount as String: session.email,
|
kSecAttrAccount as String: keychainAccount,
|
||||||
kSecValueData as String: data
|
kSecValueData as String: data
|
||||||
]
|
]
|
||||||
|
|
||||||
// Delete existing first
|
|
||||||
SecItemDelete(query as CFDictionary)
|
SecItemDelete(query as CFDictionary)
|
||||||
|
|
||||||
let status = SecItemAdd(query as CFDictionary, nil)
|
let status = SecItemAdd(query as CFDictionary, nil)
|
||||||
guard status == errSecSuccess else {
|
guard status == errSecSuccess else {
|
||||||
throw AuthError.keychainError(status)
|
throw AuthError.keychainError(status)
|
||||||
@ -241,31 +214,27 @@ final class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadFromKeychain() -> AuthSession? {
|
private func loadFromKeychain() -> AuthSession? {
|
||||||
// Try to load with a wildcard account search
|
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: keychainService,
|
kSecAttrService as String: keychainService,
|
||||||
kSecReturnData as String: true,
|
kSecReturnData as String: true,
|
||||||
kSecMatchLimit as String: kSecMatchLimitOne
|
kSecMatchLimit as String: kSecMatchLimitOne
|
||||||
]
|
]
|
||||||
|
|
||||||
var item: CFTypeRef?
|
var item: CFTypeRef?
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||||
|
|
||||||
guard status == errSecSuccess,
|
guard status == errSecSuccess,
|
||||||
let data = item as? Data,
|
let data = item as? Data,
|
||||||
let session = try? JSONDecoder().decode(AuthSession.self, from: data) else {
|
let session = try? JSONDecoder().decode(AuthSession.self, from: data) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteFromKeychain(account: String) {
|
private func deleteFromKeychain() {
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: keychainService,
|
kSecAttrService as String: keychainService,
|
||||||
kSecAttrAccount as String: account
|
kSecAttrAccount as String: keychainAccount
|
||||||
]
|
]
|
||||||
SecItemDelete(query as CFDictionary)
|
SecItemDelete(query as CFDictionary)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,112 +1,191 @@
|
|||||||
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct LoginView: View {
|
struct LoginView: View {
|
||||||
@State private var email = ""
|
@State private var digits: [String] = Array(repeating: "", count: 6)
|
||||||
@State private var password = ""
|
@FocusState private var focusedField: Int?
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
let onSuccess: (AuthSession) -> Void
|
let onSuccess: (AuthSession) -> Void
|
||||||
|
|
||||||
var body: some View {
|
private var enteredCode: String { digits.joined() }
|
||||||
VStack(spacing: 24) {
|
private var isComplete: Bool { enteredCode.count == 6 && enteredCode.allSatisfy(\.isNumber) }
|
||||||
// Logo + Header
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
Image(systemName: "shield.checkered")
|
|
||||||
.font(.system(size: 64))
|
|
||||||
.foregroundStyle(.blue)
|
|
||||||
|
|
||||||
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())
|
.font(.title.bold())
|
||||||
|
|
||||||
Text("Bitte mit deinem ReBreak-Account anmelden")
|
VStack(spacing: 4) {
|
||||||
.font(.subheadline)
|
Text("Mit der Rebreak-App verbinden")
|
||||||
.foregroundStyle(.secondary)
|
.font(.subheadline)
|
||||||
}
|
|
||||||
.padding(.top, 40)
|
|
||||||
|
|
||||||
// Form
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("Email")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Öffne in der App: Einstellungen → Rebreak Magic")
|
||||||
TextField("name@example.com", text: $email)
|
.font(.caption)
|
||||||
.textFieldStyle(.roundedBorder)
|
.foregroundStyle(.tertiary)
|
||||||
.textContentType(.emailAddress)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
// 6-stelliger Code-Input
|
||||||
Text("Passwort")
|
VStack(spacing: 14) {
|
||||||
.font(.caption)
|
HStack(spacing: 10) {
|
||||||
.foregroundStyle(.secondary)
|
ForEach(0..<6, id: \.self) { index in
|
||||||
|
digitField(index: index)
|
||||||
SecureField("••••••••", text: $password)
|
}
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.textContentType(.password)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let error = errorMessage {
|
if let error = errorMessage {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
Text(error)
|
Text(error)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(action: handleSignIn) {
|
Button(action: handleSubmit) {
|
||||||
HStack {
|
HStack(spacing: 8) {
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
.tint(.white)
|
.tint(.white)
|
||||||
}
|
}
|
||||||
Text(isLoading ? "Anmeldung läuft..." : "Anmelden")
|
Text(isLoading ? "Verbinde…" : "Verbinden")
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.disabled(email.isEmpty || password.isEmpty || isLoading)
|
.keyboardShortcut(.return, modifiers: [])
|
||||||
|
.disabled(!isComplete || isLoading)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 40)
|
.frame(maxWidth: 380)
|
||||||
.frame(maxWidth: 400)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Signup Link
|
Text("Noch keine Rebreak-App? Lade sie im App Store / Play Store.")
|
||||||
HStack(spacing: 4) {
|
.font(.caption2)
|
||||||
Text("Noch kein Account?")
|
.foregroundStyle(.tertiary)
|
||||||
.font(.caption)
|
.padding(.bottom, 18)
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
Link("Jetzt registrieren →", destination: URL(string: "https://rebreak.org/signup")!)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
.padding(.bottom, 20)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color(nsColor: .windowBackgroundColor))
|
.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 {
|
Task {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let session = try await AuthService.shared.signIn(email: email, password: password)
|
let session = try await AuthService.shared.signInWithPairingCode(code)
|
||||||
onSuccess(session)
|
onSuccess(session)
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
|
// Felder leeren bei Fehler
|
||||||
|
digits = Array(repeating: "", count: 6)
|
||||||
|
focusedField = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -114,7 +193,7 @@ struct LoginView: View {
|
|||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
LoginView { session in
|
LoginView { session in
|
||||||
print("Logged in: \(session.email)")
|
print("Magic-Session: \(session.sessionId)")
|
||||||
}
|
}
|
||||||
.frame(width: 720, height: 600)
|
.frame(width: 720, height: 600)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -387,6 +387,12 @@ export default function SettingsScreen() {
|
|||||||
sublabel: t('settings.devices_desc'),
|
sublabel: t('settings.devices_desc'),
|
||||||
onPress: () => router.push('/devices'),
|
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',
|
icon: 'star-outline',
|
||||||
label: t('settings.subscription'),
|
label: t('settings.subscription'),
|
||||||
|
|||||||
@ -129,6 +129,10 @@ export function AddMacSheet({
|
|||||||
labelError={labelError}
|
labelError={labelError}
|
||||||
onPrepare={handlePrepare}
|
onPrepare={handlePrepare}
|
||||||
enrolling={enrolling}
|
enrolling={enrolling}
|
||||||
|
onOpenMagic={() => {
|
||||||
|
handleClose();
|
||||||
|
router.push('/magic');
|
||||||
|
}}
|
||||||
colors={colors}
|
colors={colors}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
|
|||||||
389
apps/rebreak-native/components/devices/MagicSheet.tsx
Normal file
389
apps/rebreak-native/components/devices/MagicSheet.tsx
Normal 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')}`;
|
||||||
|
}
|
||||||
@ -806,6 +806,8 @@
|
|||||||
"edit_profile": "Profil bearbeiten",
|
"edit_profile": "Profil bearbeiten",
|
||||||
"devices": "Geräte",
|
"devices": "Geräte",
|
||||||
"devices_desc": "Registrierte Geräte verwalten",
|
"devices_desc": "Registrierte Geräte verwalten",
|
||||||
|
"rebreak_magic": "Rebreak Magic",
|
||||||
|
"rebreak_magic_desc": "iPhone in 30 Sek. binden (Mac-App)",
|
||||||
"subscription": "Abonnement",
|
"subscription": "Abonnement",
|
||||||
"subscription_desc": "Plan & Upgrade-Pfad",
|
"subscription_desc": "Plan & Upgrade-Pfad",
|
||||||
"subscription_plan_free": "Free",
|
"subscription_plan_free": "Free",
|
||||||
@ -1436,6 +1438,7 @@
|
|||||||
},
|
},
|
||||||
"presence": {
|
"presence": {
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
|
"typing": "schreibt",
|
||||||
"just_now": "gerade eben",
|
"just_now": "gerade eben",
|
||||||
"minutes_ago": "vor %{minutes} Min.",
|
"minutes_ago": "vor %{minutes} Min.",
|
||||||
"hours_ago": "vor %{hours} Std.",
|
"hours_ago": "vor %{hours} Std.",
|
||||||
|
|||||||
@ -806,6 +806,8 @@
|
|||||||
"edit_profile": "Edit profile",
|
"edit_profile": "Edit profile",
|
||||||
"devices": "Devices",
|
"devices": "Devices",
|
||||||
"devices_desc": "Manage registered devices",
|
"devices_desc": "Manage registered devices",
|
||||||
|
"rebreak_magic": "Rebreak Magic",
|
||||||
|
"rebreak_magic_desc": "Bind iPhone in 30s (Mac app)",
|
||||||
"subscription": "Subscription",
|
"subscription": "Subscription",
|
||||||
"subscription_desc": "Plan & upgrade path",
|
"subscription_desc": "Plan & upgrade path",
|
||||||
"subscription_plan_free": "Free",
|
"subscription_plan_free": "Free",
|
||||||
@ -1436,6 +1438,7 @@
|
|||||||
},
|
},
|
||||||
"presence": {
|
"presence": {
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
|
"typing": "typing",
|
||||||
"just_now": "just now",
|
"just_now": "just now",
|
||||||
"minutes_ago": "%{minutes} min ago",
|
"minutes_ago": "%{minutes} min ago",
|
||||||
"hours_ago": "%{hours} h ago",
|
"hours_ago": "%{hours} h ago",
|
||||||
|
|||||||
@ -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");
|
||||||
@ -1078,7 +1078,46 @@ model UserDevice {
|
|||||||
@@schema("rebreak")
|
@@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
|
// 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
|
// erreicht ist (oder User Approval explizit wünscht), erstellt das neue Gerät
|
||||||
// eine DeviceApprovalRequest mit 6-stelligem Code. Andere aktive Geräte des
|
// eine DeviceApprovalRequest mit 6-stelligem Code. Andere aktive Geräte des
|
||||||
|
|||||||
19
backend/server/api/magic/info.get.ts
Normal file
19
backend/server/api/magic/info.get.ts
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
78
backend/server/api/magic/pair/create.post.ts
Normal file
78
backend/server/api/magic/pair/create.post.ts
Normal 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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
76
backend/server/api/magic/pair/redeem.post.ts
Normal file
76
backend/server/api/magic/pair/redeem.post.ts
Normal 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(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@ -28,6 +28,31 @@ export async function requireUser(
|
|||||||
throw createError({ statusCode: 401, message: 'Nicht eingeloggt' });
|
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 config = useRuntimeConfig(event);
|
||||||
const supabaseCfg =
|
const supabaseCfg =
|
||||||
(config as any).public?.supabase ?? (config as any).supabase;
|
(config as any).public?.supabase ?? (config as any).supabase;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user