diff --git a/lib/screens/auth/login.dart b/lib/screens/auth/login.dart index 282b42d..68f68fc 100644 --- a/lib/screens/auth/login.dart +++ b/lib/screens/auth/login.dart @@ -19,6 +19,7 @@ import 'package:island/pods/network.dart'; import 'package:island/pods/userinfo.dart'; import 'package:island/pods/websocket.dart'; import 'package:island/screens/account/me/settings_connections.dart'; +import 'package:island/screens/auth/oidc.dart'; import 'package:island/services/notify.dart'; import 'package:island/services/udid.dart'; import 'package:island/widgets/alert.dart'; @@ -174,6 +175,67 @@ class _LoginCheckScreen extends HookConsumerWidget { return null; }, [isBusy]); + Future getToken({String? code}) async { + // Get token if challenge is completed + final client = ref.watch(apiClientProvider); + final tokenResp = await client.post( + '/auth/token', + data: { + 'grant_type': 'authorization_code', + 'code': code ?? challenge!.id, + }, + ); + final token = tokenResp.data['token']; + setToken(ref.watch(sharedPreferencesProvider), token); + ref.invalidate(tokenProvider); + if (!context.mounted) return; + + // Do post login tasks + final userNotifier = ref.read(userInfoProvider.notifier); + userNotifier.fetchUser().then((_) { + final apiClient = ref.read(apiClientProvider); + subscribePushNotification(apiClient); + final wsNotifier = ref.read(websocketStateProvider.notifier); + wsNotifier.connect(); + if (context.mounted) Navigator.pop(context, true); + }); + + // Update the sessions' device name is available + if (!kIsWeb) { + String? name; + if (Platform.isIOS) { + final deviceInfo = await DeviceInfoPlugin().iosInfo; + name = deviceInfo.name; + } else if (Platform.isAndroid) { + final deviceInfo = await DeviceInfoPlugin().androidInfo; + name = deviceInfo.name; + } else if (Platform.isWindows) { + final deviceInfo = await DeviceInfoPlugin().windowsInfo; + name = deviceInfo.computerName; + } + if (name != null) { + final client = ref.watch(apiClientProvider); + await client.patch( + '/accounts/me/sessions/current/label', + data: jsonEncode(name), + ); + } + } + } + + useEffect(() { + if (challenge != null && challenge?.stepRemain == 0) { + Future(() { + isBusy.value = true; + getToken().catchError((err) { + showErrorAlert(err); + isBusy.value = false; + }); + }); + } + return null; + }, [challenge]); + Future performCheckTicket() async { final pwd = passwordController.value.text; if (pwd.isEmpty) return; @@ -192,47 +254,7 @@ class _LoginCheckScreen extends HookConsumerWidget { return; } - // Get token if challenge is completed - final tokenResp = await client.post( - '/auth/token', - data: {'grant_type': 'authorization_code', 'code': result.id}, - ); - final token = tokenResp.data['token']; - setToken(ref.watch(sharedPreferencesProvider), token); - ref.invalidate(tokenProvider); - if (!context.mounted) return; - - // Do post login tasks - final userNotifier = ref.read(userInfoProvider.notifier); - userNotifier.fetchUser().then((_) { - final apiClient = ref.read(apiClientProvider); - subscribePushNotification(apiClient); - final wsNotifier = ref.read(websocketStateProvider.notifier); - wsNotifier.connect(); - if (context.mounted) Navigator.pop(context, true); - }); - - // Update the sessions' device name is available - if (!kIsWeb) { - String? name; - if (Platform.isIOS) { - final deviceInfo = await DeviceInfoPlugin().iosInfo; - name = deviceInfo.name; - } else if (Platform.isAndroid) { - final deviceInfo = await DeviceInfoPlugin().androidInfo; - name = deviceInfo.name; - } else if (Platform.isWindows) { - final deviceInfo = await DeviceInfoPlugin().windowsInfo; - name = deviceInfo.computerName; - } - if (name != null) { - final client = ref.watch(apiClientProvider); - await client.patch( - '/accounts/me/sessions/current/label', - data: jsonEncode(name), - ); - } - } + await getToken(code: result.id); } catch (err) { showErrorAlert(err); return; @@ -346,6 +368,14 @@ class _LoginPickerScreen extends HookConsumerWidget { return null; }, [isBusy]); + useEffect(() { + if (ticket != null && ticket?.stepRemain == 0) { + onPickFactor(factors!.first); + onNext(); + } + return null; + }, [ticket]); + final unfocusColor = Theme.of( context, ).colorScheme.onSurface.withAlpha((255 * 0.75).round()); @@ -569,7 +599,6 @@ class _LoginLookupScreen extends HookConsumerWidget { ); if (context.mounted) showLoadingModal(context); - final resp = await client.post( '/auth/login/apple/mobile', data: { @@ -578,20 +607,18 @@ class _LoginLookupScreen extends HookConsumerWidget { 'device_id': await getUdid(), }, ); - final token = resp.data['token']; - setToken(ref.watch(sharedPreferencesProvider), token); - ref.invalidate(tokenProvider); - if (!context.mounted) return; - // Do post login tasks - final userNotifier = ref.read(userInfoProvider.notifier); - userNotifier.fetchUser().then((_) { - final apiClient = ref.read(apiClientProvider); - subscribePushNotification(apiClient); - final wsNotifier = ref.read(websocketStateProvider.notifier); - wsNotifier.connect(); - if (context.mounted) Navigator.pop(context, true); - }); + final challenge = SnAuthChallenge.fromJson(resp.data); + onChallenge(challenge); + final factorResp = await client.get( + '/auth/challenge/${challenge.id}/factors', + ); + onFactor( + List.from( + factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)), + ), + ); + onNext(); } catch (err) { if (err is SignInWithAppleAuthorizationException) return; showErrorAlert(err); @@ -600,6 +627,32 @@ class _LoginLookupScreen extends HookConsumerWidget { } } + Future withOidc(String provider) async { + final challengeId = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => OidcScreen(provider: provider.toLowerCase()), + ), + ); + + final client = ref.watch(apiClientProvider); + try { + final resp = await client.get('/auth/challenge/$challengeId'); + final challenge = SnAuthChallenge.fromJson(resp.data); + onChallenge(challenge); + final factorResp = await client.get( + '/auth/challenge/${challenge.id}/factors', + ); + onFactor( + List.from( + factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)), + ), + ); + onNext(); + } catch (err) { + showErrorAlert(err); + } + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -635,6 +688,26 @@ class _LoginLookupScreen extends HookConsumerWidget { Text("loginOr").tr().fontSize(11).opacity(0.85), const Gap(8), Spacer(), + IconButton.filledTonal( + onPressed: () => withOidc('github'), + padding: EdgeInsets.zero, + icon: getProviderIcon( + "github", + size: 16, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + tooltip: 'GitHub', + ), + IconButton.filledTonal( + onPressed: () => withOidc('google'), + padding: EdgeInsets.zero, + icon: getProviderIcon( + "google", + size: 16, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + tooltip: 'Google', + ), IconButton.filledTonal( onPressed: withApple, padding: EdgeInsets.zero, diff --git a/lib/screens/auth/oidc.native.dart b/lib/screens/auth/oidc.native.dart index 36bb70b..ad45f4d 100644 --- a/lib/screens/auth/oidc.native.dart +++ b/lib/screens/auth/oidc.native.dart @@ -9,6 +9,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:gap/gap.dart'; import 'package:island/pods/config.dart'; import 'package:island/pods/network.dart'; +import 'package:island/services/udid.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -27,6 +28,13 @@ class _OidcScreenState extends ConsumerState { String? currentUrl; final TextEditingController _urlController = TextEditingController(); bool _isLoading = true; + late Future _deviceIdFuture; + + @override + void initState() { + super.initState(); + _deviceIdFuture = getUdid(); + } @override void dispose() { @@ -43,155 +51,174 @@ class _OidcScreenState extends ConsumerState { appBar: AppBar( title: widget.title != null ? Text(widget.title!) : Text('login').tr(), ), - body: Column( - children: [ - Expanded( - child: InAppWebView( - initialSettings: InAppWebViewSettings( - userAgent: - kIsWeb - ? null - : Platform.isIOS - ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1' - : Platform.isAndroid - ? 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36' - : 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', - ), - initialUrlRequest: URLRequest( - url: WebUri('$serverUrl/auth/login/${widget.provider}'), - headers: { - if (token?.token.isNotEmpty ?? false) - 'Authorization': 'AtField ${token!.token}', - }, - ), - onWebViewCreated: (controller) { - // Register a handler to receive the token from JavaScript - controller.addJavaScriptHandler( - handlerName: 'tokenHandler', - callback: (args) { - // args[0] will be the token string - if (args.isNotEmpty && args[0] is String) { + body: FutureBuilder( + future: _deviceIdFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center(child: Text('somethingWentWrong').tr()); + } + + final deviceId = snapshot.data!; + + return Column( + children: [ + Expanded( + child: InAppWebView( + initialSettings: InAppWebViewSettings( + userAgent: + kIsWeb + ? null + : Platform.isIOS + ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1' + : Platform.isAndroid + ? 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36' + : 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', + ), + initialUrlRequest: URLRequest( + url: WebUri('$serverUrl/auth/login/${widget.provider}'), + headers: { + if (token?.token.isNotEmpty ?? false) + 'Authorization': 'AtField ${token!.token}', + 'X-Device-Id': deviceId, + }, + ), + onWebViewCreated: (controller) { + // Register a handler to receive the token from JavaScript + controller.addJavaScriptHandler( + handlerName: 'tokenHandler', + callback: (args) { + // args[0] will be the token string + if (args.isNotEmpty && args[0] is String) { + setState(() { + authToken = args[0]; + }); + + // Return the token and close the webview + Navigator.of(context).pop(authToken); + } + }, + ); + }, + shouldOverrideUrlLoading: ( + controller, + navigationAction, + ) async { + final url = navigationAction.request.url; + if (url != null) { setState(() { - authToken = args[0]; + currentUrl = url.toString(); + _urlController.text = currentUrl ?? ''; + _isLoading = true; }); - // Return the token and close the webview - Navigator.of(context).pop(authToken); + final path = url.path; + final queryParams = url.queryParameters; + + // Check if we're on the token page + if (path.endsWith('/auth/callback')) { + // Extract token from URL + final challenge = queryParams['challenge']; + // Return the token and close the webview + Navigator.of(context).pop(challenge); + return NavigationActionPolicy.CANCEL; + } + } + return NavigationActionPolicy.ALLOW; + }, + onUpdateVisitedHistory: (controller, url, androidIsReload) { + if (url != null) { + setState(() { + currentUrl = url.toString(); + _urlController.text = currentUrl ?? ''; + }); } }, - ); - }, - shouldOverrideUrlLoading: (controller, navigationAction) async { - final url = navigationAction.request.url; - if (url != null) { - setState(() { - currentUrl = url.toString(); - _urlController.text = currentUrl ?? ''; - _isLoading = true; - }); - - final path = url.path; - final queryParams = url.queryParameters; - - // Check if we're on the token page - if (path.contains('/auth/callback')) { - // Extract token from URL - final token = queryParams['token'] ?? true; - // Return the token and close the webview - Navigator.of(context).pop(token); - return NavigationActionPolicy.CANCEL; - } - } - return NavigationActionPolicy.ALLOW; - }, - onUpdateVisitedHistory: (controller, url, androidIsReload) { - if (url != null) { - setState(() { - currentUrl = url.toString(); - _urlController.text = currentUrl ?? ''; - }); - } - }, - onLoadStop: (controller, url) { - setState(() { - _isLoading = false; - }); - }, - onLoadStart: (controller, url) { - setState(() { - _isLoading = true; - }); - }, - onLoadError: (controller, url, code, message) { - setState(() { - _isLoading = false; - }); - }, - ), - ), - // Loading progress indicator - if (_isLoading) - LinearProgressIndicator( - color: Theme.of(context).colorScheme.primary, - backgroundColor: Theme.of(context).colorScheme.surfaceVariant, - borderRadius: BorderRadius.zero, - stopIndicatorRadius: 0, - minHeight: 2, - ) - else - ColoredBox( - color: Theme.of(context).colorScheme.surfaceVariant, - ).height(2), - // Debug location bar (only visible in debug mode) - Container( - padding: EdgeInsets.only( - left: 16, - right: 0, - bottom: MediaQuery.of(context).padding.bottom + 8, - top: 8, - ), - color: Theme.of(context).colorScheme.surface, - child: Row( - children: [ - Expanded( - child: TextField( - controller: _urlController, - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(4), - ), - hintText: 'URL', - ), - style: const TextStyle(fontSize: 12), - readOnly: true, - ), + onLoadStop: (controller, url) { + setState(() { + _isLoading = false; + }); + }, + onLoadStart: (controller, url) { + setState(() { + _isLoading = true; + }); + }, + onLoadError: (controller, url, code, message) { + setState(() { + _isLoading = false; + }); + }, ), - const Gap(4), - IconButton( - icon: const Icon(Icons.copy, size: 20), - padding: const EdgeInsets.all(4), - constraints: const BoxConstraints(), - onPressed: () { - if (currentUrl != null) { - Clipboard.setData(ClipboardData(text: currentUrl!)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('copyToClipboard').tr(), - duration: const Duration(seconds: 1), + ), + // Loading progress indicator + if (_isLoading) + LinearProgressIndicator( + color: Theme.of(context).colorScheme.primary, + backgroundColor: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.zero, + stopIndicatorRadius: 0, + minHeight: 2, + ) + else + ColoredBox( + color: Theme.of(context).colorScheme.surfaceVariant, + ).height(2), + // Debug location bar (only visible in debug mode) + Container( + padding: EdgeInsets.only( + left: 16, + right: 0, + bottom: MediaQuery.of(context).padding.bottom + 8, + top: 8, + ), + color: Theme.of(context).colorScheme.surface, + child: Row( + children: [ + Expanded( + child: TextField( + controller: _urlController, + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + ), + hintText: 'URL', ), - ); - } - }, + style: const TextStyle(fontSize: 12), + readOnly: true, + ), + ), + const Gap(4), + IconButton( + icon: const Icon(Icons.copy, size: 20), + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + onPressed: () { + if (currentUrl != null) { + Clipboard.setData(ClipboardData(text: currentUrl!)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('copyToClipboard').tr(), + duration: const Duration(seconds: 1), + ), + ); + } + }, + ), + ], ), - ], - ), - ), - ], + ), + ], + ); + }, ), ); }