mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
a1143e24f3
commit
ea0c4e96d2
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@ -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 }}
|
||||
|
20
frontend/appflowy_flutter/lib/env/env.dart
vendored
20
frontend/appflowy_flutter/lib/env/env.dart
vendored
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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());
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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 {};
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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'));
|
||||
// },
|
||||
// ),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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(
|
||||
|
@ -60,7 +60,7 @@ class SettingsUserView extends StatelessWidget {
|
||||
BuildContext context,
|
||||
SettingsUserState state,
|
||||
) {
|
||||
if (!isSupabaseEnable) {
|
||||
if (!isSupabaseEnabled) {
|
||||
return _renderLogoutButton(context);
|
||||
}
|
||||
|
||||
|
@ -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
frontend/scripts/code_generation/env/generate_env.cmd
vendored
Normal file
17
frontend/scripts/code_generation/env/generate_env.cmd
vendored
Normal 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%"
|
19
frontend/scripts/code_generation/env/generate_env.sh
vendored
Normal file
19
frontend/scripts/code_generation/env/generate_env.sh
vendored
Normal 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"
|
@ -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%"
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user