feat: API auth with custom headers
This commit is contained in:
869
lib/features/auth/views/server_connection_page.dart
Normal file
869
lib/features/auth/views/server_connection_page.dart
Normal file
@@ -0,0 +1,869 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../core/models/server_config.dart';
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
import '../../../core/services/api_service.dart';
|
||||
import '../../../core/services/input_validation_service.dart';
|
||||
import '../../../core/widgets/error_boundary.dart';
|
||||
import '../../../shared/services/brand_service.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
import '../../../shared/widgets/conduit_components.dart';
|
||||
import '../../chat/views/chat_page.dart';
|
||||
import 'authentication_page.dart';
|
||||
|
||||
class ServerConnectionPage extends ConsumerStatefulWidget {
|
||||
const ServerConnectionPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ServerConnectionPage> createState() =>
|
||||
_ServerConnectionPageState();
|
||||
}
|
||||
|
||||
class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final TextEditingController _urlController = TextEditingController();
|
||||
final Map<String, String> _customHeaders = {};
|
||||
final TextEditingController _headerKeyController = TextEditingController();
|
||||
final TextEditingController _headerValueController = TextEditingController();
|
||||
|
||||
String? _connectionError;
|
||||
bool _isConnecting = false;
|
||||
bool _showAdvancedSettings = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_prefillFromState();
|
||||
}
|
||||
|
||||
Future<void> _prefillFromState() async {
|
||||
final activeServer = await ref.read(activeServerProvider.future);
|
||||
if (activeServer != null) {
|
||||
_urlController.text = activeServer.url;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlController.dispose();
|
||||
_headerKeyController.dispose();
|
||||
_headerValueController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _connectToServer() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
_connectionError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
String url = _validateAndFormatUrl(_urlController.text.trim());
|
||||
|
||||
final tempConfig = ServerConfig(
|
||||
id: const Uuid().v4(),
|
||||
name: _deriveServerNameFromUrl(url),
|
||||
url: url,
|
||||
customHeaders: Map<String, String>.from(_customHeaders),
|
||||
isActive: true,
|
||||
);
|
||||
|
||||
final api = ApiService(serverConfig: tempConfig);
|
||||
final isHealthy = await api.checkHealth();
|
||||
if (!isHealthy) {
|
||||
throw Exception('This does not appear to be an Open-WebUI server.');
|
||||
}
|
||||
|
||||
await _saveServerConfig(tempConfig);
|
||||
|
||||
// Navigate to authentication page
|
||||
if (mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AuthenticationPage(serverConfig: tempConfig),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_connectionError = _formatConnectionError(e.toString());
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isConnecting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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('Server URL cannot be empty');
|
||||
}
|
||||
|
||||
// Clean up the input
|
||||
String url = input.trim();
|
||||
|
||||
// Add protocol if missing
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = 'http://$url';
|
||||
}
|
||||
|
||||
// Remove trailing slash
|
||||
if (url.endsWith('/')) {
|
||||
url = url.substring(0, url.length - 1);
|
||||
}
|
||||
|
||||
// Parse and validate the URI
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) {
|
||||
throw Exception('Invalid URL format. Please check your input.');
|
||||
}
|
||||
|
||||
// Validate scheme
|
||||
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
||||
throw Exception('Only HTTP and HTTPS protocols are supported.');
|
||||
}
|
||||
|
||||
// Validate host
|
||||
if (uri.host.isEmpty) {
|
||||
throw Exception('Server address is required (e.g., 192.168.1.10 or example.com).');
|
||||
}
|
||||
|
||||
// Validate port if specified
|
||||
if (uri.hasPort) {
|
||||
if (uri.port < 1 || uri.port > 65535) {
|
||||
throw Exception('Port must be between 1 and 65535.');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate IP address format if it looks like an IP
|
||||
if (_isIPAddress(uri.host) && !_isValidIPAddress(uri.host)) {
|
||||
throw Exception('Invalid IP address format. Use format like 192.168.1.10.');
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
bool _isIPAddress(String host) {
|
||||
return RegExp(r'^\d+\.\d+\.\d+\.\d+$').hasMatch(host);
|
||||
}
|
||||
|
||||
bool _isValidIPAddress(String ip) {
|
||||
final parts = ip.split('.');
|
||||
if (parts.length != 4) return false;
|
||||
|
||||
for (final part in parts) {
|
||||
final num = int.tryParse(part);
|
||||
if (num == null || num < 0 || num > 255) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String _deriveServerNameFromUrl(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
if (uri.host.isNotEmpty) return uri.host;
|
||||
} catch (_) {}
|
||||
return 'Server';
|
||||
}
|
||||
|
||||
String _formatConnectionError(String error) {
|
||||
// Clean up the error message
|
||||
String cleanError = error.replaceFirst('Exception: ', '');
|
||||
|
||||
// Handle specific error types
|
||||
if (error.contains('SocketException')) {
|
||||
return 'We couldn\'t reach the server. Check your connection and that the server is running.';
|
||||
} else if (error.contains('timeout')) {
|
||||
return 'Connection timed out. The server might be busy or blocked by a firewall.';
|
||||
} else if (error.contains('Server URL cannot be empty')) {
|
||||
return 'Please enter a server address.';
|
||||
} else if (error.contains('Invalid URL format')) {
|
||||
return 'Invalid server address format. Examples:\n• 192.168.1.10:3000\n• example.com\n• https://myserver.com';
|
||||
} else if (error.contains('Only HTTP and HTTPS')) {
|
||||
return 'Use http:// or https:// only.';
|
||||
} else if (error.contains('Server address is required')) {
|
||||
return cleanError;
|
||||
} else if (error.contains('Port must be between')) {
|
||||
return cleanError;
|
||||
} else if (error.contains('Invalid IP address format')) {
|
||||
return cleanError;
|
||||
} else if (error.contains('This does not appear to be an Open-WebUI server')) {
|
||||
return 'This server doesn\'t appear to be running Open-WebUI. Please check the address.';
|
||||
}
|
||||
|
||||
return 'Couldn\'t connect. Double-check the address and try again.';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reviewerMode = ref.watch(reviewerModeProvider);
|
||||
|
||||
return ErrorBoundary(
|
||||
child: Scaffold(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.pagePadding,
|
||||
vertical: Spacing.lg,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header with progress indicator
|
||||
_buildHeader(),
|
||||
|
||||
const SizedBox(height: Spacing.extraLarge),
|
||||
|
||||
// Main content
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Brand header
|
||||
_buildBrandHeader(reviewerMode),
|
||||
|
||||
const SizedBox(height: Spacing.sectionGap),
|
||||
|
||||
// Welcome section
|
||||
_buildWelcomeSection(),
|
||||
|
||||
const SizedBox(height: Spacing.sectionGap),
|
||||
|
||||
// Reviewer mode demo (if enabled)
|
||||
if (reviewerMode) ...[
|
||||
_buildReviewerModeSection(),
|
||||
const SizedBox(height: Spacing.sectionGap),
|
||||
],
|
||||
|
||||
// Server connection form
|
||||
_buildServerForm(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom action button
|
||||
_buildConnectButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Progress indicator (step 1 of 2)
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.buttonPrimary,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Container(
|
||||
width: 32,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.dividerColor,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBrandHeader(bool reviewerMode) {
|
||||
return GestureDetector(
|
||||
onLongPress: () async {
|
||||
HapticFeedback.mediumImpact();
|
||||
await ref.read(reviewerModeProvider.notifier).toggle();
|
||||
if (!mounted) return;
|
||||
final enabled = ref.read(reviewerModeProvider);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
enabled
|
||||
? 'Reviewer Mode enabled: Demo without server'
|
||||
: 'Reviewer Mode disabled',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Glow effect
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
context.conduitTheme.buttonPrimary.withValues(alpha: 0.12),
|
||||
context.conduitTheme.buttonPrimary.withValues(alpha: 0.06),
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [0.0, 0.7, 1.0],
|
||||
radius: 0.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Brand logo
|
||||
BrandService.createBrandIcon(
|
||||
size: 64,
|
||||
useGradient: true,
|
||||
addShadow: true,
|
||||
),
|
||||
// Reviewer mode badge
|
||||
if (reviewerMode)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
child: ConduitBadge(
|
||||
text: 'Demo',
|
||||
backgroundColor: context.conduitTheme.warning.withValues(alpha: 0.15),
|
||||
textColor: context.conduitTheme.warning,
|
||||
isCompact: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
).animate().scale(
|
||||
duration: AnimationDuration.pageTransition,
|
||||
curve: Curves.easeOutBack,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWelcomeSection() {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
'Connect to Server',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.conduitTheme.headingLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.2,
|
||||
),
|
||||
).animate().fadeIn(
|
||||
duration: AnimationDuration.pageTransition,
|
||||
delay: AnimationDuration.microInteraction,
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
'Enter your Open-WebUI server address to get started',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.conduitTheme.bodyLarge?.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
).animate().fadeIn(
|
||||
duration: AnimationDuration.pageTransition,
|
||||
delay: AnimationDuration.fast,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReviewerModeSection() {
|
||||
return ConduitCard(
|
||||
isElevated: false,
|
||||
padding: const EdgeInsets.all(Spacing.lg),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Platform.isIOS ? CupertinoIcons.wand_stars : Icons.auto_awesome,
|
||||
color: context.conduitTheme.warning,
|
||||
size: IconSize.medium,
|
||||
),
|
||||
const SizedBox(width: Spacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Demo Mode Active',
|
||||
style: context.conduitTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.conduitTheme.warning,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Text(
|
||||
'Skip server setup and try the demo',
|
||||
style: context.conduitTheme.bodySmall?.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Spacing.lg),
|
||||
ConduitButton(
|
||||
text: 'Enter Demo',
|
||||
icon: Platform.isIOS ? CupertinoIcons.play_fill : Icons.play_arrow,
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ChatPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
isSecondary: true,
|
||||
isFullWidth: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildServerForm() {
|
||||
return ConduitCard(
|
||||
isElevated: true,
|
||||
padding: const EdgeInsets.all(Spacing.xl),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AccessibleFormField(
|
||||
label: 'Server URL',
|
||||
hint: 'https://your-server.com',
|
||||
controller: _urlController,
|
||||
validator: InputValidationService.combine([
|
||||
InputValidationService.validateRequired,
|
||||
(value) => InputValidationService.validateUrl(value, required: true),
|
||||
]),
|
||||
keyboardType: TextInputType.url,
|
||||
semanticLabel: 'Enter your server URL or IP address',
|
||||
onSubmitted: (_) => _connectToServer(),
|
||||
prefixIcon: Icon(
|
||||
Platform.isIOS ? CupertinoIcons.globe : Icons.public,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
autofillHints: const [AutofillHints.url],
|
||||
isRequired: true,
|
||||
).animate().slideX(
|
||||
begin: -0.05,
|
||||
duration: AnimationDuration.messageSlide,
|
||||
delay: AnimationDuration.microInteraction,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
|
||||
if (_connectionError != null) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
_buildErrorMessage(_connectionError!),
|
||||
],
|
||||
|
||||
const SizedBox(height: Spacing.lg),
|
||||
|
||||
// Advanced settings
|
||||
_buildAdvancedSettings(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAdvancedSettings() {
|
||||
return Column(
|
||||
children: [
|
||||
ConduitCard(
|
||||
isElevated: false,
|
||||
padding: const EdgeInsets.all(Spacing.lg),
|
||||
child: Column(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => setState(() => _showAdvancedSettings = !_showAdvancedSettings),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.button),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Spacing.sm),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Platform.isIOS ? CupertinoIcons.gear : Icons.tune,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
size: IconSize.medium,
|
||||
),
|
||||
const SizedBox(width: Spacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Advanced Settings',
|
||||
style: context.conduitTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (_customHeaders.isNotEmpty)
|
||||
Text(
|
||||
'${_customHeaders.length} custom header${_customHeaders.length != 1 ? 's' : ''}',
|
||||
style: context.conduitTheme.bodySmall?.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedRotation(
|
||||
duration: AnimationDuration.microInteraction,
|
||||
turns: _showAdvancedSettings ? 0.5 : 0,
|
||||
child: Icon(
|
||||
Platform.isIOS ? CupertinoIcons.chevron_down : Icons.expand_more,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: AnimationDuration.microInteraction,
|
||||
curve: Curves.easeInOutCubic,
|
||||
child: _showAdvancedSettings ? _buildAdvancedSettingsContent() : const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAdvancedSettingsContent() {
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: Spacing.lg),
|
||||
ConduitDivider(),
|
||||
const SizedBox(height: Spacing.lg),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Custom Headers',
|
||||
style: context.conduitTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (_customHeaders.isNotEmpty)
|
||||
Text(
|
||||
'${_customHeaders.length}/10',
|
||||
style: context.conduitTheme.bodySmall?.copyWith(
|
||||
color: _customHeaders.length >= 10
|
||||
? context.conduitTheme.error
|
||||
: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Text(
|
||||
'Add custom HTTP headers for authentication, API keys, or special server requirements.',
|
||||
style: context.conduitTheme.bodySmall?.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: AccessibleFormField(
|
||||
label: 'Header Name',
|
||||
hint: 'X-Custom-Header',
|
||||
controller: _headerKeyController,
|
||||
validator: (value) => _validateHeaderKey(value ?? ''),
|
||||
semanticLabel: 'Enter header name',
|
||||
isCompact: true,
|
||||
keyboardType: TextInputType.text,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.md),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: AccessibleFormField(
|
||||
label: 'Header Value',
|
||||
hint: 'api-key-123 or Bearer token',
|
||||
controller: _headerValueController,
|
||||
validator: (value) => _validateHeaderValue(value ?? ''),
|
||||
semanticLabel: 'Enter header value',
|
||||
isCompact: true,
|
||||
keyboardType: TextInputType.text,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.md),
|
||||
ConduitIconButton(
|
||||
icon: Platform.isIOS ? CupertinoIcons.plus : Icons.add,
|
||||
onPressed: _customHeaders.length >= 10 ? null : _addCustomHeader,
|
||||
tooltip: _customHeaders.length >= 10
|
||||
? 'Maximum headers reached'
|
||||
: 'Add header',
|
||||
backgroundColor: _customHeaders.length >= 10
|
||||
? context.conduitTheme.surfaceContainer
|
||||
: context.conduitTheme.buttonPrimary,
|
||||
iconColor: _customHeaders.length >= 10
|
||||
? context.conduitTheme.textDisabled
|
||||
: context.conduitTheme.buttonPrimaryText,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_customHeaders.isNotEmpty) ...[
|
||||
const SizedBox(height: Spacing.lg),
|
||||
_buildCustomHeadersList(),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCustomHeadersList() {
|
||||
return Column(
|
||||
children: _customHeaders.entries.map((entry) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: Spacing.sm),
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.button),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.dividerColor,
|
||||
width: BorderWidth.standard,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ConduitBadge(
|
||||
text: entry.key,
|
||||
backgroundColor: context.conduitTheme.buttonPrimary.withValues(alpha: 0.1),
|
||||
textColor: context.conduitTheme.buttonPrimary,
|
||||
isCompact: true,
|
||||
),
|
||||
const SizedBox(width: Spacing.md),
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.value,
|
||||
style: context.conduitTheme.bodySmall?.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.md),
|
||||
ConduitIconButton(
|
||||
icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close,
|
||||
onPressed: () => _removeCustomHeader(entry.key),
|
||||
tooltip: 'Remove header',
|
||||
backgroundColor: context.conduitTheme.error.withValues(alpha: 0.1),
|
||||
iconColor: context.conduitTheme.error,
|
||||
isCompact: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
).animate().fadeIn(duration: AnimationDuration.microInteraction);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConnectButton() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: Spacing.lg),
|
||||
child: ConduitButton(
|
||||
text: _isConnecting ? 'Connecting...' : 'Connect to Server',
|
||||
icon: _isConnecting
|
||||
? null
|
||||
: (Platform.isIOS ? CupertinoIcons.arrow_right : Icons.arrow_forward),
|
||||
onPressed: _isConnecting ? null : _connectToServer,
|
||||
isLoading: _isConnecting,
|
||||
isFullWidth: true,
|
||||
).animate().fadeIn(
|
||||
duration: AnimationDuration.pageTransition,
|
||||
delay: AnimationDuration.fast,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorMessage(String message) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.errorBackground,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.button),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.error.withValues(alpha: 0.3),
|
||||
width: BorderWidth.standard,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.exclamationmark_circle_fill
|
||||
: Icons.error_outline,
|
||||
color: context.conduitTheme.error,
|
||||
size: IconSize.medium,
|
||||
),
|
||||
const SizedBox(width: Spacing.md),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: context.conduitTheme.bodyMedium?.copyWith(
|
||||
color: context.conduitTheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
).animate().slideX(
|
||||
begin: 0.05,
|
||||
duration: AnimationDuration.messageSlide,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
void _addCustomHeader() {
|
||||
final key = _headerKeyController.text.trim();
|
||||
final value = _headerValueController.text.trim();
|
||||
|
||||
if (key.isEmpty || value.isEmpty) return;
|
||||
|
||||
// Validate header name
|
||||
final keyValidation = _validateHeaderKey(key);
|
||||
if (keyValidation != null) {
|
||||
_showHeaderError(keyValidation);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate header value
|
||||
final valueValidation = _validateHeaderValue(value);
|
||||
if (valueValidation != null) {
|
||||
_showHeaderError(valueValidation);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
if (_customHeaders.containsKey(key)) {
|
||||
_showHeaderError('Header "$key" already exists. Remove it first to update.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check header count limit
|
||||
if (_customHeaders.length >= 10) {
|
||||
_showHeaderError('Maximum of 10 custom headers allowed. Remove some to add more.');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_customHeaders[key] = value;
|
||||
_headerKeyController.clear();
|
||||
_headerValueController.clear();
|
||||
});
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
|
||||
String? _validateHeaderKey(String key) {
|
||||
// RFC 7230 compliant header name validation
|
||||
if (key.isEmpty) return 'Header name cannot be empty';
|
||||
if (key.length > 64) return 'Header name too long (max 64 characters)';
|
||||
|
||||
// Check for valid characters (RFC 7230: token characters)
|
||||
if (!RegExp(r'^[a-zA-Z0-9!#$&\-^_`|~]+$').hasMatch(key)) {
|
||||
return 'Invalid header name. Use only letters, numbers, and these symbols: !#\$&-^_`|~';
|
||||
}
|
||||
|
||||
// Check for reserved headers that should not be overridden
|
||||
final lowerKey = key.toLowerCase();
|
||||
final reservedHeaders = {
|
||||
'authorization', 'content-type', 'content-length', 'host',
|
||||
'user-agent', 'accept', 'accept-encoding', 'connection',
|
||||
'transfer-encoding', 'upgrade', 'via', 'warning'
|
||||
};
|
||||
|
||||
if (reservedHeaders.contains(lowerKey)) {
|
||||
return 'Cannot override reserved header "$key"';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateHeaderValue(String value) {
|
||||
if (value.isEmpty) return 'Header value cannot be empty';
|
||||
if (value.length > 1024) return 'Header value too long (max 1024 characters)';
|
||||
|
||||
// Check for valid characters (no control characters except tab)
|
||||
for (int i = 0; i < value.length; i++) {
|
||||
final char = value.codeUnitAt(i);
|
||||
// Allow printable ASCII (32-126) and tab (9)
|
||||
if (char != 9 && (char < 32 || char > 126)) {
|
||||
return 'Header value contains invalid characters. Use only printable ASCII.';
|
||||
}
|
||||
}
|
||||
|
||||
// Check for security-sensitive patterns
|
||||
if (value.toLowerCase().contains('script') ||
|
||||
value.contains('<') ||
|
||||
value.contains('>')) {
|
||||
return 'Header value appears to contain potentially unsafe content';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
void _showHeaderError(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: context.conduitTheme.error,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _removeCustomHeader(String key) {
|
||||
setState(() {
|
||||
_customHeaders.remove(key);
|
||||
});
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user