diff --git a/lib/services/totp_vault.dart b/lib/services/totp_vault.dart new file mode 100644 index 0000000..4f8ca51 --- /dev/null +++ b/lib/services/totp_vault.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'totp_service.dart'; + +/// One saved authenticator entry: a TOTP secret plus display metadata. The +/// [secretOrUri] is what [TotpService] consumes - a bare base32 secret or a full +/// `otpauth://` URI (as encoded in an authenticator QR code). Provider-agnostic: +/// any service's 2FA can live here. Held only in the device keystore, never +/// synced to a Schuly account. +class TotpEntry { + final String id; + final String secretOrUri; + final String? issuer; + final String? account; + + const TotpEntry({required this.id, required this.secretOrUri, this.issuer, this.account}); + + /// The normalized base32 secret (what a backend `/login` expects), or null if + /// [secretOrUri] can't be parsed. + String? get secret => TotpService.secretOf(secretOrUri); + + /// Primary display line - issuer if known, else the account, else a fallback. + String get title { + if (issuer != null && issuer!.isNotEmpty) return issuer!; + if (account != null && account!.isNotEmpty) return account!; + return 'Account'; + } + + /// Secondary display line - the account when a distinct issuer is shown. + String? get subtitle => (issuer != null && issuer!.isNotEmpty && account != null && account!.isNotEmpty) ? account : null; + + Map toJson() => {'id': id, 'secretOrUri': secretOrUri, 'issuer': issuer, 'account': account}; + + factory TotpEntry.fromJson(Map json) => TotpEntry( + id: json['id'] as String, + secretOrUri: json['secretOrUri'] as String? ?? '', + issuer: json['issuer'] as String?, + account: json['account'] as String?, + ); + + /// Builds an entry from a raw scanned/typed [payload] (an `otpauth://` URI or + /// bare secret), pulling issuer/account from the URI when present. Returns null + /// when no usable TOTP secret can be extracted. [id] is caller-supplied so the + /// factory stays deterministic; pass a fresh unique value. + static TotpEntry? fromPayload(String id, String payload, {String? issuer, String? account}) { + final config = TotpConfig.tryParse(payload); + if (config == null) return null; + return TotpEntry( + id: id, + secretOrUri: payload.trim(), + issuer: (issuer != null && issuer.isNotEmpty) ? issuer : config.issuer, + account: (account != null && account.isNotEmpty) ? account : config.account, + ); + } +} + +/// Persists the list of authenticator entries in the platform keystore +/// (Android EncryptedSharedPreferences / iOS Keychain). Secrets never leave the +/// device. +class TotpVault { + TotpVault._(); + static final TotpVault instance = TotpVault._(); + + static const _storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ); + static const _key = 'authenticator.entries'; + + Future> load() async { + final raw = await _storage.read(key: _key); + if (raw == null) return []; + try { + final list = jsonDecode(raw) as List; + return list.map((e) => TotpEntry.fromJson(e as Map)).toList(); + } catch (_) { + return []; + } + } + + Future _saveAll(List entries) => + _storage.write(key: _key, value: jsonEncode(entries.map((e) => e.toJson()).toList())); + + /// Adds [entry], replacing any existing one with the same id, and returns it. + Future add(TotpEntry entry) async { + final entries = await load(); + entries.removeWhere((e) => e.id == entry.id); + entries.add(entry); + await _saveAll(entries); + return entry; + } + + Future remove(String id) async { + final entries = await load(); + entries.removeWhere((e) => e.id == id); + await _saveAll(entries); + } +} diff --git a/lib/ui/authenticator/add_totp_screen.dart b/lib/ui/authenticator/add_totp_screen.dart new file mode 100644 index 0000000..91b1e17 --- /dev/null +++ b/lib/ui/authenticator/add_totp_screen.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import '../../services/totp_service.dart'; +import '../../services/totp_vault.dart'; +import 'totp_scan_screen.dart'; + +/// Adds a TOTP authenticator entry - either by scanning an `otpauth://` QR code +/// with the camera or by typing the setup key manually. Saves the entry to the +/// [TotpVault] and pops the created [TotpEntry] (or null if cancelled). +class AddTotpScreen extends StatefulWidget { + const AddTotpScreen({super.key}); + + @override + State createState() => _AddTotpScreenState(); +} + +class _AddTotpScreenState extends State { + final _issuerCtrl = TextEditingController(); + final _accountCtrl = TextEditingController(); + final _secretCtrl = TextEditingController(); + bool _busy = false; + String? _error; + + @override + void dispose() { + _issuerCtrl.dispose(); + _accountCtrl.dispose(); + _secretCtrl.dispose(); + super.dispose(); + } + + /// Unique-enough id for a new entry (keystore is single-writer per device). + String _newId() => DateTime.now().microsecondsSinceEpoch.toString(); + + Future _scan() async { + final scanned = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const TotpScanScreen()), + ); + if (scanned == null || scanned.isEmpty || !mounted) return; + final entry = TotpEntry.fromPayload(_newId(), scanned); + if (entry == null) { + setState(() => _error = "That QR code doesn't contain a valid TOTP secret."); + return; + } + await _save(entry); + } + + Future _saveManual() async { + final secret = _secretCtrl.text.trim(); + if (TotpConfig.tryParse(secret) == null) { + setState(() => _error = 'Enter a valid TOTP secret (base32) or otpauth:// URI.'); + return; + } + final entry = TotpEntry.fromPayload( + _newId(), + secret, + issuer: _issuerCtrl.text.trim(), + account: _accountCtrl.text.trim(), + ); + if (entry == null) { + setState(() => _error = 'Enter a valid TOTP secret (base32) or otpauth:// URI.'); + return; + } + await _save(entry); + } + + Future _save(TotpEntry entry) async { + setState(() { + _busy = true; + _error = null; + }); + try { + await TotpVault.instance.add(entry); + if (mounted) Navigator.of(context).pop(entry); + } catch (e) { + if (mounted) setState(() => _error = '$e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final colors = context.theme.colors; + final typography = context.theme.typography; + return FScaffold( + header: FHeader.nested( + title: const Text('Add authenticator'), + prefixes: [FHeaderAction.back(onPress: () => Navigator.of(context).pop())], + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + FButton( + onPress: _busy ? null : _scan, + prefix: const Icon(FIcons.scanQrCode), + child: const Text('Scan QR code'), + ), + Row( + children: [ + const Expanded(child: FDivider()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text('or enter manually', style: typography.sm.copyWith(color: colors.mutedForeground)), + ), + const Expanded(child: FDivider()), + ], + ), + FTextField( + control: FTextFieldControl.managed(controller: _issuerCtrl), + label: const Text('Provider (optional)'), + hint: 'e.g. Schulnetz, GitHub', + autocorrect: false, + ), + FTextField( + control: FTextFieldControl.managed(controller: _accountCtrl), + label: const Text('Account (optional)'), + hint: 'e.g. your username or email', + autocorrect: false, + ), + FTextField( + control: FTextFieldControl.managed(controller: _secretCtrl), + label: const Text('Setup key'), + hint: 'TOTP secret or otpauth:// URI', + autocorrect: false, + ), + FButton( + style: FButtonStyle.outline(), + onPress: _busy ? null : _saveManual, + child: Text(_busy ? 'Saving…' : 'Save'), + ), + if (_error != null) + SelectableText(_error!, style: TextStyle(color: colors.destructive)), + ], + ), + ), + ); + } +} diff --git a/lib/ui/authenticator/authenticator_vault_screen.dart b/lib/ui/authenticator/authenticator_vault_screen.dart new file mode 100644 index 0000000..8e0e2ae --- /dev/null +++ b/lib/ui/authenticator/authenticator_vault_screen.dart @@ -0,0 +1,257 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:forui/forui.dart'; + +import '../../services/private_account_store.dart'; +import '../../services/totp_service.dart'; +import '../../services/totp_vault.dart'; +import 'add_totp_screen.dart'; + +/// One row's source: a parsed TOTP plus its display metadata. [id] is the vault +/// entry id, or null for a pinned, non-deletable row (e.g. a linked school's +/// seed surfaced from the private-mode store). +class _Row { + final String? id; + final String title; + final String? subtitle; + final TotpConfig config; + const _Row({required this.id, required this.title, required this.config, this.subtitle}); +} + +/// In-app authenticator vault. Schuly acts as the TOTP client: it lists every +/// saved secret, generates the current code on-device and refreshes each second, +/// with a per-entry countdown and tap-to-copy. Add entries by scanning a QR code +/// or typing the setup key. Provider-agnostic - any service's 2FA can live here. +class AuthenticatorVaultScreen extends StatefulWidget { + const AuthenticatorVaultScreen({super.key}); + + @override + State createState() => _AuthenticatorVaultScreenState(); +} + +class _AuthenticatorVaultScreenState extends State { + Timer? _timer; + bool _loading = true; + List<_Row> _rows = []; + + @override + void initState() { + super.initState(); + _load(); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted && _rows.isNotEmpty) setState(() {}); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + Future _load() async { + final entries = await TotpVault.instance.load(); + final private = await PrivateAccountStore.instance.load(); + final rows = <_Row>[]; + + // Surface a linked private-mode school's seed as a pinned, non-deletable row + // so the old single-account authenticator keeps working through this screen. + final privateConfig = TotpConfig.tryParse(private?.totpSecret); + if (privateConfig != null) { + rows.add(_Row(id: null, title: private!.displayName, subtitle: 'Linked school', config: privateConfig)); + } + + for (final e in entries) { + final config = TotpConfig.tryParse(e.secretOrUri); + if (config == null) continue; + rows.add(_Row(id: e.id, title: e.title, subtitle: e.subtitle, config: config)); + } + + if (!mounted) return; + setState(() { + _rows = rows; + _loading = false; + }); + } + + Future _add() async { + final added = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AddTotpScreen()), + ); + if (added != null) await _load(); + } + + Future _copy(String? code) async { + if (code == null) return; + await Clipboard.setData(ClipboardData(text: code)); + if (!mounted) return; + showFToast(context: context, title: const Text('Code copied')); + } + + Future _confirmDelete(_Row row) async { + final id = row.id; + if (id == null) return; // pinned rows aren't deletable + final confirmed = await showFDialog( + context: context, + builder: (ctx, style, animation) => FDialog( + animation: animation, + title: const Text('Remove authenticator?'), + body: Text('This deletes the saved 2FA secret for "${row.title}" from this device.'), + actions: [ + FButton(style: FButtonStyle.outline(), onPress: () => Navigator.of(ctx).pop(false), child: const Text('Cancel')), + FButton(style: FButtonStyle.destructive(), onPress: () => Navigator.of(ctx).pop(true), child: const Text('Remove')), + ], + ), + ); + if (confirmed != true) return; + await TotpVault.instance.remove(id); + await _load(); + } + + @override + Widget build(BuildContext context) { + return FScaffold( + header: FHeader.nested( + title: const Text('Authenticator'), + prefixes: [FHeaderAction.back(onPress: () => Navigator.of(context).pop())], + suffixes: [FHeaderAction(icon: const Icon(FIcons.plus), onPress: _add)], + ), + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _rows.isEmpty + ? _empty(context) + : ListView.separated( + itemCount: _rows.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (_, i) => _CodeCard( + row: _rows[i], + onCopy: _copy, + onDelete: () => _confirmDelete(_rows[i]), + ), + ), + ); + } + + Widget _empty(BuildContext context) { + final colors = context.theme.colors; + final typography = context.theme.typography; + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(FIcons.keyRound, size: 48, color: colors.mutedForeground), + const SizedBox(height: 16), + Text( + 'No authenticators yet', + style: typography.lg.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text( + 'Add a 2FA account to generate its codes here. Scan the QR code shown ' + 'when you set up two-factor authentication, or enter the setup key.', + textAlign: TextAlign.center, + style: typography.sm.copyWith(color: colors.mutedForeground), + ), + const SizedBox(height: 24), + FButton(prefix: const Icon(FIcons.plus), onPress: _add, child: const Text('Add authenticator')), + ], + ), + ), + ); + } +} + +/// A single live-updating code card: title/subtitle, the current code (tap to +/// copy), and a countdown bar. Long-press to delete (deletable rows only). +class _CodeCard extends StatelessWidget { + final _Row row; + final Future Function(String? code) onCopy; + final VoidCallback onDelete; + + const _CodeCard({required this.row, required this.onCopy, required this.onDelete}); + + /// `123456` → `123 456` for readability; leaves other lengths untouched. + String _format(String code) { + if (code.length != 6) return code; + return '${code.substring(0, 3)} ${code.substring(3)}'; + } + + @override + Widget build(BuildContext context) { + final colors = context.theme.colors; + final typography = context.theme.typography; + final code = TotpService.generate(row.config); + final fraction = code?.fraction ?? 0; + final low = (code?.secondsRemaining ?? 0) <= 5; + final accent = low ? colors.destructive : colors.primary; + + return FTappable( + onPress: () => onCopy(code?.code), + onLongPress: row.id == null ? null : onDelete, + child: FCard( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + row.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: typography.base.copyWith(fontWeight: FontWeight.w600), + ), + if (row.subtitle != null) + Text( + row.subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: typography.sm.copyWith(color: colors.mutedForeground), + ), + ], + ), + ), + const SizedBox(width: 12), + Text( + '${code?.secondsRemaining ?? 0}s', + style: typography.sm.copyWith(color: accent, fontFeatures: const [FontFeature.tabularFigures()]), + ), + ], + ), + const SizedBox(height: 8), + Text( + code == null ? '------' : _format(code.code), + style: typography.xl3.copyWith( + color: accent, + fontWeight: FontWeight.w700, + letterSpacing: 4, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: fraction, + minHeight: 4, + backgroundColor: colors.muted, + valueColor: AlwaysStoppedAnimation(accent), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/authenticator/totp_field_picker.dart b/lib/ui/authenticator/totp_field_picker.dart new file mode 100644 index 0000000..99e70e5 --- /dev/null +++ b/lib/ui/authenticator/totp_field_picker.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import '../../domain/school_system.dart'; +import '../../services/totp_vault.dart'; +import 'add_totp_screen.dart'; + +/// Login-form control for a `totp` field. Instead of typing a raw secret, the +/// user picks a saved authenticator from the [TotpVault] or adds a new one +/// (scanning a QR or entering a key) - the same vault the in-app authenticator +/// uses. The chosen entry's normalized base32 secret is written into +/// [controller] so the connect flow submits it unchanged. Provider-agnostic: +/// rendered for any system that advertises a `totp` field. +class TotpFieldPicker extends StatefulWidget { + final TextEditingController controller; + final SchoolSystemLoginField field; + + const TotpFieldPicker({required this.controller, required this.field, super.key}); + + @override + State createState() => _TotpFieldPickerState(); +} + +class _TotpFieldPickerState extends State { + List _entries = []; + TotpEntry? _selected; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final entries = await TotpVault.instance.load(); + if (!mounted) return; + setState(() { + _entries = entries; + // Re-resolve the current selection against the (possibly changed) vault by + // matching the secret already in the controller. + final current = widget.controller.text.trim(); + _selected = current.isEmpty ? null : entries.where((e) => e.secret == current).firstOrNull; + }); + } + + void _select(TotpEntry? entry) { + setState(() { + _selected = entry; + widget.controller.text = entry?.secret ?? ''; + }); + } + + Future _openMenu() async { + final action = await showFDialog<_PickerAction>( + context: context, + builder: (ctx, style, animation) => FDialog( + animation: animation, + title: Text(widget.field.label), + body: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FTile( + prefix: const Icon(FIcons.ban), + title: const Text('None'), + suffix: _selected == null ? const Icon(FIcons.check) : null, + onPress: () => Navigator.of(ctx).pop(const _PickerAction.none()), + ), + for (final e in _entries) + FTile( + prefix: const Icon(FIcons.keyRound), + title: Text(e.title), + subtitle: e.subtitle == null ? null : Text(e.subtitle!), + suffix: _selected?.id == e.id ? const Icon(FIcons.check) : null, + onPress: () => Navigator.of(ctx).pop(_PickerAction.select(e)), + ), + FTile( + prefix: const Icon(FIcons.plus), + title: const Text('Add new authenticator'), + onPress: () => Navigator.of(ctx).pop(const _PickerAction.add()), + ), + ], + ), + actions: [ + FButton(style: FButtonStyle.outline(), onPress: () => Navigator.of(ctx).pop(), child: const Text('Close')), + ], + ), + ); + if (action == null || !mounted) return; + + switch (action.kind) { + case _PickerKind.none: + _select(null); + case _PickerKind.select: + _select(action.entry); + case _PickerKind.add: + final added = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AddTotpScreen()), + ); + if (added == null || !mounted) return; + await _load(); + _select(added); + } + } + + @override + Widget build(BuildContext context) { + final colors = context.theme.colors; + final selected = _selected; + final subtitle = selected == null + ? 'Not set — tap to choose' + : [selected.title, if (selected.subtitle != null) selected.subtitle].join(' · '); + return FCard( + child: FTile( + prefix: const Icon(FIcons.shieldCheck), + title: Text(widget.field.label), + subtitle: Text(subtitle, style: selected == null ? TextStyle(color: colors.mutedForeground) : null), + suffix: const Icon(FIcons.chevronRight), + onPress: _openMenu, + ), + ); + } +} + +enum _PickerKind { none, select, add } + +/// Result of the picker menu - which action the user chose. +class _PickerAction { + final _PickerKind kind; + final TotpEntry? entry; + const _PickerAction.none() : kind = _PickerKind.none, entry = null; + const _PickerAction.add() : kind = _PickerKind.add, entry = null; + const _PickerAction.select(this.entry) : kind = _PickerKind.select; +} diff --git a/lib/ui/private/totp_scan_screen.dart b/lib/ui/authenticator/totp_scan_screen.dart similarity index 100% rename from lib/ui/private/totp_scan_screen.dart rename to lib/ui/authenticator/totp_scan_screen.dart diff --git a/lib/ui/dashboard/widgets/accounts_sidebar.dart b/lib/ui/dashboard/widgets/accounts_sidebar.dart index 202caf6..2aa393e 100644 --- a/lib/ui/dashboard/widgets/accounts_sidebar.dart +++ b/lib/ui/dashboard/widgets/accounts_sidebar.dart @@ -4,8 +4,7 @@ import 'package:forui/forui.dart'; import '../../../domain/my_school.dart'; import '../../../services/active_account_service.dart'; import '../../../services/app_mode_service.dart'; -import '../../../services/private_account_store.dart'; -import '../../private/authenticator_screen.dart'; +import '../../authenticator/authenticator_vault_screen.dart'; import '../../settings/settings_screen.dart'; import 'add_school_modal.dart'; @@ -174,31 +173,16 @@ class AccountsSidebar extends StatelessWidget { ), const SizedBox(height: 4), ], - if (isPrivate) - FutureBuilder( - future: PrivateAccountStore.instance.load(), - builder: (context, snapshot) { - final hasTotp = - snapshot.data?.totpSecret?.isNotEmpty ?? false; - if (!hasTotp) return const SizedBox.shrink(); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - FTile( - prefix: const Icon(FIcons.keyRound), - title: const Text('Authenticator'), - onPress: () { - Navigator.of(context).maybePop(); - parentNavigator.push(MaterialPageRoute( - builder: (_) => - const AuthenticatorScreen())); - }, - ), - const SizedBox(height: 4), - ], - ); - }, - ), + FTile( + prefix: const Icon(FIcons.keyRound), + title: const Text('Authenticator'), + onPress: () { + Navigator.of(context).maybePop(); + parentNavigator.push(MaterialPageRoute( + builder: (_) => const AuthenticatorVaultScreen())); + }, + ), + const SizedBox(height: 4), FTile( prefix: const Icon(FIcons.settings), title: const Text('Settings'), diff --git a/lib/ui/private/authenticator_screen.dart b/lib/ui/private/authenticator_screen.dart deleted file mode 100644 index 50c1a45..0000000 --- a/lib/ui/private/authenticator_screen.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:forui/forui.dart'; - -import '../../services/private_account_store.dart'; -import '../../services/totp_service.dart'; - -/// In-app authenticator for the connected private-mode school. Schuly acts as -/// the TOTP client: it generates the current code from the vaulted seed and -/// refreshes it every second, with a countdown and tap-to-copy - so the code is -/// available for use elsewhere, not just for Schuly's own re-login. -class AuthenticatorScreen extends StatefulWidget { - const AuthenticatorScreen({super.key}); - - @override - State createState() => _AuthenticatorScreenState(); -} - -class _AuthenticatorScreenState extends State { - Timer? _timer; - bool _loading = true; - TotpConfig? _config; - String _title = 'Authenticator'; - TotpCode? _code; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - final account = await PrivateAccountStore.instance.load(); - final config = TotpConfig.tryParse(account?.totpSecret); - if (!mounted) return; - setState(() { - _config = config; - _title = account?.displayName ?? 'Authenticator'; - _code = config == null ? null : TotpService.generate(config); - _loading = false; - }); - if (config != null) { - _timer = Timer.periodic(const Duration(seconds: 1), (_) => _tick()); - } - } - - void _tick() { - final config = _config; - if (config == null) return; - setState(() => _code = TotpService.generate(config)); - } - - Future _copy() async { - final code = _code?.code; - if (code == null) return; - await Clipboard.setData(ClipboardData(text: code)); - if (!mounted) return; - showFToast(context: context, title: const Text('Code copied')); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - /// `123456` → `123 456` for readability; leaves other lengths untouched. - String _format(String code) { - if (code.length != 6) return code; - return '${code.substring(0, 3)} ${code.substring(3)}'; - } - - @override - Widget build(BuildContext context) { - final colors = context.theme.colors; - final typography = context.theme.typography; - - return FScaffold( - header: FHeader.nested( - title: const Text('Authenticator'), - prefixes: [ - FHeaderAction.back(onPress: () => Navigator.of(context).pop()), - ], - ), - child: _loading - ? const Center(child: CircularProgressIndicator()) - : _config == null - ? Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text( - 'No authenticator secret is stored for this connection. ' - 'Reconnect and scan or enter your TOTP code to enable it.', - textAlign: TextAlign.center, - style: typography.sm - .copyWith(color: colors.mutedForeground), - ), - ), - ) - : _content(context), - ); - } - - Widget _content(BuildContext context) { - final colors = context.theme.colors; - final typography = context.theme.typography; - final code = _code; - final fraction = code?.fraction ?? 0; - final low = (code?.secondsRemaining ?? 0) <= 5; - final accent = low ? colors.destructive : colors.primary; - - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - _title, - textAlign: TextAlign.center, - style: typography.lg.copyWith(fontWeight: FontWeight.w600), - ), - Text( - 'Tap the code to copy', - textAlign: TextAlign.center, - style: typography.sm.copyWith(color: colors.mutedForeground), - ), - const SizedBox(height: 24), - GestureDetector( - onTap: _copy, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), - decoration: BoxDecoration( - color: colors.secondary, - borderRadius: BorderRadius.circular(16), - ), - child: Text( - code == null ? '------' : _format(code.code), - textAlign: TextAlign.center, - style: typography.xl4.copyWith( - color: accent, - fontWeight: FontWeight.w700, - letterSpacing: 4, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ), - ), - const SizedBox(height: 16), - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: fraction, - minHeight: 6, - backgroundColor: colors.muted, - valueColor: AlwaysStoppedAnimation(accent), - ), - ), - const SizedBox(height: 8), - Text( - 'Refreshes in ${code?.secondsRemaining ?? 0}s', - textAlign: TextAlign.center, - style: typography.sm.copyWith(color: colors.mutedForeground), - ), - ], - ), - ); - } -} diff --git a/lib/ui/private/private_connect_screen.dart b/lib/ui/private/private_connect_screen.dart index bb0c355..9837f32 100644 --- a/lib/ui/private/private_connect_screen.dart +++ b/lib/ui/private/private_connect_screen.dart @@ -8,7 +8,6 @@ import '../../services/scrape_proxy_client.dart'; import '../../services/token_proxy_client.dart'; import '../../services/totp_service.dart'; import '../widgets/dynamic_login_form.dart'; -import 'totp_scan_screen.dart'; /// Generic private-mode connect screen. Renders the chosen [system]'s /// backend-described `loginFields` and connects headlessly - no WebView. The @@ -121,17 +120,6 @@ class _PrivateConnectScreenState extends State { if (mounted) Navigator.of(context).pop(true); } - /// Opens the QR scanner and writes the scanned `otpauth://` URI / secret back - /// into the TOTP field. - Future _scanTotp(String fieldKey) async { - final scanned = await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const TotpScanScreen()), - ); - if (scanned != null && scanned.isNotEmpty) { - _form.controllerFor(fieldKey).text = scanned; - } - } - Future _connectScrape( String baseUrl, String name, String basePath) async { final account = PrivateAccount( @@ -165,7 +153,7 @@ class _PrivateConnectScreenState extends State { 'Private mode keeps everything on this device - no account, ' 'nothing stored on a server.', ), - DynamicLoginForm(controller: _form, onScanField: _scanTotp), + DynamicLoginForm(controller: _form), FTextField( control: FTextFieldControl.managed(controller: _nameCtrl), label: const Text('Display Name'), diff --git a/lib/ui/widgets/dynamic_login_form.dart b/lib/ui/widgets/dynamic_login_form.dart index 31bab0c..7ae7751 100644 --- a/lib/ui/widgets/dynamic_login_form.dart +++ b/lib/ui/widgets/dynamic_login_form.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:forui/forui.dart'; import '../../domain/school_system.dart'; +import '../authenticator/totp_field_picker.dart'; /// Owns the text controllers for a set of backend-described login fields and /// exposes their collected values. The screen creates one from a system's @@ -47,20 +48,12 @@ class DynamicLoginFormController { class DynamicLoginForm extends StatelessWidget { final DynamicLoginFormController controller; - /// Invoked when the user taps "Scan QR code" on a TOTP field. Receives the - /// field key; the caller scans and writes the result back into the - /// controller. When null, no scan affordance is shown. - final Future Function(String fieldKey)? onScanField; + const DynamicLoginForm({required this.controller, super.key}); - const DynamicLoginForm({ - required this.controller, - this.onScanField, - super.key, - }); - - /// A field that carries a TOTP seed - obscured and offered a QR scanner. - /// Detected by an explicit `totp` type or the conventional `totp` key, so the - /// backend catalog can opt in without the app hardcoding a provider. + /// A field that carries a TOTP secret - rendered as the authenticator picker + /// (select a saved entry or add a new one). Detected by an explicit `totp` + /// type or the conventional `totp` key, so the backend catalog can opt in + /// without the app hardcoding a provider. static bool _isTotp(SchoolSystemLoginField f) => f.type == 'totp' || f.key.toLowerCase() == 'totp'; @@ -73,12 +66,9 @@ class DynamicLoginForm extends StatelessWidget { children: [ for (final field in controller.fields) if (_isTotp(field)) - _TotpField( + TotpFieldPicker( controller: controller.controllerFor(field.key), field: field, - onScan: onScanField == null - ? null - : () => onScanField!(field.key), ) else FTextField( @@ -97,41 +87,3 @@ class DynamicLoginForm extends StatelessWidget { ); } } - -/// A TOTP seed input: obscured text plus an optional "Scan QR code" button. -class _TotpField extends StatelessWidget { - final TextEditingController controller; - final SchoolSystemLoginField field; - final VoidCallback? onScan; - - const _TotpField({ - required this.controller, - required this.field, - this.onScan, - }); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - FTextField( - control: FTextFieldControl.managed(controller: controller), - label: Text(field.label), - hint: field.placeholder ?? 'Secret or otpauth:// URI', - obscureText: true, - autocorrect: false, - ), - if (onScan != null) - FButton( - style: FButtonStyle.outline(), - prefix: const Icon(FIcons.scanQrCode), - onPress: onScan, - child: const Text('Scan QR code'), - ), - ], - ); - } -}