diff --git a/lib/screens/auth/create_account_content.dart b/lib/screens/auth/create_account_content.dart index fc2a9494..d0ff6214 100644 --- a/lib/screens/auth/create_account_content.dart +++ b/lib/screens/auth/create_account_content.dart @@ -1,22 +1,71 @@ +import 'dart:convert'; + import 'package:easy_localization/easy_localization.dart'; import 'package:email_validator/email_validator.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:gap/gap.dart'; +import 'package:island/pods/config.dart'; import 'package:island/pods/network.dart'; +import 'package:island/pods/userinfo.dart'; +import 'package:island/pods/websocket.dart'; import 'package:island/screens/account/me/profile_update.dart'; +import 'package:island/services/event_bus.dart'; +import 'package:island/services/notify.dart'; import 'package:island/widgets/alert.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:url_launcher/url_launcher_string.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'captcha.dart'; +Widget getProviderIcon(String provider, {double size = 24, Color? color}) { + final providerLower = provider.toLowerCase(); + + // Check if we have an SVG for this provider + switch (providerLower) { + case 'apple': + case 'microsoft': + case 'google': + case 'github': + case 'discord': + case 'afdian': + case 'steam': + return SvgPicture.asset( + 'assets/images/oidc/$providerLower.svg', + width: size, + height: size, + colorFilter: + color != null ? ColorFilter.mode(color, BlendMode.srcIn) : null, + ); + case 'spotify': + return Image.asset( + 'assets/images/oidc/spotify.png', + width: size, + height: size, + color: color, + ); + default: + return Icon(Symbols.link, size: size); + } +} + class CreateAccountContent extends HookConsumerWidget { const CreateAccountContent({super.key}); + Map decodeJwt(String token) { + final parts = token.split('.'); + if (parts.length != 3) throw FormatException('Invalid JWT'); + final payload = parts[1]; + final normalized = base64Url.normalize(payload); + final decoded = utf8.decode(base64Url.decode(normalized)); + return json.decode(decoded); + } + @override Widget build(BuildContext context, WidgetRef ref) { final formKey = useMemoized(GlobalKey.new, const []); @@ -25,6 +74,8 @@ class CreateAccountContent extends HookConsumerWidget { final usernameController = useTextEditingController(); final nicknameController = useTextEditingController(); final passwordController = useTextEditingController(); + final waitingForOidc = useState(false); + final onboardingToken = useState(null); void showPostCreateModal() { showModalBottomSheet( @@ -37,38 +88,129 @@ class CreateAccountContent extends HookConsumerWidget { void performAction() async { if (!formKey.currentState!.validate()) return; - final captchaTk = await CaptchaScreen.show(context); - if (captchaTk == null) return; + String endpoint = '/pass/accounts'; + Map data = {}; + + if (onboardingToken.value != null) { + // OIDC onboarding + endpoint = '/pass/account/onboard'; + data['onboarding_token'] = onboardingToken.value; + data['name'] = usernameController.text; + data['nick'] = nicknameController.text; + // Password is required in form, but might be optional + } else { + // Manual account creation + final captchaTk = await CaptchaScreen.show(context); + if (captchaTk == null) return; + if (!context.mounted) return; + data['captcha_token'] = captchaTk; + data['name'] = usernameController.text; + data['nick'] = nicknameController.text; + data['email'] = emailController.text; + data['password'] = passwordController.text; + data['language'] = + kServerSupportedLanguages[EasyLocalization.of( + context, + )!.currentLocale.toString()] ?? + 'en-us'; + } if (!context.mounted) return; try { showLoadingModal(context); final client = ref.watch(apiClientProvider); - await client.post( - '/pass/accounts', - data: { - 'name': usernameController.text, - 'nick': nicknameController.text, - 'email': emailController.text, - 'password': passwordController.text, - 'language': - kServerSupportedLanguages[EasyLocalization.of( - context, - )!.currentLocale.toString()] ?? - 'en-us', - 'captcha_token': captchaTk, - }, - ); - if (!context.mounted) return; - hideLoadingModal(context); - showPostCreateModal(); + final resp = await client.post(endpoint, data: data); + if (endpoint == '/pass/account/onboard') { + // Onboard response has tokens, set them + final token = resp.data['token']; + setToken(ref.watch(sharedPreferencesProvider), token); + ref.invalidate(tokenProvider); + final userNotifier = ref.read(userInfoProvider.notifier); + await userNotifier.fetchUser(); + final apiClient = ref.read(apiClientProvider); + subscribePushNotification(apiClient); + final wsNotifier = ref.read(websocketStateProvider.notifier); + wsNotifier.connect(); + if (context.mounted) Navigator.pop(context, true); + } else { + if (!context.mounted) return; + hideLoadingModal(context); + onboardingToken.value = null; // reset + showPostCreateModal(); + } } catch (err) { if (context.mounted) hideLoadingModal(context); showErrorAlert(err); } } + useEffect(() { + final subscription = eventBus.on().listen(( + event, + ) async { + if (!waitingForOidc.value || !context.mounted) return; + waitingForOidc.value = false; + final client = ref.watch(apiClientProvider); + try { + // Exchange code for tokens + final resp = await client.post( + '/pass/auth/token', + data: { + 'grant_type': 'authorization_code', + 'code': event.challengeId, + }, + ); + final data = resp.data; + if (data.containsKey('onboarding_token')) { + // New user onboarding + final token = data['onboarding_token'] as String; + final decoded = decodeJwt(token); + final name = decoded['name'] as String?; + final email = decoded['email'] as String?; + final provider = decoded['provider'] as String?; + // Pre-fill form + usernameController.text = ''; + nicknameController.text = name ?? ''; + emailController.text = email ?? ''; + passwordController.clear(); // User needs to set password + onboardingToken.value = token; + // Optionally show a message + showSnackBar('Pre-filled from ${provider ?? 'provider'}'); + } else { + // Existing user, switch to login + showSnackBar('Account already exists. Redirecting to login.'); + context.goNamed('login'); + } + } catch (err) { + showErrorAlert(err); + } + }); + return subscription.cancel; + }, [waitingForOidc.value, context.mounted]); + + Future withOidc(String provider) async { + waitingForOidc.value = true; + final serverUrl = ref.watch(serverUrlProvider); + final url = + Uri.parse('$serverUrl/pass/auth/login/${provider.toLowerCase()}') + .replace( + queryParameters: {'redirect_uri': 'solian://auth/callback'}, + ) + .toString(); + final isLaunched = await launchUrlString( + url, + mode: + kIsWeb + ? LaunchMode.platformDefault + : LaunchMode.externalApplication, + ); + if (!isLaunched) { + waitingForOidc.value = false; + showErrorAlert('failedToLaunchBrowser'.tr()); + } + } + return StyledWidget( Container( constraints: const BoxConstraints(maxWidth: 380), @@ -90,6 +232,48 @@ class CreateAccountContent extends HookConsumerWidget { fontWeight: FontWeight.w900, ), ).tr().padding(left: 4, bottom: 16), + if (!kIsWeb) + Row( + spacing: 6, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text("orCreateWith").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: () => withOidc('apple'), + padding: EdgeInsets.zero, + icon: getProviderIcon( + "apple", + size: 16, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + tooltip: 'Apple Account', + ), + ], + ).padding(horizontal: 8, vertical: 8) + else + const Gap(12), Form( key: formKey, autovalidateMode: AutovalidateMode.onUserInteraction, diff --git a/lib/screens/auth/login_content.dart b/lib/screens/auth/login_content.dart index e5c1af05..56dfd6e1 100644 --- a/lib/screens/auth/login_content.dart +++ b/lib/screens/auth/login_content.dart @@ -15,7 +15,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/event_bus.dart'; import 'package:island/services/notify.dart'; import 'package:island/services/udid.dart'; import 'package:island/widgets/alert.dart'; @@ -503,12 +503,42 @@ class _LoginLookupScreen extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final isBusy = useState(false); final usernameController = useTextEditingController(); + final waitingForOidc = useState(false); useEffect(() { onBusy.call(isBusy.value); return null; }, [isBusy]); + useEffect(() { + final subscription = eventBus.on().listen(( + event, + ) async { + if (!waitingForOidc.value || !context.mounted) return; + waitingForOidc.value = false; + final client = ref.watch(apiClientProvider); + try { + final resp = await client.get( + '/pass/auth/challenge/${event.challengeId}', + ); + final challenge = SnAuthChallenge.fromJson(resp.data); + onChallenge(challenge); + final factorResp = await client.get( + '/pass/auth/challenge/${challenge.id}/factors', + ); + onFactor( + List.from( + factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)), + ), + ); + onNext(); + } catch (err) { + showErrorAlert(err); + } + }); + return subscription.cancel; + }, [waitingForOidc.value, context.mounted]); + Future requestResetPassword() async { final uname = usernameController.value.text; if (uname.isEmpty) { @@ -618,28 +648,33 @@ class _LoginLookupScreen extends HookConsumerWidget { } Future withOidc(String provider) async { - final challengeId = await Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( - builder: (context) => OidcScreen(provider: provider.toLowerCase()), - ), + waitingForOidc.value = true; + final serverUrl = ref.watch(serverUrlProvider); + final token = ref.watch(tokenProvider); + final deviceId = await getUdid(); + final queryParams = { + 'redirect_uri': 'solian://auth/callback', + 'device_id': deviceId, + }; + if (token?.token != null) { + queryParams['token'] = token!.token; + } + final url = + Uri.parse( + '$serverUrl/pass/auth/login/${provider.toLowerCase()}', + ).replace(queryParameters: queryParams).toString(); + final isLaunched = await launchUrlString( + url, + mode: + kIsWeb + ? LaunchMode.platformDefault + : LaunchMode.externalApplication, + webOnlyWindowName: + token?.token != null ? 'auth-${token!.token}' : 'auth', ); - - final client = ref.watch(apiClientProvider); - try { - final resp = await client.get('/pass/auth/challenge/$challengeId'); - final challenge = SnAuthChallenge.fromJson(resp.data); - onChallenge(challenge); - final factorResp = await client.get( - '/pass/auth/challenge/${challenge.id}/factors', - ); - onFactor( - List.from( - factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)), - ), - ); - onNext(); - } catch (err) { - showErrorAlert(err); + if (!isLaunched) { + waitingForOidc.value = false; + showErrorAlert('failedToLaunchBrowser'.tr()); } } diff --git a/lib/services/event_bus.dart b/lib/services/event_bus.dart index 847c5450..5e5eeb41 100644 --- a/lib/services/event_bus.dart +++ b/lib/services/event_bus.dart @@ -16,3 +16,10 @@ class PostCreatedEvent { class ChatRoomsRefreshEvent { const ChatRoomsRefreshEvent(); } + +/// Event fired when OIDC auth callback is received +class OidcAuthCallbackEvent { + final String challengeId; + + const OidcAuthCallbackEvent(this.challengeId); +} diff --git a/lib/widgets/app_wrapper.dart b/lib/widgets/app_wrapper.dart index 7bb830a9..3fbac56c 100644 --- a/lib/widgets/app_wrapper.dart +++ b/lib/widgets/app_wrapper.dart @@ -8,6 +8,7 @@ import 'package:island/pods/activity/activity_rpc.dart'; import 'package:island/pods/websocket.dart'; import 'package:island/route.dart'; import 'package:island/screens/tray_manager.dart'; +import 'package:island/services/event_bus.dart'; import 'package:island/services/notify.dart'; import 'package:island/services/sharing_intent.dart'; import 'package:island/services/update_service.dart'; @@ -115,6 +116,18 @@ class _AppWrapperState extends ConsumerState } void _handleDeepLink(Uri uri, WidgetRef ref) { + // Special handling for OIDC auth callback + if (uri.path == '/auth/callback' && + uri.queryParameters.containsKey('challenge')) { + final challenge = uri.queryParameters['challenge']!; + eventBus.fire(OidcAuthCallbackEvent(challenge)); + if (!kIsWeb && + (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { + windowManager.show(); + } + return; + } + final router = ref.read(routerProvider); String path = '/${uri.host}${uri.path}'; if (uri.queryParameters.isNotEmpty) {