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 <42929161+Xazin@users.noreply.github.com>

* chore: fix compile

---------

Co-authored-by: Mathias Mogensen <42929161+Xazin@users.noreply.github.com>
This commit is contained in:
Nathan.fooo 2023-08-03 08:48:04 +08:00 committed by GitHub
parent a1143e24f3
commit ea0c4e96d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 244 additions and 82 deletions

View File

@ -20,6 +20,14 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 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 - name: Build release notes
run: | run: |
touch ${{ env.RELEASE_NOTES_PATH }} touch ${{ env.RELEASE_NOTES_PATH }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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( Future<void> _performActionOnSignInAsGuest(
SignInState state, SignInState state,
Emitter<SignInState> emit, Emitter<SignInState> emit,
@ -154,6 +185,8 @@ class SignInEvent with _$SignInEvent {
const factory SignInEvent.signedInWithOAuth(String platform) = const factory SignInEvent.signedInWithOAuth(String platform) =
SignedInWithOAuth; SignedInWithOAuth;
const factory SignInEvent.signedInAsGuest() = SignedInAsGuest; const factory SignInEvent.signedInAsGuest() = SignedInAsGuest;
const factory SignInEvent.signedWithMagicLink(String email) =
SignedWithMagicLink;
const factory SignInEvent.emailChanged(String email) = EmailChanged; const factory SignInEvent.emailChanged(String email) = EmailChanged;
const factory SignInEvent.passwordChanged(String password) = PasswordChanged; const factory SignInEvent.passwordChanged(String password) = PasswordChanged;
} }

View File

@ -333,31 +333,31 @@ class ThirdPartySignInButtons extends StatelessWidget {
icon: 'login/google-mark', icon: 'login/google-mark',
onPressed: () { onPressed: () {
getIt<KeyValueStorage>().set(KVKeys.loginType, 'supabase'); getIt<KeyValueStorage>().set(KVKeys.loginType, 'supabase');
context context.read<SignInBloc>().add(
.read<SignInBloc>() const SignInEvent.signedInWithOAuth('google'),
.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'));
}, },
), ),
// 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'));
// },
// ),
], ],
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -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%"

View File

@ -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"

View File

@ -22,6 +22,13 @@ cd freezed
REM Allow execution permissions on CI REM Allow execution permissions on CI
chmod +x generate_freezed.cmd chmod +x generate_freezed.cmd
call 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 REM Return to the original directory
cd /d "%original_dir%" cd /d "%original_dir%"

View File

@ -22,6 +22,13 @@ cd freezed
# Allow execution permissions on CI # Allow execution permissions on CI
chmod +x ./generate_freezed.sh chmod +x ./generate_freezed.sh
./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 # Return to the original directory
cd "$original_dir" cd "$original_dir"