stremio/apps/ios/VMC/UI/ServerAuthScreens.swift
Jos Vooges | STH 303383ea4e Add server URL choice, email login, and login lock controls.
Clients can use VMC or a custom server with email/password or device code; API locks after 3 failed logins with admin unlock.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 01:32:35 +02:00

224 lines
8.1 KiB
Swift

import SwiftUI
enum ServerUrl {
static func normalize(_ raw: String, fallback: String = SessionStore.defaultApiBase) -> String {
var s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if s.isEmpty { return fallback.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }
if !s.lowercased().hasPrefix("http://") && !s.lowercased().hasPrefix("https://") {
s = "https://\(s)"
}
return s.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
}
}
private struct AuthBackground: View {
var body: some View {
LinearGradient(
colors: [
Color(red: 0.12, green: 0.04, blue: 0.06),
McColors.bg,
Color(red: 0.04, green: 0.05, blue: 0.1),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
}
}
struct ServerChoiceScreen: View {
@EnvironmentObject private var app: AppModel
@State private var customMode = false
@State private var urlInput = ""
@State private var error: String?
@State private var busy = false
var body: some View {
ZStack {
AuthBackground()
VStack(spacing: 16) {
Text("VMC")
.font(McFonts.brand)
.foregroundStyle(McColors.accent)
Text("Connect to your media")
.font(McFonts.title)
.foregroundStyle(McColors.text)
Text("VMC account or your own self-hosted server")
.font(McFonts.body)
.foregroundStyle(McColors.textDim)
.multilineTextAlignment(.center)
if !customMode {
Button("Continue with VMC") { useVmc() }
.buttonStyle(McPrimaryButtonStyle())
.disabled(busy)
Button("Connect to your server") {
customMode = true
error = nil
if app.store.apiBase != SessionStore.defaultApiBase {
urlInput = app.store.apiBase
}
}
.buttonStyle(McSecondaryButtonStyle())
} else {
TextField("https://media.example.com", text: $urlInput)
.textInputAutocapitalization(.never)
.keyboardType(.URL)
.autocorrectionDisabled()
.padding(14)
.background(McColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.foregroundStyle(McColors.text)
Button(busy ? "Connecting…" : "Continue") { useCustom() }
.buttonStyle(McPrimaryButtonStyle())
.disabled(busy)
Button("Back") {
customMode = false
error = nil
}
.buttonStyle(McSecondaryButtonStyle())
}
if let error {
Text(error).font(McFonts.body).foregroundStyle(McColors.danger)
}
}
.padding(28)
.frame(maxWidth: 420)
.background(McColors.bgElevated.opacity(0.92), in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.padding()
}
}
private func useVmc() {
busy = true
error = nil
app.store.setApiBase(SessionStore.defaultApiBase)
Task {
await app.api.configure(baseUrl: app.store.apiBase, token: nil)
busy = false
app.phase = .auth
}
}
private func useCustom() {
guard !urlInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
error = "Enter a server URL"
return
}
busy = true
error = nil
app.store.setApiBase(ServerUrl.normalize(urlInput))
Task {
await app.api.configure(baseUrl: app.store.apiBase, token: nil)
busy = false
app.phase = .auth
}
}
}
struct AuthChoiceScreen: View {
@EnvironmentObject private var app: AppModel
var body: some View {
ZStack {
AuthBackground()
VStack(spacing: 16) {
Text("VMC")
.font(McFonts.brand)
.foregroundStyle(McColors.accent)
Text("Sign in")
.font(McFonts.title)
.foregroundStyle(McColors.text)
Text("Use your account email, or link this device with a code")
.font(McFonts.body)
.foregroundStyle(McColors.textDim)
.multilineTextAlignment(.center)
Button("Sign in with email") { app.phase = .password }
.buttonStyle(McPrimaryButtonStyle())
Button("Link with code") { app.phase = .link }
.buttonStyle(McSecondaryButtonStyle())
Button("Change server") { app.phase = .server }
.buttonStyle(McSecondaryButtonStyle())
}
.padding(28)
.frame(maxWidth: 420)
.background(McColors.bgElevated.opacity(0.92), in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.padding()
}
}
}
struct PasswordLoginScreen: View {
@EnvironmentObject private var app: AppModel
@State private var email = ""
@State private var password = ""
@State private var error: String?
@State private var busy = false
var body: some View {
ZStack {
AuthBackground()
VStack(spacing: 14) {
Text("Sign in with email")
.font(McFonts.title)
.foregroundStyle(McColors.text)
Text("Your VMC account credentials")
.font(McFonts.caption)
.foregroundStyle(McColors.textDim)
TextField("Email", text: $email)
.textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
.autocorrectionDisabled()
.padding(14)
.background(McColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.foregroundStyle(McColors.text)
SecureField("Password", text: $password)
.padding(14)
.background(McColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.foregroundStyle(McColors.text)
if let error {
Text(error).font(McFonts.body).foregroundStyle(McColors.danger).multilineTextAlignment(.center)
}
Button(busy ? "Signing in…" : "Sign in") { Task { await submit() } }
.buttonStyle(McPrimaryButtonStyle())
.disabled(busy)
Button("Back") { app.phase = .auth }
.buttonStyle(McSecondaryButtonStyle())
}
.padding(28)
.frame(maxWidth: 420)
.background(McColors.bgElevated.opacity(0.92), in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.padding()
}
}
private func submit() async {
guard !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !password.isEmpty else {
error = "E-mail and password required"
return
}
busy = true
error = nil
await app.api.configure(baseUrl: app.store.apiBase, token: nil)
do {
let result = try await app.api.login(
email: email.trimmingCharacters(in: .whitespacesAndNewlines),
password: password,
deviceName: AppModel.deviceName
)
await app.didLink(token: result.refreshToken)
} catch {
self.error = error.localizedDescription
}
busy = false
}
}