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
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 }}

View File

@ -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;
}
}

View File

@ -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());

View File

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

View File

@ -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",
);
}
}

View File

@ -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();

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-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 {};
}
}

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(
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;
}

View File

@ -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'));
// },
// ),
],
);
}

View File

@ -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);

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
if (isSupabaseEnable &&
if (isSupabaseEnabled &&
context.read<SettingsDialogBloc>().state.userProfile.authType !=
AuthTypePB.Local)
SettingsMenuElement(

View File

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

View File

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

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

View File

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