chahinebrini 941dd60f36 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
2026-06-03 00:18:24 +02:00

200 lines
6.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import AppKit
import SwiftUI
struct LoginView: View {
@State private var digits: [String] = Array(repeating: "", count: 6)
@FocusState private var focusedField: Int?
@State private var isLoading = false
@State private var errorMessage: String?
let onSuccess: (AuthSession) -> Void
private var enteredCode: String { digits.joined() }
private var isComplete: Bool { enteredCode.count == 6 && enteredCode.allSatisfy(\.isNumber) }
var body: some View {
VStack(spacing: 28) {
Spacer().frame(height: 20)
// App-Icon + Header
VStack(spacing: 14) {
appIconView
.frame(width: 84, height: 84)
Text("Rebreak Magic")
.font(.title.bold())
VStack(spacing: 4) {
Text("Mit der Rebreak-App verbinden")
.font(.subheadline)
.foregroundStyle(.secondary)
Text("Öffne in der App: Einstellungen → Rebreak Magic")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
// 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: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
}
Button(action: handleSubmit) {
HStack(spacing: 8) {
if isLoading {
ProgressView()
.controlSize(.small)
.tint(.white)
}
Text(isLoading ? "Verbinde…" : "Verbinden")
.fontWeight(.medium)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.return, modifiers: [])
.disabled(!isComplete || isLoading)
}
.frame(maxWidth: 380)
Spacer()
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 }
}
// MARK: - Components
@ViewBuilder
private var appIconView: some View {
if let icon = NSApplication.shared.applicationIconImage,
icon.size.width > 2 {
Image(nsImage: icon)
.resizable()
.interpolation(.high)
.frame(width: 84, height: 84)
} else {
// Fallback: gefärbtes RoundedRect (entspricht macOS-Stil)
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(LinearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing))
.overlay(
Image(systemName: "wand.and.stars")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.white)
)
}
}
@ViewBuilder
private func digitField(index: Int) -> some View {
TextField("", text: Binding(
get: { digits[index] },
set: { newValue in handleDigitInput(newValue, at: index) }
))
.textFieldStyle(.plain)
.multilineTextAlignment(.center)
.font(.system(size: 28, weight: .semibold, design: .rounded))
.frame(width: 48, height: 60)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(
focusedField == index ? Color.accentColor : Color.gray.opacity(0.3),
lineWidth: focusedField == index ? 2 : 1
)
)
.focused($focusedField, equals: index)
}
// MARK: - Logic
private func handleDigitInput(_ raw: String, at index: Int) {
// Erlaubt: 09. Mehrere Zeichen (Paste) über alle Felder verteilen.
let onlyDigits = raw.filter(\.isNumber)
if onlyDigits.count > 1 {
// Paste / Auto-Fill: über Felder ab `index` verteilen
let chars = Array(onlyDigits.prefix(6 - index))
for (offset, ch) in chars.enumerated() {
let target = index + offset
if target < 6 {
digits[target] = String(ch)
}
}
let nextFocus = min(index + chars.count, 5)
focusedField = nextFocus
if digits.allSatisfy({ !$0.isEmpty }) && !isLoading {
handleSubmit()
}
return
}
if onlyDigits.isEmpty {
// Backspace
digits[index] = ""
if index > 0 {
focusedField = index - 1
}
return
}
digits[index] = String(onlyDigits.prefix(1))
if index < 5 {
focusedField = index + 1
} else if isComplete && !isLoading {
// Letztes Feld gefüllt automatisch absenden
handleSubmit()
}
}
private func handleSubmit() {
guard isComplete, !isLoading else { return }
let code = enteredCode
Task {
isLoading = true
errorMessage = nil
do {
let session = try await AuthService.shared.signInWithPairingCode(code)
onSuccess(session)
} catch {
errorMessage = error.localizedDescription
// Felder leeren bei Fehler
digits = Array(repeating: "", count: 6)
focusedField = 0
}
isLoading = false
}
}
}
#Preview {
LoginView { session in
print("Magic-Session: \(session.sessionId)")
}
.frame(width: 720, height: 600)
}