Files
yattee/Yattee/Views/Settings/InstanceLoginView.swift
Arkadiusz Fal eefd49f743 Fix three basic-auth regressions surfaced by end-to-end testing
- InstanceDetector: a single 401 from one probe was over-eagerly concluded
  as "credentials invalid" / "credentials required". On instances behind a
  reverse proxy where one probe path (e.g. Yattee Server's /info) hits a
  same-origin redirect, iOS URLSession strips the Authorization header on
  the redirect and the request 401s even with valid credentials. Track 401s
  across all probes and only conclude basicAuthRequired/basicAuthInvalid
  when no probe matched and at least one returned 401.

- InstanceLoginView: the Invidious/Piped login flow constructed an API
  client backed by the shared appEnvironment.httpClient, which has no
  per-instance basic-auth headers. For instances behind a reverse proxy,
  the login POST 401d before reaching the upstream login endpoint. Build a
  per-instance HTTPClient with the basic-auth Authorization header baked in
  via setDefaultHeaders, mirroring ContentService.httpClientWithBasicAuth.

- InvidiousAPI.login: the login function constructs its own URLSession (to
  capture Set-Cookie via a redirect-blocking delegate), so it never
  inherits headers from the injected httpClient. Add an optional
  extraHeaders parameter and have InstanceLoginView pass the basic-auth
  header through when present. PipedAPI.login uses httpClient.fetch and
  inherits defaultHeaders correctly, so no change is needed there.
2026-04-18 20:38:00 +02:00

230 lines
7.9 KiB
Swift

//
// InstanceLoginView.swift
// Yattee
//
// Shared login sheet for Invidious and Piped accounts.
//
import SwiftUI
struct InstanceLoginView: View {
let instance: Instance
let onLoginSuccess: (String) -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.appEnvironment) private var appEnvironment
@State private var username = ""
@State private var password = ""
@State private var isLoading = false
@State private var errorMessage: String?
var body: some View {
NavigationStack {
#if os(tvOS)
VStack(spacing: 0) {
HStack {
Button(String(localized: "common.cancel")) {
dismiss()
}
.buttonStyle(TVToolbarButtonStyle())
Spacer()
Text(String(localized: "login.title"))
.font(.title2)
.fontWeight(.semibold)
Spacer()
Color.clear.frame(width: 100)
}
.padding(.horizontal, 48)
.padding(.vertical, 24)
formContent
}
.accessibilityIdentifier("instance.login.view")
#else
formContent
.navigationTitle(String(localized: "login.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "common.cancel")) {
dismiss()
}
}
}
.accessibilityIdentifier("instance.login.view")
#endif
}
}
private var formContent: some View {
Form {
Section {
#if os(tvOS)
TVSettingsTextField(title: usernameFieldLabel, text: $username)
TVSettingsTextField(title: String(localized: "login.password"), text: $password, isSecure: true)
#else
TextField(usernameFieldLabel, text: $username)
.textContentType(.username)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
.accessibilityIdentifier("instance.login.usernameField")
SecureField(String(localized: "login.password"), text: $password)
.textContentType(.password)
.accessibilityIdentifier("instance.login.passwordField")
#endif
} header: {
Text(String(localized: "login.header.credentials"))
} footer: {
Text(footerText)
}
if let error = errorMessage {
Section {
Label(error, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red)
.accessibilityIdentifier("instance.login.error")
}
}
Section {
Button {
login()
} label: {
if isLoading {
HStack {
ProgressView()
.controlSize(.small)
Text(String(localized: "login.loggingIn"))
}
} else {
Text(String(localized: "login.signIn"))
}
}
.disabled(username.isEmpty || password.isEmpty || isLoading)
.accessibilityIdentifier("instance.login.submitButton")
#if os(tvOS)
.buttonStyle(TVSettingsButtonStyle())
#endif
}
}
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#endif
}
// MARK: - Computed Properties
/// Returns the appropriate label for the username field based on instance type.
private var usernameFieldLabel: String {
switch instance.type {
case .piped:
return String(localized: "login.username")
case .invidious:
return String(localized: "login.email")
default:
return String(localized: "login.username")
}
}
/// Returns the appropriate footer text based on instance type.
private var footerText: String {
switch instance.type {
case .piped:
return String(localized: "login.footer.pipedAccount \(instance.displayName)")
case .invidious:
return String(localized: "login.footer.invidiousAccount \(instance.displayName)")
default:
return ""
}
}
// MARK: - Actions
private func login() {
guard let appEnvironment else { return }
isLoading = true
errorMessage = nil
Task {
do {
let credential = try await performLogin(appEnvironment: appEnvironment)
await MainActor.run {
onLoginSuccess(credential)
dismiss()
}
} catch APIError.unauthorized {
await MainActor.run {
errorMessage = String(localized: "login.error.invalidCredentials")
isLoading = false
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
}
/// Performs the login based on instance type.
/// - Returns: The credential (SID for Invidious, token for Piped)
private func performLogin(appEnvironment: AppEnvironment) async throws -> String {
// If the instance sits behind an HTTP Basic Auth reverse proxy, the login
// POST must carry that proxy's Authorization header too otherwise the
// request 401s before reaching the upstream login endpoint. Bake the
// header into a fresh per-instance HTTPClient.
let basicAuthHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: instance)
let extraHeaders: [String: String]? = basicAuthHeader.map { ["Authorization": $0] }
let httpClient: HTTPClient
if let basicAuthHeader {
httpClient = appEnvironment.httpClientFactory.createClient(for: instance)
await httpClient.setDefaultHeaders(["Authorization": basicAuthHeader])
} else {
httpClient = appEnvironment.httpClient
}
switch instance.type {
case .invidious:
// InvidiousAPI.login uses its own URLSession (to handle redirect/Set-Cookie),
// so it doesn't inherit defaultHeaders from the injected HTTPClient. Pass
// the basic-auth header explicitly so the login POST passes the proxy.
let api = InvidiousAPI(httpClient: httpClient)
return try await api.login(email: username, password: password, instance: instance, extraHeaders: extraHeaders)
case .piped:
// PipedAPI.login uses httpClient.fetch which DOES inherit defaultHeaders,
// so the basic-auth header on the per-instance client is sufficient.
let api = PipedAPI(httpClient: httpClient)
return try await api.login(username: username, password: password, instance: instance)
default:
throw APIError.notSupported
}
}
}
// MARK: - Preview
#Preview("Invidious") {
InstanceLoginView(
instance: Instance(type: .invidious, url: URL(string: "https://invidious.example.com")!)
) { _ in }
.appEnvironment(.preview)
}
#Preview("Piped") {
InstanceLoginView(
instance: Instance(type: .piped, url: URL(string: "https://piped.example.com")!)
) { _ in }
.appEnvironment(.preview)
}