Merge pull request #294 from cogwheel0/add-proxy-authentication-webview
add-proxy-authentication-webview
This commit is contained in:
@@ -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<String>("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<String, String>()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -14,3 +14,4 @@
|
||||
</vector>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
</vector>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -12,3 +12,4 @@
|
||||
</vector>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -12,3 +12,4 @@
|
||||
</vector>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
</vector>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
android:pathData="M7,18h2V6H7v12zM11,22h2V2h-2v20zM3,14h2v-4H3v4zM15,18h2V6h-2v12zM19,10v4h2v-4h-2z" />
|
||||
</vector>
|
||||
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@
|
||||
</shape>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -10,3 +10,4 @@
|
||||
</item>
|
||||
</ripple>
|
||||
|
||||
|
||||
|
||||
@@ -10,3 +10,4 @@
|
||||
</ripple>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
</ripple>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
</ripple>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
</ripple>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -12,3 +12,4 @@
|
||||
</vector>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -14,3 +14,4 @@
|
||||
</vector>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
</vector>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -12,3 +12,4 @@
|
||||
</vector>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -12,3 +12,4 @@
|
||||
</vector>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
</vector>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
android:pathData="M7,18h2V6H7v12zM11,22h2V2h-2v20zM3,14h2v-4H3v4zM15,18h2V6h-2v12zM19,10v4h2v-4h-2z" />
|
||||
</vector>
|
||||
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@
|
||||
</shape>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -10,3 +10,4 @@
|
||||
</item>
|
||||
</ripple>
|
||||
|
||||
|
||||
|
||||
@@ -10,3 +10,4 @@
|
||||
</ripple>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
</ripple>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
</ripple>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
</ripple>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -53,3 +53,4 @@
|
||||
</layer-list>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -129,3 +129,4 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
|
||||
@@ -30,3 +30,4 @@
|
||||
</resources>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -9,3 +9,4 @@
|
||||
</resources>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -30,3 +30,4 @@
|
||||
</resources>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -9,3 +9,4 @@
|
||||
</resources>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -13,3 +13,4 @@
|
||||
</resources>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -17,3 +17,4 @@
|
||||
android:widgetFeatures="reconfigurable" />
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -20,3 +20,4 @@
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,3 +6,4 @@
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -16,3 +16,4 @@
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M240-40q-50 0-85-35t-35-85q0-50 35-85t85-35q14 0 26 3t23 8l57-71q-28-31-39-70t-5-78l-81-27q-17 25-43 40t-58 15q-50 0-85-35T0-580q0-50 35-85t85-35q50 0 85 35t35 85v8l81 28q20-36 53.5-61t75.5-32v-87q-39-11-64.5-42.5T360-840q0-50 35-85t85-35q50 0 85 35t35 85q0 42-26 73.5T510-724v87q42 7 75.5 32t53.5 61l81-28v-8q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35q-32 0-58.5-15T739-515l-81 27q6 39-5 77.5T614-340l57 70q11-5 23-7.5t26-2.5q50 0 85 35t35 85q0 50-35 85t-85 35q-50 0-85-35t-35-85q0-20 6.5-38.5T624-232l-57-71q-41 23-87.5 23T392-303l-56 71q11 15 17.5 33.5T360-160q0 50-35 85t-85 35Z" fill="currentColor"/></svg>
|
||||
|
||||
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 720 B After Width: | Height: | Size: 721 B |
@@ -38,3 +38,4 @@
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -10,3 +10,4 @@
|
||||
</plist>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
</plist>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* Localized versions of Info.plist keys */
|
||||
CFBundleDisplayName = "Conduit";
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* Localized versions of Info.plist keys */
|
||||
CFBundleDisplayName = "Conduit";
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* Localized versions of Info.plist keys */
|
||||
CFBundleDisplayName = "Conduit";
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* Localized versions of Info.plist keys */
|
||||
CFBundleDisplayName = "Conduit";
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* Localized versions of Info.plist keys */
|
||||
CFBundleDisplayName = "Conduit";
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* Localized versions of Info.plist keys */
|
||||
CFBundleDisplayName = "Conduit";
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* Localized versions of Info.plist keys */
|
||||
CFBundleDisplayName = "Conduit";
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* Localized versions of Info.plist keys */
|
||||
CFBundleDisplayName = "Conduit";
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* Localized versions of Info.plist keys */
|
||||
CFBundleDisplayName = "Conduit";
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* Localized versions of Info.plist keys */
|
||||
CFBundleDisplayName = "Conduit";
|
||||
|
||||
|
||||
|
||||
62
lib/core/auth/native_cookie_manager.dart
Normal file
62
lib/core/auth/native_cookie_manager.dart
Normal file
@@ -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<Map<String, String>> getCookiesForUrl(String url) async {
|
||||
if (kIsWeb) return {};
|
||||
|
||||
try {
|
||||
final result = await _channel.invokeMethod<Map<dynamic, dynamic>>(
|
||||
'getCookies',
|
||||
{'url': url},
|
||||
);
|
||||
|
||||
if (result == null) return {};
|
||||
|
||||
final cookies = <String, String>{};
|
||||
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<String, String> cookies) {
|
||||
if (cookies.isEmpty) return '';
|
||||
return cookies.entries.map((e) => '${e.key}=${e.value}').join('; ');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Map<String, String>> 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 = <String, String>{};
|
||||
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<String, String> cookies) {
|
||||
if (cookies.isEmpty) return '';
|
||||
return cookies.entries.map((e) => '${e.key}=${e.value}').join('; ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<GoRouter>((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,
|
||||
|
||||
@@ -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<Map<String, dynamic>> _convertCodeExecutionsToOpenWebUIFormat(
|
||||
// Convert the result if present
|
||||
if (exec.result != null) {
|
||||
final execResult = <String, dynamic>{};
|
||||
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<HealthCheckResult> 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<String, String>.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).
|
||||
///
|
||||
|
||||
@@ -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';
|
||||
|
||||
641
lib/features/auth/views/proxy_auth_page.dart
Normal file
641
lib/features/auth/views/proxy_auth_page.dart
Normal file
@@ -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<String, String>? 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<ProxyAuthPage> createState() => _ProxyAuthPageState();
|
||||
}
|
||||
|
||||
class _ProxyAuthPageState extends ConsumerState<ProxyAuthPage> {
|
||||
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<void> _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<void> _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<void> _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<void> _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<String?> _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<void> _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<void> _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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
@@ -104,19 +107,36 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
|
||||
workerManager: workerManager,
|
||||
);
|
||||
|
||||
// First check basic connectivity
|
||||
// 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 +186,204 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<void> _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<ProxyAuthResult>(
|
||||
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<String, String>.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<void> _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<void> _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 +811,13 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
|
||||
}
|
||||
|
||||
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 +848,7 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
|
||||
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 +856,7 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.allowSelfSignedCertificatesDescription,
|
||||
l10n.allowSelfSignedCertificatesDescription,
|
||||
style: context.conduitTheme.bodySmall?.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
@@ -665,7 +881,7 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.customHeaders,
|
||||
l10n.customHeaders,
|
||||
style: context.conduitTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -683,7 +899,7 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.customHeadersDescription,
|
||||
l10n.customHeadersDescription,
|
||||
style: context.conduitTheme.bodySmall?.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
@@ -695,7 +911,7 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
|
||||
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 +924,8 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
|
||||
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 +940,8 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
|
||||
? 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,
|
||||
|
||||
@@ -886,5 +886,15 @@
|
||||
"usageLoadDuration": "Modellladezeit",
|
||||
"usageQueueTime": "Wartezeit",
|
||||
"usageReasoningTokens": "Reasoning-Tokens",
|
||||
"usageTotalTokens": "Gesamte Tokens"
|
||||
"usageTotalTokens": "Gesamte Tokens",
|
||||
"proxyAuthentication": "Proxy-Authentifizierung",
|
||||
"proxyAuthPlatformNotSupported": "Proxy-Authentifizierung erfordert ein mobiles Gerät. Bitte authentifizieren Sie sich zuerst über einen Browser.",
|
||||
"proxyAuthLoading": "Authentifizierungsseite wird geladen...",
|
||||
"proxyAuthFailed": "Proxy-Authentifizierung fehlgeschlagen",
|
||||
"proxyAuthHelpText": "Melden Sie sich über den Proxy Ihrer Organisation an und dann bei Open WebUI. Sie werden automatisch weitergeleitet.",
|
||||
"proxyAuthHelpTextSimple": "Melden Sie sich über Ihren Proxy an. Nach der Authentifizierung tippen Sie auf Weiter, um sich anzumelden.",
|
||||
"continueButton": "Weiter",
|
||||
"proxyAuthRequired": "Dieser Server erfordert Proxy-Authentifizierung",
|
||||
"proxyAuthRequiredDescription": "Ihr Server scheint hinter einem Authentifizierungs-Proxy (wie oauth2-proxy) zu sein. Sie müssen sich zuerst über den Proxy anmelden.",
|
||||
"authenticateThroughProxy": "Authentifizieren"
|
||||
}
|
||||
|
||||
@@ -1903,6 +1903,46 @@
|
||||
"@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."
|
||||
},
|
||||
"continueWithProvider": "Continue with {provider}",
|
||||
"@continueWithProvider": {
|
||||
"description": "Button text for OAuth provider sign-in.",
|
||||
|
||||
@@ -886,5 +886,15 @@
|
||||
"usageLoadDuration": "Tiempo de carga del modelo",
|
||||
"usageQueueTime": "Tiempo en cola",
|
||||
"usageReasoningTokens": "Tokens de razonamiento",
|
||||
"usageTotalTokens": "Tokens totales"
|
||||
"usageTotalTokens": "Tokens totales",
|
||||
"proxyAuthentication": "Autenticación de Proxy",
|
||||
"proxyAuthPlatformNotSupported": "La autenticación de proxy requiere un dispositivo móvil. Por favor, autentíquese primero a través de un navegador.",
|
||||
"proxyAuthLoading": "Cargando página de autenticación...",
|
||||
"proxyAuthFailed": "Error en la autenticación de proxy",
|
||||
"proxyAuthHelpText": "Complete el inicio de sesión a través del proxy de su organización, luego inicie sesión en Open WebUI. Será redirigido automáticamente.",
|
||||
"proxyAuthHelpTextSimple": "Inicie sesión a través de su proxy. Una vez autenticado, toque Continuar para iniciar sesión.",
|
||||
"continueButton": "Continuar",
|
||||
"proxyAuthRequired": "Este servidor requiere autenticación de proxy",
|
||||
"proxyAuthRequiredDescription": "Su servidor parece estar detrás de un proxy de autenticación (como oauth2-proxy). Deberá iniciar sesión a través del proxy primero.",
|
||||
"authenticateThroughProxy": "Autenticar"
|
||||
}
|
||||
|
||||
@@ -886,5 +886,15 @@
|
||||
"usageLoadDuration": "Temps de chargement du modèle",
|
||||
"usageQueueTime": "Temps d'attente",
|
||||
"usageReasoningTokens": "Tokens de raisonnement",
|
||||
"usageTotalTokens": "Tokens totaux"
|
||||
"usageTotalTokens": "Tokens totaux",
|
||||
"proxyAuthentication": "Authentification Proxy",
|
||||
"proxyAuthPlatformNotSupported": "L'authentification proxy nécessite un appareil mobile. Veuillez d'abord vous authentifier via un navigateur.",
|
||||
"proxyAuthLoading": "Chargement de la page d'authentification...",
|
||||
"proxyAuthFailed": "Échec de l'authentification proxy",
|
||||
"proxyAuthHelpText": "Connectez-vous via le proxy de votre organisation, puis connectez-vous à Open WebUI. Vous serez redirigé automatiquement.",
|
||||
"proxyAuthHelpTextSimple": "Connectez-vous via votre proxy. Une fois authentifié, appuyez sur Continuer pour vous connecter.",
|
||||
"continueButton": "Continuer",
|
||||
"proxyAuthRequired": "Ce serveur nécessite une authentification proxy",
|
||||
"proxyAuthRequiredDescription": "Votre serveur semble être derrière un proxy d'authentification (comme oauth2-proxy). Vous devrez d'abord vous connecter via le proxy.",
|
||||
"authenticateThroughProxy": "S'authentifier"
|
||||
}
|
||||
|
||||
@@ -886,5 +886,15 @@
|
||||
"usageLoadDuration": "Tempo di caricamento modello",
|
||||
"usageQueueTime": "Tempo in coda",
|
||||
"usageReasoningTokens": "Token di ragionamento",
|
||||
"usageTotalTokens": "Token totali"
|
||||
"usageTotalTokens": "Token totali",
|
||||
"proxyAuthentication": "Autenticazione Proxy",
|
||||
"proxyAuthPlatformNotSupported": "L'autenticazione proxy richiede un dispositivo mobile. Si prega di autenticarsi prima tramite un browser.",
|
||||
"proxyAuthLoading": "Caricamento della pagina di autenticazione...",
|
||||
"proxyAuthFailed": "Autenticazione proxy fallita",
|
||||
"proxyAuthHelpText": "Completa l'accesso tramite il proxy della tua organizzazione, poi accedi a Open WebUI. Sarai reindirizzato automaticamente.",
|
||||
"proxyAuthHelpTextSimple": "Accedi tramite il tuo proxy. Una volta autenticato, tocca Continua per procedere all'accesso.",
|
||||
"continueButton": "Continua",
|
||||
"proxyAuthRequired": "Questo server richiede l'autenticazione proxy",
|
||||
"proxyAuthRequiredDescription": "Il tuo server sembra essere dietro un proxy di autenticazione (come oauth2-proxy). Dovrai prima accedere tramite il proxy.",
|
||||
"authenticateThroughProxy": "Autentica"
|
||||
}
|
||||
|
||||
@@ -664,5 +664,15 @@
|
||||
"usageLoadDuration": "모델 로드 시간",
|
||||
"usageQueueTime": "대기 시간",
|
||||
"usageReasoningTokens": "추론 토큰",
|
||||
"usageTotalTokens": "총 토큰"
|
||||
"usageTotalTokens": "총 토큰",
|
||||
"proxyAuthentication": "프록시 인증",
|
||||
"proxyAuthPlatformNotSupported": "프록시 인증은 모바일 장치가 필요합니다. 먼저 브라우저를 통해 인증해 주세요.",
|
||||
"proxyAuthLoading": "인증 페이지 로딩 중...",
|
||||
"proxyAuthFailed": "프록시 인증 실패",
|
||||
"proxyAuthHelpText": "조직의 프록시를 통해 로그인한 다음 Open WebUI에 로그인하세요. 자동으로 리디렉션됩니다.",
|
||||
"proxyAuthHelpTextSimple": "프록시를 통해 로그인하세요. 인증 후 계속을 탭하여 로그인을 진행하세요.",
|
||||
"continueButton": "계속",
|
||||
"proxyAuthRequired": "이 서버는 프록시 인증이 필요합니다",
|
||||
"proxyAuthRequiredDescription": "서버가 인증 프록시(예: oauth2-proxy) 뒤에 있는 것 같습니다. 먼저 프록시를 통해 로그인해야 합니다.",
|
||||
"authenticateThroughProxy": "인증"
|
||||
}
|
||||
|
||||
@@ -886,5 +886,15 @@
|
||||
"usageLoadDuration": "Modellaadtijd",
|
||||
"usageQueueTime": "Wachttijd",
|
||||
"usageReasoningTokens": "Redeneertokens",
|
||||
"usageTotalTokens": "Totaal tokens"
|
||||
"usageTotalTokens": "Totaal tokens",
|
||||
"proxyAuthentication": "Proxy-authenticatie",
|
||||
"proxyAuthPlatformNotSupported": "Proxy-authenticatie vereist een mobiel apparaat. Authenticeer eerst via een browser.",
|
||||
"proxyAuthLoading": "Authenticatiepagina laden...",
|
||||
"proxyAuthFailed": "Proxy-authenticatie mislukt",
|
||||
"proxyAuthHelpText": "Meld u aan via de proxy van uw organisatie en vervolgens bij Open WebUI. U wordt automatisch doorgestuurd.",
|
||||
"proxyAuthHelpTextSimple": "Meld u aan via uw proxy. Na authenticatie tikt u op Doorgaan om in te loggen.",
|
||||
"continueButton": "Doorgaan",
|
||||
"proxyAuthRequired": "Deze server vereist proxy-authenticatie",
|
||||
"proxyAuthRequiredDescription": "Uw server lijkt achter een authenticatieproxy (zoals oauth2-proxy) te staan. U moet eerst inloggen via de proxy.",
|
||||
"authenticateThroughProxy": "Authenticeren"
|
||||
}
|
||||
|
||||
@@ -886,5 +886,15 @@
|
||||
"usageLoadDuration": "Время загрузки модели",
|
||||
"usageQueueTime": "Время в очереди",
|
||||
"usageReasoningTokens": "Токены рассуждений",
|
||||
"usageTotalTokens": "Всего токенов"
|
||||
"usageTotalTokens": "Всего токенов",
|
||||
"proxyAuthentication": "Аутентификация через прокси",
|
||||
"proxyAuthPlatformNotSupported": "Аутентификация через прокси требует мобильное устройство. Пожалуйста, сначала авторизуйтесь через браузер.",
|
||||
"proxyAuthLoading": "Загрузка страницы аутентификации...",
|
||||
"proxyAuthFailed": "Ошибка аутентификации через прокси",
|
||||
"proxyAuthHelpText": "Войдите через прокси вашей организации, затем войдите в Open WebUI. Вы будете перенаправлены автоматически.",
|
||||
"proxyAuthHelpTextSimple": "Войдите через ваш прокси. После аутентификации нажмите Продолжить для входа.",
|
||||
"continueButton": "Продолжить",
|
||||
"proxyAuthRequired": "Этот сервер требует аутентификацию через прокси",
|
||||
"proxyAuthRequiredDescription": "Ваш сервер, похоже, находится за прокси аутентификации (например, oauth2-proxy). Сначала необходимо войти через прокси.",
|
||||
"authenticateThroughProxy": "Аутентифицировать"
|
||||
}
|
||||
|
||||
@@ -886,5 +886,15 @@
|
||||
"usageLoadDuration": "模型加载时间",
|
||||
"usageQueueTime": "排队时间",
|
||||
"usageReasoningTokens": "推理 token",
|
||||
"usageTotalTokens": "总 token"
|
||||
"usageTotalTokens": "总 token",
|
||||
"proxyAuthentication": "代理认证",
|
||||
"proxyAuthPlatformNotSupported": "代理认证需要移动设备。请先通过浏览器进行认证。",
|
||||
"proxyAuthLoading": "正在加载认证页面...",
|
||||
"proxyAuthFailed": "代理认证失败",
|
||||
"proxyAuthHelpText": "通过您组织的代理完成登录,然后登录 Open WebUI。您将被自动重定向。",
|
||||
"proxyAuthHelpTextSimple": "通过您的代理登录。认证后,点击继续以进行登录。",
|
||||
"continueButton": "继续",
|
||||
"proxyAuthRequired": "此服务器需要代理认证",
|
||||
"proxyAuthRequiredDescription": "您的服务器似乎位于认证代理(如 oauth2-proxy)后面。您需要先通过代理登录。",
|
||||
"authenticateThroughProxy": "认证"
|
||||
}
|
||||
|
||||
@@ -886,5 +886,15 @@
|
||||
"usageLoadDuration": "模型載入時間",
|
||||
"usageQueueTime": "排隊時間",
|
||||
"usageReasoningTokens": "推理 token",
|
||||
"usageTotalTokens": "總 token"
|
||||
"usageTotalTokens": "總 token",
|
||||
"proxyAuthentication": "代理認證",
|
||||
"proxyAuthPlatformNotSupported": "代理認證需要行動裝置。請先透過瀏覽器進行認證。",
|
||||
"proxyAuthLoading": "正在載入認證頁面...",
|
||||
"proxyAuthFailed": "代理認證失敗",
|
||||
"proxyAuthHelpText": "透過您組織的代理完成登入,然後登入 Open WebUI。您將被自動重新導向。",
|
||||
"proxyAuthHelpTextSimple": "透過您的代理登入。認證後,點擊繼續以進行登入。",
|
||||
"continueButton": "繼續",
|
||||
"proxyAuthRequired": "此伺服器需要代理認證",
|
||||
"proxyAuthRequiredDescription": "您的伺服器似乎位於認證代理(如 oauth2-proxy)後面。您需要先透過代理登入。",
|
||||
"authenticateThroughProxy": "認證"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user