Allow HTTP Basic Auth credentials for any remote-server instance type

EditSourceView now exposes the basic-auth username/password fields for every
instance type (Invidious, Piped, PeerTube, Yattee Server), keeping the
existing required-credentials UI for Yattee Server and adding an optional
section for the others. Credentials are loaded and persisted via
BasicAuthCredentialsManager regardless of type, and clearing both fields
deletes stored credentials for non-Yattee types.

AddRemoteServerView gains a new basicAuthRequired UI state: when instance
detection hits a 401 (the entire instance is behind a reverse proxy), the
view reveals username/password fields and a Retry Detection button. The
retry calls the detector with the credentials injected as an Authorization
header; on success the form transitions into the normal detected state with
the credentials pre-populated. A repeat 401 shows an inline "invalid
credentials" message instead of restarting the flow. For non-Yattee types,
any credentials entered during the flow are persisted alongside the new
instance.
This commit is contained in:
Arkadiusz Fal
2026-04-06 20:42:43 +02:00
parent 222b53d520
commit 3dd4073db7
3 changed files with 239 additions and 56 deletions

View File

@@ -14560,6 +14560,17 @@
} }
} }
}, },
"sources.retryDetection" : {
"comment" : "Button to retry instance detection after providing HTTP Basic Auth credentials",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Retry Detection"
}
}
}
},
"sources.editSource" : { "sources.editSource" : {
"comment" : "Title for edit source view", "comment" : "Title for edit source view",
"localizations" : { "localizations" : {
@@ -15011,6 +15022,28 @@
} }
} }
}, },
"sources.footer.basicAuth" : {
"comment" : "Footer text explaining optional HTTP Basic Auth credentials for instances behind a reverse proxy",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Optional. Provide a username and password if this instance sits behind a reverse proxy that requires HTTP Basic Auth."
}
}
}
},
"sources.footer.basicAuthRequired" : {
"comment" : "Footer text shown when the instance is behind HTTP Basic Auth and credentials are required to detect it",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "This server is behind HTTP Basic Auth. Enter the username and password to continue."
}
}
}
},
"sources.footer.yatteeServerAuth" : { "sources.footer.yatteeServerAuth" : {
"comment" : "Footer text explaining Yattee Server authentication", "comment" : "Footer text explaining Yattee Server authentication",
"localizations" : { "localizations" : {
@@ -15055,6 +15088,17 @@
} }
} }
}, },
"sources.header.basicAuth" : {
"comment" : "Section header for HTTP Basic Auth credentials",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "HTTP Basic Authentication"
}
}
}
},
"sources.header.displayName" : { "sources.header.displayName" : {
"comment" : "Section header for display name", "comment" : "Section header for display name",
"localizations" : { "localizations" : {

View File

@@ -19,6 +19,10 @@ private enum RemoteServerUIState: Equatable {
case detected(InstanceType) case detected(InstanceType)
/// Detection failed: fields auto-revealed with error message /// Detection failed: fields auto-revealed with error message
case error(String) case error(String)
/// Detection hit a 401: the instance is fronted by HTTP Basic Auth.
/// The view exposes username/password fields and a "Retry detection" button.
/// `invalidCredentials` is true after a retry that also returned 401.
case basicAuthRequired(invalidCredentials: Bool)
static func == (lhs: RemoteServerUIState, rhs: RemoteServerUIState) -> Bool { static func == (lhs: RemoteServerUIState, rhs: RemoteServerUIState) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
@@ -26,6 +30,7 @@ private enum RemoteServerUIState: Equatable {
case (.detecting, .detecting): return true case (.detecting, .detecting): return true
case (.detected(let a), .detected(let b)): return a == b case (.detected(let a), .detected(let b)): return a == b
case (.error(let a), .error(let b)): return a == b case (.error(let a), .error(let b)): return a == b
case (.basicAuthRequired(let a), .basicAuthRequired(let b)): return a == b
default: return false default: return false
} }
} }
@@ -52,9 +57,10 @@ struct AddRemoteServerView: View {
@State private var allowInvalidCertificates = false @State private var allowInvalidCertificates = false
@State private var showSSLToggle = false @State private var showSSLToggle = false
// Yattee Server authentication (always required) // HTTP Basic Auth credentials. Required for Yattee Server, optional for any other
@State private var yatteeServerUsername = "" // instance type that sits behind a reverse proxy requiring HTTP Basic Auth.
@State private var yatteeServerPassword = "" @State private var basicAuthUsername = ""
@State private var basicAuthPassword = ""
@State private var isValidatingCredentials = false @State private var isValidatingCredentials = false
@State private var credentialValidationError: String? @State private var credentialValidationError: String?
@@ -69,19 +75,24 @@ struct AddRemoteServerView: View {
private var isFieldsRevealed: Bool { private var isFieldsRevealed: Bool {
switch uiState { switch uiState {
case .initial, .detecting: case .initial, .detecting, .basicAuthRequired:
return false return false
case .detected, .error: case .detected, .error:
return true return true
} }
} }
private var isAwaitingBasicAuth: Bool {
if case .basicAuthRequired = uiState { return true }
return false
}
private var canAdd: Bool { private var canAdd: Bool {
guard !urlString.isEmpty else { return false } guard !urlString.isEmpty else { return false }
// For detected Yattee Server, require credentials // For detected Yattee Server, require credentials
if detectedType == .yatteeServer { if detectedType == .yatteeServer {
return !yatteeServerUsername.isEmpty && !yatteeServerPassword.isEmpty return !basicAuthUsername.isEmpty && !basicAuthPassword.isEmpty
} }
return true return true
@@ -155,6 +166,10 @@ struct AddRemoteServerView: View {
errorSection(message) errorSection(message)
} }
if case .basicAuthRequired(let invalidCredentials) = uiState {
basicAuthRequiredSection(invalidCredentials: invalidCredentials)
}
if isFieldsRevealed { if isFieldsRevealed {
serverConfigurationFields serverConfigurationFields
actionSection actionSection
@@ -175,7 +190,7 @@ struct AddRemoteServerView: View {
handleURLChange() handleURLChange()
} }
if !isFieldsRevealed { if !isFieldsRevealed && !isAwaitingBasicAuth {
Button { Button {
startDetection() startDetection()
} label: { } label: {
@@ -205,7 +220,7 @@ struct AddRemoteServerView: View {
handleURLChange() handleURLChange()
} }
if !isFieldsRevealed { if !isFieldsRevealed && !isAwaitingBasicAuth {
Button { Button {
startDetection() startDetection()
} label: { } label: {
@@ -254,6 +269,58 @@ struct AddRemoteServerView: View {
} }
} }
// MARK: - Basic Auth Required Section
@ViewBuilder
private func basicAuthRequiredSection(invalidCredentials: Bool) -> some View {
Section {
#if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.field.username"), text: $basicAuthUsername)
TVSettingsTextField(title: String(localized: "sources.field.password"), text: $basicAuthPassword, isSecure: true)
#else
TextField(String(localized: "sources.field.username"), text: $basicAuthUsername)
.textContentType(.username)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
SecureField(String(localized: "sources.field.password"), text: $basicAuthPassword)
.textContentType(.password)
#endif
} header: {
Text(String(localized: "sources.header.basicAuth"))
} footer: {
if invalidCredentials {
Text(String(localized: "sources.error.basicAuthInvalid"))
.foregroundStyle(.red)
} else {
Text(String(localized: "sources.footer.basicAuthRequired"))
}
}
Section {
Button {
retryDetectionWithBasicAuth()
} label: {
if case .detecting = uiState {
HStack {
ProgressView()
.controlSize(.small)
Text(String(localized: "sources.detecting"))
}
} else {
Text(String(localized: "sources.retryDetection"))
}
}
.disabled(basicAuthUsername.isEmpty || basicAuthPassword.isEmpty || uiState == .detecting)
#if os(tvOS)
.buttonStyle(TVSettingsButtonStyle())
#endif
.accessibilityIdentifier("addRemoteServer.retryDetectionButton")
}
}
// MARK: - Server Configuration Fields // MARK: - Server Configuration Fields
@ViewBuilder @ViewBuilder
@@ -302,27 +369,33 @@ struct AddRemoteServerView: View {
} }
} }
// Authentication fields for Yattee Server (always required) // HTTP Basic Auth credentials.
if detectedType == .yatteeServer { // Required for Yattee Server (always shown). Optional for other types show only
// when credentials were already provided (e.g., via the basic-auth-required retry
// path), so we don't clutter the form for the normal "no proxy" case.
let showBasicAuthSection = detectedType == .yatteeServer
|| (!basicAuthUsername.isEmpty || !basicAuthPassword.isEmpty)
if showBasicAuthSection {
Section { Section {
#if os(tvOS) #if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.field.username"), text: $yatteeServerUsername) TVSettingsTextField(title: String(localized: "sources.field.username"), text: $basicAuthUsername)
TVSettingsTextField(title: String(localized: "sources.field.password"), text: $yatteeServerPassword, isSecure: true) TVSettingsTextField(title: String(localized: "sources.field.password"), text: $basicAuthPassword, isSecure: true)
#else #else
TextField(String(localized: "sources.field.username"), text: $yatteeServerUsername) TextField(String(localized: "sources.field.username"), text: $basicAuthUsername)
.textContentType(.username) .textContentType(.username)
#if os(iOS) #if os(iOS)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
#endif #endif
.autocorrectionDisabled() .autocorrectionDisabled()
SecureField(String(localized: "sources.field.password"), text: $yatteeServerPassword) SecureField(String(localized: "sources.field.password"), text: $basicAuthPassword)
.textContentType(.password) .textContentType(.password)
#endif #endif
} header: { } header: {
Text(String(localized: "sources.header.auth")) Text(String(localized: detectedType == .yatteeServer ? "sources.header.auth" : "sources.header.basicAuth"))
} footer: { } footer: {
Text(String(localized: "sources.footer.yatteeServerAuth")) Text(String(localized: detectedType == .yatteeServer ? "sources.footer.yatteeServerAuth" : "sources.footer.basicAuth"))
} }
if let error = credentialValidationError { if let error = credentialValidationError {
@@ -364,12 +437,15 @@ struct AddRemoteServerView: View {
private func handleURLChange() { private func handleURLChange() {
cancelDetection() cancelDetection()
if isFieldsRevealed { if isFieldsRevealed || isAwaitingBasicAuth {
withAnimation { withAnimation {
uiState = .initial uiState = .initial
detectedType = nil detectedType = nil
detectionResult = nil detectionResult = nil
showSSLToggle = false showSSLToggle = false
basicAuthUsername = ""
basicAuthPassword = ""
credentialValidationError = nil
} }
} }
} }
@@ -400,7 +476,7 @@ struct AddRemoteServerView: View {
} }
} }
private func performDetection(url: URL) async { private func performDetection(url: URL, basicAuthHeader: String? = nil) async {
guard let appEnvironment else { return } guard let appEnvironment else { return }
let detector: InstanceDetector let detector: InstanceDetector
@@ -411,7 +487,7 @@ struct AddRemoteServerView: View {
detector = appEnvironment.instanceDetector detector = appEnvironment.instanceDetector
} }
let result = await detector.detectWithResult(url: url) let result = await detector.detectWithResult(url: url, basicAuthHeader: basicAuthHeader)
if Task.isCancelled { return } if Task.isCancelled { return }
@@ -425,6 +501,18 @@ struct AddRemoteServerView: View {
self.uiState = .detected(detectionResult.type) self.uiState = .detected(detectionResult.type)
} }
case .failure(.basicAuthRequired):
LoggingService.shared.debug("[AddRemoteServerView] Detection requires basic auth credentials", category: .api)
withAnimation {
self.uiState = .basicAuthRequired(invalidCredentials: false)
}
case .failure(.basicAuthInvalid):
LoggingService.shared.debug("[AddRemoteServerView] Basic auth credentials rejected by server", category: .api)
withAnimation {
self.uiState = .basicAuthRequired(invalidCredentials: true)
}
case .failure(let error): case .failure(let error):
LoggingService.shared.debug("[AddRemoteServerView] Detection failed: \(error)", category: .api) LoggingService.shared.debug("[AddRemoteServerView] Detection failed: \(error)", category: .api)
withAnimation { withAnimation {
@@ -437,6 +525,30 @@ struct AddRemoteServerView: View {
} }
} }
private func retryDetectionWithBasicAuth() {
guard !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty else { return }
guard let url = Instance.normalizeSourceURL(urlString) else {
withAnimation {
uiState = .error(String(localized: "sources.validation.invalidURL"))
}
return
}
let credentials = "\(basicAuthUsername):\(basicAuthPassword)"
guard let credentialData = credentials.data(using: .utf8) else { return }
let authHeader = "Basic \(credentialData.base64EncodedString())"
cancelDetection()
withAnimation {
uiState = .detecting
}
detectionTask = Task {
await performDetection(url: url, basicAuthHeader: authHeader)
}
}
private func addSource() { private func addSource() {
guard let appEnvironment else { return } guard let appEnvironment else { return }
@@ -487,6 +599,16 @@ struct AddRemoteServerView: View {
addServer(type: detectionResult.type, url: url, appEnvironment: appEnvironment) addServer(type: detectionResult.type, url: url, appEnvironment: appEnvironment)
} }
case .failure(.basicAuthRequired):
withAnimation {
self.uiState = .basicAuthRequired(invalidCredentials: false)
}
case .failure(.basicAuthInvalid):
withAnimation {
self.uiState = .basicAuthRequired(invalidCredentials: true)
}
case .failure(let error): case .failure(let error):
withAnimation { withAnimation {
if case .sslCertificateError = error { if case .sslCertificateError = error {
@@ -502,7 +624,7 @@ struct AddRemoteServerView: View {
private func addServer(type: InstanceType, url: URL, appEnvironment: AppEnvironment) { private func addServer(type: InstanceType, url: URL, appEnvironment: AppEnvironment) {
// For Yattee Server, always validate credentials first // For Yattee Server, always validate credentials first
if type == .yatteeServer { if type == .yatteeServer {
guard !yatteeServerUsername.isEmpty, !yatteeServerPassword.isEmpty else { guard !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty else {
credentialValidationError = String(localized: "sources.error.credentialsRequired") credentialValidationError = String(localized: "sources.error.credentialsRequired")
return return
} }
@@ -513,8 +635,8 @@ struct AddRemoteServerView: View {
Task { Task {
let isValid = await validateYatteeServerCredentials( let isValid = await validateYatteeServerCredentials(
url: url, url: url,
username: yatteeServerUsername, username: basicAuthUsername,
password: yatteeServerPassword, password: basicAuthPassword,
appEnvironment: appEnvironment appEnvironment: appEnvironment
) )
@@ -530,8 +652,8 @@ struct AddRemoteServerView: View {
) )
appEnvironment.basicAuthCredentialsManager.setCredentials( appEnvironment.basicAuthCredentialsManager.setCredentials(
username: yatteeServerUsername, username: basicAuthUsername,
password: yatteeServerPassword, password: basicAuthPassword,
for: instance for: instance
) )
@@ -554,7 +676,8 @@ struct AddRemoteServerView: View {
return return
} }
// For other instance types (no auth required) // For other instance types: optionally store HTTP Basic Auth credentials
// (used when the instance is fronted by a reverse proxy that requires basic auth).
let instance = Instance( let instance = Instance(
type: type, type: type,
url: url, url: url,
@@ -562,6 +685,14 @@ struct AddRemoteServerView: View {
allowInvalidCertificates: allowInvalidCertificates allowInvalidCertificates: allowInvalidCertificates
) )
if !basicAuthUsername.isEmpty && !basicAuthPassword.isEmpty {
appEnvironment.basicAuthCredentialsManager.setCredentials(
username: basicAuthUsername,
password: basicAuthPassword,
for: instance
)
}
appEnvironment.instancesManager.add(instance) appEnvironment.instancesManager.add(instance)
if let dismissSheet { if let dismissSheet {
dismissSheet() dismissSheet()

View File

@@ -36,9 +36,13 @@ private struct EditRemoteServerContent: View {
@State private var allowInvalidCertificates: Bool @State private var allowInvalidCertificates: Bool
@State private var proxiesVideos: Bool @State private var proxiesVideos: Bool
// Yattee Server credentials // HTTP Basic Auth credentials (for any instance type behind a reverse proxy;
@State private var yatteeServerUsername: String = "" // required for Yattee Server, optional for Invidious/Piped/PeerTube).
@State private var yatteeServerPassword: String = "" @State private var basicAuthUsername: String = ""
@State private var basicAuthPassword: String = ""
/// Tracks whether credentials existed when the view loaded, so we can detect
/// "user cleared the fields" and delete the stored credentials on save.
@State private var hadStoredBasicAuth: Bool = false
// Invidious login state // Invidious login state
@State private var showLoginSheet = false @State private var showLoginSheet = false
@@ -137,28 +141,28 @@ private struct EditRemoteServerContent: View {
#endif #endif
} }
if instance.type == .yatteeServer {
Section { Section {
#if os(tvOS) #if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.field.username"), text: $yatteeServerUsername) TVSettingsTextField(title: String(localized: "sources.field.username"), text: $basicAuthUsername)
TVSettingsTextField(title: String(localized: "sources.field.password"), text: $yatteeServerPassword, isSecure: true) TVSettingsTextField(title: String(localized: "sources.field.password"), text: $basicAuthPassword, isSecure: true)
#else #else
TextField(String(localized: "sources.field.username"), text: $yatteeServerUsername) TextField(String(localized: "sources.field.username"), text: $basicAuthUsername)
.textContentType(.username) .textContentType(.username)
#if os(iOS) #if os(iOS)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
#endif #endif
.autocorrectionDisabled() .autocorrectionDisabled()
SecureField(String(localized: "sources.field.password"), text: $yatteeServerPassword) SecureField(String(localized: "sources.field.password"), text: $basicAuthPassword)
.textContentType(.password) .textContentType(.password)
#endif #endif
} header: { } header: {
Text(String(localized: "sources.header.auth")) Text(String(localized: instance.type == .yatteeServer ? "sources.header.auth" : "sources.header.basicAuth"))
} footer: { } footer: {
Text(String(localized: "sources.footer.yatteeServerAuth")) Text(String(localized: instance.type == .yatteeServer ? "sources.footer.yatteeServerAuth" : "sources.footer.basicAuth"))
} }
if instance.type == .yatteeServer {
// Server Info Section // Server Info Section
Section { Section {
if isLoadingServerInfo { if isLoadingServerInfo {
@@ -344,11 +348,11 @@ private struct EditRemoteServerContent: View {
.onAppear { .onAppear {
isLoggedIn = appEnvironment?.credentialsManager(for: instance)?.isLoggedIn(for: instance) ?? false isLoggedIn = appEnvironment?.credentialsManager(for: instance)?.isLoggedIn(for: instance) ?? false
// Load existing Yattee Server credentials // Load existing HTTP Basic Auth credentials (works for any instance type)
if instance.type == .yatteeServer, if let credentials = appEnvironment?.basicAuthCredentialsManager.credentials(for: instance) {
let credentials = appEnvironment?.basicAuthCredentialsManager.credentials(for: instance) { basicAuthUsername = credentials.username
yatteeServerUsername = credentials.username basicAuthPassword = credentials.password
yatteeServerPassword = credentials.password hadStoredBasicAuth = true
} }
} }
.task { .task {
@@ -436,13 +440,17 @@ private struct EditRemoteServerContent: View {
updated.allowInvalidCertificates = allowInvalidCertificates updated.allowInvalidCertificates = allowInvalidCertificates
updated.proxiesVideos = proxiesVideos updated.proxiesVideos = proxiesVideos
// Save Yattee Server credentials if provided // Save / clear HTTP Basic Auth credentials.
if instance.type == .yatteeServer && !yatteeServerUsername.isEmpty && !yatteeServerPassword.isEmpty { // Works for any instance type, but for Yattee Server we never auto-clear
// (credentials are required there; the user can re-enter to overwrite).
if !basicAuthUsername.isEmpty && !basicAuthPassword.isEmpty {
appEnvironment?.basicAuthCredentialsManager.setCredentials( appEnvironment?.basicAuthCredentialsManager.setCredentials(
username: yatteeServerUsername, username: basicAuthUsername,
password: yatteeServerPassword, password: basicAuthPassword,
for: instance for: instance
) )
} else if hadStoredBasicAuth && instance.type != .yatteeServer {
appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance)
} }
appEnvironment?.instancesManager.update(updated) appEnvironment?.instancesManager.update(updated)