From 9da9f9e2b39f2cb21e833f00fa7151b2a8b5c173 Mon Sep 17 00:00:00 2001 From: cogwheel <172976095+cogwheel0@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:40:16 +0530 Subject: [PATCH] (auth): Add proxy authentication WebView for server login --- .../app/cogwheel/conduit/MainActivity.kt | 36 + .../res/drawable-v31/ic_widget_camera.xml | 1 + .../res/drawable-v31/ic_widget_clipboard.xml | 1 + .../main/res/drawable-v31/ic_widget_mic.xml | 1 + .../res/drawable-v31/ic_widget_mic_accent.xml | 1 + .../res/drawable-v31/ic_widget_photos.xml | 1 + .../res/drawable-v31/ic_widget_waveform.xml | 1 + .../res/drawable-v31/widget_background.xml | 1 + .../res/drawable-v31/widget_button_circle.xml | 1 + .../res/drawable-v31/widget_button_mic.xml | 1 + .../res/drawable-v31/widget_button_pill.xml | 1 + .../drawable-v31/widget_button_primary.xml | 1 + .../drawable-v31/widget_button_secondary.xml | 1 + android/app/src/main/res/drawable/ic_hub.xml | 1 + .../main/res/drawable/ic_widget_camera.xml | 1 + .../main/res/drawable/ic_widget_clipboard.xml | 1 + .../src/main/res/drawable/ic_widget_mic.xml | 1 + .../res/drawable/ic_widget_mic_accent.xml | 1 + .../main/res/drawable/ic_widget_photos.xml | 1 + .../main/res/drawable/ic_widget_waveform.xml | 1 + .../main/res/drawable/widget_background.xml | 1 + .../res/drawable/widget_button_circle.xml | 1 + .../main/res/drawable/widget_button_mic.xml | 1 + .../main/res/drawable/widget_button_pill.xml | 1 + .../res/drawable/widget_button_primary.xml | 1 + .../res/drawable/widget_button_secondary.xml | 1 + .../src/main/res/drawable/widget_preview.xml | 1 + .../src/main/res/layout/conduit_widget.xml | 1 + .../app/src/main/res/values-night/colors.xml | 1 + .../app/src/main/res/values-night/dimens.xml | 1 + android/app/src/main/res/values/colors.xml | 1 + android/app/src/main/res/values/dimens.xml | 1 + android/app/src/main/res/values/strings.xml | 1 + .../src/main/res/xml/conduit_widget_info.xml | 1 + .../AccentColor.colorset/Contents.json | 1 + .../Assets.xcassets/Contents.json | 1 + .../HubIcon.imageset/Contents.json | 1 + .../Assets.xcassets/HubIcon.imageset/hub.svg | 1 + .../WidgetBackground.colorset/Contents.json | 1 + ios/ConduitWidget/ConduitWidget.entitlements | 1 + ios/ConduitWidget/Info.plist | 1 + ios/Runner/AppDelegate.swift | 53 ++ ios/Runner/de.lproj/InfoPlist.strings | 1 + ios/Runner/en.lproj/InfoPlist.strings | 1 + ios/Runner/es.lproj/InfoPlist.strings | 1 + ios/Runner/fr.lproj/InfoPlist.strings | 1 + ios/Runner/it.lproj/InfoPlist.strings | 1 + ios/Runner/ko.lproj/InfoPlist.strings | 1 + ios/Runner/nl.lproj/InfoPlist.strings | 1 + ios/Runner/ru.lproj/InfoPlist.strings | 1 + ios/Runner/zh-Hans.lproj/InfoPlist.strings | 1 + ios/Runner/zh-Hant.lproj/InfoPlist.strings | 1 + lib/core/auth/native_cookie_manager.dart | 62 ++ lib/core/auth/webview_cookie_helper.dart | 66 +- lib/core/router/app_router.dart | 21 +- lib/core/services/api_service.dart | 177 ++++- lib/core/services/navigation_service.dart | 2 + lib/features/auth/views/proxy_auth_page.dart | 641 ++++++++++++++++++ .../auth/views/server_connection_page.dart | 319 ++++++++- lib/l10n/app_en.arb | 48 ++ 60 files changed, 1453 insertions(+), 22 deletions(-) create mode 100644 lib/core/auth/native_cookie_manager.dart create mode 100644 lib/features/auth/views/proxy_auth_page.dart diff --git a/android/app/src/main/kotlin/app/cogwheel/conduit/MainActivity.kt b/android/app/src/main/kotlin/app/cogwheel/conduit/MainActivity.kt index 149c004..c9c2e90 100644 --- a/android/app/src/main/kotlin/app/cogwheel/conduit/MainActivity.kt +++ b/android/app/src/main/kotlin/app/cogwheel/conduit/MainActivity.kt @@ -4,6 +4,7 @@ import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import android.os.Build import android.os.Bundle +import android.webkit.CookieManager import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsControllerCompat @@ -35,6 +36,41 @@ class MainActivity : FlutterActivity() { methodChannel = io.flutter.plugin.common.MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) + // Setup cookie manager channel for WebView cookie access + val cookieChannel = io.flutter.plugin.common.MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "com.conduit.app/cookies" + ) + + cookieChannel.setMethodCallHandler { call, result -> + if (call.method == "getCookies") { + val url = call.argument("url") + if (url == null) { + result.error("INVALID_ARGS", "Invalid URL", null) + return@setMethodCallHandler + } + + // Get cookies from Android's CookieManager (shared with WebView) + val cookieManager = CookieManager.getInstance() + val cookieString = cookieManager.getCookie(url) + + val cookieMap = mutableMapOf() + if (cookieString != null) { + // Parse cookie string: "name1=value1; name2=value2" + cookieString.split(";").forEach { cookie -> + val parts = cookie.trim().split("=", limit = 2) + if (parts.size == 2) { + cookieMap[parts[0].trim()] = parts[1].trim() + } + } + } + + result.success(cookieMap) + } else { + result.notImplemented() + } + } + // Check if started with context handleIntent(intent) } diff --git a/android/app/src/main/res/drawable-v31/ic_widget_camera.xml b/android/app/src/main/res/drawable-v31/ic_widget_camera.xml index ca3e72d..613912f 100644 --- a/android/app/src/main/res/drawable-v31/ic_widget_camera.xml +++ b/android/app/src/main/res/drawable-v31/ic_widget_camera.xml @@ -14,3 +14,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/ic_widget_clipboard.xml b/android/app/src/main/res/drawable-v31/ic_widget_clipboard.xml index 3c93bd9..9681d95 100644 --- a/android/app/src/main/res/drawable-v31/ic_widget_clipboard.xml +++ b/android/app/src/main/res/drawable-v31/ic_widget_clipboard.xml @@ -11,3 +11,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/ic_widget_mic.xml b/android/app/src/main/res/drawable-v31/ic_widget_mic.xml index 9f4acba..e313d51 100644 --- a/android/app/src/main/res/drawable-v31/ic_widget_mic.xml +++ b/android/app/src/main/res/drawable-v31/ic_widget_mic.xml @@ -12,3 +12,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/ic_widget_mic_accent.xml b/android/app/src/main/res/drawable-v31/ic_widget_mic_accent.xml index 31b19e9..6bd3007 100644 --- a/android/app/src/main/res/drawable-v31/ic_widget_mic_accent.xml +++ b/android/app/src/main/res/drawable-v31/ic_widget_mic_accent.xml @@ -12,3 +12,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/ic_widget_photos.xml b/android/app/src/main/res/drawable-v31/ic_widget_photos.xml index 3ff25a5..ebf4564 100644 --- a/android/app/src/main/res/drawable-v31/ic_widget_photos.xml +++ b/android/app/src/main/res/drawable-v31/ic_widget_photos.xml @@ -11,3 +11,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/ic_widget_waveform.xml b/android/app/src/main/res/drawable-v31/ic_widget_waveform.xml index 54d82d2..a27a5c0 100644 --- a/android/app/src/main/res/drawable-v31/ic_widget_waveform.xml +++ b/android/app/src/main/res/drawable-v31/ic_widget_waveform.xml @@ -11,3 +11,4 @@ android:pathData="M7,18h2V6H7v12zM11,22h2V2h-2v20zM3,14h2v-4H3v4zM15,18h2V6h-2v12zM19,10v4h2v-4h-2z" /> + diff --git a/android/app/src/main/res/drawable-v31/widget_background.xml b/android/app/src/main/res/drawable-v31/widget_background.xml index b5fd0f3..a8819d2 100644 --- a/android/app/src/main/res/drawable-v31/widget_background.xml +++ b/android/app/src/main/res/drawable-v31/widget_background.xml @@ -7,3 +7,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/widget_button_circle.xml b/android/app/src/main/res/drawable-v31/widget_button_circle.xml index c67d351..8d8a59d 100644 --- a/android/app/src/main/res/drawable-v31/widget_button_circle.xml +++ b/android/app/src/main/res/drawable-v31/widget_button_circle.xml @@ -10,3 +10,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/widget_button_mic.xml b/android/app/src/main/res/drawable-v31/widget_button_mic.xml index d60f226..644d7c1 100644 --- a/android/app/src/main/res/drawable-v31/widget_button_mic.xml +++ b/android/app/src/main/res/drawable-v31/widget_button_mic.xml @@ -10,3 +10,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/widget_button_pill.xml b/android/app/src/main/res/drawable-v31/widget_button_pill.xml index f5aa082..e538a1a 100644 --- a/android/app/src/main/res/drawable-v31/widget_button_pill.xml +++ b/android/app/src/main/res/drawable-v31/widget_button_pill.xml @@ -11,3 +11,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/widget_button_primary.xml b/android/app/src/main/res/drawable-v31/widget_button_primary.xml index 9cf73cb..2481c0c 100644 --- a/android/app/src/main/res/drawable-v31/widget_button_primary.xml +++ b/android/app/src/main/res/drawable-v31/widget_button_primary.xml @@ -11,3 +11,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/widget_button_secondary.xml b/android/app/src/main/res/drawable-v31/widget_button_secondary.xml index 64e0a6c..bbef1b5 100644 --- a/android/app/src/main/res/drawable-v31/widget_button_secondary.xml +++ b/android/app/src/main/res/drawable-v31/widget_button_secondary.xml @@ -11,3 +11,4 @@ + diff --git a/android/app/src/main/res/drawable/ic_hub.xml b/android/app/src/main/res/drawable/ic_hub.xml index da8c6c9..4d93da6 100644 --- a/android/app/src/main/res/drawable/ic_hub.xml +++ b/android/app/src/main/res/drawable/ic_hub.xml @@ -12,3 +12,4 @@ + diff --git a/android/app/src/main/res/drawable/ic_widget_camera.xml b/android/app/src/main/res/drawable/ic_widget_camera.xml index c541b06..99a51ae 100644 --- a/android/app/src/main/res/drawable/ic_widget_camera.xml +++ b/android/app/src/main/res/drawable/ic_widget_camera.xml @@ -14,3 +14,4 @@ + diff --git a/android/app/src/main/res/drawable/ic_widget_clipboard.xml b/android/app/src/main/res/drawable/ic_widget_clipboard.xml index c3cafa4..b1a01a8 100644 --- a/android/app/src/main/res/drawable/ic_widget_clipboard.xml +++ b/android/app/src/main/res/drawable/ic_widget_clipboard.xml @@ -11,3 +11,4 @@ + diff --git a/android/app/src/main/res/drawable/ic_widget_mic.xml b/android/app/src/main/res/drawable/ic_widget_mic.xml index da11e3e..408a920 100644 --- a/android/app/src/main/res/drawable/ic_widget_mic.xml +++ b/android/app/src/main/res/drawable/ic_widget_mic.xml @@ -12,3 +12,4 @@ + diff --git a/android/app/src/main/res/drawable/ic_widget_mic_accent.xml b/android/app/src/main/res/drawable/ic_widget_mic_accent.xml index 3f70fb3..db16990 100644 --- a/android/app/src/main/res/drawable/ic_widget_mic_accent.xml +++ b/android/app/src/main/res/drawable/ic_widget_mic_accent.xml @@ -12,3 +12,4 @@ + diff --git a/android/app/src/main/res/drawable/ic_widget_photos.xml b/android/app/src/main/res/drawable/ic_widget_photos.xml index d3d4b5b..13bb9d1 100644 --- a/android/app/src/main/res/drawable/ic_widget_photos.xml +++ b/android/app/src/main/res/drawable/ic_widget_photos.xml @@ -11,3 +11,4 @@ + diff --git a/android/app/src/main/res/drawable/ic_widget_waveform.xml b/android/app/src/main/res/drawable/ic_widget_waveform.xml index c0e3793..9873bb0 100644 --- a/android/app/src/main/res/drawable/ic_widget_waveform.xml +++ b/android/app/src/main/res/drawable/ic_widget_waveform.xml @@ -11,3 +11,4 @@ android:pathData="M7,18h2V6H7v12zM11,22h2V2h-2v20zM3,14h2v-4H3v4zM15,18h2V6h-2v12zM19,10v4h2v-4h-2z" /> + diff --git a/android/app/src/main/res/drawable/widget_background.xml b/android/app/src/main/res/drawable/widget_background.xml index 2b74004..3145b11 100644 --- a/android/app/src/main/res/drawable/widget_background.xml +++ b/android/app/src/main/res/drawable/widget_background.xml @@ -7,3 +7,4 @@ + diff --git a/android/app/src/main/res/drawable/widget_button_circle.xml b/android/app/src/main/res/drawable/widget_button_circle.xml index 54edb3b..dd0e8fc 100644 --- a/android/app/src/main/res/drawable/widget_button_circle.xml +++ b/android/app/src/main/res/drawable/widget_button_circle.xml @@ -10,3 +10,4 @@ + diff --git a/android/app/src/main/res/drawable/widget_button_mic.xml b/android/app/src/main/res/drawable/widget_button_mic.xml index b451005..1a3344c 100644 --- a/android/app/src/main/res/drawable/widget_button_mic.xml +++ b/android/app/src/main/res/drawable/widget_button_mic.xml @@ -10,3 +10,4 @@ + diff --git a/android/app/src/main/res/drawable/widget_button_pill.xml b/android/app/src/main/res/drawable/widget_button_pill.xml index 11362b8..eaf88ee 100644 --- a/android/app/src/main/res/drawable/widget_button_pill.xml +++ b/android/app/src/main/res/drawable/widget_button_pill.xml @@ -11,3 +11,4 @@ + diff --git a/android/app/src/main/res/drawable/widget_button_primary.xml b/android/app/src/main/res/drawable/widget_button_primary.xml index f2b8aa7..6625113 100644 --- a/android/app/src/main/res/drawable/widget_button_primary.xml +++ b/android/app/src/main/res/drawable/widget_button_primary.xml @@ -11,3 +11,4 @@ + diff --git a/android/app/src/main/res/drawable/widget_button_secondary.xml b/android/app/src/main/res/drawable/widget_button_secondary.xml index 351db3e..aaa263d 100644 --- a/android/app/src/main/res/drawable/widget_button_secondary.xml +++ b/android/app/src/main/res/drawable/widget_button_secondary.xml @@ -11,3 +11,4 @@ + diff --git a/android/app/src/main/res/drawable/widget_preview.xml b/android/app/src/main/res/drawable/widget_preview.xml index f8e9a4a..b89f5d2 100644 --- a/android/app/src/main/res/drawable/widget_preview.xml +++ b/android/app/src/main/res/drawable/widget_preview.xml @@ -53,3 +53,4 @@ + diff --git a/android/app/src/main/res/layout/conduit_widget.xml b/android/app/src/main/res/layout/conduit_widget.xml index 6b52940..8f8b4f1 100644 --- a/android/app/src/main/res/layout/conduit_widget.xml +++ b/android/app/src/main/res/layout/conduit_widget.xml @@ -129,3 +129,4 @@ + diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml index a6fe3e2..984cd08 100644 --- a/android/app/src/main/res/values-night/colors.xml +++ b/android/app/src/main/res/values-night/colors.xml @@ -30,3 +30,4 @@ + diff --git a/android/app/src/main/res/values-night/dimens.xml b/android/app/src/main/res/values-night/dimens.xml index 27e1c62..8a0f40d 100644 --- a/android/app/src/main/res/values-night/dimens.xml +++ b/android/app/src/main/res/values-night/dimens.xml @@ -9,3 +9,4 @@ + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 2dad92a..f4e89eb 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -30,3 +30,4 @@ + diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml index 1ad875a..f86f067 100644 --- a/android/app/src/main/res/values/dimens.xml +++ b/android/app/src/main/res/values/dimens.xml @@ -9,3 +9,4 @@ + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index fc9066f..a156457 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -13,3 +13,4 @@ + diff --git a/android/app/src/main/res/xml/conduit_widget_info.xml b/android/app/src/main/res/xml/conduit_widget_info.xml index 2032113..f0c92ac 100644 --- a/android/app/src/main/res/xml/conduit_widget_info.xml +++ b/android/app/src/main/res/xml/conduit_widget_info.xml @@ -17,3 +17,4 @@ android:widgetFeatures="reconfigurable" /> + diff --git a/ios/ConduitWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/ConduitWidget/Assets.xcassets/AccentColor.colorset/Contents.json index 94cd704..2c12335 100644 --- a/ios/ConduitWidget/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/ios/ConduitWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -20,3 +20,4 @@ } + diff --git a/ios/ConduitWidget/Assets.xcassets/Contents.json b/ios/ConduitWidget/Assets.xcassets/Contents.json index f43ce43..509ef7e 100644 --- a/ios/ConduitWidget/Assets.xcassets/Contents.json +++ b/ios/ConduitWidget/Assets.xcassets/Contents.json @@ -6,3 +6,4 @@ } + diff --git a/ios/ConduitWidget/Assets.xcassets/HubIcon.imageset/Contents.json b/ios/ConduitWidget/Assets.xcassets/HubIcon.imageset/Contents.json index c1352e2..c3e4b5c 100644 --- a/ios/ConduitWidget/Assets.xcassets/HubIcon.imageset/Contents.json +++ b/ios/ConduitWidget/Assets.xcassets/HubIcon.imageset/Contents.json @@ -16,3 +16,4 @@ } + diff --git a/ios/ConduitWidget/Assets.xcassets/HubIcon.imageset/hub.svg b/ios/ConduitWidget/Assets.xcassets/HubIcon.imageset/hub.svg index 5239cb1..6d1073e 100644 --- a/ios/ConduitWidget/Assets.xcassets/HubIcon.imageset/hub.svg +++ b/ios/ConduitWidget/Assets.xcassets/HubIcon.imageset/hub.svg @@ -1,3 +1,4 @@ + diff --git a/ios/ConduitWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/ios/ConduitWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json index 1d81132..7461877 100644 --- a/ios/ConduitWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json +++ b/ios/ConduitWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -38,3 +38,4 @@ } + diff --git a/ios/ConduitWidget/ConduitWidget.entitlements b/ios/ConduitWidget/ConduitWidget.entitlements index 4f2f739..46f015b 100644 --- a/ios/ConduitWidget/ConduitWidget.entitlements +++ b/ios/ConduitWidget/ConduitWidget.entitlements @@ -10,3 +10,4 @@ + diff --git a/ios/ConduitWidget/Info.plist b/ios/ConduitWidget/Info.plist index 47e1784..b330d5a 100644 --- a/ios/ConduitWidget/Info.plist +++ b/ios/ConduitWidget/Info.plist @@ -11,3 +11,4 @@ + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 14c9fc5..95b1462 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -4,6 +4,7 @@ import Flutter import AppIntents import UIKit import UniformTypeIdentifiers +import WebKit final class VoiceBackgroundAudioManager { static let shared = VoiceBackgroundAudioManager() @@ -613,6 +614,18 @@ struct AppShortcuts: AppShortcutsProvider { @objc class AppDelegate: FlutterAppDelegate { private var backgroundStreamingHandler: BackgroundStreamingHandler? + /// Checks if a cookie matches a given URL based on domain. + private func cookieMatchesUrl(cookie: HTTPCookie, url: URL) -> Bool { + guard let host = url.host?.lowercased() else { return false } + let domain = cookie.domain.lowercased() + + // Remove leading dot from cookie domain if present + let cleanDomain = domain.hasPrefix(".") ? String(domain.dropFirst()) : domain + + // Exact match or subdomain match + return host == cleanDomain || host.hasSuffix(".\(cleanDomain)") + } + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -645,6 +658,46 @@ struct AppShortcuts: AppShortcutsProvider { } } + // Setup cookie manager channel for WebView cookie access + if let registrar = self.registrar(forPlugin: "CookieManagerChannel") { + let cookieChannel = FlutterMethodChannel( + name: "com.conduit.app/cookies", + binaryMessenger: registrar.messenger() + ) + + cookieChannel.setMethodCallHandler { [weak self] (call, result) in + if call.method == "getCookies" { + guard let args = call.arguments as? [String: Any], + let urlString = args["url"] as? String, + let url = URL(string: urlString) else { + result(FlutterError(code: "INVALID_ARGS", message: "Invalid URL", details: nil)) + return + } + + // Get cookies from WKWebView's cookie store + WKWebsiteDataStore.default().httpCookieStore.getAllCookies { [weak self] cookies in + guard let self = self else { + // Always call result to avoid leaving Dart side hanging + result([:]) + return + } + var cookieDict: [String: String] = [:] + + for cookie in cookies { + // Filter cookies for this domain + if self.cookieMatchesUrl(cookie: cookie, url: url) { + cookieDict[cookie.name] = cookie.value + } + } + + result(cookieDict) + } + } else { + result(FlutterMethodNotImplemented) + } + } + } + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } diff --git a/ios/Runner/de.lproj/InfoPlist.strings b/ios/Runner/de.lproj/InfoPlist.strings index 345c5b6..fe5b0cf 100644 --- a/ios/Runner/de.lproj/InfoPlist.strings +++ b/ios/Runner/de.lproj/InfoPlist.strings @@ -1,3 +1,4 @@ /* Localized versions of Info.plist keys */ CFBundleDisplayName = "Conduit"; + diff --git a/ios/Runner/en.lproj/InfoPlist.strings b/ios/Runner/en.lproj/InfoPlist.strings index 345c5b6..fe5b0cf 100644 --- a/ios/Runner/en.lproj/InfoPlist.strings +++ b/ios/Runner/en.lproj/InfoPlist.strings @@ -1,3 +1,4 @@ /* Localized versions of Info.plist keys */ CFBundleDisplayName = "Conduit"; + diff --git a/ios/Runner/es.lproj/InfoPlist.strings b/ios/Runner/es.lproj/InfoPlist.strings index 345c5b6..fe5b0cf 100644 --- a/ios/Runner/es.lproj/InfoPlist.strings +++ b/ios/Runner/es.lproj/InfoPlist.strings @@ -1,3 +1,4 @@ /* Localized versions of Info.plist keys */ CFBundleDisplayName = "Conduit"; + diff --git a/ios/Runner/fr.lproj/InfoPlist.strings b/ios/Runner/fr.lproj/InfoPlist.strings index 345c5b6..fe5b0cf 100644 --- a/ios/Runner/fr.lproj/InfoPlist.strings +++ b/ios/Runner/fr.lproj/InfoPlist.strings @@ -1,3 +1,4 @@ /* Localized versions of Info.plist keys */ CFBundleDisplayName = "Conduit"; + diff --git a/ios/Runner/it.lproj/InfoPlist.strings b/ios/Runner/it.lproj/InfoPlist.strings index 345c5b6..fe5b0cf 100644 --- a/ios/Runner/it.lproj/InfoPlist.strings +++ b/ios/Runner/it.lproj/InfoPlist.strings @@ -1,3 +1,4 @@ /* Localized versions of Info.plist keys */ CFBundleDisplayName = "Conduit"; + diff --git a/ios/Runner/ko.lproj/InfoPlist.strings b/ios/Runner/ko.lproj/InfoPlist.strings index 345c5b6..fe5b0cf 100644 --- a/ios/Runner/ko.lproj/InfoPlist.strings +++ b/ios/Runner/ko.lproj/InfoPlist.strings @@ -1,3 +1,4 @@ /* Localized versions of Info.plist keys */ CFBundleDisplayName = "Conduit"; + diff --git a/ios/Runner/nl.lproj/InfoPlist.strings b/ios/Runner/nl.lproj/InfoPlist.strings index 345c5b6..fe5b0cf 100644 --- a/ios/Runner/nl.lproj/InfoPlist.strings +++ b/ios/Runner/nl.lproj/InfoPlist.strings @@ -1,3 +1,4 @@ /* Localized versions of Info.plist keys */ CFBundleDisplayName = "Conduit"; + diff --git a/ios/Runner/ru.lproj/InfoPlist.strings b/ios/Runner/ru.lproj/InfoPlist.strings index 345c5b6..fe5b0cf 100644 --- a/ios/Runner/ru.lproj/InfoPlist.strings +++ b/ios/Runner/ru.lproj/InfoPlist.strings @@ -1,3 +1,4 @@ /* Localized versions of Info.plist keys */ CFBundleDisplayName = "Conduit"; + diff --git a/ios/Runner/zh-Hans.lproj/InfoPlist.strings b/ios/Runner/zh-Hans.lproj/InfoPlist.strings index 345c5b6..fe5b0cf 100644 --- a/ios/Runner/zh-Hans.lproj/InfoPlist.strings +++ b/ios/Runner/zh-Hans.lproj/InfoPlist.strings @@ -1,3 +1,4 @@ /* Localized versions of Info.plist keys */ CFBundleDisplayName = "Conduit"; + diff --git a/ios/Runner/zh-Hant.lproj/InfoPlist.strings b/ios/Runner/zh-Hant.lproj/InfoPlist.strings index 345c5b6..fe5b0cf 100644 --- a/ios/Runner/zh-Hant.lproj/InfoPlist.strings +++ b/ios/Runner/zh-Hant.lproj/InfoPlist.strings @@ -1,3 +1,4 @@ /* Localized versions of Info.plist keys */ CFBundleDisplayName = "Conduit"; + diff --git a/lib/core/auth/native_cookie_manager.dart b/lib/core/auth/native_cookie_manager.dart new file mode 100644 index 0000000..de95829 --- /dev/null +++ b/lib/core/auth/native_cookie_manager.dart @@ -0,0 +1,62 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../utils/debug_logger.dart'; + +/// Native cookie manager for accessing cookies from the platform's cookie store. +/// +/// On iOS, this accesses WKHTTPCookieStore (shared with WKWebView). +/// On Android, this accesses CookieManager (shared with WebView). +/// +/// This is necessary because dart:io HttpClient has its own isolated cookie +/// store that doesn't share with WebView. +class NativeCookieManager { + static const _channel = MethodChannel('com.conduit.app/cookies'); + + /// Gets all cookies for a given URL from the native cookie store. + /// + /// Returns a map of cookie name -> value. + /// Returns empty map on web or if native method fails. + static Future> getCookiesForUrl(String url) async { + if (kIsWeb) return {}; + + try { + final result = await _channel.invokeMethod>( + 'getCookies', + {'url': url}, + ); + + if (result == null) return {}; + + final cookies = {}; + for (final entry in result.entries) { + cookies[entry.key.toString()] = entry.value.toString(); + } + + DebugLogger.auth('Retrieved ${cookies.length} cookies from native store'); + return cookies; + } on MissingPluginException { + // Platform channels not implemented - fall back gracefully + DebugLogger.log( + 'Native cookie manager not available on this platform', + scope: 'auth/cookies', + ); + return {}; + } catch (e) { + DebugLogger.warning( + 'Failed to get native cookies', + scope: 'auth/cookies', + data: {'error': e.toString()}, + ); + return {}; + } + } + + /// Formats cookies as a Cookie header string. + static String formatCookieHeader(Map cookies) { + if (cookies.isEmpty) return ''; + return cookies.entries.map((e) => '${e.key}=${e.value}').join('; '); + } +} + + diff --git a/lib/core/auth/webview_cookie_helper.dart b/lib/core/auth/webview_cookie_helper.dart index 7245e71..8c60225 100644 --- a/lib/core/auth/webview_cookie_helper.dart +++ b/lib/core/auth/webview_cookie_helper.dart @@ -11,7 +11,7 @@ import '../utils/debug_logger.dart'; bool get isWebViewSupported => !kIsWeb && (Platform.isIOS || Platform.isAndroid); -/// Helper for clearing WebView data on supported platforms. +/// Helper for managing WebView data and cookies. /// /// This is isolated in its own file to prevent platform coupling issues /// when the webview_flutter package isn't available. @@ -71,4 +71,68 @@ class WebViewCookieHelper { return success; } + + /// Gets cookies from a WebView controller via JavaScript. + /// + /// This can be used to extract session cookies set by proxy authentication + /// and pass them to HTTP clients like Dio. + /// + /// Note: Only works for cookies without the HttpOnly flag. + /// For HttpOnly cookies, iOS/Android platforms may share cookies + /// automatically through the shared cookie store. + /// + /// Returns a map of cookie names to values, or empty map if unavailable. + static Future> getCookiesFromController( + WebViewController controller, + ) async { + if (!isWebViewSupported) return {}; + + try { + final result = await controller.runJavaScriptReturningResult( + 'document.cookie', + ); + + final cookieString = result.toString(); + // Remove surrounding quotes if present + final cleaned = + cookieString.startsWith('"') && cookieString.endsWith('"') + ? cookieString.substring(1, cookieString.length - 1) + : cookieString; + + if (cleaned.isEmpty || cleaned == 'null') return {}; + + final cookieMap = {}; + final pairs = cleaned.split(';'); + for (final pair in pairs) { + final trimmed = pair.trim(); + final idx = trimmed.indexOf('='); + if (idx > 0) { + final name = trimmed.substring(0, idx).trim(); + final value = trimmed.substring(idx + 1).trim(); + cookieMap[name] = value; + } + } + + DebugLogger.auth( + 'Retrieved ${cookieMap.length} cookies from WebView', + ); + return cookieMap; + } catch (e) { + DebugLogger.warning( + 'webview-get-cookies-failed', + scope: 'auth/webview', + data: {'error': e.toString()}, + ); + return {}; + } + } + + /// Formats cookies as a Cookie header string. + /// + /// This converts a map of cookie names to values into a properly formatted + /// Cookie header that can be sent with HTTP requests. + static String formatCookieHeader(Map cookies) { + if (cookies.isEmpty) return ''; + return cookies.entries.map((e) => '${e.key}=${e.value}').join('; '); + } } diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 3261002..3a99ad6 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -13,6 +13,7 @@ import '../../features/chat/providers/chat_providers.dart'; import '../../features/auth/views/authentication_page.dart'; import '../../features/auth/views/connect_signin_page.dart'; import '../../features/auth/views/connection_issue_page.dart'; +import '../../features/auth/views/proxy_auth_page.dart'; import '../../features/auth/views/server_connection_page.dart'; import '../../features/auth/views/sso_auth_page.dart'; import '../../features/chat/views/chat_page.dart'; @@ -94,10 +95,13 @@ class RouterNotifier extends ChangeNotifier { final hasActiveServer = activeServer != null; if (!hasActiveServer) { // No server configured - redirect to server connection - // Exception: allow staying on server connection or authentication pages + // Exception: allow staying on server connection, authentication, + // proxy auth, and SSO pages during the connection/auth flow. // But always redirect away from connection issue page (user logged out) if (location == Routes.serverConnection || location == Routes.authentication || + location == Routes.proxyAuth || + location == Routes.ssoAuth || location == Routes.login) { return null; } @@ -181,7 +185,8 @@ class RouterNotifier extends ChangeNotifier { location == Routes.login || location == Routes.authentication || location == Routes.connectionIssue || - location == Routes.ssoAuth; + location == Routes.ssoAuth || + location == Routes.proxyAuth; } @override @@ -256,6 +261,18 @@ final goRouterProvider = Provider((ref) { ); }, ), + GoRoute( + path: Routes.proxyAuth, + name: RouteNames.proxyAuth, + builder: (context, state) { + final config = state.extra; + if (config is! ProxyAuthConfig) { + // Fallback - should not happen in normal flow + return const ServerConnectionPage(); + } + return ProxyAuthPage(config: config); + }, + ), GoRoute( path: Routes.profile, name: RouteNames.profile, diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 2ec01be..b38a92f 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -33,6 +33,28 @@ void _traceApi(String message) { DebugLogger.log(message, scope: 'api/trace'); } +/// Result of a health check with proxy detection. +/// +/// This enum distinguishes between different failure modes: +/// - [healthy]: Server is reachable and responding normally +/// - [unhealthy]: Server responded but not with expected status +/// - [proxyAuthRequired]: Server is behind an auth proxy (oauth2-proxy, etc.) +/// - [unreachable]: Server could not be reached at all +enum HealthCheckResult { + /// Server is healthy and responding normally + healthy, + + /// Server responded but not with expected status + unhealthy, + + /// Server appears to be behind an authentication proxy + /// (detected via redirect or HTML login page response) + proxyAuthRequired, + + /// Server could not be reached + unreachable, +} + /// Converts ChatSourceReference list back to OpenWebUI's expected format. /// OpenWebUI expects: { source: {...}, document: [...], metadata: [...] } /// But ChatSourceReference stores: { id, title, url, snippet, type, metadata } @@ -114,9 +136,12 @@ List> _convertCodeExecutionsToOpenWebUIFormat( // Convert the result if present if (exec.result != null) { final execResult = {}; - if (exec.result!.output != null) + if (exec.result!.output != null) { execResult['output'] = exec.result!.output; - if (exec.result!.error != null) execResult['error'] = exec.result!.error; + } + if (exec.result!.error != null) { + execResult['error'] = exec.result!.error; + } if (exec.result!.files.isNotEmpty) { execResult['files'] = exec.result!.files .map( @@ -330,6 +355,154 @@ class ApiService { } } + /// Health check with proxy detection. + /// + /// This method detects when the server is behind an authentication proxy + /// (like oauth2-proxy) by checking for: + /// - HTTP redirects (302, 307, 308) to login pages + /// - HTML responses instead of expected JSON/text + /// + /// When a proxy is detected, returns [HealthCheckResult.proxyAuthRequired] + /// so the app can show a WebView for proxy authentication. + Future checkHealthWithProxyDetection() async { + try { + // Create a temporary Dio instance that doesn't follow redirects + // so we can detect proxy redirects + final tempDio = Dio( + BaseOptions( + baseUrl: serverConfig.url, + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 15), + followRedirects: false, + validateStatus: (status) => true, // Accept all status codes + headers: serverConfig.customHeaders.isNotEmpty + ? Map.from(serverConfig.customHeaders) + : null, + ), + ); + + // Configure self-signed cert support if needed + if (!kIsWeb && serverConfig.allowSelfSignedCertificates) { + final baseUri = _parseBaseUri(serverConfig.url); + if (baseUri != null) { + final host = baseUri.host.toLowerCase(); + final port = baseUri.hasPort ? baseUri.port : null; + (tempDio.httpClientAdapter as IOHttpClientAdapter) + .createHttpClient = () { + final client = HttpClient(); + client.badCertificateCallback = + (X509Certificate cert, String requestHost, int requestPort) { + if (requestHost.toLowerCase() != host) return false; + if (port == null) return true; + return requestPort == port; + }; + return client; + }; + } + } + + final response = await tempDio.get('/health'); + final statusCode = response.statusCode ?? 0; + + DebugLogger.log( + 'Proxy detection health check: status=$statusCode', + scope: 'api/proxy-detect', + ); + + // Check for redirects (proxy authentication pages) + if (statusCode == 302 || statusCode == 307 || statusCode == 308) { + final location = response.headers.value('location'); + DebugLogger.log( + 'Detected redirect to: $location - likely proxy auth required', + scope: 'api/proxy-detect', + ); + return HealthCheckResult.proxyAuthRequired; + } + + // Check for 401/403 which may indicate proxy auth + if (statusCode == 401 || statusCode == 403) { + // Check if the response is HTML (proxy login page) + final contentType = response.headers.value('content-type') ?? ''; + if (contentType.contains('text/html')) { + DebugLogger.log( + 'Detected HTML response on 401/403 - likely proxy auth required', + scope: 'api/proxy-detect', + ); + return HealthCheckResult.proxyAuthRequired; + } + } + + // Check for successful response + if (statusCode == 200) { + // Verify it's not an HTML login page masquerading as 200 + final contentType = response.headers.value('content-type') ?? ''; + final data = response.data; + + // OpenWebUI's /health returns {"status": true} or plain "true" + // If we get HTML, it's probably a proxy login page + if (contentType.contains('text/html')) { + // OpenWebUI's /health returns JSON, not HTML. + // Any HTML response indicates a proxy page or misconfiguration. + final htmlContent = data?.toString().toLowerCase() ?? ''; + final hasLoginKeywords = htmlContent.contains('login') || + htmlContent.contains('sign in') || + htmlContent.contains('authenticate') || + htmlContent.contains('oauth'); + + DebugLogger.log( + 'Detected HTML response on /health - ' + '${hasLoginKeywords ? 'login page detected' : 'unexpected HTML'}', + scope: 'api/proxy-detect', + ); + + // All HTML responses suggest proxy auth is needed + // (either login page or custom proxy page) + return HealthCheckResult.proxyAuthRequired; + } + + return HealthCheckResult.healthy; + } + + return HealthCheckResult.unhealthy; + } on DioException catch (e) { + DebugLogger.log( + 'Proxy detection failed with DioException: ${e.type}', + scope: 'api/proxy-detect', + ); + + // Connection errors mean unreachable + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.connectionError || + e.type == DioExceptionType.unknown) { + return HealthCheckResult.unreachable; + } + + // Check if response indicates proxy + final response = e.response; + if (response != null) { + final statusCode = response.statusCode ?? 0; + if (statusCode == 302 || statusCode == 307 || statusCode == 308) { + return HealthCheckResult.proxyAuthRequired; + } + + final contentType = response.headers.value('content-type') ?? ''; + if (contentType.contains('text/html') && + (statusCode == 401 || statusCode == 403 || statusCode == 200)) { + return HealthCheckResult.proxyAuthRequired; + } + } + + return HealthCheckResult.unreachable; + } catch (e) { + DebugLogger.error( + 'proxy-detection-failed', + scope: 'api/proxy-detect', + error: e, + ); + return HealthCheckResult.unreachable; + } + } + /// Verifies this is actually an OpenWebUI server by checking the /api/config /// endpoint for OpenWebUI-specific fields (version, status, features). /// diff --git a/lib/core/services/navigation_service.dart b/lib/core/services/navigation_service.dart index 573fd09..c0b09e4 100644 --- a/lib/core/services/navigation_service.dart +++ b/lib/core/services/navigation_service.dart @@ -102,6 +102,7 @@ class Routes { static const String connectionIssue = '/connection-issue'; static const String authentication = '/authentication'; static const String ssoAuth = '/sso-auth'; + static const String proxyAuth = '/proxy-auth'; static const String profile = '/profile'; static const String appCustomization = '/profile/customization'; static const String notes = '/notes'; @@ -117,6 +118,7 @@ class RouteNames { static const String connectionIssue = 'connection-issue'; static const String authentication = 'authentication'; static const String ssoAuth = 'sso-auth'; + static const String proxyAuth = 'proxy-auth'; static const String profile = 'profile'; static const String appCustomization = 'app-customization'; static const String notes = 'notes'; diff --git a/lib/features/auth/views/proxy_auth_page.dart b/lib/features/auth/views/proxy_auth_page.dart new file mode 100644 index 0000000..52722dd --- /dev/null +++ b/lib/features/auth/views/proxy_auth_page.dart @@ -0,0 +1,641 @@ +import 'dart:io' show Platform; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +import '../../../core/auth/native_cookie_manager.dart'; +import '../../../core/auth/webview_cookie_helper.dart'; +import '../../../core/models/server_config.dart'; +import '../../../core/utils/debug_logger.dart'; +import '../../../core/widgets/error_boundary.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import '../../../shared/widgets/conduit_components.dart'; +import 'package:conduit/l10n/app_localizations.dart'; + +/// Result of proxy authentication. +class ProxyAuthResult { + /// Whether authentication was successful. + final bool success; + + /// Proxy session cookies to be injected into API requests. + final Map? cookies; + + /// JWT token if user is already authenticated via trusted headers. + /// When oauth2-proxy uses trusted headers, OpenWebUI auto-authenticates + /// the user after proxy auth, so no separate sign-in is needed. + final String? jwtToken; + + const ProxyAuthResult({required this.success, this.cookies, this.jwtToken}); + + /// Creates a failed result. + const ProxyAuthResult.failed() + : success = false, + cookies = null, + jwtToken = null; + + /// Creates a successful result with captured cookies. + const ProxyAuthResult.success({this.cookies, this.jwtToken}) : success = true; + + /// Whether the user is fully authenticated (has JWT token). + bool get isFullyAuthenticated => jwtToken != null && jwtToken!.isNotEmpty; +} + +/// Configuration for the proxy authentication flow. +class ProxyAuthConfig { + /// The server configuration to authenticate against. + final ServerConfig serverConfig; + + /// Optional callback when proxy authentication completes successfully. + final VoidCallback? onAuthComplete; + + const ProxyAuthConfig({required this.serverConfig, this.onAuthComplete}); +} + +/// Proxy Authentication page that uses a WebView to handle authentication +/// through reverse proxies like oauth2-proxy or Pangolin. +/// +/// This page loads the server URL in a WebView, allowing users to authenticate +/// through the proxy. Once the proxy auth is complete (detected by reaching +/// the actual server), the proxy session cookies are captured and returned. +/// +/// The user will then be redirected to the normal sign-in flow, where the +/// proxy cookies will be injected into API requests. +class ProxyAuthPage extends ConsumerStatefulWidget { + final ProxyAuthConfig config; + + const ProxyAuthPage({super.key, required this.config}); + + @override + ConsumerState createState() => _ProxyAuthPageState(); +} + +class _ProxyAuthPageState extends ConsumerState { + WebViewController? _controller; + bool _isLoading = true; + bool _cookiesCaptured = false; + String? _error; + bool _isOnTargetServer = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initializeWebView(); + }); + } + + @override + void dispose() { + _controller = null; + super.dispose(); + } + + Future _initializeWebView() async { + if (!isWebViewSupported) { + if (!mounted) return; + final l10n = AppLocalizations.of(context); + setState(() { + _error = + l10n?.proxyAuthPlatformNotSupported ?? + 'Proxy authentication requires a mobile device. ' + 'Please authenticate through a browser first.'; + _isLoading = false; + }); + return; + } + + final serverUrl = widget.config.serverConfig.url; + DebugLogger.auth('Initializing Proxy Auth WebView for $serverUrl'); + + final controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: _onPageStarted, + onPageFinished: _onPageFinished, + onWebResourceError: _onWebResourceError, + onNavigationRequest: _onNavigationRequest, + ), + ) + ..setUserAgent(_buildUserAgent()); + + // Don't clear cookies - preserve any existing proxy session + if (!mounted) return; + + // Load the server URL - the proxy will intercept and show its login + await controller.loadRequest(Uri.parse(serverUrl)); + + if (!mounted) return; + + setState(() { + _controller = controller; + }); + } + + String _buildUserAgent() { + if (!kIsWeb && Platform.isIOS) { + return 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' + 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; + } else { + return 'Mozilla/5.0 (Linux; Android 14) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'; + } + } + + void _onPageStarted(String url) { + if (!mounted) return; + DebugLogger.auth('Proxy auth page started: $url'); + setState(() { + _isLoading = true; + _error = null; + }); + } + + Future _onPageFinished(String url) async { + if (!mounted) return; + DebugLogger.auth('Proxy auth page finished: $url'); + + setState(() { + _isLoading = false; + }); + + if (_cookiesCaptured) return; + + final uri = Uri.parse(url); + + // Check for error parameter + final error = uri.queryParameters['error']; + if (error != null && error.isNotEmpty) { + DebugLogger.auth('Proxy auth error from URL: $error'); + setState(() { + _error = error; + }); + return; + } + + // Check if we're on our target server + final serverUrl = widget.config.serverConfig.url; + final serverUri = Uri.parse(serverUrl); + if (uri.host == serverUri.host) { + // We've reached our server - proxy auth must be complete + _isOnTargetServer = true; + await _checkIfOpenWebUI(); + } + } + + /// Checks if we're on the OpenWebUI page and captures cookies if so. + Future _checkIfOpenWebUI() async { + if (_cookiesCaptured || !mounted) return; + + final controller = _controller; + if (controller == null) return; + + try { + // Check if this is an OpenWebUI page by looking for specific elements + // or the /api/config endpoint being accessible + final result = await controller.runJavaScriptReturningResult( + ''' + (function() { + // Check for OpenWebUI specific elements or title + var isOpenWebUI = + document.querySelector('div[class*="chat"]') !== null || + document.querySelector('[data-testid]') !== null || + document.title.toLowerCase().includes('open webui') || + document.title.toLowerCase().includes('chat'); + return isOpenWebUI ? "true" : "false"; + })() + ''', + ); + + if (!mounted) return; + + final isOpenWebUI = result.toString().contains('true'); + DebugLogger.auth( + 'OpenWebUI detection: $isOpenWebUI (on target server: $_isOnTargetServer)', + ); + + // If we're on the target server, capture cookies + // The user might be on a login page or the main page + if (_isOnTargetServer) { + await _captureProxyCookies(); + } + } catch (e) { + DebugLogger.log( + 'OpenWebUI detection failed: ${e.toString().split('\n').first}', + scope: 'auth/proxy', + ); + + // If detection fails but we're on target server, still try to capture + if (_isOnTargetServer) { + try { + await _captureProxyCookies(); + } catch (captureError) { + if (!mounted) return; + setState(() { + _error = captureError.toString(); + }); + } + } + } + } + + /// Captures proxy session cookies and checks for JWT token. + /// + /// When oauth2-proxy uses trusted headers (like X-Forwarded-Email), + /// OpenWebUI auto-authenticates the user after proxy auth. In this case, + /// we can capture the JWT token and skip the sign-in page entirely. + Future _captureProxyCookies() async { + if (_cookiesCaptured || !mounted) return; + + // Set flag immediately to prevent race conditions from rapid taps + // or multiple page finish events triggering concurrent calls + _cookiesCaptured = true; + + try { + final serverUrl = widget.config.serverConfig.url; + DebugLogger.auth('Capturing proxy cookies for $serverUrl'); + + // Get cookies from native cookie store + final cookies = await NativeCookieManager.getCookiesForUrl(serverUrl); + + if (!mounted) return; + + DebugLogger.auth( + 'Captured ${cookies.length} cookies: ${cookies.keys.toList()}', + ); + + if (cookies.isEmpty) { + DebugLogger.warning( + 'No cookies captured - proxy may use HttpOnly cookies not accessible', + scope: 'auth/proxy', + ); + } + + // Check if OpenWebUI has already authenticated via trusted headers + // This happens when oauth2-proxy sets X-Forwarded-Email and OpenWebUI + // auto-creates/logs in the user + String? jwtToken = await _tryCaptureJwtToken(); + + // Notify callback if provided + widget.config.onAuthComplete?.call(); + + // Pop with success result, cookies, and possibly JWT token + if (!mounted) return; + context.pop(ProxyAuthResult.success(cookies: cookies, jwtToken: jwtToken)); + } catch (e) { + // Reset flag on failure so user can retry + _cookiesCaptured = false; + DebugLogger.warning( + 'Cookie capture failed: $e', + scope: 'auth/proxy', + ); + rethrow; + } + } + + /// Attempts to capture the JWT token from cookies or localStorage. + /// + /// If the proxy uses trusted headers, OpenWebUI will have already + /// authenticated the user and set a JWT token. + Future _tryCaptureJwtToken() async { + final controller = _controller; + if (controller == null || !mounted) return null; + + // Strategy 1: Check token cookie + try { + final cookieResult = await controller.runJavaScriptReturningResult( + ''' + (function() { + var cookies = document.cookie.split(";"); + for (var i = 0; i < cookies.length; i++) { + var cookie = cookies[i].trim(); + if (cookie.startsWith("token=")) { + return cookie.substring(6); + } + } + return ""; + })() + ''', + ); + + if (!mounted) return null; + + String tokenValue = _cleanJsString(cookieResult.toString()); + if (_isValidJwtFormat(tokenValue)) { + DebugLogger.auth( + 'Found JWT token in cookie - user already authenticated via ' + 'trusted headers', + ); + return tokenValue; + } + } catch (e) { + DebugLogger.log( + 'Cookie JWT check failed: ${e.toString().split('\n').first}', + scope: 'auth/proxy', + ); + } + + if (!mounted) return null; + + // Strategy 2: Check localStorage + try { + final result = await controller.runJavaScriptReturningResult( + 'localStorage.getItem("token")', + ); + + if (!mounted) return null; + + String tokenValue = _cleanJsString(result.toString()); + if (_isValidJwtFormat(tokenValue)) { + DebugLogger.auth( + 'Found JWT token in localStorage - user already authenticated via ' + 'trusted headers', + ); + return tokenValue; + } + } catch (e) { + DebugLogger.log( + 'localStorage JWT check failed: ${e.toString().split('\n').first}', + scope: 'auth/proxy', + ); + } + + DebugLogger.auth( + 'No JWT token found - proxy may not use trusted headers, ' + 'will proceed to normal sign-in', + ); + return null; + } + + String _cleanJsString(String value) { + if (value.startsWith('"') && value.endsWith('"')) { + return value.substring(1, value.length - 1); + } + return value; + } + + bool _isValidJwtFormat(String value) { + if (value.isEmpty) return false; + final trimmed = value.trim(); + if (trimmed == 'null' || + trimmed == 'undefined' || + trimmed == 'false' || + trimmed == 'true') { + return false; + } + final segments = trimmed.split('.'); + return segments.length == 3 && trimmed.length >= 50; + } + + void _onWebResourceError(WebResourceError error) { + if (!mounted) return; + DebugLogger.error( + 'proxy-webview-error', + scope: 'auth/proxy', + data: { + 'errorCode': error.errorCode, + 'description': error.description, + 'errorType': error.errorType?.name, + }, + ); + + if (error.isForMainFrame ?? false) { + setState(() { + _error = error.description; + _isLoading = false; + }); + } + } + + NavigationDecision _onNavigationRequest(NavigationRequest request) { + final url = request.url; + DebugLogger.auth('Proxy auth navigation request: $url'); + return NavigationDecision.navigate; + } + + Future _refresh() async { + final controller = _controller; + if (controller == null || !mounted) return; + + setState(() { + _isLoading = true; + _error = null; + _cookiesCaptured = false; + _isOnTargetServer = false; + }); + + if (!mounted) return; + + await controller.loadRequest(Uri.parse(widget.config.serverConfig.url)); + } + + /// Manual completion button for when auto-detection doesn't work. + Future _manualComplete() async { + try { + await _captureProxyCookies(); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return ErrorBoundary( + child: Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + extendBodyBehindAppBar: true, + appBar: FloatingAppBar( + leading: FloatingAppBarBackButton( + onTap: () => context.pop(const ProxyAuthResult.failed()), + ), + title: FloatingAppBarTitle( + text: l10n?.proxyAuthentication ?? 'Proxy Authentication', + ), + actions: [ + if (_controller != null) + FloatingAppBarAction( + child: FloatingAppBarIconButton( + icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh, + onTap: _refresh, + ), + ), + ], + ), + body: SafeArea(child: _buildBody(l10n)), + ), + ); + } + + Widget _buildBody(AppLocalizations? l10n) { + if (_error != null) { + return _buildErrorState(l10n); + } + + if (_controller == null || !isWebViewSupported) { + return _buildLoadingState(l10n); + } + + return Stack( + children: [ + WebViewWidget(controller: _controller!), + if (_isLoading) _buildLoadingOverlay(l10n), + // Help text and manual continue button at the bottom + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _buildHelpBanner(l10n), + ), + ], + ); + } + + Widget _buildHelpBanner(AppLocalizations? l10n) { + return Container( + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer.withValues(alpha: 0.95), + border: Border( + top: BorderSide( + color: context.conduitTheme.dividerColor, + width: BorderWidth.standard, + ), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon( + Platform.isIOS ? CupertinoIcons.info : Icons.info_outline, + size: IconSize.small, + color: context.conduitTheme.iconSecondary, + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Text( + l10n?.proxyAuthHelpTextSimple ?? + 'Sign in through your proxy. Once authenticated, ' + 'tap Continue to proceed to sign in.', + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ), + ], + ), + const SizedBox(height: Spacing.sm), + SizedBox( + width: double.infinity, + child: ConduitButton( + text: l10n?.continueButton ?? 'Continue', + icon: + Platform.isIOS + ? CupertinoIcons.arrow_right + : Icons.arrow_forward, + onPressed: _manualComplete, + ), + ), + ], + ), + ); + } + + Widget _buildLoadingState(AppLocalizations? l10n) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator.adaptive(), + const SizedBox(height: Spacing.lg), + Text( + l10n?.proxyAuthLoading ?? 'Loading authentication page...', + style: context.conduitTheme.bodyMedium?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ], + ), + ); + } + + Widget _buildLoadingOverlay(AppLocalizations? l10n) { + return Positioned.fill( + child: Container( + color: context.conduitTheme.surfaceBackground.withValues(alpha: 0.8), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator.adaptive(), + const SizedBox(height: Spacing.lg), + Text( + l10n?.proxyAuthLoading ?? 'Loading...', + style: context.conduitTheme.bodyMedium?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildErrorState(AppLocalizations? l10n) { + return Padding( + padding: const EdgeInsets.all(Spacing.pagePadding), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.exclamationmark_circle + : Icons.error_outline, + size: IconSize.xxl, + color: context.conduitTheme.error, + ), + const SizedBox(height: Spacing.lg), + Text( + l10n?.proxyAuthFailed ?? 'Authentication failed', + style: context.conduitTheme.headingMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.sm), + Text( + _error ?? '', + style: context.conduitTheme.bodyMedium?.copyWith( + color: context.conduitTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.xl), + ConduitButton( + text: l10n?.retry ?? 'Retry', + icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh, + onPressed: _refresh, + ), + const SizedBox(height: Spacing.md), + ConduitButton( + text: l10n?.back ?? 'Back', + icon: Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back, + onPressed: () => context.pop(const ProxyAuthResult.failed()), + isSecondary: true, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/auth/views/server_connection_page.dart b/lib/features/auth/views/server_connection_page.dart index c612294..8aaa43e 100644 --- a/lib/features/auth/views/server_connection_page.dart +++ b/lib/features/auth/views/server_connection_page.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import 'package:uuid/uuid.dart'; import 'package:conduit/l10n/app_localizations.dart'; +import '../../../core/auth/webview_cookie_helper.dart'; import '../../../core/models/server_config.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/services/api_service.dart'; @@ -16,9 +17,11 @@ import '../../../core/services/input_validation_service.dart'; import '../../../core/services/navigation_service.dart'; import '../../../core/utils/debug_logger.dart'; import '../../../core/widgets/error_boundary.dart'; +import '../providers/unified_auth_providers.dart'; import '../../../shared/services/brand_service.dart'; import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/widgets/conduit_components.dart'; +import 'proxy_auth_page.dart'; class ServerConnectionPage extends ConsumerStatefulWidget { const ServerConnectionPage({super.key}); @@ -39,6 +42,7 @@ class _ServerConnectionPageState extends ConsumerState { bool _isConnecting = false; bool _showAdvancedSettings = false; bool _allowSelfSignedCertificates = false; + bool _serverBehindProxy = false; @override void initState() { @@ -104,19 +108,46 @@ class _ServerConnectionPageState extends ConsumerState { workerManager: workerManager, ); - // First check basic connectivity + // If user indicated server is behind proxy, go directly to proxy auth + if (_serverBehindProxy) { + DebugLogger.log( + 'User indicated server behind proxy, starting proxy auth', + scope: 'auth/connection', + ); + await _handleProxyAuth(tempConfig, api, workerManager); + return; + } + + // First check connectivity with proxy detection DebugLogger.log('Checking server health...', scope: 'auth/connection'); - final isReachable = await api.checkHealth(); + final healthResult = await api.checkHealthWithProxyDetection(); DebugLogger.log( - 'Health check result: $isReachable', + 'Health check result: $healthResult', scope: 'auth/connection', ); - if (!isReachable) { + + // Handle proxy authentication requirement + if (healthResult == HealthCheckResult.proxyAuthRequired) { + DebugLogger.log( + 'Server behind proxy detected, prompting for proxy auth', + scope: 'auth/connection', + ); + await _handleProxyAuth(tempConfig, api, workerManager); + return; + } + + if (healthResult == HealthCheckResult.unreachable) { throw Exception( 'Could not reach the server. Please check the address.', ); } + if (healthResult == HealthCheckResult.unhealthy) { + throw Exception( + 'Server responded but may not be healthy. Please try again.', + ); + } + // Then verify it's actually an OpenWebUI server and get its config DebugLogger.log( 'Verifying OpenWebUI server...', @@ -166,6 +197,204 @@ class _ServerConnectionPageState extends ConsumerState { } } + /// Handles proxy authentication flow. + /// + /// Opens the proxy auth page in a WebView where the user authenticates + /// through the proxy (oauth2-proxy, Pangolin, etc.). + /// + /// After proxy auth completes, the cookies are captured and added to + /// the server config. Then the normal authentication flow proceeds. + Future _handleProxyAuth( + ServerConfig tempConfig, + ApiService api, + WorkerManager workerManager, + ) async { + // Check if WebView is supported + if (!isWebViewSupported) { + throw Exception( + AppLocalizations.of(context)?.proxyAuthPlatformNotSupported ?? + 'Proxy authentication requires a mobile device.', + ); + } + + // Show proxy auth page + final proxyConfig = ProxyAuthConfig(serverConfig: tempConfig); + + if (!mounted) return; + + final result = await context.pushNamed( + RouteNames.proxyAuth, + extra: proxyConfig, + ); + + if (!mounted) return; + + // If user cancelled or proxy auth failed, show error + if (result == null || !result.success) { + setState(() { + _connectionError = + AppLocalizations.of(context)?.proxyAuthFailed ?? + 'Proxy authentication was cancelled or failed.'; + _isConnecting = false; + }); + return; + } + + DebugLogger.log( + 'Proxy auth completed, captured ${result.cookies?.length ?? 0} cookies, ' + 'JWT: ${result.isFullyAuthenticated}', + scope: 'auth/connection', + ); + + // Build updated headers with proxy cookies + final updatedHeaders = Map.from(tempConfig.customHeaders); + if (result.cookies != null && result.cookies!.isNotEmpty) { + // Format cookies as Cookie header + final proxyCookieHeader = result.cookies!.entries + .map((e) => '${e.key}=${e.value}') + .join('; '); + + // Merge with existing Cookie header if present (from advanced settings) + final existingCookies = updatedHeaders['Cookie']; + if (existingCookies != null && existingCookies.isNotEmpty) { + updatedHeaders['Cookie'] = '$existingCookies; $proxyCookieHeader'; + DebugLogger.log( + 'Merged ${result.cookies!.length} proxy cookies with existing Cookie header', + scope: 'auth/connection', + ); + } else { + updatedHeaders['Cookie'] = proxyCookieHeader; + DebugLogger.log( + 'Added Cookie header with ${result.cookies!.length} cookies', + scope: 'auth/connection', + ); + } + } + + // Create updated config with proxy cookies (and possibly JWT token) + final configWithCookies = ServerConfig( + id: tempConfig.id, + name: tempConfig.name, + url: tempConfig.url, + customHeaders: updatedHeaders, + isActive: tempConfig.isActive, + allowSelfSignedCertificates: tempConfig.allowSelfSignedCertificates, + // If we got a JWT token, store it as apiKey for API auth + apiKey: result.jwtToken, + ); + + // Create new API service with updated config + final apiWithCookies = ApiService( + serverConfig: configWithCookies, + workerManager: workerManager, + // If we have a JWT token, use it as auth token + authToken: result.jwtToken, + ); + + // Now verify it's an OpenWebUI server + DebugLogger.log( + 'Verifying OpenWebUI server with proxy cookies...', + scope: 'auth/connection', + ); + + final backendConfig = await apiWithCookies.verifyAndGetConfig(); + if (backendConfig == null) { + if (mounted) { + setState(() { + _connectionError = + 'Could not verify OpenWebUI server. The proxy cookies may ' + 'have expired or be invalid. Please try again.'; + _isConnecting = false; + }); + } + return; + } + + // Check if user is already fully authenticated via trusted headers + // (e.g., oauth2-proxy with X-Forwarded-Email) + if (result.isFullyAuthenticated) { + DebugLogger.log( + 'User already authenticated via trusted headers, ' + 'skipping sign-in page', + scope: 'auth/connection', + ); + + // Save the server config and go directly to chat + await _completeAuthWithToken( + configWithCookies, + result.jwtToken!, + ); + return; + } + + DebugLogger.log( + 'Server validated with proxy cookies, navigating to auth page', + scope: 'auth/connection', + ); + + if (mounted) { + final authFlowConfig = AuthFlowConfig( + serverConfig: configWithCookies, + backendConfig: backendConfig, + ); + context.pushNamed(RouteNames.authentication, extra: authFlowConfig); + } + } + + /// Completes authentication when user is already authenticated via + /// trusted headers (oauth2-proxy with X-Forwarded-Email). + Future _completeAuthWithToken( + ServerConfig serverConfig, + String token, + ) async { + try { + // Save the server config first (needed for auth actions) + await _saveServerConfig(serverConfig); + + // Use the same auth flow as SSO - loginWithApiKey handles + // saving credentials and updating auth state + final authActions = ref.read(authActionsProvider); + final success = await authActions.loginWithApiKey( + token, + rememberCredentials: true, + authType: 'proxy-sso', // Mark as proxy-obtained token + ); + + if (!mounted) return; + + if (success) { + DebugLogger.auth('Proxy SSO login successful'); + // Navigation is handled automatically by the router when auth state + // changes to authenticated. The router redirect will navigate to chat. + } else { + throw Exception('Login failed'); + } + } catch (e, stack) { + DebugLogger.error( + 'Failed to complete auth with token', + scope: 'auth/connection', + error: e, + stackTrace: stack, + ); + if (mounted) { + setState(() { + _connectionError = + 'Authentication failed. Please try signing in manually.'; + _isConnecting = false; + }); + } + } + } + + /// Saves server config (extracted from authentication_page.dart) + Future _saveServerConfig(ServerConfig config) async { + final storage = ref.read(optimizedStorageServiceProvider); + await storage.saveServerConfigs([config]); + await storage.setActiveServerId(config.id); + ref.invalidate(serverConfigsProvider); + ref.invalidate(activeServerProvider); + } + String _validateAndFormatUrl(String input) { if (input.isEmpty) { throw Exception(AppLocalizations.of(context)!.serverUrlEmpty); @@ -593,11 +822,13 @@ class _ServerConnectionPageState extends ConsumerState { } Widget _buildAdvancedSettingsContent() { + final l10n = AppLocalizations.of(context)!; return Padding( padding: const EdgeInsets.all(Spacing.md), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Self-signed certificates toggle Container( width: double.infinity, padding: const EdgeInsets.all(Spacing.md), @@ -628,9 +859,7 @@ class _ServerConnectionPageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - AppLocalizations.of( - context, - )!.allowSelfSignedCertificates, + l10n.allowSelfSignedCertificates, style: context.conduitTheme.bodySmall?.copyWith( fontWeight: FontWeight.w600, color: context.conduitTheme.textPrimary, @@ -638,9 +867,7 @@ class _ServerConnectionPageState extends ConsumerState { ), const SizedBox(height: Spacing.xs), Text( - AppLocalizations.of( - context, - )!.allowSelfSignedCertificatesDescription, + l10n.allowSelfSignedCertificatesDescription, style: context.conduitTheme.bodySmall?.copyWith( color: context.conduitTheme.textSecondary, ), @@ -661,11 +888,69 @@ class _ServerConnectionPageState extends ConsumerState { ], ), ), + // Server behind proxy toggle + Container( + width: double.infinity, + padding: const EdgeInsets.all(Spacing.md), + margin: const EdgeInsets.only(bottom: Spacing.md), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.small), + border: Border.all( + color: context.conduitTheme.dividerColor.withValues(alpha: 0.4), + width: BorderWidth.thin, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Platform.isIOS ? CupertinoIcons.shield : Icons.security, + color: context.conduitTheme.iconSecondary, + size: IconSize.small, + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.serverBehindProxy, + style: context.conduitTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + color: context.conduitTheme.textPrimary, + ), + ), + const SizedBox(height: Spacing.xs), + Text( + l10n.serverBehindProxyDescription, + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(width: Spacing.sm), + Switch.adaptive( + value: _serverBehindProxy, + onChanged: (value) { + setState(() { + _serverBehindProxy = value; + }); + }, + activeTrackColor: context.conduitTheme.buttonPrimary, + ), + ], + ), + ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - AppLocalizations.of(context)!.customHeaders, + l10n.customHeaders, style: context.conduitTheme.bodySmall?.copyWith( fontWeight: FontWeight.w500, ), @@ -683,7 +968,7 @@ class _ServerConnectionPageState extends ConsumerState { ), const SizedBox(height: Spacing.xs), Text( - AppLocalizations.of(context)!.customHeadersDescription, + l10n.customHeadersDescription, style: context.conduitTheme.bodySmall?.copyWith( color: context.conduitTheme.textSecondary, ), @@ -695,7 +980,7 @@ class _ServerConnectionPageState extends ConsumerState { Expanded( flex: 2, child: AccessibleFormField( - label: AppLocalizations.of(context)!.headerName, + label: l10n.headerName, hint: 'X-Custom-Header', controller: _headerKeyController, validator: (value) => _validateHeaderKey(value ?? ''), @@ -708,8 +993,8 @@ class _ServerConnectionPageState extends ConsumerState { Expanded( flex: 3, child: AccessibleFormField( - label: AppLocalizations.of(context)!.headerValue, - hint: AppLocalizations.of(context)!.headerValueHint, + label: l10n.headerValue, + hint: l10n.headerValueHint, controller: _headerValueController, validator: (value) => _validateHeaderValue(value ?? ''), semanticLabel: 'Enter header value', @@ -724,8 +1009,8 @@ class _ServerConnectionPageState extends ConsumerState { ? null : _addCustomHeader, tooltip: _customHeaders.length >= 10 - ? AppLocalizations.of(context)!.maximumHeadersReached - : AppLocalizations.of(context)!.addHeader, + ? l10n.maximumHeadersReached + : l10n.addHeader, backgroundColor: _customHeaders.length >= 10 ? context.conduitTheme.surfaceContainer : context.conduitTheme.buttonPrimary, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ec6355c..a8bd195 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1903,6 +1903,54 @@ "@ssoPlatformNotSupported": { "description": "Error message when SSO is attempted on an unsupported platform (desktop/web)." }, + "proxyAuthentication": "Proxy Authentication", + "@proxyAuthentication": { + "description": "Title for the proxy authentication page." + }, + "proxyAuthPlatformNotSupported": "Proxy authentication requires a mobile device. Please authenticate through a browser first.", + "@proxyAuthPlatformNotSupported": { + "description": "Error message when proxy auth is attempted on an unsupported platform." + }, + "proxyAuthLoading": "Loading authentication page...", + "@proxyAuthLoading": { + "description": "Loading message while the proxy login page loads." + }, + "proxyAuthFailed": "Proxy authentication failed", + "@proxyAuthFailed": { + "description": "Error message when proxy authentication fails." + }, + "proxyAuthHelpText": "Complete sign-in through your organization's proxy, then sign in to Open WebUI. You'll be redirected automatically.", + "@proxyAuthHelpText": { + "description": "Help text explaining how to complete proxy authentication." + }, + "proxyAuthHelpTextSimple": "Sign in through your proxy. Once authenticated, tap Continue to proceed to sign in.", + "@proxyAuthHelpTextSimple": { + "description": "Simplified help text for proxy-only authentication flow." + }, + "continueButton": "Continue", + "@continueButton": { + "description": "Generic continue button text." + }, + "proxyAuthRequired": "This server requires proxy authentication", + "@proxyAuthRequired": { + "description": "Message when server is behind an authentication proxy." + }, + "proxyAuthRequiredDescription": "Your server appears to be behind an authentication proxy (like oauth2-proxy). You'll need to sign in through the proxy first.", + "@proxyAuthRequiredDescription": { + "description": "Detailed explanation of proxy authentication requirement." + }, + "authenticateThroughProxy": "Authenticate", + "@authenticateThroughProxy": { + "description": "Button text to start proxy authentication." + }, + "serverBehindProxy": "Server behind proxy", + "@serverBehindProxy": { + "description": "Toggle label for servers behind authentication proxies." + }, + "serverBehindProxyDescription": "Enable if your server uses oauth2-proxy or similar authentication.", + "@serverBehindProxyDescription": { + "description": "Description for the proxy server toggle." + }, "continueWithProvider": "Continue with {provider}", "@continueWithProvider": { "description": "Button text for OAuth provider sign-in.",