Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 113 additions & 16 deletions packaging/ios-companion/Sources/AccountViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import AuthenticationServices
import CryptoKit
#endif

/// Shared cloud sign-in form (PLAN_ACCOUNTS_BILLING B1) — the PRIMARY flow:
/// Sign in with Apple first, email+password second, and the legacy paste-a-token
/// link tucked behind an "Advanced" disclosure. Used from both Settings and the
/// first-run onboarding sheet so the two entry points can't drift apart.
/// Shared cloud sign-in form (PLAN_ACCOUNTS_BILLING B1; PLAN_AUTH_OTP_GOOGLE A2)
/// — the PRIMARY flow: Sign in with Apple first, then a **mailed one-time code**
/// (which registers the account if the address is new, so there's no separate
/// sign-up), with passwords behind a disclosure and the legacy paste-a-token
/// link behind another. Used from both Settings and the first-run onboarding
/// sheet so the two entry points can't drift apart.
///
/// Emits `onSignedIn` after the connection is saved AND live-verified — the
/// Emits `onResult` after the connection is saved AND live-verified — the
/// parse-only fake success is what got build 1782924012 rejected (2.1).
struct CloudSignInForm: View {
@EnvironmentObject var app: AppState
Expand All @@ -18,10 +20,13 @@ struct CloudSignInForm: View {

@State private var cloudURL = AppState.defaultCloudBase
@State private var email = ""
@State private var code = ""
@State private var codeSent = false
@State private var password = ""
@State private var pasteText = ""
@State private var busy = false
@State private var error: String?
@State private var note: String?
#if LISA_ENABLE_SIWA
/// Raw (un-hashed) nonce for the in-flight Apple request (#261). We send
/// sha256(raw) to Apple and the raw value to our server, which re-hashes it
Expand Down Expand Up @@ -52,20 +57,40 @@ struct CloudSignInForm: View {
TextField("Email", text: $email)
.autocorrectionDisabled().textInputAutocapitalization(.never)
.keyboardType(.emailAddress).textContentType(.username)
SecureField("Password (8+ characters)", text: $password)
.textContentType(.password)
HStack {
Button("Sign in") { emailAuth(register: false) }
.disabled(!emailFormReady)
Spacer()
Button("Create account") { emailAuth(register: true) }
.disabled(!emailFormReady)
// Editing the address invalidates whatever code is outstanding.
.onChange(of: email) { if codeSent { codeSent = false; note = nil } }
if codeSent {
TextField("6-digit code", text: $code)
.keyboardType(.numberPad).textContentType(.oneTimeCode)
Button("Sign in") { submitCode() }
.disabled(busy || code.isEmpty)
Button("Send another code") { sendCode() }
.disabled(busy || !addressReady)
} else {
Button("Email me a code") { sendCode() }
.disabled(busy || !addressReady)
}
if busy { ProgressView().tint(Theme.accent) }
} header: {
Text("LISA account")
} footer: {
Text("Sign in and go — no Mac, no API key. A free usage allowance refreshes every 12 hours.")
Text("Sign in and go — no Mac, no API key, no password to remember. New here? The code creates your account. A free usage allowance refreshes every 12 hours.")
}

Section {
DisclosureGroup("Use a password instead") {
SecureField("Password (8+ characters)", text: $password)
.textContentType(.password)
HStack {
Button("Sign in") { emailAuth(register: false) }
.disabled(!passwordFormReady)
Spacer()
Button("Create account") { emailAuth(register: true) }
.disabled(!passwordFormReady)
}
}
} footer: {
Text("For accounts that already have a password.")
}

Section {
Expand All @@ -79,13 +104,20 @@ struct CloudSignInForm: View {
Text("For self-hosted instances using a shared LISA_WEB_TOKEN.")
}

if let note {
Section { Text(note).font(.caption).foregroundStyle(Theme.accent) }
}
if let error {
Section { Text(error).font(.caption).foregroundStyle(Theme.danger) }
}
}

private var emailFormReady: Bool {
!busy && !email.isEmpty && password.count >= 8 && AppState.parseCloudBase(cloudURL) != nil
private var addressReady: Bool {
!email.isEmpty && AppState.parseCloudBase(cloudURL) != nil
}

private var passwordFormReady: Bool {
!busy && addressReady && password.count >= 8
}

/// Verify after saving so success is REAL success (2.1 fix), then report.
Expand All @@ -95,6 +127,71 @@ struct CloudSignInForm: View {
onResult(outcome)
}

/// Human-readable reason for a refused code request/redemption.
private func describe(_ err: LisaClient.SignInCodeError) -> String {
switch err.reason {
case "otp_cooldown": return "A code just went out — check your inbox."
case "otp_daily_cap": return "Too many codes for this address today. Try a password, or again tomorrow."
case "bad_code": return "That code isn't right."
case "expired": return "That code expired — send another."
case "no_pending": return "No code outstanding — send one first."
case "too_many_attempts": return "Too many wrong codes. Send a fresh one."
case "invalid_email": return "That doesn't look like an email address."
case "rate_limited": return "Too many attempts from this network — try again later."
default:
return err.status == 404
? "This instance doesn't offer accounts — use the token link below."
: "The server answered with an error (\(err.status))."
}
}

private func sendCode() {
error = nil
note = nil
busy = true
Task {
do {
let sent = try await app.requestSignInCode(baseURL: cloudURL, email: email)
busy = false
codeSent = true
code = ""
if sent {
note = "We sent a 6-digit code to \(email). It expires in 10 minutes."
} else {
error = "This instance couldn't send the mail. Try a password instead."
}
} catch let err as LisaClient.SignInCodeError {
busy = false
error = describe(err)
} catch {
busy = false
self.error = "Couldn't reach that LISA Cloud URL."
}
}
}

private func submitCode() {
error = nil
note = nil
busy = true
Task {
do {
try await app.connectCloudWithCode(baseURL: cloudURL, email: email, code: code)
await verifyThenReport()
} catch let err as LisaClient.SignInCodeError {
busy = false
error = describe(err)
// A burned or expired code can't be retried — back to sending one.
if ["expired", "no_pending", "too_many_attempts"].contains(err.reason) {
codeSent = false
}
} catch {
busy = false
self.error = "Couldn't reach that LISA Cloud URL."
}
}
}

private func emailAuth(register: Bool) {
error = nil
busy = true
Expand Down
16 changes: 16 additions & 0 deletions packaging/ios-companion/Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,22 @@ final class AppState: ObservableObject {
await refreshAccount()
}

/// Ask the instance to mail a one-time sign-in code. Returns false when the
/// instance accepted the request but couldn't send the mail.
func requestSignInCode(baseURL raw: String, email: String) async throws -> Bool {
guard let base = AppState.parseCloudBase(raw) else { throw LisaError.notConfigured }
return try await LisaClient.requestSignInCode(base: base, email: email)
}

/// Spend a mailed code: signs in, registering the account if the address is
/// new (PLAN_AUTH_OTP_GOOGLE A2).
func connectCloudWithCode(baseURL raw: String, email: String, code: String) async throws {
guard let base = AppState.parseCloudBase(raw) else { throw LisaError.notConfigured }
let token = try await LisaClient.verifySignInCode(base: base, email: email, code: code)
update(host: base.host, port: base.port, token: token, scheme: base.scheme)
await refreshAccount()
}

/// Refresh `account` (best-effort — leaves the last value on transport errors,
/// clears to signed-out shape on a definitive 401).
func refreshAccount() async {
Expand Down
61 changes: 61 additions & 0 deletions packaging/ios-companion/Sources/LisaClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,67 @@ final class LisaClient {
return r.token
}

/// A refusal from the sign-in-by-code endpoints, carrying the server's typed
/// reason. The status alone can't tell "a code just went out" from "you've
/// asked too many times today", and those need different words.
struct SignInCodeError: Error {
let status: Int
/// Server code — otp_cooldown, otp_daily_cap, bad_code, expired,
/// no_pending, too_many_attempts, invalid_email, rate_limited. Empty if
/// the body carried none.
let reason: String
}

private static func postAuth(
base: ServerConfig,
path: String,
payload: [String: String],
session: URLSession,
) async throws -> Data {
guard let baseURL = base.baseURL, let url = URL(string: path, relativeTo: baseURL) else {
throw LisaError.notConfigured
}
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.timeoutInterval = 15
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: payload)
let (data, resp) = try await session.data(for: req)
let status = (resp as? HTTPURLResponse)?.statusCode ?? -1
guard (200..<300).contains(status) else {
struct E: Decodable { let error: String? }
let reason = (try? JSONDecoder().decode(E.self, from: data))?.error ?? ""
throw SignInCodeError(status: status, reason: reason)
}
return data
}

/// Ask for a one-time sign-in code (`POST /api/auth/otp/request`,
/// PLAN_AUTH_OTP_GOOGLE A2). Returns whether the mail actually went out —
/// false means the instance has no mail provider configured, which the UI
/// must say plainly rather than leaving someone waiting for a code.
static func requestSignInCode(base: ServerConfig, email: String,
session: URLSession = .shared) async throws -> Bool {
let data = try await postAuth(base: base, path: "/api/auth/otp/request",
payload: ["email": email], session: session)
struct R: Decodable { let sent: Bool? }
return (try? JSONDecoder().decode(R.self, from: data))?.sent ?? true
}

/// Spend a code (`POST /api/auth/otp/verify`) and get the account session
/// token. Registers the account if the address is new — reading the mail is
/// the proof, so there's no separate sign-up step.
static func verifySignInCode(base: ServerConfig, email: String, code: String,
session: URLSession = .shared) async throws -> String {
let data = try await postAuth(base: base, path: "/api/auth/otp/verify",
payload: ["email": email, "code": code], session: session)
struct R: Decodable { let token: String }
guard let r = try? JSONDecoder().decode(R.self, from: data), !r.token.isEmpty else {
throw LisaError.decode
}
return r.token
}

/// The signed-in account behind the current session (`GET /api/auth/me`).
/// `signedIn: false` means the connection authenticates with a legacy shared
/// or device token rather than a LISA account.
Expand Down
Loading