diff --git a/packaging/ios-companion/Sources/AccountViews.swift b/packaging/ios-companion/Sources/AccountViews.swift index e685be7..15afda8 100644 --- a/packaging/ios-companion/Sources/AccountViews.swift +++ b/packaging/ios-companion/Sources/AccountViews.swift @@ -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 @@ -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 @@ -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 { @@ -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. @@ -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 diff --git a/packaging/ios-companion/Sources/AppState.swift b/packaging/ios-companion/Sources/AppState.swift index 0c1ac14..c99072b 100644 --- a/packaging/ios-companion/Sources/AppState.swift +++ b/packaging/ios-companion/Sources/AppState.swift @@ -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 { diff --git a/packaging/ios-companion/Sources/LisaClient.swift b/packaging/ios-companion/Sources/LisaClient.swift index f2def09..8e13ee2 100644 --- a/packaging/ios-companion/Sources/LisaClient.swift +++ b/packaging/ios-companion/Sources/LisaClient.swift @@ -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. diff --git a/packaging/mac-client/Sources/Lisa/AccountWindow.swift b/packaging/mac-client/Sources/Lisa/AccountWindow.swift index bf6f18e..fee03e4 100644 --- a/packaging/mac-client/Sources/Lisa/AccountWindow.swift +++ b/packaging/mac-client/Sources/Lisa/AccountWindow.swift @@ -2,13 +2,15 @@ // AccountWindow.swift // Lisa // -// "Sign in to LISA Cloud" (PLAN_ACCOUNTS_BILLING B8d): email+password against -// the hosted cloud; on success the account session is written to -// ~/.lisa/config.env (LISA_MANAGED_SESSION/_BASE) and the local backend runs -// KEY-FREE — its LLM calls route through the LISA inference gateway, metered -// against the account's allowance. BYO keys in config.env always win; signing -// out simply clears the session. The backend reads config.env at start, so -// apply = restart (offered in-window). +// "Sign in to LISA Cloud" (PLAN_ACCOUNTS_BILLING B8d; PLAN_AUTH_OTP_GOOGLE A2): +// by default a one-time code is mailed to the address — which also creates the +// account if it's new — with the password field kept for accounts that have one. +// On success the account session is written to ~/.lisa/config.env +// (LISA_MANAGED_SESSION/_BASE) and the local backend runs KEY-FREE — its LLM +// calls route through the LISA inference gateway, metered against the account's +// allowance. BYO keys in config.env always win; signing out simply clears the +// session. The backend reads config.env at start, so apply = restart (offered +// in-window). // import AppKit @@ -18,8 +20,10 @@ final class AccountWindowController: NSWindowController { private let baseField = NSTextField(string: "") private let emailField = NSTextField(string: "") + private let codeField = NSTextField(string: "") private let passwordField = NSSecureTextField(string: "") private let statusLabel = NSTextField(wrappingLabelWithString: "") + private lazy var sendCodeButton = NSButton(title: "Email me a code", target: self, action: #selector(sendCode)) private lazy var signInButton = NSButton(title: "Sign in", target: self, action: #selector(signIn)) private lazy var signOutButton = NSButton(title: "Sign out", target: self, action: #selector(signOut)) private lazy var restartButton = NSButton(title: "Restart backend to apply", target: self, action: #selector(restartBackend)) @@ -58,24 +62,26 @@ final class AccountWindowController: NSWindowController { baseField.placeholderString = "https://cloud.meetlisa.ai" emailField.placeholderString = "Email" - passwordField.placeholderString = "Password" + codeField.placeholderString = "6-digit code from your inbox" + passwordField.placeholderString = "Password (only if your account has one)" statusLabel.font = .systemFont(ofSize: 12) + sendCodeButton.bezelStyle = .rounded signInButton.bezelStyle = .rounded signInButton.keyEquivalent = "\r" signOutButton.bezelStyle = .rounded restartButton.bezelStyle = .rounded restartButton.isHidden = true - let buttonRow = NSStackView(views: [signInButton, signOutButton, restartButton]) + let buttonRow = NSStackView(views: [sendCodeButton, signInButton, signOutButton, restartButton]) buttonRow.spacing = 8 - let stack = NSStackView(views: [title, blurb, baseField, emailField, passwordField, buttonRow, statusLabel]) + let stack = NSStackView(views: [title, blurb, baseField, emailField, codeField, passwordField, buttonRow, statusLabel]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = 10 stack.edgeInsets = NSEdgeInsets(top: 36, left: 24, bottom: 24, right: 24) - for f in [baseField, emailField, passwordField] { + for f in [baseField, emailField, codeField, passwordField] { f.translatesAutoresizingMaskIntoConstraints = false f.widthAnchor.constraint(equalToConstant: 352).isActive = true } @@ -95,46 +101,116 @@ final class AccountWindowController: NSWindowController { : "" } - @objc private func signIn() { - let base = baseField.stringValue.trimmingCharacters(in: .whitespaces) + private var trimmedBase: String { + baseField.stringValue.trimmingCharacters(in: .whitespaces) .trimmingCharacters(in: CharacterSet(charactersIn: "/")) - let email = emailField.stringValue.trimmingCharacters(in: .whitespaces) - let password = passwordField.stringValue - guard let url = URL(string: "\(base)/api/auth/login"), !email.isEmpty, !password.isEmpty else { - setStatus("Enter the cloud URL, email, and password.", error: true) + } + + /// POST JSON to an auth endpoint and hand the parsed body (or a reason) back + /// on the main queue. + private func postAuth( + path: String, + payload: [String: String], + completion: @escaping (_ body: [String: Any]?, _ reason: String) -> Void, + ) { + guard let url = URL(string: "\(trimmedBase)\(path)") else { + completion(nil, "that cloud URL doesn't look right") return } - signInButton.isEnabled = false - setStatus("Signing in…", error: false) var req = URLRequest(url: url, timeoutInterval: 15) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") - req.httpBody = try? JSONSerialization.data(withJSONObject: ["email": email, "password": password]) + req.httpBody = try? JSONSerialization.data(withJSONObject: payload) URLSession.shared.dataTask(with: req) { data, resp, _ in - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.signInButton.isEnabled = true - let code = (resp as? HTTPURLResponse)?.statusCode ?? -1 - guard code == 200, let data, - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let token = obj["token"] as? String, !token.isEmpty else { - let hint = code == 401 ? "wrong email or password" - : code == 429 ? "too many attempts — wait 15 minutes" - : code == 404 ? "this instance doesn't offer accounts" - : code == -1 ? "couldn't reach the server" : "HTTP \(code)" - self.setStatus("Sign-in failed (\(hint)).", error: true) - return + let status = (resp as? HTTPURLResponse)?.statusCode ?? -1 + let obj = data.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } ?? nil + DispatchQueue.main.async { + if status == 200 { + completion(obj, "") + } else { + completion(nil, Self.hint(status: status, error: obj?["error"] as? String ?? "")) } - BackendController.shared.upsertConfigEnv("LISA_MANAGED_SESSION", value: token) - BackendController.shared.upsertConfigEnv("LISA_MANAGED_BASE", value: base) - self.passwordField.stringValue = "" - self.refreshState() - self.setStatus("Signed in. Restart the backend so it picks up the session.", error: false) - self.restartButton.isHidden = false } }.resume() } + private static func hint(status: Int, error: String) -> String { + switch error { + case "otp_cooldown": return "a code just went out — check your inbox" + case "otp_daily_cap": return "too many codes for this address today" + 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 "bad_credentials": return "wrong email or password" + case "invalid_email": return "that doesn't look like an email address" + case "rate_limited", "throttled": return "too many attempts — wait a while" + default: + return status == 404 ? "this instance doesn't offer accounts" + : status == -1 ? "couldn't reach the server" : "HTTP \(status)" + } + } + + @objc private func sendCode() { + let email = emailField.stringValue.trimmingCharacters(in: .whitespaces) + guard !email.isEmpty else { + setStatus("Enter your email address first.", error: true) + return + } + sendCodeButton.isEnabled = false + setStatus("Sending a code…", error: false) + postAuth(path: "/api/auth/otp/request", payload: ["email": email]) { [weak self] body, reason in + guard let self else { return } + self.sendCodeButton.isEnabled = true + guard let body else { + self.setStatus("Couldn't send a code (\(reason)).", error: true) + return + } + if body["sent"] as? Bool == false { + self.setStatus("This instance couldn't send the mail — use a password instead.", error: true) + return + } + self.codeField.stringValue = "" + self.window?.makeFirstResponder(self.codeField) + self.setStatus("A 6-digit code is on its way to \(email). It expires in 10 minutes.", error: false) + } + } + + /// Signs in with whichever credential is filled: the mailed code first (it + /// also registers a new address), else the password. + @objc private func signIn() { + let email = emailField.stringValue.trimmingCharacters(in: .whitespaces) + let code = codeField.stringValue.trimmingCharacters(in: .whitespaces) + let password = passwordField.stringValue + guard !email.isEmpty, !code.isEmpty || !password.isEmpty else { + setStatus("Enter your email, then the code we mail you (or your password).", error: true) + return + } + let usingCode = !code.isEmpty + let path = usingCode ? "/api/auth/otp/verify" : "/api/auth/login" + let payload = usingCode + ? ["email": email, "code": code] + : ["email": email, "password": password] + signInButton.isEnabled = false + setStatus("Signing in…", error: false) + let base = trimmedBase + postAuth(path: path, payload: payload) { [weak self] body, reason in + guard let self else { return } + self.signInButton.isEnabled = true + guard let token = body?["token"] as? String, !token.isEmpty else { + self.setStatus("Sign-in failed (\(reason.isEmpty ? "unexpected server response" : reason)).", error: true) + return + } + BackendController.shared.upsertConfigEnv("LISA_MANAGED_SESSION", value: token) + BackendController.shared.upsertConfigEnv("LISA_MANAGED_BASE", value: base) + self.passwordField.stringValue = "" + self.codeField.stringValue = "" + self.refreshState() + self.setStatus("Signed in. Restart the backend so it picks up the session.", error: false) + self.restartButton.isHidden = false + } + } + @objc private func signOut() { BackendController.shared.upsertConfigEnv("LISA_MANAGED_SESSION", value: "") refreshState() diff --git a/src/cli.ts b/src/cli.ts index fd66231..6faafb3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -105,6 +105,14 @@ SKILLS (executable, Phase 3.1) lisa skills enable Remove a disable flag. lisa skills audit Show the audit trail. +LISA CLOUD (managed inference — models without a BYO key run key-free) + lisa login [url] [--password] + Sign in. Mails a one-time code by default, and + creates the account if the address is new; + --password uses an existing password instead. + lisa logout Sign out. BYO keys are unaffected. + lisa billing Session allowance, credits and recent usage. + lisa --help Show this message. lisa --version Print the installed Lisa version. diff --git a/src/cli/account.ts b/src/cli/account.ts index e7dd212..1076c07 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -2,22 +2,41 @@ * `lisa login` / `lisa logout` / `lisa billing` — the managed-inference CLI * (docs/PLAN_ACCOUNTS_BILLING_v1.0.md §6.6, milestone B6). * - * login: email+password against a LISA Cloud instance → stores the account - * session in config.env (LISA_MANAGED_SESSION/_BASE). From then on, any model - * WITHOUT its own BYO key routes through the cloud gateway — no API key needed. - * BYO keys keep winning; `lisa logout` removes the session. + * login: signs in against a LISA Cloud instance → stores the account session in + * config.env (LISA_MANAGED_SESSION/_BASE). From then on, any model WITHOUT its + * own BYO key routes through the cloud gateway — no API key needed. BYO keys + * keep winning; `lisa logout` removes the session. * - * The password is read from the TTY with echo off (never from argv — argv - * leaks into `ps`). Sign in with Apple isn't possible in a terminal; email - * accounts are the CLI path. + * By default it mails a one-time code (PLAN_AUTH_OTP_GOOGLE A2): nothing to + * remember, and it registers an account on the spot if the address is new. + * `--password` keeps the email+password path for accounts that have one. + * + * Secrets are read from the TTY with echo off (never from argv — argv leaks + * into `ps`); the mailed code is echoed, since it's single-use and expiring. + * Sign in with Apple and Google both need a browser, so email is the CLI path. */ import readline from "node:readline"; import { saveConfigEnv } from "../env.js"; import { managedConfig } from "../providers/registry.js"; import { formatMicroUSD } from "../billing/prices.js"; +/** + * One interface for the whole command. A fresh one per prompt drops whatever + * the previous reader had already buffered, which breaks every prompt after the + * first as soon as stdin isn't an interactive terminal. + */ +let prompts: readline.Interface | null = null; +function promptStream(): readline.Interface { + prompts ??= readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true }); + return prompts; +} +function closePrompts(): void { + prompts?.close(); + prompts = null; +} + function ask(question: string, opts: { hidden?: boolean } = {}): Promise { - const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true }); + const rl = promptStream(); return new Promise((resolve) => { if (opts.hidden) { // Mute the echo: readline writes the prompt, we swallow the keystrokes. @@ -31,60 +50,141 @@ function ask(question: string, opts: { hidden?: boolean } = {}): Promise rl.question(question, (answer) => { (stream as unknown as { write: typeof origWrite }).write = origWrite; origWrite("\n"); - rl.close(); resolve(answer); }); muted = true; } else { rl.question(question, (answer) => { - rl.close(); resolve(answer); }); } }); } -export async function cmdLogin(subargs: string[]): Promise { - const base = (subargs[0] ?? process.env.LISA_MANAGED_BASE ?? "https://cloud.meetlisa.ai").replace(/\/+$/, ""); - const email = (await ask(`LISA Cloud (${base})\nEmail: `)).trim(); - const password = await ask("Password: ", { hidden: true }); - if (!email || !password) { - console.error("login cancelled — email and password are both required."); - process.exitCode = 1; - return; - } +/** + * POST JSON, returning the parsed body plus the error code on failure. An + * unreachable server is reported as the `unreachable` code rather than thrown, + * so callers never have to wrap this in a catch broad enough to swallow — and + * mislabel — a bug as a network problem. + */ +async function post( + url: string, + payload: Record, +): Promise<{ ok: boolean; status: number; code: string; body: Record }> { let res: Response; try { - res = await fetch(`${base}/api/auth/login`, { + res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ email, password }), + body: JSON.stringify(payload), }); } catch { - console.error(`✗ could not reach ${base}`); - process.exitCode = 1; - return; + return { ok: false, status: 0, code: "unreachable", body: {} }; } + let body: Record = {}; + try { body = (await res.json()) as Record; } catch { /* text body */ } + return { ok: res.ok, status: res.status, code: typeof body.error === "string" ? body.error : "", body }; +} + +const HINTS: Record = { + unreachable: "could not reach the server", + bad_credentials: "wrong email or password", + throttled: "too many attempts — wait 15 minutes", + rate_limited: "too many attempts from this network — try again later", + invalid_email: "that doesn't look like an email address", + otp_cooldown: "a code was just sent — check your inbox", + otp_daily_cap: "too many codes for this address today", + bad_code: "that code isn't right", + expired: "that code expired — run login again", + no_pending: "no code outstanding — run login again", + too_many_attempts: "too many wrong codes — run login again", +}; + +function hintFor(code: string, status: number): string { + return HINTS[code] ?? `HTTP ${status}`; +} + +interface Session { + token: string; + uid?: string; +} + +/** Pull the session out of a successful auth response. */ +function sessionFrom(body: Record): Session | null { + if (typeof body.token !== "string" || !body.token) return null; + return { token: body.token, ...(typeof body.uid === "string" ? { uid: body.uid } : {}) }; +} + +/** + * Sign in with a mailed code (the default). Returns the session, or null after + * reporting why not. Wrong digits are retried in place while the code is still + * live — the server burns it after five, so three tries here is safe. + */ +async function loginWithCode(base: string, email: string): Promise { + const asked = await post(`${base}/api/auth/otp/request`, { email }); + if (!asked.ok) { + console.error(`✗ couldn't send a code (${hintFor(asked.code, asked.status)}).`); + return null; + } + if (asked.body.sent === false) { + console.error("✗ the server couldn't deliver the mail. Try again shortly, or use --password."); + return null; + } + console.error(`✉ A 6-digit code is on its way to ${email}.`); + for (let attempt = 0; attempt < 3; attempt++) { + const code = (await ask("Code: ")).trim(); + if (!code) { + console.error("login cancelled."); + return null; + } + const spent = await post(`${base}/api/auth/otp/verify`, { email, code }); + if (spent.ok) return sessionFrom(spent.body); + console.error(`✗ ${hintFor(spent.code, spent.status)}.`); + if (spent.code !== "bad_code") return null; // expired/burned — a retry can't help + } + return null; +} + +async function loginWithPassword(base: string, email: string): Promise { + const password = await ask("Password: ", { hidden: true }); + if (!password) { + console.error("login cancelled — a password is required."); + return null; + } + const res = await post(`${base}/api/auth/login`, { email, password }); if (!res.ok) { - let code = ""; - try { code = ((await res.json()) as { error?: string }).error ?? ""; } catch { /* text body */ } - const hint = - code === "bad_credentials" ? "wrong email or password" - : code === "throttled" ? "too many attempts — wait 15 minutes" - : `HTTP ${res.status}`; - console.error(`✗ sign-in rejected (${hint}). No account? Create one in the iOS app or on the web page.`); - process.exitCode = 1; - return; + console.error( + `✗ sign-in rejected (${hintFor(res.code, res.status)}). No password on this account? Run 'lisa login' without --password.`, + ); + return null; } - const body = (await res.json()) as { token?: string; uid?: string }; - if (!body.token) { - console.error("✗ unexpected server response (no session token)"); - process.exitCode = 1; - return; + return sessionFrom(res.body); +} + +export async function cmdLogin(subargs: string[]): Promise { + const usePassword = subargs.includes("--password"); + const positional = subargs.filter((a) => !a.startsWith("-")); + const base = (positional[0] ?? process.env.LISA_MANAGED_BASE ?? "https://cloud.meetlisa.ai").replace(/\/+$/, ""); + try { + const email = (await ask(`LISA Cloud (${base})\nEmail: `)).trim(); + if (!email) { + console.error("login cancelled — an email address is required."); + process.exitCode = 1; + return; + } + const session = usePassword + ? await loginWithPassword(base, email) + : await loginWithCode(base, email); + if (!session) { + process.exitCode = 1; + return; + } + await saveConfigEnv({ LISA_MANAGED_SESSION: session.token, LISA_MANAGED_BASE: base }); + console.error(`✓ signed in as ${email} (${session.uid ?? "?"})`); + console.error(" Models without a BYO key now route through LISA Cloud — no API key needed."); + } finally { + closePrompts(); } - await saveConfigEnv({ LISA_MANAGED_SESSION: body.token, LISA_MANAGED_BASE: base }); - console.error(`✓ signed in as ${email} (${body.uid ?? "?"})`); - console.error(" Models without a BYO key now route through LISA Cloud — no API key needed."); await cmdBilling([]); } diff --git a/src/web/html-syntax.test.ts b/src/web/html-syntax.test.ts index 453bf02..f827a61 100644 --- a/src/web/html-syntax.test.ts +++ b/src/web/html-syntax.test.ts @@ -4,6 +4,7 @@ import vm from "node:vm"; import { MAIN_HTML } from "./lisa-html.js"; import { ISLAND_HTML } from "./island.js"; import { ROOM_HTML } from "./room.js"; +import { LOGIN_HTML } from "./login.js"; /** * Regression guard: the whole GUI/island is one big inline