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
200 lines
6.6 KiB
Swift
200 lines
6.6 KiB
Swift
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: 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.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)
|
||
}
|