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 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)
|
||||
}
|
||||
|
||||
@ -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: 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)
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -129,6 +129,10 @@ export function AddMacSheet({
|
||||
labelError={labelError}
|
||||
onPrepare={handlePrepare}
|
||||
enrolling={enrolling}
|
||||
onOpenMagic={() => {
|
||||
handleClose();
|
||||
router.push('/magic');
|
||||
}}
|
||||
colors={colors}
|
||||
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",
|
||||
"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.",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user