From 2868a431efd3c65d7f6f2f9a7f6c2a5af6255ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Sun, 23 Aug 2026 18:53:11 +0200 Subject: [PATCH 1/5] remember mfa on connected locations --- client/ios/RunnerTests/RunnerTests.swift | 3 +- client/ios/VPNExtension/Shared.swift | 11 +- client/lib/data/plugin/plugin.dart | 11 ++ client/lib/data/plugin/plugin.g.dart | 26 +++- .../instance/next_instance_screen.dart | 81 ++++++++---- .../instance/services/tunnel_service.dart | 3 + .../open/widgets/next/next_location_card.dart | 1 + .../lib/open/widgets/next_mfa_selector.dart | 122 ++++++++++++++++++ .../wireguard_plugin/TunnelStartData.kt | 5 +- .../wireguard_plugin/WireguardPlugin.kt | 1 + .../net/defguard/wireguard_plugin/event.kt | 1 + 11 files changed, 232 insertions(+), 33 deletions(-) create mode 100644 client/lib/open/widgets/next_mfa_selector.dart diff --git a/client/ios/RunnerTests/RunnerTests.swift b/client/ios/RunnerTests/RunnerTests.swift index 3b50f9e8..11f608de 100644 --- a/client/ios/RunnerTests/RunnerTests.swift +++ b/client/ios/RunnerTests/RunnerTests.swift @@ -51,7 +51,8 @@ class RunnerTests: XCTestCase { traffic: .All, locationName: "Test Location", locationId: 1, - instanceId: 1 + instanceId: 1, + mfaMethod: 0 ) func testConnectDisconnect() { diff --git a/client/ios/VPNExtension/Shared.swift b/client/ios/VPNExtension/Shared.swift index 43fb683c..c70c5b12 100644 --- a/client/ios/VPNExtension/Shared.swift +++ b/client/ios/VPNExtension/Shared.swift @@ -25,10 +25,14 @@ public struct TunnelStartData: Codable { public var locationName: String public var locationId: Int public var instanceId: Int + // Opaque MFA method identifier, passed through from Dart for display only. + // Nil when the tunnel was authorized without MFA. + public var mfaMethod: Int? public init(publicKey: String, privateKey: String, address: String, dns: String? = nil, endpoint: String, allowedIps: String, keepalive: Int, presharedKey: String? = nil, - traffic: TunnelTraffic, locationName: String, locationId: Int, instanceId: Int) { + traffic: TunnelTraffic, locationName: String, locationId: Int, instanceId: Int, + mfaMethod: Int? = nil) { self.publicKey = publicKey self.privateKey = privateKey self.address = address @@ -41,6 +45,7 @@ public struct TunnelStartData: Codable { self.locationName = locationName self.locationId = locationId self.instanceId = instanceId + self.mfaMethod = mfaMethod } } @@ -48,11 +53,13 @@ public struct ActiveTunnelData: Codable { var locationId: Int var instanceId: Int var traffic: TunnelTraffic - + var mfaMethod: Int? + init(fromConfig: TunnelStartData) { self.locationId = fromConfig.locationId self.instanceId = fromConfig.instanceId self.traffic = fromConfig.traffic + self.mfaMethod = fromConfig.mfaMethod } } diff --git a/client/lib/data/plugin/plugin.dart b/client/lib/data/plugin/plugin.dart index 4b7b7a90..eac91f72 100644 --- a/client/lib/data/plugin/plugin.dart +++ b/client/lib/data/plugin/plugin.dart @@ -25,6 +25,10 @@ class PluginConnectPayload { RoutingMethod traffic; final bool postureCheckRequired; + /// MFA method actually used to authorize this connection. + /// `null` means the tunnel was established without MFA. + MfaMethod? mfaMethod; + PluginConnectPayload({ required this.publicKey, required this.devicePublicKey, @@ -41,6 +45,7 @@ class PluginConnectPayload { this.dns, this.presharedKey, required this.postureCheckRequired, + this.mfaMethod, }); factory PluginConnectPayload.fromJson(Map json) => @@ -55,10 +60,16 @@ class PluginTunnelEventData { final int locationId; final RoutingMethod traffic; + /// MFA method used to authorize the active connection. + /// `null` means no MFA was used, or the tunnel was started by a native + /// build predating this field. + final MfaMethod? mfaMethod; + const PluginTunnelEventData({ required this.instanceId, required this.locationId, required this.traffic, + this.mfaMethod, }); factory PluginTunnelEventData.fromJson(Map json) => diff --git a/client/lib/data/plugin/plugin.g.dart b/client/lib/data/plugin/plugin.g.dart index 6fb711c7..0b88f4ba 100644 --- a/client/lib/data/plugin/plugin.g.dart +++ b/client/lib/data/plugin/plugin.g.dart @@ -34,6 +34,10 @@ PluginConnectPayload _$PluginConnectPayloadFromJson( 'posture_check_required', (v) => v as bool, ), + mfaMethod: $checkedConvert( + 'mfa_method', + (v) => $enumDecodeNullable(_$MfaMethodEnumMap, v), + ), ); return val; }, @@ -48,6 +52,7 @@ PluginConnectPayload _$PluginConnectPayloadFromJson( 'networkId': 'network_id', 'presharedKey': 'preshared_key', 'postureCheckRequired': 'posture_check_required', + 'mfaMethod': 'mfa_method', }, ); @@ -67,6 +72,7 @@ const _$PluginConnectPayloadFieldMap = { 'networkId': 'network_id', 'traffic': 'traffic', 'postureCheckRequired': 'posture_check_required', + 'mfaMethod': 'mfa_method', }; Map _$PluginConnectPayloadToJson( @@ -87,6 +93,7 @@ Map _$PluginConnectPayloadToJson( 'network_id': instance.networkId, 'traffic': _$RoutingMethodEnumMap[instance.traffic]!, 'posture_check_required': instance.postureCheckRequired, + 'mfa_method': _$MfaMethodEnumMap[instance.mfaMethod], }; const _$RoutingMethodEnumMap = { @@ -94,6 +101,13 @@ const _$RoutingMethodEnumMap = { RoutingMethod.predefined: 'predefined', }; +const _$MfaMethodEnumMap = { + MfaMethod.totp: 0, + MfaMethod.email: 1, + MfaMethod.openid: 2, + MfaMethod.biometric: 3, +}; + PluginTunnelEventData _$PluginTunnelEventDataFromJson( Map json, ) => $checkedCreate( @@ -107,16 +121,25 @@ PluginTunnelEventData _$PluginTunnelEventDataFromJson( 'traffic', (v) => $enumDecode(_$RoutingMethodEnumMap, v), ), + mfaMethod: $checkedConvert( + 'mfa_method', + (v) => $enumDecodeNullable(_$MfaMethodEnumMap, v), + ), ); return val; }, - fieldKeyMap: const {'instanceId': 'instance_id', 'locationId': 'location_id'}, + fieldKeyMap: const { + 'instanceId': 'instance_id', + 'locationId': 'location_id', + 'mfaMethod': 'mfa_method', + }, ); const _$PluginTunnelEventDataFieldMap = { 'instanceId': 'instance_id', 'locationId': 'location_id', 'traffic': 'traffic', + 'mfaMethod': 'mfa_method', }; Map _$PluginTunnelEventDataToJson( @@ -125,4 +148,5 @@ Map _$PluginTunnelEventDataToJson( 'instance_id': instance.instanceId, 'location_id': instance.locationId, 'traffic': _$RoutingMethodEnumMap[instance.traffic]!, + 'mfa_method': _$MfaMethodEnumMap[instance.mfaMethod], }; diff --git a/client/lib/open/screens/instance/next_instance_screen.dart b/client/lib/open/screens/instance/next_instance_screen.dart index 1854ae08..c1183e42 100644 --- a/client/lib/open/screens/instance/next_instance_screen.dart +++ b/client/lib/open/screens/instance/next_instance_screen.dart @@ -9,6 +9,7 @@ import 'package:mobile/open/api.dart'; import 'package:mobile/open/riverpod/biometrics_state.dart'; import 'package:mobile/open/riverpod/plugin/plugin.dart'; import 'package:mobile/open/screens/instance/services/tunnel_service.dart'; +import 'package:mobile/open/screens/instance/widgets/connection_conflict_dialog.dart'; import 'package:mobile/open/screens/instance/widgets/delete_instance_dialog.dart'; import 'package:mobile/open/screens/instance/widgets/next_refresh_instance_dialog.dart'; import 'package:mobile/open/screens/mfa/remote_mfa_qr_screen.dart'; @@ -380,7 +381,7 @@ class _InstanceAppBar extends StatelessWidget implements PreferredSizeWidget { Size get preferredSize => Size.fromHeight(NextAppBar.baseHeight + topPadding); } -class _LocationList extends StatelessWidget { +class _LocationList extends HookConsumerWidget { final _ScreenData data; final PluginTunnelEventData? activeTunnel; final WireguardPlugin wireguardPlugin; @@ -393,39 +394,60 @@ class _LocationList extends StatelessWidget { required this.biometricStatus, }); - Future _onDisconnect(Location location) async { - try { - await wireguardPlugin.closeTunnel(); - talker.debug("Disconnected from ${location.name}"); - } catch (e) { - talker.error("Failed to disconnect", e); - SnackbarService.showError("Failed to disconnect"); + @override + Widget build(BuildContext context, WidgetRef ref) { + final loadingLocationId = useState(null); + + Future onDisconnect(Location location) async { + try { + await wireguardPlugin.closeTunnel(); + talker.debug("Disconnected from ${location.name}"); + } catch (e) { + talker.error("Failed to disconnect", e); + SnackbarService.showError("Failed to disconnect"); + } } - } - Future _onConnect(BuildContext context, Location location) async { - try { - final permissionsGranted = await wireguardPlugin.requestPermissions(); - if (permissionsGranted) { - if (context.mounted) { - await TunnelService.connect( + Future onConnect(BuildContext context, Location location) async { + loadingLocationId.value = location.id; + try { + if (activeTunnel != null) { + final bool? changeConnection = await showDialog( context: context, - instance: data.instance, - location: location, - wireguardPlugin: wireguardPlugin, - biometricsStatus: biometricStatus, + builder: (BuildContext context) { + return const ConnectionConflictDialog(); + }, ); - talker.debug("Connected to ${location.name}"); + + if (changeConnection != true) { + loadingLocationId.value = null; + return; + } + + await wireguardPlugin.closeTunnel(); + } + + final permissionsGranted = await wireguardPlugin.requestPermissions(); + if (permissionsGranted) { + if (context.mounted) { + await TunnelService.connect( + context: context, + instance: data.instance, + location: location, + wireguardPlugin: wireguardPlugin, + biometricsStatus: biometricStatus, + ); + talker.debug("Connected to ${location.name}"); + } } + } catch (e) { + talker.error("Failed to connect", e); + SnackbarService.showError("Failed to connect"); + } finally { + loadingLocationId.value = null; } - } catch (e) { - talker.error("Failed to connect", e); - SnackbarService.showError("Failed to connect"); } - } - @override - Widget build(BuildContext context) { final locations = data.locations; final connectedLocation = locations.firstWhereOrNull( (l) => @@ -461,7 +483,8 @@ class _LocationList extends StatelessWidget { location: connectedLocation, isConnected: true, routingMethod: activeTunnel?.traffic, - onDisconnectTap: () => _onDisconnect(connectedLocation), + mfaMethod: connectedLocation.mfaMethod, + onDisconnectTap: () => onDisconnect(connectedLocation), ), const SizedBox(height: NextSpacing.xl), ], @@ -477,7 +500,9 @@ class _LocationList extends StatelessWidget { child: NextLocationCard( location: location, isConnected: false, - onConnectTap: () => _onConnect(context, location), + loading: loadingLocationId.value == location.id, + mfaMethod: location.mfaMethod, + onConnectTap: () => onConnect(context, location), ), ), ), diff --git a/client/lib/open/screens/instance/services/tunnel_service.dart b/client/lib/open/screens/instance/services/tunnel_service.dart index 705bacae..6f4333d6 100644 --- a/client/lib/open/screens/instance/services/tunnel_service.dart +++ b/client/lib/open/screens/instance/services/tunnel_service.dart @@ -120,6 +120,9 @@ class TunnelService { return; } payload.presharedKey = presharedKey; + // record which method actually authorized this connection, so the UI can + // display it for the active tunnel + payload.mfaMethod = mfaMethod; } else if (payload.postureCheckRequired) { final presharedKey = await _performPostureCheck( navigator: navigator, diff --git a/client/lib/open/widgets/next/next_location_card.dart b/client/lib/open/widgets/next/next_location_card.dart index e128c58d..91a01eb3 100644 --- a/client/lib/open/widgets/next/next_location_card.dart +++ b/client/lib/open/widgets/next/next_location_card.dart @@ -182,6 +182,7 @@ class NextLocationCard extends StatelessWidget { NextButton( text: "Connect", onTap: onConnectTap, + loading: loading, size: NextButtonSize.big, style: NextButtonStyle.secondary, height: 36, diff --git a/client/lib/open/widgets/next_mfa_selector.dart b/client/lib/open/widgets/next_mfa_selector.dart new file mode 100644 index 00000000..f6ae9e75 --- /dev/null +++ b/client/lib/open/widgets/next_mfa_selector.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widget_previews.dart'; +import 'package:mobile/data/db/enums.dart'; +import 'package:mobile/open/widgets/next/icons/next_icon.dart'; +import 'package:mobile/open/widgets/next/next_preview_wrapper.dart'; +import 'package:mobile/theme/next/color.dart'; +import 'package:mobile/theme/next/spacing.dart'; +import 'package:mobile/theme/next/text.dart'; + +class NextMfaSelector extends StatelessWidget { + final bool active; + final MfaMethod factor; + final VoidCallback? onTap; + final bool disabled; + + const NextMfaSelector({ + super.key, + required this.active, + required this.factor, + this.onTap, + this.disabled = false, + }); + + String get getIcon { + switch (factor) { + case MfaMethod.totp: + return 'mobile_lock'; + case MfaMethod.email: + return 'mail'; + case MfaMethod.biometric: + return 'biometric'; + case MfaMethod.openid: + return 'globe'; + } + } + + Color get getIconColor { + if (disabled) return NextColor.fgDisabled; + return active ? NextColor.fgWhite100 : NextColor.fgWhite80; + } + + String get getLabel => factor.toUiString(); + + Color get getLabelColor { + if (disabled) return NextColor.fgDisabled; + return active ? NextColor.fgWhite100 : NextColor.fgWhite80; + } + + @override + Widget build(BuildContext context) { + final contentColor = getLabelColor; + const duration = Duration(milliseconds: 200); + const curve = Curves.easeOut; + + return GestureDetector( + onTap: disabled ? null : onTap, + child: AnimatedContainer( + duration: duration, + curve: curve, + padding: const EdgeInsets.fromLTRB(16, 8, 12, 8), + decoration: BoxDecoration( + color: active ? NextColor.bgWhite10 : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: NextColor.bgWhite10, width: 1), + ), + child: TweenAnimationBuilder( + duration: duration, + curve: curve, + tween: ColorTween(end: contentColor), + builder: (context, color, child) { + final effectiveColor = color ?? contentColor; + return Row( + mainAxisSize: MainAxisSize.min, + spacing: NextSpacing.md, + children: [ + NextIcon(getIcon, size: 20, color: effectiveColor), + Text( + getLabel, + style: NextText.bodySm400.copyWith(color: effectiveColor), + ), + ], + ); + }, + ), + ), + ); + } +} + +@Preview(name: 'NextMfaSelector States', group: 'NextMfaSelector') +Widget previewNextMfaSelector() { + return NextPreviewWrapper( + child: Padding( + padding: const EdgeInsets.all(NextSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: NextSpacing.md, + children: [ + NextMfaSelector(active: true, factor: MfaMethod.totp, onTap: () {}), + NextMfaSelector(active: false, factor: MfaMethod.totp, onTap: () {}), + NextMfaSelector(active: false, factor: MfaMethod.email, onTap: () {}), + NextMfaSelector( + active: false, + factor: MfaMethod.biometric, + onTap: () {}, + ), + NextMfaSelector( + active: false, + factor: MfaMethod.openid, + onTap: () {}, + ), + NextMfaSelector( + active: false, + factor: MfaMethod.totp, + disabled: true, + onTap: () {}, + ), + ], + ), + ), + ); +} diff --git a/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/TunnelStartData.kt b/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/TunnelStartData.kt index 76bf81d5..5b3ae9c7 100644 --- a/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/TunnelStartData.kt +++ b/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/TunnelStartData.kt @@ -27,5 +27,8 @@ data class TunnelStartData( // context val locationName: String, val locationId: Int, - val instanceId: Int + val instanceId: Int, + // Opaque MFA method identifier, passed through from Dart for display only. + // Null when the tunnel was authorized without MFA. + val mfaMethod: Int? = null ) \ No newline at end of file diff --git a/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/WireguardPlugin.kt b/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/WireguardPlugin.kt index 3add22b4..01dbbe3d 100644 --- a/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/WireguardPlugin.kt +++ b/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/WireguardPlugin.kt @@ -303,6 +303,7 @@ class WireguardPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, instanceId = configData.instanceId, traffic = configData.traffic, mfaEnabled = !configData.presharedKey.isNullOrBlank(), + mfaMethod = configData.mfaMethod, ) futureBackend.await().setState(tunnel, Tunnel.State.UP, config) diff --git a/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/event.kt b/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/event.kt index 7361a391..43c660b8 100644 --- a/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/event.kt +++ b/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/event.kt @@ -15,4 +15,5 @@ data class ActiveTunnelData( val locationId: Int, val traffic: TunnelTraffic, val mfaEnabled: Boolean, + val mfaMethod: Int? = null, ) From 22b48a9147b0e80aff1309644321b60e52e19cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Mon, 24 Aug 2026 09:01:21 +0200 Subject: [PATCH 2/5] connect next --- client/assets/next/rive/add_instance.riv | Bin 76576 -> 70731 bytes client/assets/next/rive/biometric.riv | Bin 0 -> 1804 bytes client/assets/next/rive/biometric_sad.riv | Bin 0 -> 1606 bytes .../next/rive/biometric_unavailable.riv | Bin 0 -> 1919 bytes .../instance/next_instance_screen.dart | 47 +++++- .../instance/services/tunnel_service.dart | 18 +- .../instance/widgets/next_connect_dialog.dart | 157 ++++++++++++++++++ .../open/widgets/next/next_bottom_sheet.dart | 49 +++--- client/lib/open/widgets/next/next_button.dart | 50 +++--- 9 files changed, 265 insertions(+), 56 deletions(-) create mode 100644 client/assets/next/rive/biometric.riv create mode 100644 client/assets/next/rive/biometric_sad.riv create mode 100644 client/assets/next/rive/biometric_unavailable.riv create mode 100644 client/lib/open/screens/instance/widgets/next_connect_dialog.dart diff --git a/client/assets/next/rive/add_instance.riv b/client/assets/next/rive/add_instance.riv index 5c40ea27589a1cd9d549ff0003026e147dfbf3c1..37a629b9853abeabfe212f1ee196abbb81da02f9 100644 GIT binary patch literal 70731 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 MQ_vGw=k<909~**D3jhEB diff --git a/client/assets/next/rive/biometric.riv b/client/assets/next/rive/biometric.riv new file mode 100644 index 0000000000000000000000000000000000000000..e0f41373c5127b80d7316d1b187e202d776b92a3 GIT binary patch literal 1804 zcma)7U1$_n6h3$7&hEOH*_wpxOcb-yCI)P@YuLur?9SX((`+>en%($h+3HdQCMny{ z(hxCJ+J{1E=nth&itUS{BDG4XmE8rwpC-^3QLuuj(Y1+bl~6&^^xQjlGNZmY54-0( z``z<>=bV|@E$QrrfHHdR2XdDDO&`!x^bVaS)ATMqO7GL#bViw?6*@`JQE)CIg9w~Z zj?z%JFaL4(;LiN+7@Sm2QJ}%}z=uP*fgO3knh*dukP7mT86`eV01MF|D-69im@DiA zp9)n|PnR<-Ki9xOQAyRKr&COObgv}^uc*FJ;sd{`=&!yLDjQwJ{$loUxUteKm#Gz`nt#J-7A$9PxTTDCh*CmEhK~U8N z`dDnxG#>i@EMWnOW=4pWy&Aq?_Ezt^VmkREXIVYbuC8Kc?>OeG)irM6g`X}?Y1dx+ zl^52qaM<{0^XiflSV(Sn$&WXF%^Y$Ka%vs22srsI5BWxtY0Eya_;hq68~VfT5oR1O z)G@SX=#l*aOCYqIwt6f7{lRA;SY>0|MSlsWb-~OGpToR7UFQ5U%B*!|&cEG}a%39# z5>z#+2i}bcE?0``+5Lf%tt6)Y#8wh4_M(!^OQnwIlzz67d{j*+v)q#+po~NtqVAp_TerJ&sN)t<;CV^jiYiqdR0b zBv|Dam1JHjC2gf@_07iS=H@y0Nl{^vlG~Krp`=2|U5XDEnWFe$k^7W9pag1FX!e1w z`c?sy%r{7kAP9Pa<^?rgpnE}s7c_Z6iwl;R0Hdb0Em1km&KPMkHBs1Wg3KB>zxth> zsCyr?10yChw1s;5`t!T{2J+EZ`*me4>43y_B}u?&t%3UZB3Qa^(R$L!D`HrO;l%iO z0>i~lwmx2kaMOC<1`}!DG6Y!xLc6wdFyaND6M@uUCiqXsoD@sDjJ?uciHy@b2AcR#zw&w;&r31oM(d)9cmlW4v>tl{nM<#!)%3vV6D m4dtWVxgER2?F{W-kie$gC;37&28r+RugnDYJ$@y@g8u>E58xL7 literal 0 HcmV?d00001 diff --git a/client/assets/next/rive/biometric_sad.riv b/client/assets/next/rive/biometric_sad.riv new file mode 100644 index 0000000000000000000000000000000000000000..4cbb260f91479a0e80a336de14821e9bda7f4c26 GIT binary patch literal 1606 zcmcJPT}TvB6vxk01(2Eg46bMpiLBZ}qV46V>Jp{g0<|jrG2}RjGcV?U^pPPr>^FQ-H_jm4@xpOcQ zt93cU_EWH5Dic0Kc z)*uWzhA2>Xr2SM^yuB#_=j0?}&Gv06~OofPm%!@N3`@ ziFe!^QL}&ip2bvE7*>cv4-vpAI;3@bGShNhvMm{V==&UMtj{P?F^?&w0n4IUH==3` z+ib+hEYf&U!^Fl7YLpk{h~-6#>_T4l-OC>l^-W_aH;7_})En$jAOSpDU%4 z_|$*tB<|9dlZ?DUs``iYNDbkriWz3Tjv2(3oJ?^HD*fRa81B;Kwo#{w8%jn$=o>Qf z%&)NxRa=9m`(bIHcP$vY;*=`v($afsdA-{?t#7`?n*Bw8F_;g(n&euwxU2F zun6}YB1}+rrpP2EQ#Q`n=RJ?P<5YJH@wTouXIg9x=t6VO1 zvra7lp4=sXAxyx49pDVx09@#RF$=)Uc%I170qd zdj8L>@msUhsUgfx1-xU(0Vc6W137CUk4pF^S_>S&~)7BU*e zFFH|!frMZbF$Nd^1x$p+VAv88+iT)K0vb*5i%X0jgn?T?h>1+2_`L7kwb$&c{cw4H z&;8!t?|Gj0-nZi;>CGPD_JMcE7vwj3i_X#ox?m+99O>>sg$ z7Whm!O&im>>~o{LcVzQXI4jIjpbaC1y~RvnA`7Sq9)R}-8`$Hz@aPf&6rv5F6mHnnVHI zB9#gs>V|x#qco7-Gx0}B-}&TA3eUI9jrFp8DLEe)QB%LZd_qaHl64l@D^U0*+7VczinePMykM@Di;61;g;o|@aEVrhu>B@~XZXa( zj9$iRomWzSy6;i#)du4mXp^k7QEi*>6mcPU4RhjLrif@?qHH?xsasB*rLJ_c@|II- zr4t*~I<$#YZP*?lvTO`1%3@O9+gjnstgPcmm~BB!dh5*7 z75U%jan0P+x8rLZ`Jbc5E9|>1>(#uiQ!$TBRp&=TlcxziE61f8( z2_jsk;z#a zSmy*CPO#Al`Zz$#yOq$pMMVpLvsW3+b?#T7wWl$ednTLD6|&*zrc?Vgu2_H zZHR2c?f?@3Oss;{Lu43WN`ci62^fBRF+PByyOw>h7WQ+(#eO8Hb~e=QLwJY`xi)Vf zX15QbV>( + context: context, + child: NextConnectDialog( instance: data.instance, location: location, - wireguardPlugin: wireguardPlugin, - biometricsStatus: biometricStatus, - ); - talker.debug("Connected to ${location.name}"); + ), + ); + + if (result == null) { + loadingLocationId.value = null; + return; + } + + // Update location with selected traffic method so TunnelService uses it + final db = ref.read(databaseProvider); + await (db.update( + db.locations, + )..where((t) => t.id.equals(location.id))).write( + LocationsCompanion( + trafficMethod: drift.Value(result['traffic'] as RoutingMethod), + ), + ); + + final permissionsGranted = await wireguardPlugin.requestPermissions(); + if (permissionsGranted) { + if (context.mounted) { + await TunnelService.connect( + context: context, + instance: data.instance, + location: location, + wireguardPlugin: wireguardPlugin, + biometricsStatus: biometricStatus, + trafficMethod: result['traffic'] as RoutingMethod, + ); + talker.debug("Connected to ${location.name}"); + } } } } catch (e) { diff --git a/client/lib/open/screens/instance/services/tunnel_service.dart b/client/lib/open/screens/instance/services/tunnel_service.dart index 6f4333d6..7511a443 100644 --- a/client/lib/open/screens/instance/services/tunnel_service.dart +++ b/client/lib/open/screens/instance/services/tunnel_service.dart @@ -30,23 +30,27 @@ class TunnelService { required Location location, required dynamic wireguardPlugin, required BiometricsState biometricsStatus, + RoutingMethod? trafficMethod, }) async { // prepare navigator to avoid "context use across async gaps" final navigator = Navigator.of(context); // handle traffic type selection if necessary - late RoutingMethod trafficMethod; - if (instance.clientTrafficPolicy == ClientTrafficPolicy.disableAllTraffic) { + late RoutingMethod selectedTrafficMethod; + if (trafficMethod != null) { + selectedTrafficMethod = trafficMethod; + } else if (instance.clientTrafficPolicy == + ClientTrafficPolicy.disableAllTraffic) { // instance enforces predefined traffic - trafficMethod = RoutingMethod.predefined; + selectedTrafficMethod = RoutingMethod.predefined; } else if (instance.clientTrafficPolicy == ClientTrafficPolicy.forceAllTraffic) { // instance enforces all traffic - trafficMethod = RoutingMethod.all; + selectedTrafficMethod = RoutingMethod.all; } else { // instance allows traffic type selection - use stored method or display selection dialog if (location.trafficMethod != null) { - trafficMethod = location.trafficMethod!; + selectedTrafficMethod = location.trafficMethod!; } else { // no pre selected traffic choice available, ask user RoutingMethodDialogIntention dialogIntention = checkMfaEnabled(location) @@ -64,7 +68,7 @@ class TunnelService { if (userSelection == null) { return; } - trafficMethod = userSelection; + selectedTrafficMethod = userSelection; } } @@ -72,7 +76,7 @@ class TunnelService { PluginConnectPayload payload = _makePayload( instance, location, - trafficMethod, + selectedTrafficMethod, ); // handle MFA if configured diff --git a/client/lib/open/screens/instance/widgets/next_connect_dialog.dart b/client/lib/open/screens/instance/widgets/next_connect_dialog.dart new file mode 100644 index 00000000..e46c88e9 --- /dev/null +++ b/client/lib/open/screens/instance/widgets/next_connect_dialog.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:mobile/data/db/database.dart'; +import 'package:mobile/data/db/enums.dart'; +import 'package:mobile/open/riverpod/biometrics_state.dart'; +import 'package:mobile/open/screens/instance/services/tunnel_service.dart'; +import 'package:mobile/open/widgets/next/icons/next_icon.dart'; +import 'package:mobile/open/widgets/next/next_button.dart'; +import 'package:mobile/open/widgets/next/next_toggle.dart'; +import 'package:mobile/open/widgets/next_mfa_selector.dart'; +import 'package:mobile/theme/next/color.dart'; +import 'package:mobile/theme/next/spacing.dart'; +import 'package:mobile/theme/next/text.dart'; + +class NextConnectDialog extends HookConsumerWidget { + final DefguardInstance instance; + final Location location; + + const NextConnectDialog({ + super.key, + required this.instance, + required this.location, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final biometricsStatus = ref.watch(biometricsCapabilityProvider); + final isMfaEnabled = TunnelService.checkMfaEnabled(location); + + final bool canChangeTraffic = + instance.clientTrafficPolicy == ClientTrafficPolicy.none; + final bool initialAllTraffic = + instance.clientTrafficPolicy == ClientTrafficPolicy.forceAllTraffic || + (canChangeTraffic && location.trafficMethod == RoutingMethod.all); + + final allTraffic = useState(initialAllTraffic); + + final availableMfaMethods = useMemoized(() { + if (location.locationMfaMode == LocationMfaMode.external) { + return [MfaMethod.openid]; + } + final methods = [MfaMethod.totp, MfaMethod.email]; + if (instance.mfaKeysStored && biometricsStatus.canOpenStorage) { + methods.insert(0, MfaMethod.biometric); + } + return methods; + }, [instance, location, biometricsStatus]); + + final selectedMfaMethod = useState( + location.mfaMethod ?? availableMfaMethods.first, + ); + + final isMfaExpanded = useState(false); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Connect ${location.name}", + style: NextText.bodyPrimary600.copyWith(color: NextColor.fgWhite100), + textAlign: TextAlign.center, + ), + const Padding( + padding: EdgeInsets.only(top: 20, bottom: 16), + child: Divider(height: 1, color: NextColor.bgWhite10), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + allTraffic.value ? "All traffic" : "Predefined traffic only", + style: NextText.bodySm400.copyWith(color: NextColor.fgWhite100), + ), + NextToggle( + value: allTraffic.value, + onTap: canChangeTraffic + ? (val) => allTraffic.value = val + : (_) {}, + ), + ], + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: NextSpacing.md), + child: Divider(height: 1, color: NextColor.bgWhite10), + ), + if (isMfaEnabled) ...[ + InkWell( + onTap: () => isMfaExpanded.value = !isMfaExpanded.value, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: NextSpacing.xs), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + selectedMfaMethod.value.toUiString( + openidDisplayName: instance.openidDisplayName, + ), + style: NextText.bodySm400.copyWith( + color: NextColor.fgWhite100, + ), + ), + NextIcon( + "arrow_small", + size: 20, + color: NextColor.fgWhite100, + direction: isMfaExpanded.value + ? NextIconDirection.up + : NextIconDirection.down, + ), + ], + ), + ), + ), + if (isMfaExpanded.value) + Padding( + padding: const EdgeInsets.only(top: NextSpacing.md), + child: Column( + children: availableMfaMethods + .map( + (method) => Padding( + padding: const EdgeInsets.only(bottom: NextSpacing.md), + child: NextMfaSelector( + active: selectedMfaMethod.value == method, + factor: method, + onTap: () { + selectedMfaMethod.value = method; + isMfaExpanded.value = false; + }, + ), + ), + ) + .toList(), + ), + ), + const Divider(height: 1, color: NextColor.bgWhite10), + const SizedBox(height: NextSpacing.md), + ], + NextButton( + text: "Connect VPN", + size: NextButtonSize.big, + style: NextButtonStyle.primary, + disabled: isMfaEnabled, + onTap: () { + Navigator.pop(context, { + 'traffic': allTraffic.value + ? RoutingMethod.all + : RoutingMethod.predefined, + 'mfa': selectedMfaMethod.value, + }); + }, + ), + ], + ); + } +} diff --git a/client/lib/open/widgets/next/next_bottom_sheet.dart b/client/lib/open/widgets/next/next_bottom_sheet.dart index 4c23acd5..09f96ef2 100644 --- a/client/lib/open/widgets/next/next_bottom_sheet.dart +++ b/client/lib/open/widgets/next/next_bottom_sheet.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter/widget_previews.dart'; import 'package:mobile/open/widgets/next/next_preview_wrapper.dart'; @@ -44,12 +46,14 @@ class NextBottomSheet extends StatelessWidget { final bottomSafeArea = MediaQuery.paddingOf(context).bottom; final effectivePadding = contentPadding != null - ? contentPadding!.add(EdgeInsets.only(bottom: bottomSafeArea)) + ? contentPadding!.add( + EdgeInsets.only(bottom: bottomSafeArea + NextSpacing.xl), + ) : EdgeInsets.only( left: NextSpacing.xl, right: NextSpacing.xl, top: NextSpacing.sm, - bottom: NextSpacing.xs + bottomSafeArea, + bottom: NextSpacing.xl + bottomSafeArea, ); return BottomSheet( @@ -63,29 +67,32 @@ class NextBottomSheet extends StatelessWidget { ), clipBehavior: Clip.antiAlias, builder: (BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (showDragHandle) - SizedBox( - height: 37, - child: Center( - child: Container( - width: 50, - height: 5, - decoration: BoxDecoration( - color: NextColor.fgDisabled, - borderRadius: BorderRadius.circular(100), + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showDragHandle) + SizedBox( + height: 37, + child: Center( + child: Container( + width: 50, + height: 5, + decoration: BoxDecoration( + color: NextColor.fgDisabled, + borderRadius: BorderRadius.circular(100), + ), ), ), ), + Padding( + padding: effectivePadding, + child: builder?.call(context) ?? child!, ), - Padding( - padding: effectivePadding, - child: builder?.call(context) ?? child!, - ), - ], + ], + ), ); }, ); diff --git a/client/lib/open/widgets/next/next_button.dart b/client/lib/open/widgets/next/next_button.dart index 8375cfbe..63d5fc9b 100644 --- a/client/lib/open/widgets/next/next_button.dart +++ b/client/lib/open/widgets/next/next_button.dart @@ -164,22 +164,37 @@ class NextButton extends StatelessWidget { child: InkWell( onTap: isInteractive ? onTap : null, borderRadius: borderRadius, - child: AnimatedPadding( - duration: duration, - curve: curve, - padding: EdgeInsets.symmetric(horizontal: padding, vertical: 0), - child: AnimatedDefaultTextStyle( - duration: duration, - curve: curve, - style: textStyle, - child: Row( - spacing: spacing, - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: _getRow(), + child: Stack( + alignment: Alignment.center, + children: [ + AnimatedOpacity( + duration: duration, + curve: curve, + opacity: loading ? 0.0 : 1.0, + child: AnimatedPadding( + duration: duration, + curve: curve, + padding: EdgeInsets.symmetric( + horizontal: padding, + vertical: 0, + ), + child: AnimatedDefaultTextStyle( + duration: duration, + curve: curve, + style: textStyle, + child: Row( + spacing: spacing, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: _getRow(), + ), + ), + ), ), - ), + if (loading) + NextCircularProgress(color: textStyle.color, size: 16), + ], ), ), ), @@ -189,10 +204,7 @@ class NextButton extends StatelessWidget { List _getRow() { final List children = []; - if (loading) { - children.add(NextCircularProgress(color: textStyle.color, size: 16)); - return children; - } else if (icon != null) { + if (icon != null) { children.add(icon!); } From 52cc75e80519bd0961dea8e146c92e77d618f103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Mon, 24 Aug 2026 13:12:09 +0200 Subject: [PATCH 3/5] tweak bottom sheet dialog - navigation bar is transparent now ( android ) --- .../src/main/res/values-night-v31/styles.xml | 12 +- .../app/src/main/res/values-night/styles.xml | 12 +- .../app/src/main/res/values-v31/styles.xml | 12 +- .../app/src/main/res/values/styles.xml | 12 +- .../instance/widgets/next_connect_dialog.dart | 153 +++++++++++------- .../open/widgets/next/next_bottom_sheet.dart | 71 +++----- .../widgets/next/next_radio_indicator.dart | 79 +++++++++ client/lib/open/widgets/next/next_toggle.dart | 10 +- .../lib/open/widgets/next_mfa_selector.dart | 13 +- 9 files changed, 255 insertions(+), 119 deletions(-) create mode 100644 client/lib/open/widgets/next/next_radio_indicator.dart diff --git a/client/android/app/src/main/res/values-night-v31/styles.xml b/client/android/app/src/main/res/values-night-v31/styles.xml index c2eb1a2d..15edd042 100644 --- a/client/android/app/src/main/res/values-night-v31/styles.xml +++ b/client/android/app/src/main/res/values-night-v31/styles.xml @@ -4,7 +4,11 @@ diff --git a/client/android/app/src/main/res/values-night/styles.xml b/client/android/app/src/main/res/values-night/styles.xml index dbc9ea9f..a58bd353 100644 --- a/client/android/app/src/main/res/values-night/styles.xml +++ b/client/android/app/src/main/res/values-night/styles.xml @@ -7,7 +7,11 @@ @drawable/launch_background false false - false + true + @android:color/transparent + @android:color/transparent + false + false shortEdges diff --git a/client/android/app/src/main/res/values-v31/styles.xml b/client/android/app/src/main/res/values-v31/styles.xml index 937de5fc..447fbea3 100644 --- a/client/android/app/src/main/res/values-v31/styles.xml +++ b/client/android/app/src/main/res/values-v31/styles.xml @@ -4,7 +4,11 @@ diff --git a/client/android/app/src/main/res/values/styles.xml b/client/android/app/src/main/res/values/styles.xml index 0d1fa8fc..2562bf60 100644 --- a/client/android/app/src/main/res/values/styles.xml +++ b/client/android/app/src/main/res/values/styles.xml @@ -7,7 +7,11 @@ @drawable/launch_background false false - false + true + @android:color/transparent + @android:color/transparent + false + false shortEdges diff --git a/client/lib/open/screens/instance/widgets/next_connect_dialog.dart b/client/lib/open/screens/instance/widgets/next_connect_dialog.dart index e46c88e9..8fc98417 100644 --- a/client/lib/open/screens/instance/widgets/next_connect_dialog.dart +++ b/client/lib/open/screens/instance/widgets/next_connect_dialog.dart @@ -1,10 +1,11 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mobile/data/db/database.dart'; import 'package:mobile/data/db/enums.dart'; import 'package:mobile/open/riverpod/biometrics_state.dart'; -import 'package:mobile/open/screens/instance/services/tunnel_service.dart'; import 'package:mobile/open/widgets/next/icons/next_icon.dart'; import 'package:mobile/open/widgets/next/next_button.dart'; import 'package:mobile/open/widgets/next/next_toggle.dart'; @@ -26,7 +27,7 @@ class NextConnectDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final biometricsStatus = ref.watch(biometricsCapabilityProvider); - final isMfaEnabled = TunnelService.checkMfaEnabled(location); + final isMfaEnabled = location.locationMfaMode != LocationMfaMode.disabled; final bool canChangeTraffic = instance.clientTrafficPolicy == ClientTrafficPolicy.none; @@ -51,92 +52,128 @@ class NextConnectDialog extends HookConsumerWidget { location.mfaMethod ?? availableMfaMethods.first, ); - final isMfaExpanded = useState(false); + final mfaController = useMemoized(() => ExpansibleController(), []); + useEffect(() => mfaController.dispose, [mfaController]); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - "Connect ${location.name}", + "Connect ${location.name} location", style: NextText.bodyPrimary600.copyWith(color: NextColor.fgWhite100), - textAlign: TextAlign.center, + textAlign: TextAlign.left, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), const Padding( padding: EdgeInsets.only(top: 20, bottom: 16), child: Divider(height: 1, color: NextColor.bgWhite10), ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - allTraffic.value ? "All traffic" : "Predefined traffic only", - style: NextText.bodySm400.copyWith(color: NextColor.fgWhite100), - ), - NextToggle( - value: allTraffic.value, - onTap: canChangeTraffic - ? (val) => allTraffic.value = val - : (_) {}, + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: canChangeTraffic + ? () => allTraffic.value = !allTraffic.value + : null, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 44), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + allTraffic.value ? "All traffic" : "Predefined traffic only", + style: NextText.bodySm400.copyWith( + color: NextColor.fgWhite100, + ), + ), + NextToggle(value: allTraffic.value), + ], ), - ], - ), - const Padding( - padding: EdgeInsets.symmetric(vertical: NextSpacing.md), - child: Divider(height: 1, color: NextColor.bgWhite10), + ), ), if (isMfaEnabled) ...[ - InkWell( - onTap: () => isMfaExpanded.value = !isMfaExpanded.value, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: NextSpacing.xs), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - selectedMfaMethod.value.toUiString( - openidDisplayName: instance.openidDisplayName, + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Divider(color: NextColor.bgWhite10, height: 1), + ), + Expansible( + controller: mfaController, + expansibleBuilder: (context, header, body, animation) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [header, body], + ), + headerBuilder: (context, animation) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => mfaController.toggle(), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 44), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: NextColor.bgWhite100, + ), + child: Text( + "MFA", + style: NextText.bodyXs500.copyWith( + color: const Color(0xff061a74), + ), + ), ), - style: NextText.bodySm400.copyWith( + const SizedBox(width: 8), + Expanded( + child: Text( + selectedMfaMethod.value.toUiString( + openidDisplayName: instance.openidDisplayName, + ), + style: NextText.bodySm400.copyWith( + color: NextColor.fgWhite100, + ), + ), + ), + const SizedBox(width: 4), + NextIcon( + "arrow_small", + size: 20, color: NextColor.fgWhite100, + rotation: animation.value * (math.pi / 2), ), - ), - NextIcon( - "arrow_small", - size: 20, - color: NextColor.fgWhite100, - direction: isMfaExpanded.value - ? NextIconDirection.up - : NextIconDirection.down, - ), - ], + ], + ), ), ), - ), - if (isMfaExpanded.value) - Padding( + bodyBuilder: (context, animation) => Padding( padding: const EdgeInsets.only(top: NextSpacing.md), child: Column( + spacing: NextSpacing.md, + crossAxisAlignment: CrossAxisAlignment.stretch, children: availableMfaMethods .map( - (method) => Padding( - padding: const EdgeInsets.only(bottom: NextSpacing.md), - child: NextMfaSelector( - active: selectedMfaMethod.value == method, - factor: method, - onTap: () { - selectedMfaMethod.value = method; - isMfaExpanded.value = false; - }, - ), + (method) => NextMfaSelector( + active: selectedMfaMethod.value == method, + factor: method, + onTap: () { + selectedMfaMethod.value = method; + }, ), ) .toList(), ), ), - const Divider(height: 1, color: NextColor.bgWhite10), - const SizedBox(height: NextSpacing.md), + ), ], + const Padding( + padding: EdgeInsets.symmetric(vertical: NextSpacing.xl2), + child: Divider(height: 1, color: NextColor.bgWhite10), + ), NextButton( text: "Connect VPN", size: NextButtonSize.big, diff --git a/client/lib/open/widgets/next/next_bottom_sheet.dart b/client/lib/open/widgets/next/next_bottom_sheet.dart index 09f96ef2..84b1c32b 100644 --- a/client/lib/open/widgets/next/next_bottom_sheet.dart +++ b/client/lib/open/widgets/next/next_bottom_sheet.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:flutter/widget_previews.dart'; import 'package:mobile/open/widgets/next/next_preview_wrapper.dart'; @@ -11,12 +9,6 @@ class NextBottomSheet extends StatelessWidget { final WidgetBuilder? builder; - final VoidCallback? onClosing; - - final AnimationController? animationController; - - final bool enableDrag; - final bool showDragHandle; final Color? backgroundColor; @@ -29,9 +21,6 @@ class NextBottomSheet extends StatelessWidget { super.key, this.child, this.builder, - this.onClosing, - this.animationController, - this.enableDrag = true, this.showDragHandle = true, this.backgroundColor, this.elevation, @@ -56,45 +45,37 @@ class NextBottomSheet extends StatelessWidget { bottom: NextSpacing.xl + bottomSafeArea, ); - return BottomSheet( - onClosing: onClosing ?? () {}, - animationController: animationController, - enableDrag: enableDrag, - backgroundColor: backgroundColor ?? NextColor.bgDarkBlue80, + return Material( + color: backgroundColor ?? NextColor.bgDarkBlue80, elevation: elevation ?? 0, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), clipBehavior: Clip.antiAlias, - builder: (BuildContext context) { - return BackdropFilter( - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (showDragHandle) - SizedBox( - height: 37, - child: Center( - child: Container( - width: 50, - height: 5, - decoration: BoxDecoration( - color: NextColor.fgDisabled, - borderRadius: BorderRadius.circular(100), - ), - ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showDragHandle) + SizedBox( + height: 37, + child: Center( + child: Container( + width: 50, + height: 5, + decoration: BoxDecoration( + color: NextColor.fgDisabled, + borderRadius: BorderRadius.circular(100), ), ), - Padding( - padding: effectivePadding, - child: builder?.call(context) ?? child!, ), - ], + ), + Padding( + padding: effectivePadding, + child: builder?.call(context) ?? child!, ), - ); - }, + ], + ), ); } } @@ -126,20 +107,14 @@ Future showNextBottomSheet({ isDismissible: isDismissible, enableDrag: enableDrag, backgroundColor: Colors.transparent, - barrierColor: barrierColor, + barrierColor: barrierColor ?? const Color(0x80000000), routeSettings: routeSettings, transitionAnimationController: transitionAnimationController, builder: (modalContext) { return NextBottomSheet( - enableDrag: enableDrag, showDragHandle: showDragHandle, backgroundColor: backgroundColor, contentPadding: contentPadding, - onClosing: () { - if (Navigator.canPop(modalContext)) { - Navigator.pop(modalContext); - } - }, builder: builder, child: child, ); diff --git a/client/lib/open/widgets/next/next_radio_indicator.dart b/client/lib/open/widgets/next/next_radio_indicator.dart new file mode 100644 index 00000000..d12efef2 --- /dev/null +++ b/client/lib/open/widgets/next/next_radio_indicator.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widget_previews.dart'; +import 'package:mobile/open/widgets/next/next_preview_wrapper.dart'; +import 'package:mobile/theme/next/color.dart'; + +class NextRadioIndicator extends StatelessWidget { + final int? size; + final bool value; + + const NextRadioIndicator({super.key, this.size, required this.value}); + + @override + Widget build(BuildContext context) { + final double boxSize = (size ?? 20).toDouble(); + final double circleSize = boxSize - 4; + const duration = Duration(milliseconds: 200); + const curve = Curves.easeInOut; + + return SizedBox( + width: boxSize, + height: boxSize, + child: Center( + child: AnimatedContainer( + duration: duration, + curve: curve, + width: circleSize, + height: circleSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: value ? NextColor.bgWhite100 : Colors.transparent, + border: Border.all( + color: value ? NextColor.bgWhite100 : NextColor.borderAction, + width: 2, + ), + ), + child: Center( + child: AnimatedScale( + duration: duration, + curve: curve, + scale: value ? 1.0 : 0.0, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: NextColor.fgAction, + ), + ), + ), + ), + ), + ), + ); + } +} + +@Preview(name: 'Off') +Widget previewNextRadioIndicatorOff() { + return const NextPreviewWrapper(child: NextRadioIndicator(value: false)); +} + +@Preview(name: 'On') +Widget previewNextRadioIndicatorOn() { + return const NextPreviewWrapper(child: NextRadioIndicator(value: true)); +} + +@Preview(name: 'Big Off') +Widget previewNextRadioIndicatorBigOff() { + return const NextPreviewWrapper( + child: NextRadioIndicator(value: false, size: 40), + ); +} + +@Preview(name: 'Big On') +Widget previewNextRadioIndicatorBigOn() { + return const NextPreviewWrapper( + child: NextRadioIndicator(value: true, size: 40), + ); +} diff --git a/client/lib/open/widgets/next/next_toggle.dart b/client/lib/open/widgets/next/next_toggle.dart index f4cd6fa3..4246500c 100644 --- a/client/lib/open/widgets/next/next_toggle.dart +++ b/client/lib/open/widgets/next/next_toggle.dart @@ -5,9 +5,9 @@ import 'package:mobile/theme/next/color.dart'; class NextToggle extends StatelessWidget { final bool value; - final ValueChanged onTap; + final ValueChanged? onTap; - const NextToggle({super.key, required this.value, required this.onTap}); + const NextToggle({super.key, required this.value, this.onTap}); @override Widget build(BuildContext context) { @@ -15,7 +15,7 @@ class NextToggle extends StatelessWidget { const curve = Curves.easeOut; return GestureDetector( - onTap: () => onTap(!value), + onTap: onTap != null ? () => onTap!(!value) : null, child: AnimatedContainer( duration: duration, curve: curve, @@ -50,10 +50,10 @@ class NextToggle extends StatelessWidget { @Preview(name: 'Toggle Off') Widget previewNextToggleOff() { - return NextPreviewWrapper(child: NextToggle(value: false, onTap: (_) {})); + return NextPreviewWrapper(child: NextToggle(value: false)); } @Preview(name: 'Toggle On') Widget previewNextToggleOn() { - return NextPreviewWrapper(child: NextToggle(value: true, onTap: (_) {})); + return NextPreviewWrapper(child: NextToggle(value: true)); } diff --git a/client/lib/open/widgets/next_mfa_selector.dart b/client/lib/open/widgets/next_mfa_selector.dart index f6ae9e75..ebabed4b 100644 --- a/client/lib/open/widgets/next_mfa_selector.dart +++ b/client/lib/open/widgets/next_mfa_selector.dart @@ -3,6 +3,7 @@ import 'package:flutter/widget_previews.dart'; import 'package:mobile/data/db/enums.dart'; import 'package:mobile/open/widgets/next/icons/next_icon.dart'; import 'package:mobile/open/widgets/next/next_preview_wrapper.dart'; +import 'package:mobile/open/widgets/next/next_radio_indicator.dart'; import 'package:mobile/theme/next/color.dart'; import 'package:mobile/theme/next/spacing.dart'; import 'package:mobile/theme/next/text.dart'; @@ -57,10 +58,11 @@ class NextMfaSelector extends StatelessWidget { child: AnimatedContainer( duration: duration, curve: curve, + constraints: BoxConstraints(minHeight: 44), padding: const EdgeInsets.fromLTRB(16, 8, 12, 8), decoration: BoxDecoration( color: active ? NextColor.bgWhite10 : Colors.transparent, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(12), border: Border.all(color: NextColor.bgWhite10, width: 1), ), child: TweenAnimationBuilder( @@ -74,10 +76,13 @@ class NextMfaSelector extends StatelessWidget { spacing: NextSpacing.md, children: [ NextIcon(getIcon, size: 20, color: effectiveColor), - Text( - getLabel, - style: NextText.bodySm400.copyWith(color: effectiveColor), + Expanded( + child: Text( + getLabel, + style: NextText.bodySm400.copyWith(color: effectiveColor), + ), ), + NextRadioIndicator(value: active, size: 20), ], ); }, From 4dcbcbbd604cb7a25e34440b0c819488e9622640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Mon, 24 Aug 2026 13:14:54 +0200 Subject: [PATCH 4/5] Update add_instance.riv --- client/assets/next/rive/add_instance.riv | Bin 70731 -> 70752 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/client/assets/next/rive/add_instance.riv b/client/assets/next/rive/add_instance.riv index 37a629b9853abeabfe212f1ee196abbb81da02f9..adfc50c3b6c656e65572c5cb0bcfecd1a0a6fbe8 100644 GIT binary patch delta 225 zcmX@Tg5|*qmWC~i&jqFn3o?pKe=5MJqb_gG6_S~onvnhK2?M zP;)p?ObDpg#f8arsx*+{4Q2q1X|M-xH!!*|x-l>~I86U0$fz>CS&*@8yOa=PGvoAI z!i;hWQ>B5D4-Ntqeq(fDa-9a`Hr#Xu>g59p9Rdk~RcIYTR>9!v%phqT4pb!s6m|hB snl24wc!L>0^BU}dykH=2qV#kF5k>_8I|c^^dj`kpjLZzSQyCc;0BHO;8~^|S From 6107874ec05f5c3a3e0556116fa6fef8a556dc88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Mon, 24 Aug 2026 15:27:25 +0200 Subject: [PATCH 5/5] revert persisting pref in connection --- client/ios/RunnerTests/RunnerTests.swift | 3 +- client/ios/VPNExtension/Shared.swift | 9 +-- client/lib/data/plugin/plugin.dart | 11 --- client/lib/data/plugin/plugin.g.dart | 26 +----- .../screens/instance/instance_screen.dart | 1 + .../instance/next_instance_screen.dart | 25 +++--- .../instance/services/tunnel_service.dart | 79 ++++++++++++++++--- .../instance/widgets/next_connect_dialog.dart | 5 +- .../wireguard_plugin/TunnelStartData.kt | 5 +- .../wireguard_plugin/WireguardPlugin.kt | 1 - .../net/defguard/wireguard_plugin/event.kt | 1 - 11 files changed, 92 insertions(+), 74 deletions(-) diff --git a/client/ios/RunnerTests/RunnerTests.swift b/client/ios/RunnerTests/RunnerTests.swift index 11f608de..3b50f9e8 100644 --- a/client/ios/RunnerTests/RunnerTests.swift +++ b/client/ios/RunnerTests/RunnerTests.swift @@ -51,8 +51,7 @@ class RunnerTests: XCTestCase { traffic: .All, locationName: "Test Location", locationId: 1, - instanceId: 1, - mfaMethod: 0 + instanceId: 1 ) func testConnectDisconnect() { diff --git a/client/ios/VPNExtension/Shared.swift b/client/ios/VPNExtension/Shared.swift index c70c5b12..dc691b4a 100644 --- a/client/ios/VPNExtension/Shared.swift +++ b/client/ios/VPNExtension/Shared.swift @@ -25,14 +25,10 @@ public struct TunnelStartData: Codable { public var locationName: String public var locationId: Int public var instanceId: Int - // Opaque MFA method identifier, passed through from Dart for display only. - // Nil when the tunnel was authorized without MFA. - public var mfaMethod: Int? public init(publicKey: String, privateKey: String, address: String, dns: String? = nil, endpoint: String, allowedIps: String, keepalive: Int, presharedKey: String? = nil, - traffic: TunnelTraffic, locationName: String, locationId: Int, instanceId: Int, - mfaMethod: Int? = nil) { + traffic: TunnelTraffic, locationName: String, locationId: Int, instanceId: Int) { self.publicKey = publicKey self.privateKey = privateKey self.address = address @@ -45,7 +41,6 @@ public struct TunnelStartData: Codable { self.locationName = locationName self.locationId = locationId self.instanceId = instanceId - self.mfaMethod = mfaMethod } } @@ -53,13 +48,11 @@ public struct ActiveTunnelData: Codable { var locationId: Int var instanceId: Int var traffic: TunnelTraffic - var mfaMethod: Int? init(fromConfig: TunnelStartData) { self.locationId = fromConfig.locationId self.instanceId = fromConfig.instanceId self.traffic = fromConfig.traffic - self.mfaMethod = fromConfig.mfaMethod } } diff --git a/client/lib/data/plugin/plugin.dart b/client/lib/data/plugin/plugin.dart index eac91f72..4b7b7a90 100644 --- a/client/lib/data/plugin/plugin.dart +++ b/client/lib/data/plugin/plugin.dart @@ -25,10 +25,6 @@ class PluginConnectPayload { RoutingMethod traffic; final bool postureCheckRequired; - /// MFA method actually used to authorize this connection. - /// `null` means the tunnel was established without MFA. - MfaMethod? mfaMethod; - PluginConnectPayload({ required this.publicKey, required this.devicePublicKey, @@ -45,7 +41,6 @@ class PluginConnectPayload { this.dns, this.presharedKey, required this.postureCheckRequired, - this.mfaMethod, }); factory PluginConnectPayload.fromJson(Map json) => @@ -60,16 +55,10 @@ class PluginTunnelEventData { final int locationId; final RoutingMethod traffic; - /// MFA method used to authorize the active connection. - /// `null` means no MFA was used, or the tunnel was started by a native - /// build predating this field. - final MfaMethod? mfaMethod; - const PluginTunnelEventData({ required this.instanceId, required this.locationId, required this.traffic, - this.mfaMethod, }); factory PluginTunnelEventData.fromJson(Map json) => diff --git a/client/lib/data/plugin/plugin.g.dart b/client/lib/data/plugin/plugin.g.dart index 0b88f4ba..6fb711c7 100644 --- a/client/lib/data/plugin/plugin.g.dart +++ b/client/lib/data/plugin/plugin.g.dart @@ -34,10 +34,6 @@ PluginConnectPayload _$PluginConnectPayloadFromJson( 'posture_check_required', (v) => v as bool, ), - mfaMethod: $checkedConvert( - 'mfa_method', - (v) => $enumDecodeNullable(_$MfaMethodEnumMap, v), - ), ); return val; }, @@ -52,7 +48,6 @@ PluginConnectPayload _$PluginConnectPayloadFromJson( 'networkId': 'network_id', 'presharedKey': 'preshared_key', 'postureCheckRequired': 'posture_check_required', - 'mfaMethod': 'mfa_method', }, ); @@ -72,7 +67,6 @@ const _$PluginConnectPayloadFieldMap = { 'networkId': 'network_id', 'traffic': 'traffic', 'postureCheckRequired': 'posture_check_required', - 'mfaMethod': 'mfa_method', }; Map _$PluginConnectPayloadToJson( @@ -93,7 +87,6 @@ Map _$PluginConnectPayloadToJson( 'network_id': instance.networkId, 'traffic': _$RoutingMethodEnumMap[instance.traffic]!, 'posture_check_required': instance.postureCheckRequired, - 'mfa_method': _$MfaMethodEnumMap[instance.mfaMethod], }; const _$RoutingMethodEnumMap = { @@ -101,13 +94,6 @@ const _$RoutingMethodEnumMap = { RoutingMethod.predefined: 'predefined', }; -const _$MfaMethodEnumMap = { - MfaMethod.totp: 0, - MfaMethod.email: 1, - MfaMethod.openid: 2, - MfaMethod.biometric: 3, -}; - PluginTunnelEventData _$PluginTunnelEventDataFromJson( Map json, ) => $checkedCreate( @@ -121,25 +107,16 @@ PluginTunnelEventData _$PluginTunnelEventDataFromJson( 'traffic', (v) => $enumDecode(_$RoutingMethodEnumMap, v), ), - mfaMethod: $checkedConvert( - 'mfa_method', - (v) => $enumDecodeNullable(_$MfaMethodEnumMap, v), - ), ); return val; }, - fieldKeyMap: const { - 'instanceId': 'instance_id', - 'locationId': 'location_id', - 'mfaMethod': 'mfa_method', - }, + fieldKeyMap: const {'instanceId': 'instance_id', 'locationId': 'location_id'}, ); const _$PluginTunnelEventDataFieldMap = { 'instanceId': 'instance_id', 'locationId': 'location_id', 'traffic': 'traffic', - 'mfaMethod': 'mfa_method', }; Map _$PluginTunnelEventDataToJson( @@ -148,5 +125,4 @@ Map _$PluginTunnelEventDataToJson( 'instance_id': instance.instanceId, 'location_id': instance.locationId, 'traffic': _$RoutingMethodEnumMap[instance.traffic]!, - 'mfa_method': _$MfaMethodEnumMap[instance.mfaMethod], }; diff --git a/client/lib/open/screens/instance/instance_screen.dart b/client/lib/open/screens/instance/instance_screen.dart index 75a88885..9afcabd7 100644 --- a/client/lib/open/screens/instance/instance_screen.dart +++ b/client/lib/open/screens/instance/instance_screen.dart @@ -584,6 +584,7 @@ class _LocationItem extends HookConsumerWidget { location: location, wireguardPlugin: wireguardPlugin, biometricsStatus: biometricStatus, + db: ref.read(databaseProvider), ); talker.debug( "Location ${location.name} (${location.id}) connected", diff --git a/client/lib/open/screens/instance/next_instance_screen.dart b/client/lib/open/screens/instance/next_instance_screen.dart index 7eb6eb6c..e6531a28 100644 --- a/client/lib/open/screens/instance/next_instance_screen.dart +++ b/client/lib/open/screens/instance/next_instance_screen.dart @@ -444,26 +444,21 @@ class _LocationList extends HookConsumerWidget { return; } - // Update location with selected traffic method so TunnelService uses it - final db = ref.read(databaseProvider); - await (db.update( - db.locations, - )..where((t) => t.id.equals(location.id))).write( - LocationsCompanion( - trafficMethod: drift.Value(result['traffic'] as RoutingMethod), - ), - ); - final permissionsGranted = await wireguardPlugin.requestPermissions(); if (permissionsGranted) { if (context.mounted) { + // both preferences are passed explicitly - TunnelService persists + // them once the tunnel is up, so the legacy dialogs are never + // reachable from this path await TunnelService.connect( context: context, instance: data.instance, location: location, wireguardPlugin: wireguardPlugin, biometricsStatus: biometricStatus, + db: ref.read(databaseProvider), trafficMethod: result['traffic'] as RoutingMethod, + mfaMethod: result['mfa'] as MfaMethod?, ); talker.debug("Connected to ${location.name}"); } @@ -512,7 +507,11 @@ class _LocationList extends HookConsumerWidget { location: connectedLocation, isConnected: true, routingMethod: activeTunnel?.traffic, - mfaMethod: connectedLocation.mfaMethod, + // gated so a stored preference never renders an MFA chip on a + // location whose MFA has since been disabled server-side + mfaMethod: TunnelService.checkMfaEnabled(connectedLocation) + ? connectedLocation.mfaMethod + : null, onDisconnectTap: () => onDisconnect(connectedLocation), ), const SizedBox(height: NextSpacing.xl), @@ -530,7 +529,9 @@ class _LocationList extends HookConsumerWidget { location: location, isConnected: false, loading: loadingLocationId.value == location.id, - mfaMethod: location.mfaMethod, + mfaMethod: TunnelService.checkMfaEnabled(location) + ? location.mfaMethod + : null, onConnectTap: () => onConnect(context, location), ), ), diff --git a/client/lib/open/screens/instance/services/tunnel_service.dart b/client/lib/open/screens/instance/services/tunnel_service.dart index 7511a443..ef7f0ed8 100644 --- a/client/lib/open/screens/instance/services/tunnel_service.dart +++ b/client/lib/open/screens/instance/services/tunnel_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:drift/drift.dart' as drift; import 'package:flutter/material.dart'; import 'package:mobile/data/db/database.dart'; import 'package:mobile/data/plugin/plugin.dart'; @@ -30,7 +31,9 @@ class TunnelService { required Location location, required dynamic wireguardPlugin, required BiometricsState biometricsStatus, + required AppDatabase db, RoutingMethod? trafficMethod, + MfaMethod? mfaMethod, }) async { // prepare navigator to avoid "context use across async gaps" final navigator = Navigator.of(context); @@ -79,14 +82,25 @@ class TunnelService { selectedTrafficMethod, ); + // the method that actually authorized this connection - stays null when no + // MFA was performed, which is what keeps non-MFA locations from having a + // phantom method remembered for them + MfaMethod? authorizedWith; + // handle MFA if configured if (checkMfaEnabled(location)) { // Request notification permissions for MFA session expiry alerts await requestNotificationPermissions(); - MfaMethod mfaMethod; + late MfaMethod selectedMfaMethod; if (location.locationMfaMode == LocationMfaMode.external) { - // location setup for openid mfa login - mfaMethod = MfaMethod.openid; + // location setup for openid mfa login - the server dictates the method, + // so this stays ahead of any caller-supplied choice + selectedMfaMethod = MfaMethod.openid; + } else if (mfaMethod != null && + !(mfaMethod == MfaMethod.biometric && + !biometricsStatus.canOpenStorage)) { + // caller already collected the choice from the user + selectedMfaMethod = mfaMethod; } else { // non-openid mfa setup, use stored method or show method choice dialog if (location.mfaMethod == null || @@ -104,9 +118,9 @@ class TunnelService { // dialog dismissed return; } - mfaMethod = userSelection; + selectedMfaMethod = userSelection; } else { - mfaMethod = location.mfaMethod!; + selectedMfaMethod = location.mfaMethod!; } } @@ -115,7 +129,7 @@ class TunnelService { navigator: navigator, proxyUrl: instance.proxyUrl, payload: payload, - method: mfaMethod, + method: selectedMfaMethod, secureStorageKey: instance.secureStorageKey, openidDisplayName: instance.openidDisplayName, ); @@ -124,9 +138,7 @@ class TunnelService { return; } payload.presharedKey = presharedKey; - // record which method actually authorized this connection, so the UI can - // display it for the active tunnel - payload.mfaMethod = mfaMethod; + authorizedWith = selectedMfaMethod; } else if (payload.postureCheckRequired) { final presharedKey = await _performPostureCheck( navigator: navigator, @@ -142,6 +154,55 @@ class TunnelService { // start the tunnel await wireguardPlugin.startTunnel(jsonEncode(payload.toJson())); + + // only remember what the caller explicitly collected from the user - the + // legacy dialogs keep owning their own "Remember my choice" checkbox + await _rememberPreferences( + db, + instance, + location, + trafficMethod: trafficMethod, + mfaMethod: mfaMethod != null ? authorizedWith : null, + ); + } + + /// Stores the connection preferences on the location row so the next connect + /// can pre-select them, and so the UI can show what authorized the tunnel. + /// + /// Runs only after the tunnel actually started, so a cancelled or failed + /// connect never overwrites a working preference. Fields left absent keep + /// their stored value - passing `Value(null)` would clear them instead. + static Future _rememberPreferences( + AppDatabase db, + DefguardInstance instance, + Location location, { + RoutingMethod? trafficMethod, + MfaMethod? mfaMethod, + }) async { + // only the "none" policy leaves the routing choice to the user - under an + // enforced policy the value is the server's, not a preference + final traffic = + instance.clientTrafficPolicy == ClientTrafficPolicy.none && + trafficMethod != null + ? drift.Value(trafficMethod) + : const drift.Value.absent(); + final mfa = mfaMethod != null + ? drift.Value(mfaMethod) + : const drift.Value.absent(); + + if (!traffic.present && !mfa.present) { + return; + } + + try { + await (db.update(db.locations)..where((t) => t.id.equals(location.id))) + .write(LocationsCompanion(trafficMethod: traffic, mfaMethod: mfa)); + } catch (e) { + talker.error( + "Failed to remember connection preferences for location ${location.id}", + e, + ); + } } /// Checks if MFA is enabled for specified location taking into account diff --git a/client/lib/open/screens/instance/widgets/next_connect_dialog.dart b/client/lib/open/screens/instance/widgets/next_connect_dialog.dart index 8fc98417..0ce31ed3 100644 --- a/client/lib/open/screens/instance/widgets/next_connect_dialog.dart +++ b/client/lib/open/screens/instance/widgets/next_connect_dialog.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mobile/data/db/database.dart'; import 'package:mobile/data/db/enums.dart'; import 'package:mobile/open/riverpod/biometrics_state.dart'; +import 'package:mobile/open/screens/instance/services/tunnel_service.dart'; import 'package:mobile/open/widgets/next/icons/next_icon.dart'; import 'package:mobile/open/widgets/next/next_button.dart'; import 'package:mobile/open/widgets/next/next_toggle.dart'; @@ -27,7 +28,9 @@ class NextConnectDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final biometricsStatus = ref.watch(biometricsCapabilityProvider); - final isMfaEnabled = location.locationMfaMode != LocationMfaMode.disabled; + // must stay the same predicate TunnelService branches on, otherwise the + // sheet offers a factor the connect flow never asks for + final isMfaEnabled = TunnelService.checkMfaEnabled(location); final bool canChangeTraffic = instance.clientTrafficPolicy == ClientTrafficPolicy.none; diff --git a/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/TunnelStartData.kt b/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/TunnelStartData.kt index 5b3ae9c7..76bf81d5 100644 --- a/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/TunnelStartData.kt +++ b/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/TunnelStartData.kt @@ -27,8 +27,5 @@ data class TunnelStartData( // context val locationName: String, val locationId: Int, - val instanceId: Int, - // Opaque MFA method identifier, passed through from Dart for display only. - // Null when the tunnel was authorized without MFA. - val mfaMethod: Int? = null + val instanceId: Int ) \ No newline at end of file diff --git a/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/WireguardPlugin.kt b/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/WireguardPlugin.kt index 01dbbe3d..3add22b4 100644 --- a/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/WireguardPlugin.kt +++ b/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/WireguardPlugin.kt @@ -303,7 +303,6 @@ class WireguardPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, instanceId = configData.instanceId, traffic = configData.traffic, mfaEnabled = !configData.presharedKey.isNullOrBlank(), - mfaMethod = configData.mfaMethod, ) futureBackend.await().setState(tunnel, Tunnel.State.UP, config) diff --git a/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/event.kt b/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/event.kt index 43c660b8..7361a391 100644 --- a/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/event.kt +++ b/wireguard_plugin/android/src/main/kotlin/net/defguard/wireguard_plugin/event.kt @@ -15,5 +15,4 @@ data class ActiveTunnelData( val locationId: Int, val traffic: TunnelTraffic, val mfaEnabled: Boolean, - val mfaMethod: Int? = null, )