284299b946
Complete Flutter app (Android + iOS) mirroring the web frontend: - Core: Riverpod state, Dio networking with auth interceptor + auto-refresh, go_router navigation, flutter_secure_storage, light/dark theme with MedievalSharp/Crimson Pro fonts, German l10n - Market: search with text/GPS/radius/date/sort filters, list + map views (flutter_map + OSM), detail screen with opening hours, admission prices, single-marker map, pagination - Auth: login (password + magic link tabs), register, OAuth button placeholders, 2FA code prompt on 401, sealed auth state provider - User: profile view/edit/delete with confirm dialog, 2FA setup/disable on security screen - GPS: geolocator with IP-based fallback (geojs.io) matching web behavior - Platform: Android internet + location permissions, iOS NSLocation description - Tests: date/currency/distance formatter unit tests (13 passing) - Zero analysis issues, debug APK builds successfully
292 lines
8.6 KiB
Dart
292 lines
8.6 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../../shared/widgets/alert_banner.dart';
|
|
import '../../../shared/widgets/app_button.dart';
|
|
import '../../auth/domain/auth_provider.dart';
|
|
import '../../auth/domain/models/totp_setup_data.dart';
|
|
|
|
class SecurityScreen extends ConsumerStatefulWidget {
|
|
const SecurityScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<SecurityScreen> createState() => _SecurityScreenState();
|
|
}
|
|
|
|
class _SecurityScreenState extends ConsumerState<SecurityScreen> {
|
|
TotpSetupData? _setupData;
|
|
bool _settingUp = false;
|
|
bool _disabling = false;
|
|
String? _error;
|
|
String? _success;
|
|
final _codeController = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_codeController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _startSetup() async {
|
|
setState(() {
|
|
_settingUp = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
final data = await ref.read(authRepositoryProvider).setupTotp();
|
|
setState(() {
|
|
_setupData = data;
|
|
_settingUp = false;
|
|
});
|
|
} catch (e) {
|
|
setState(() {
|
|
_error = 'Fehler beim Starten der 2FA-Einrichtung.';
|
|
_settingUp = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _verifyCode() async {
|
|
final code = _codeController.text.trim();
|
|
if (code.length != 6) return;
|
|
|
|
setState(() {
|
|
_settingUp = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
await ref.read(authRepositoryProvider).verifyTotp(code);
|
|
setState(() {
|
|
_setupData = null;
|
|
_settingUp = false;
|
|
_success = '2FA wurde erfolgreich aktiviert.';
|
|
});
|
|
_codeController.clear();
|
|
} catch (e) {
|
|
setState(() {
|
|
_error = 'Ungültiger Code. Bitte versuche es erneut.';
|
|
_settingUp = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _disableTotp() async {
|
|
final code = _codeController.text.trim();
|
|
if (code.length != 6) return;
|
|
|
|
setState(() {
|
|
_disabling = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
await ref.read(authRepositoryProvider).disableTotp(code);
|
|
setState(() {
|
|
_disabling = false;
|
|
_success = '2FA wurde deaktiviert.';
|
|
});
|
|
_codeController.clear();
|
|
} catch (e) {
|
|
setState(() {
|
|
_error = 'Ungültiger Code. Bitte versuche es erneut.';
|
|
_disabling = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Sicherheit')),
|
|
body: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 500),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Zwei-Faktor-Authentifizierung',
|
|
style: theme.textTheme.headlineMedium),
|
|
const SizedBox(height: 16),
|
|
|
|
if (_success != null) ...[
|
|
AlertBanner(
|
|
message: _success!,
|
|
type: AlertType.success,
|
|
onDismiss: () => setState(() => _success = null),
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
|
|
if (_error != null) ...[
|
|
AlertBanner(
|
|
message: _error!,
|
|
type: AlertType.error,
|
|
onDismiss: () => setState(() => _error = null),
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
|
|
// Setup flow
|
|
if (_setupData != null) ...[
|
|
_buildSetupFlow(theme),
|
|
] else ...[
|
|
_buildActions(theme),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSetupFlow(ThemeData theme) {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('2FA einrichten', style: theme.textTheme.titleLarge),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'Scanne diesen QR-Code mit deiner Authenticator-App:',
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// QR code URL display (user would use the URL in their auth app)
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Icon(Icons.qr_code_2, size: 120, color: theme.colorScheme.onSurface),
|
|
const SizedBox(height: 8),
|
|
SelectableText(
|
|
_setupData!.secret,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
fontFamily: 'monospace',
|
|
fontSize: 12,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
TextField(
|
|
controller: _codeController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Bestätigungscode',
|
|
hintText: '6-stelliger Code',
|
|
prefixIcon: Icon(Icons.pin),
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
maxLength: 6,
|
|
textInputAction: TextInputAction.done,
|
|
onSubmitted: (_) => _verifyCode(),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: () => setState(() => _setupData = null),
|
|
child: const Text('Abbrechen'),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: AppButton(
|
|
label: 'Bestätigen',
|
|
isLoading: _settingUp,
|
|
onPressed: _verifyCode,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildActions(ThemeData theme) {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.security, color: theme.colorScheme.primary),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('2FA-Status', style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Schütze dein Konto mit einem zweiten Faktor.',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.outline,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Enable 2FA
|
|
AppButton(
|
|
label: '2FA aktivieren',
|
|
icon: Icons.shield,
|
|
isLoading: _settingUp,
|
|
onPressed: _startSetup,
|
|
fullWidth: true,
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
const Divider(),
|
|
const SizedBox(height: 16),
|
|
|
|
// Disable 2FA section
|
|
Text('2FA deaktivieren', style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
controller: _codeController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Aktueller 2FA-Code',
|
|
hintText: '6-stelliger Code',
|
|
prefixIcon: Icon(Icons.pin),
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
maxLength: 6,
|
|
),
|
|
const SizedBox(height: 8),
|
|
AppButton(
|
|
label: '2FA deaktivieren',
|
|
variant: AppButtonVariant.danger,
|
|
isLoading: _disabling,
|
|
onPressed: _disableTotp,
|
|
fullWidth: true,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|