Browse Source

feat: implement magic link login (#3086)

* feat: implement magic link login

* ci: create env file

* ci: generate flutter env files

* ci: disable inject env

* chore: update table name

* Update frontend/appflowy_flutter/lib/env/env.dart

Co-authored-by: Mathias Mogensen <[email protected]>

* chore: fix compile

---------

Co-authored-by: Mathias Mogensen <[email protected]>
Nathan.fooo 1 year ago
parent
commit
ea0c4e96d2

+ 8 - 0
.github/workflows/release.yml

@@ -20,6 +20,14 @@ jobs:
       - name: Checkout
         uses: actions/checkout@v2
 
+#      - name: Create .env file
+#        working-directory: frontend/appflowy_flutter
+#        run: |
+#          touch .env
+#          echo SUPABASE_URL=${{ secrets.HOST_URL }} >> .env
+#          echo SUPABASE_ANON_KEY=${{ secrets.HOST_ANON_KEY }} >> .env
+#          echo SUPABASE_JWT_SECRET=${{ secrets.HOST_JWT_SECRET }} >> .env
+
       - name: Build release notes
         run: |
           touch ${{ env.RELEASE_NOTES_PATH }}

+ 11 - 9
frontend/appflowy_flutter/lib/env/env.dart

@@ -1,4 +1,5 @@
 // lib/env/env.dart
+import 'package:appflowy/startup/startup.dart';
 import 'package:envied/envied.dart';
 
 part 'env.g.dart';
@@ -37,12 +38,13 @@ abstract class Env {
   static final String supabaseJwtSecret = _Env.supabaseJwtSecret;
 }
 
-bool get isSupabaseEnable => false;
-    // Env.supabaseUrl.isNotEmpty &&
-    // Env.supabaseAnonKey.isNotEmpty &&
-    // Env.supabaseKey.isNotEmpty &&
-    // Env.supabaseJwtSecret.isNotEmpty &&
-    // Env.supabaseDb.isNotEmpty &&
-    // Env.supabaseDbUser.isNotEmpty &&
-    // Env.supabaseDbPassword.isNotEmpty &&
-    // Env.supabaseDbPort.isNotEmpty;
+bool get isSupabaseEnabled {
+  // Only enable supabase in release and develop mode.
+  if (integrationEnv().isRelease || integrationEnv().isDevelop) {
+    return Env.supabaseUrl.isNotEmpty &&
+        Env.supabaseAnonKey.isNotEmpty &&
+        Env.supabaseJwtSecret.isNotEmpty;
+  } else {
+    return false;
+  }
+}

+ 6 - 2
frontend/appflowy_flutter/lib/startup/deps_resolver.dart

@@ -1,5 +1,6 @@
 import 'package:appflowy/core/config/kv.dart';
 import 'package:appflowy/core/network_monitor.dart';
+import 'package:appflowy/env/env.dart';
 import 'package:appflowy/plugins/database_view/application/field/field_action_sheet_bloc.dart';
 import 'package:appflowy/plugins/database_view/application/field/field_controller.dart';
 import 'package:appflowy/plugins/database_view/application/field/field_service.dart';
@@ -82,8 +83,11 @@ void _resolveCommonService(
 }
 
 void _resolveUserDeps(GetIt getIt) {
-  // getIt.registerFactory<AuthService>(() => AppFlowyAuthService());
-  getIt.registerFactory<AuthService>(() => SupabaseAuthService());
+  if (isSupabaseEnabled) {
+    getIt.registerFactory<AuthService>(() => SupabaseAuthService());
+  } else {
+    getIt.registerFactory<AuthService>(() => AppFlowyAuthService());
+  }
 
   getIt.registerFactory<AuthRouter>(() => AuthRouter());
 

+ 3 - 2
frontend/appflowy_flutter/lib/startup/tasks/supabase_task.dart

@@ -8,7 +8,7 @@ bool isSupabaseInitialized = false;
 class InitSupabaseTask extends LaunchTask {
   @override
   Future<void> initialize(LaunchContext context) async {
-    if (!isSupabaseEnable) {
+    if (!isSupabaseEnabled) {
       return;
     }
 
@@ -18,7 +18,8 @@ class InitSupabaseTask extends LaunchTask {
     await Supabase.initialize(
       url: Env.supabaseUrl,
       anonKey: Env.supabaseAnonKey,
-      debug: false,
+      debug: true,
+      // authFlowType: AuthFlowType.pkce,
     );
 
     isSupabaseInitialized = true;

+ 18 - 2
frontend/appflowy_flutter/lib/user/application/auth/appflowy_auth_service.dart

@@ -75,12 +75,28 @@ class AppFlowyAuthService implements AuthService {
     required String platform,
     AuthTypePB authType = AuthTypePB.Local,
     Map<String, String> map = const {},
-  }) {
-    throw UnimplementedError();
+  }) async {
+    return left(
+      FlowyError.create()
+        ..code = 0
+        ..msg = "Unsupported sign up action",
+    );
   }
 
   @override
   Future<Either<FlowyError, UserProfilePB>> getUser() async {
     return UserBackendService.getCurrentUserProfile();
   }
+
+  @override
+  Future<Either<FlowyError, UserProfilePB>> signInWithMagicLink({
+    required String email,
+    Map<String, String> map = const {},
+  }) async {
+    return left(
+      FlowyError.create()
+        ..code = 0
+        ..msg = "Unsupported sign up action",
+    );
+  }
 }

+ 5 - 0
frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart

@@ -42,6 +42,11 @@ abstract class AuthService {
     Map<String, String> map,
   });
 
+  Future<Either<FlowyError, UserProfilePB>> signInWithMagicLink({
+    required String email,
+    Map<String, String> map,
+  });
+
   ///
   Future<void> signOut();
 

+ 83 - 40
frontend/appflowy_flutter/lib/user/application/auth/supabase_auth_service.dart

@@ -9,9 +9,13 @@ import 'package:appflowy_backend/log.dart';
 import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
 import 'package:dartz/dartz.dart';
+import 'package:flutter/foundation.dart';
 import 'package:supabase_flutter/supabase_flutter.dart';
 import 'auth_error.dart';
 
+// can't use underscore here.
+const loginCallback = 'io.appflowy.appflowy-flutter://login-callback';
+
 class SupabaseAuthService implements AuthService {
   SupabaseAuthService();
 
@@ -28,7 +32,7 @@ class SupabaseAuthService implements AuthService {
     AuthTypePB authType = AuthTypePB.Supabase,
     Map<String, String> map = const {},
   }) async {
-    if (!isSupabaseEnable) {
+    if (!isSupabaseEnabled) {
       return _appFlowyAuthService.signUp(
         name: name,
         email: email,
@@ -65,7 +69,7 @@ class SupabaseAuthService implements AuthService {
     AuthTypePB authType = AuthTypePB.Supabase,
     Map<String, String> map = const {},
   }) async {
-    if (!isSupabaseEnable) {
+    if (!isSupabaseEnabled) {
       return _appFlowyAuthService.signIn(
         email: email,
         password: password,
@@ -101,39 +105,25 @@ class SupabaseAuthService implements AuthService {
     AuthTypePB authType = AuthTypePB.Supabase,
     Map<String, String> map = const {},
   }) async {
-    if (!isSupabaseEnable) {
-      return _appFlowyAuthService.signUpWithOAuth(
-        platform: platform,
-      );
+    if (!isSupabaseEnabled) {
+      return _appFlowyAuthService.signUpWithOAuth(platform: platform);
     }
     final provider = platform.toProvider();
-    final completer = Completer<Either<FlowyError, UserProfilePB>>();
-    late final StreamSubscription<AuthState> subscription;
-    subscription = _auth.onAuthStateChange.listen((event) async {
-      final user = event.session?.user;
-      if (event.event != AuthChangeEvent.signedIn || user == null) {
-        completer.complete(left(AuthError.supabaseSignInWithOauthError));
-      } else {
-        final Either<FlowyError, UserProfilePB> response = await setupAuth(
+    final completer = supabaseLoginCompleter(
+      onSuccess: (userId, userEmail) async {
+        return await setupAuth(
           map: {
-            AuthServiceMapKeys.uuid: user.id,
-            AuthServiceMapKeys.email: user.email ?? user.newEmail ?? ''
+            AuthServiceMapKeys.uuid: userId,
+            AuthServiceMapKeys.email: userEmail
           },
         );
-        completer.complete(response);
-      }
-      subscription.cancel();
-    });
-    final Map<String, String> query = {};
-    if (provider == Provider.google) {
-      query['access_type'] = 'offline';
-      query['prompt'] = 'consent';
-    }
+      },
+    );
+
     final response = await _auth.signInWithOAuth(
       provider,
-      queryParams: query,
-      redirectTo:
-          'io.appflowy.appflowy-flutter://login-callback', // can't use underscore here.
+      queryParams: queryParamsForProvider(provider),
+      redirectTo: loginCallback,
     );
     if (!response) {
       completer.complete(left(AuthError.supabaseSignInWithOauthError));
@@ -145,7 +135,7 @@ class SupabaseAuthService implements AuthService {
   Future<void> signOut({
     AuthTypePB authType = AuthTypePB.Supabase,
   }) async {
-    if (isSupabaseEnable) {
+    if (isSupabaseEnabled) {
       await _auth.signOut();
     }
     await _appFlowyAuthService.signOut(
@@ -163,17 +153,28 @@ class SupabaseAuthService implements AuthService {
     return _appFlowyAuthService.signUpAsGuest();
   }
 
-  // @override
-  // Future<Either<FlowyError, UserProfilePB>> getUser() async {
-  //   final loginType = await getIt<KeyValueStorage>()
-  //       .get(KVKeys.loginType)
-  //       .then((value) => value.toOption().toNullable());
-  //   if (!isSupabaseEnable || (loginType != null && loginType != 'supabase')) {
-  //     return _appFlowyAuthService.getUser();
-  //   }
-  //   final user = await getSupabaseUser();
-  //   return user.map((r) => r.toUserProfile());
-  // }
+  @override
+  Future<Either<FlowyError, UserProfilePB>> signInWithMagicLink({
+    required String email,
+    Map<String, String> map = const {},
+  }) async {
+    final completer = supabaseLoginCompleter(
+      onSuccess: (userId, userEmail) async {
+        return await setupAuth(
+          map: {
+            AuthServiceMapKeys.uuid: userId,
+            AuthServiceMapKeys.email: userEmail
+          },
+        );
+      },
+    );
+
+    await _auth.signInWithOtp(
+      email: email,
+      emailRedirectTo: kIsWeb ? null : loginCallback,
+    );
+    return completer.future;
+  }
 
   @override
   Future<Either<FlowyError, UserProfilePB>> getUser() async {
@@ -215,3 +216,45 @@ extension on String {
     }
   }
 }
+
+Completer<Either<FlowyError, UserProfilePB>> supabaseLoginCompleter({
+  required Future<Either<FlowyError, UserProfilePB>> Function(
+    String userId,
+    String userEmail,
+  ) onSuccess,
+}) {
+  final completer = Completer<Either<FlowyError, UserProfilePB>>();
+  late final StreamSubscription<AuthState> subscription;
+  final auth = Supabase.instance.client.auth;
+
+  subscription = auth.onAuthStateChange.listen((event) async {
+    final user = event.session?.user;
+    if (event.event != AuthChangeEvent.signedIn || user == null) {
+      completer.complete(left(AuthError.supabaseSignInWithOauthError));
+    } else {
+      final response = await onSuccess(
+        user.id,
+        user.email ?? user.newEmail ?? '',
+      );
+      completer.complete(response);
+    }
+    subscription.cancel();
+  });
+  return completer;
+}
+
+Map<String, String> queryParamsForProvider(Provider provider) {
+  switch (provider) {
+    case Provider.github:
+      return {};
+    case Provider.google:
+      return {
+        'access_type': 'offline',
+        'prompt': 'consent',
+      };
+    case Provider.discord:
+      return {};
+    default:
+      return {};
+  }
+}

+ 33 - 0
frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart

@@ -48,6 +48,9 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
             ),
           );
         },
+        signedWithMagicLink: (SignedWithMagicLink value) async {
+          await _performActionOnSignInWithMagicLink(state, emit, value.email);
+        },
       );
     });
   }
@@ -99,6 +102,34 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
     );
   }
 
+  Future<void> _performActionOnSignInWithMagicLink(
+    SignInState state,
+    Emitter<SignInState> emit,
+    String email,
+  ) async {
+    emit(
+      state.copyWith(
+        isSubmitting: true,
+        emailError: none(),
+        passwordError: none(),
+        successOrFail: none(),
+      ),
+    );
+
+    final result = await authService.signInWithMagicLink(
+      email: email,
+    );
+    emit(
+      result.fold(
+        (error) => stateFromCode(error),
+        (userProfile) => state.copyWith(
+          isSubmitting: false,
+          successOrFail: some(left(userProfile)),
+        ),
+      ),
+    );
+  }
+
   Future<void> _performActionOnSignInAsGuest(
     SignInState state,
     Emitter<SignInState> emit,
@@ -154,6 +185,8 @@ class SignInEvent with _$SignInEvent {
   const factory SignInEvent.signedInWithOAuth(String platform) =
       SignedInWithOAuth;
   const factory SignInEvent.signedInAsGuest() = SignedInAsGuest;
+  const factory SignInEvent.signedWithMagicLink(String email) =
+      SignedWithMagicLink;
   const factory SignInEvent.emailChanged(String email) = EmailChanged;
   const factory SignInEvent.passwordChanged(String password) = PasswordChanged;
 }

+ 23 - 23
frontend/appflowy_flutter/lib/user/presentation/sign_in_screen.dart

@@ -333,31 +333,31 @@ class ThirdPartySignInButtons extends StatelessWidget {
           icon: 'login/google-mark',
           onPressed: () {
             getIt<KeyValueStorage>().set(KVKeys.loginType, 'supabase');
-            context
-                .read<SignInBloc>()
-                .add(const SignInEvent.signedInWithOAuth('google'));
-          },
-        ),
-        const SizedBox(width: 20),
-        ThirdPartySignInButton(
-          icon: 'login/github-mark',
-          onPressed: () {
-            getIt<KeyValueStorage>().set(KVKeys.loginType, 'supabase');
-            context
-                .read<SignInBloc>()
-                .add(const SignInEvent.signedInWithOAuth('github'));
-          },
-        ),
-        const SizedBox(width: 20),
-        ThirdPartySignInButton(
-          icon: 'login/discord-mark',
-          onPressed: () {
-            getIt<KeyValueStorage>().set(KVKeys.loginType, 'supabase');
-            context
-                .read<SignInBloc>()
-                .add(const SignInEvent.signedInWithOAuth('discord'));
+            context.read<SignInBloc>().add(
+                  const SignInEvent.signedInWithOAuth('google'),
+                );
           },
         ),
+        // const SizedBox(width: 20),
+        // ThirdPartySignInButton(
+        //   icon: 'login/github-mark',
+        //   onPressed: () {
+        //     getIt<KeyValueStorage>().set(KVKeys.loginType, 'supabase');
+        //     context
+        //         .read<SignInBloc>()
+        //         .add(const SignInEvent.signedInWithOAuth('github'));
+        //   },
+        // ),
+        // const SizedBox(width: 20),
+        // ThirdPartySignInButton(
+        //   icon: 'login/discord-mark',
+        //   onPressed: () {
+        //     getIt<KeyValueStorage>().set(KVKeys.loginType, 'supabase');
+        //     context
+        //         .read<SignInBloc>()
+        //         .add(const SignInEvent.signedInWithOAuth('discord'));
+        //   },
+        // ),
       ],
     );
   }

+ 1 - 1
frontend/appflowy_flutter/lib/user/presentation/splash_screen.dart

@@ -88,7 +88,7 @@ class SplashScreen extends StatelessWidget {
 
   void _handleUnauthenticated(BuildContext context, Unauthenticated result) {
     // if the env is not configured, we will skip to the 'skip login screen'.
-    if (isSupabaseEnable) {
+    if (isSupabaseEnabled) {
       getIt<SplashRoute>().pushSignInScreen(context);
     } else {
       getIt<SplashRoute>().pushSkipLoginScreen(context);

+ 1 - 1
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart

@@ -60,7 +60,7 @@ class SettingsMenu extends StatelessWidget {
         ),
 
         // Only show supabase setting if supabase is enabled and the current auth type is not local
-        if (isSupabaseEnable &&
+        if (isSupabaseEnabled &&
             context.read<SettingsDialogBloc>().state.userProfile.authType !=
                 AuthTypePB.Local)
           SettingsMenuElement(

+ 1 - 1
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart

@@ -60,7 +60,7 @@ class SettingsUserView extends StatelessWidget {
     BuildContext context,
     SettingsUserState state,
   ) {
-    if (!isSupabaseEnable) {
+    if (!isSupabaseEnabled) {
       return _renderLogoutButton(context);
     }
 

+ 1 - 1
frontend/rust-lib/flowy-server/src/supabase/api/collab_storage.rs

@@ -181,7 +181,7 @@ async fn send_update(
 
   let params = builder.build();
   postgrest
-    .from(&table_name(&object.ty))
+    .from(AF_COLLAB_UPDATE_TABLE)
     .insert(params)
     .execute()
     .await?

+ 17 - 0
frontend/scripts/code_generation/env/generate_env.cmd

@@ -0,0 +1,17 @@
+@echo off
+
+REM Store the current working directory
+set "original_dir=%CD%"
+
+REM Change the current working directory to the script's location
+cd /d "%~dp0"
+
+REM Navigate to the project root
+cd ..\..\..\appflowy_flutter
+
+REM Navigate to the appflowy_flutter directory and generate files
+echo Generating env files
+call flutter packages pub get >nul 2>&1 && call dart run build_runner clean && call dart run build_runner build --delete-conflicting-outputs
+echo Done generating env files
+
+cd /d "%original_dir%"

+ 19 - 0
frontend/scripts/code_generation/env/generate_env.sh

@@ -0,0 +1,19 @@
+#!/bin/bash
+
+# Store the current working directory
+original_dir=$(pwd)
+
+cd "$(dirname "$0")"
+
+# Navigate to the project root
+cd ../../../appflowy_flutter
+
+# Navigate to the appflowy_flutter directory and generate files
+echo "Generating env files"
+# flutter clean >/dev/null 2>&1 && flutter packages pub get >/dev/null 2>&1 && dart run build_runner clean &&
+flutter packages pub get >/dev/null 2>&1
+dart run build_runner clean && dart run build_runner build --delete-conflicting-outputs
+echo "Done generating env files"
+
+# Return to the original directory
+cd "$original_dir"

+ 7 - 0
frontend/scripts/code_generation/generate.cmd

@@ -22,6 +22,13 @@ cd freezed
 REM Allow execution permissions on CI
 chmod +x generate_freezed.cmd
 call generate_freezed.cmd %*
+cd ..
+
+echo Generating env files using build_runner
+cd env
+REM Allow execution permissions on CI
+chmod +x generate_env.cmd
+call generate_env.cmd %*
 
 REM Return to the original directory
 cd /d "%original_dir%"

+ 7 - 0
frontend/scripts/code_generation/generate.sh

@@ -22,6 +22,13 @@ cd freezed
 # Allow execution permissions on CI
 chmod +x ./generate_freezed.sh
 ./generate_freezed.sh "$@"
+cd..
+
+echo "Generating env files using build_runner"
+cd env
+# Allow execution permissions on CI
+chmod +x ./generate_env.sh
+./generate_env.sh "$@"
 
 # Return to the original directory
 cd "$original_dir"