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>
224 lines
8.1 KiB
Swift
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
|
|
}
|
|
}
|