fix: push to sign in screen when logout (#3127)

* fix: push to sign in screen when logout

* chore: show historical login users

* chore: open historical user

* chore: show historical user

* chore: reload app widget with unique key

* chore: add tooltip for user history
This commit is contained in:
Nathan.fooo 2023-08-07 22:24:04 +08:00 committed by GitHub
parent a3bea472bf
commit 3c04b72932
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 528 additions and 123 deletions

View File

@ -89,6 +89,7 @@ class ApplicationWidget extends StatelessWidget {
],
child: BlocBuilder<AppearanceSettingsCubit, AppearanceSettingsState>(
builder: (context, state) => MaterialApp(
key: UniqueKey(),
builder: overlayManagerBuilder(),
debugShowCheckedModeBanner: false,
theme: state.lightTheme,

View File

@ -215,6 +215,22 @@ extension on String {
}
}
/// Creates a completer that listens to Supabase authentication state changes and
/// completes when a user signs in.
///
/// This function sets up a listener on Supabase's authentication state. When a user
/// signs in, it triggers the provided [onSuccess] callback with the user's `id` and
/// `email`. Once the [onSuccess] callback is executed and a response is received,
/// the completer completes with the response, and the listener is canceled.
///
/// Parameters:
/// - [onSuccess]: A callback function that's executed when a user signs in. It
/// should take in a user's `id` and `email` and return a `Future` containing either
/// a `FlowyError` or a `UserProfilePB`.
///
/// Returns:
/// A completer of type `Either<FlowyError, UserProfilePB>`. This completer completes
/// with the response from the [onSuccess] callback when a user signs in.
Completer<Either<FlowyError, UserProfilePB>> supabaseLoginCompleter({
required Future<Either<FlowyError, UserProfilePB>> Function(
String userId,
@ -227,16 +243,15 @@ Completer<Either<FlowyError, UserProfilePB>> supabaseLoginCompleter({
subscription = auth.onAuthStateChange.listen((event) async {
final user = event.session?.user;
if (event.event != AuthChangeEvent.signedIn || user == null) {
completer.complete(left(AuthError.supabaseSignInWithOauthError));
} else {
if (event.event == AuthChangeEvent.signedIn && user != null) {
final response = await onSuccess(
user.id,
user.email ?? user.newEmail ?? '',
);
// Only cancel the subscription if the Event is signedIn.
subscription.cancel();
completer.complete(response);
}
subscription.cancel();
});
return completer;
}

View File

@ -70,6 +70,24 @@ class UserBackendService {
return UserEventInitUser().send();
}
Future<Either<List<HistoricalUserPB>, FlowyError>>
loadHistoricalUsers() async {
return UserEventGetHistoricalUsers().send().then(
(result) {
return result.fold(
(historicalUsers) => left(historicalUsers.items),
(error) => right(error),
);
},
);
}
Future<Either<Unit, FlowyError>> openHistoricalUser(
HistoricalUserPB user,
) async {
return UserEventOpenHistoricalUser(user).send();
}
Future<Either<List<WorkspacePB>, FlowyError>> getWorkspaces() {
final request = WorkspaceIdPB.create();

View File

@ -13,6 +13,13 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
import 'package:flutter/material.dart';
const routerNameRoot = '/';
const routerNameSignUp = '/signUp';
const routerNameSignIn = '/signIn';
const routerNameSkipLogIn = '/skipLogIn';
const routerNameWelcome = '/welcome';
const routerNameHome = '/home';
class AuthRouter {
void pushForgetPasswordScreen(BuildContext context) {}
@ -24,6 +31,7 @@ class AuthRouter {
Navigator.of(context).push(
PageRoutes.fade(
() => SignUpScreen(router: getIt<AuthRouter>()),
const RouteSettings(name: routerNameSignUp),
),
);
}
@ -41,6 +49,7 @@ class AuthRouter {
workspaceSetting,
key: ValueKey(profile.id),
),
const RouteSettings(name: routerNameHome),
RouteDurations.slow.inMilliseconds * .001,
),
);
@ -71,6 +80,7 @@ class SplashRoute {
await Navigator.of(context).push(
PageRoutes.fade(
() => screen,
const RouteSettings(name: routerNameWelcome),
RouteDurations.slow.inMilliseconds * .001,
),
);
@ -97,6 +107,7 @@ class SplashRoute {
workspaceSetting,
key: ValueKey(userProfile.id),
),
const RouteSettings(name: routerNameWelcome),
RouteDurations.slow.inMilliseconds * .001,
),
);
@ -107,6 +118,7 @@ class SplashRoute {
context,
PageRoutes.fade(
() => SignInScreen(router: getIt<AuthRouter>()),
const RouteSettings(name: routerNameSignIn),
RouteDurations.slow.inMilliseconds * .001,
),
);
@ -120,6 +132,7 @@ class SplashRoute {
router: getIt<AuthRouter>(),
authService: getIt<AuthService>(),
),
const RouteSettings(name: routerNameSkipLogIn),
RouteDurations.slow.inMilliseconds * .001,
),
);

View File

@ -320,14 +320,16 @@ class ThirdPartySignInButton extends StatelessWidget {
}
class ThirdPartySignInButtons extends StatelessWidget {
final MainAxisAlignment mainAxisAlignment;
const ThirdPartySignInButtons({
this.mainAxisAlignment = MainAxisAlignment.center,
super.key,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: mainAxisAlignment,
children: [
ThirdPartySignInButton(
icon: 'login/google-mark',

View File

@ -8,10 +8,9 @@ import 'package:protobuf/protobuf.dart';
part 'setting_supabase_bloc.freezed.dart';
class SettingSupabaseBloc
extends Bloc<SettingSupabaseEvent, SettingSupabaseState> {
SettingSupabaseBloc() : super(SettingSupabaseState.initial()) {
on<SettingSupabaseEvent>((event, emit) async {
class SyncSettingBloc extends Bloc<SyncSettingEvent, SyncSettingState> {
SyncSettingBloc() : super(SyncSettingState.initial()) {
on<SyncSettingEvent>((event, emit) async {
await event.when(
initial: () async {
await getSupabaseConfig();
@ -27,7 +26,7 @@ class SettingSupabaseBloc
emit(state.copyWith(config: newConfig));
}
},
didReceiveSupabseConfig: (SupabaseConfigPB config) {
didReceiveSyncConfig: (SupabaseConfigPB config) {
emit(state.copyWith(config: config));
},
);
@ -43,7 +42,7 @@ class SettingSupabaseBloc
result.fold(
(config) {
if (!isClosed) {
add(SettingSupabaseEvent.didReceiveSupabseConfig(config));
add(SyncSettingEvent.didReceiveSyncConfig(config));
}
},
(r) => Log.error(r),
@ -52,22 +51,22 @@ class SettingSupabaseBloc
}
@freezed
class SettingSupabaseEvent with _$SettingSupabaseEvent {
const factory SettingSupabaseEvent.initial() = _Initial;
const factory SettingSupabaseEvent.didReceiveSupabseConfig(
class SyncSettingEvent with _$SyncSettingEvent {
const factory SyncSettingEvent.initial() = _Initial;
const factory SyncSettingEvent.didReceiveSyncConfig(
SupabaseConfigPB config,
) = _DidReceiveSupabaseConfig;
const factory SettingSupabaseEvent.enableSync(bool enable) = _EnableSync;
) = _DidSyncSupabaseConfig;
const factory SyncSettingEvent.enableSync(bool enable) = _EnableSync;
}
@freezed
class SettingSupabaseState with _$SettingSupabaseState {
const factory SettingSupabaseState({
class SyncSettingState with _$SyncSettingState {
const factory SyncSettingState({
SupabaseConfigPB? config,
required Either<Unit, String> successOrFailure,
}) = _SettingSupabaseState;
}) = _SyncSettingState;
factory SettingSupabaseState.initial() => SettingSupabaseState(
factory SyncSettingState.initial() => SyncSettingState(
successOrFailure: left(unit),
);
}

View File

@ -13,7 +13,7 @@ enum SettingsPage {
language,
files,
user,
supabaseSetting,
syncSetting,
shortcuts,
}

View File

@ -23,6 +23,7 @@ class SettingsUserViewBloc extends Bloc<SettingsUserEvent, SettingsUserState> {
initial: () async {
_userListener.start(onProfileUpdated: _profileUpdated);
await _initUser();
_loadHistoricalUsers();
},
didReceiveUserProfile: (UserProfilePB newUserProfile) {
emit(state.copyWith(userProfile: newUserProfile));
@ -51,6 +52,12 @@ class SettingsUserViewBloc extends Bloc<SettingsUserEvent, SettingsUserState> {
);
});
},
didLoadHistoricalUsers: (List<HistoricalUserPB> historicalUsers) {
emit(state.copyWith(historicalUsers: historicalUsers));
},
openHistoricalUser: (HistoricalUserPB historicalUser) async {
await _userService.openHistoricalUser(historicalUser);
},
);
});
}
@ -66,10 +73,22 @@ class SettingsUserViewBloc extends Bloc<SettingsUserEvent, SettingsUserState> {
result.fold((l) => null, (error) => Log.error(error));
}
Future<void> _loadHistoricalUsers() async {
final result = await _userService.loadHistoricalUsers();
result.fold(
(historicalUsers) {
add(SettingsUserEvent.didLoadHistoricalUsers(historicalUsers));
},
(error) => Log.error(error),
);
}
void _profileUpdated(Either<UserProfilePB, FlowyError> userProfileOrFailed) {
userProfileOrFailed.fold(
(newUserProfile) =>
add(SettingsUserEvent.didReceiveUserProfile(newUserProfile)),
(newUserProfile) {
add(SettingsUserEvent.didReceiveUserProfile(newUserProfile));
_loadHistoricalUsers();
},
(err) => Log.error(err),
);
}
@ -86,18 +105,26 @@ class SettingsUserEvent with _$SettingsUserEvent {
const factory SettingsUserEvent.didReceiveUserProfile(
UserProfilePB newUserProfile,
) = _DidReceiveUserProfile;
const factory SettingsUserEvent.didLoadHistoricalUsers(
List<HistoricalUserPB> historicalUsers,
) = _DidLoadHistoricalUsers;
const factory SettingsUserEvent.openHistoricalUser(
HistoricalUserPB historicalUser,
) = _OpenHistoricalUser;
}
@freezed
class SettingsUserState with _$SettingsUserState {
const factory SettingsUserState({
required UserProfilePB userProfile,
required List<HistoricalUserPB> historicalUsers,
required Either<Unit, String> successOrFailure,
}) = _SettingsUserState;
factory SettingsUserState.initial(UserProfilePB userProfile) =>
SettingsUserState(
userProfile: userProfile,
historicalUsers: [],
successOrFailure: left(unit),
);
}

View File

@ -105,12 +105,13 @@ class MenuUser extends StatelessWidget {
onPressed: () {
showDialog(
context: context,
builder: (context) {
builder: (dialogContext) {
return BlocProvider<DocumentAppearanceCubit>.value(
value: BlocProvider.of<DocumentAppearanceCubit>(context),
child: SettingsDialog(
userProfile,
didLogout: () async {
Navigator.of(dialogContext).pop();
Navigator.of(context).pop();
await FlowyRunner.run(
FlowyApp(),
@ -118,6 +119,7 @@ class MenuUser extends StatelessWidget {
);
},
dismissDialog: () => Navigator.of(context).pop(),
didOpenUser: () {},
),
);
},

View File

@ -109,19 +109,30 @@ class SidebarUser extends StatelessWidget {
onPressed: () {
showDialog(
context: context,
builder: (context) {
builder: (dialogContext) {
return BlocProvider<DocumentAppearanceCubit>.value(
value: BlocProvider.of<DocumentAppearanceCubit>(context),
child: SettingsDialog(
userProfile,
didLogout: () async {
Navigator.of(context).pop();
// Pop the dialog using the dialog context
Navigator.of(dialogContext).pop();
await FlowyRunner.run(
FlowyApp(),
integrationEnv(),
);
},
dismissDialog: () => Navigator.of(context).pop(),
didOpenUser: () async {
// Pop the dialog using the dialog context
Navigator.of(dialogContext).pop();
await FlowyRunner.run(
FlowyApp(),
integrationEnv(),
);
},
),
);
},

View File

@ -1,6 +1,6 @@
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_supabase_view.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/sync_setting_view.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance_view.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_file_system_view.dart';
@ -20,11 +20,13 @@ const _contentInsetPadding = EdgeInsets.fromLTRB(0.0, 12.0, 0.0, 16.0);
class SettingsDialog extends StatelessWidget {
final VoidCallback dismissDialog;
final VoidCallback didLogout;
final VoidCallback didOpenUser;
final UserProfilePB user;
SettingsDialog(
this.user, {
required this.dismissDialog,
required this.didLogout,
required this.didOpenUser,
Key? key,
}) : super(key: ValueKey(user.id));
@ -97,9 +99,10 @@ class SettingsDialog extends StatelessWidget {
user,
didLogin: () => dismissDialog(),
didLogout: didLogout,
didOpenUser: didOpenUser,
);
case SettingsPage.supabaseSetting:
return const SupabaseSettingView();
case SettingsPage.syncSetting:
return const SyncSettingView();
case SettingsPage.shortcuts:
return const SettingsCustomizeShortcutsWrapper();
default:

View File

@ -0,0 +1,110 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/user/settings_user_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class HistoricalUserList extends StatelessWidget {
final VoidCallback didOpenUser;
const HistoricalUserList({required this.didOpenUser, super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<SettingsUserViewBloc, SettingsUserState>(
builder: (context, state) {
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
FlowyText.medium(
LocaleKeys.settings_menu_historicalUserList.tr(),
fontSize: 13,
),
const Spacer(),
Tooltip(
message:
LocaleKeys.settings_menu_historicalUserListTooltip.tr(),
child: const Icon(
Icons.question_mark_rounded,
size: 16,
),
),
],
),
Expanded(
child: ListView.builder(
itemBuilder: (context, index) {
final user = state.historicalUsers[index];
return HistoricalUserItem(
key: ValueKey(user.userId),
user: user,
isSelected: state.userProfile.id == user.userId,
didOpenUser: didOpenUser,
);
},
itemCount: state.historicalUsers.length,
),
)
],
),
);
},
);
}
}
class HistoricalUserItem extends StatelessWidget {
final VoidCallback didOpenUser;
final bool isSelected;
final HistoricalUserPB user;
const HistoricalUserItem({
required this.user,
required this.isSelected,
required this.didOpenUser,
super.key,
});
@override
Widget build(BuildContext context) {
final icon = isSelected ? const FlowySvg(name: "grid/checkmark") : null;
final isDisabled = isSelected || user.authType != AuthTypePB.Local;
final outputFormat = DateFormat('MM/dd/yyyy');
final date =
DateTime.fromMillisecondsSinceEpoch(user.lastTime.toInt() * 1000);
final lastTime = outputFormat.format(date);
final desc = "${user.userName} ${user.authType} $lastTime";
final child = SizedBox(
height: 30,
child: FlowyButton(
disable: isDisabled,
text: FlowyText.medium(desc),
rightIcon: icon,
onTap: () {
if (user.userId ==
context.read<SettingsUserViewBloc>().userProfile.id) {
return;
}
context
.read<SettingsUserViewBloc>()
.add(SettingsUserEvent.openHistoricalUser(user));
didOpenUser();
},
),
);
if (isSelected) {
return child;
} else {
return Tooltip(
message: LocaleKeys.settings_menu_openHistoricalUser.tr(),
child: child,
);
}
}
}

View File

@ -1,30 +0,0 @@
import 'package:appflowy/workspace/application/settings/setting_supabase_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SupabaseSettingView extends StatelessWidget {
const SupabaseSettingView({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
SettingSupabaseBloc()..add(const SettingSupabaseEvent.initial()),
child: BlocBuilder<SettingSupabaseBloc, SettingSupabaseState>(
builder: (context, state) {
return Align(
alignment: Alignment.topRight,
child: Switch(
onChanged: (bool value) {
context.read<SettingSupabaseBloc>().add(
SettingSupabaseEvent.enableSync(value),
);
},
value: state.config?.enableSync ?? false,
),
);
},
),
);
}
}

View File

@ -1,3 +1,4 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/entry_point.dart';
import 'package:appflowy/startup/launch_configuration.dart';
import 'package:appflowy/startup/startup.dart';
@ -6,6 +7,8 @@ import 'package:appflowy/user/presentation/sign_in_screen.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:dartz/dartz.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/snap_bar.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -25,7 +28,15 @@ class SettingThirdPartyLogin extends StatelessWidget {
(result) => _handleSuccessOrFail(result, context),
);
},
builder: (_, __) => const ThirdPartySignInButtons(),
builder: (_, __) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText.medium(LocaleKeys.signIn_signInWith.tr()),
const ThirdPartySignInButtons(
mainAxisAlignment: MainAxisAlignment.start,
),
],
),
),
);
}

View File

@ -64,9 +64,9 @@ class SettingsMenu extends StatelessWidget {
context.read<SettingsDialogBloc>().state.userProfile.authType !=
AuthTypePB.Local)
SettingsMenuElement(
page: SettingsPage.supabaseSetting,
page: SettingsPage.syncSetting,
selectedPage: currentPage,
label: LocaleKeys.settings_menu_supabaseSetting.tr(),
label: LocaleKeys.settings_menu_syncSetting.tr(),
icon: Icons.sync,
changeSelectedPage: changeSelectedPage,
),

View File

@ -16,19 +16,25 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'historical_user.dart';
import 'setting_third_party_login.dart';
const defaultUserAvatar = '1F600';
const _iconSize = Size(60, 60);
class SettingsUserView extends StatelessWidget {
// Called when the user login in the setting dialog
final VoidCallback didLogin;
// Called when the user logout in the setting dialog
final VoidCallback didLogout;
// Called when the user open a historical user in the setting dialog
final VoidCallback didOpenUser;
final UserProfilePB user;
SettingsUserView(
this.user, {
required this.didLogin,
required this.didLogout,
required this.didOpenUser,
Key? key,
}) : super(key: ValueKey(user.id));
@ -47,6 +53,8 @@ class SettingsUserView extends StatelessWidget {
_renderCurrentIcon(context),
const VSpace(20),
_renderCurrentOpenaiKey(context),
const VSpace(20),
_renderHistoricalUser(context),
const Spacer(),
_renderLoginOrLogoutButton(context, state),
const VSpace(20),
@ -56,21 +64,25 @@ class SettingsUserView extends StatelessWidget {
);
}
/// Renders either a login or logout button based on the user's authentication status.
///
/// This function checks the current user's authentication type and Supabase
/// configuration to determine whether to render a third-party login button
/// or a logout button.
Widget _renderLoginOrLogoutButton(
BuildContext context,
SettingsUserState state,
) {
if (!isSupabaseEnabled) {
return _renderLogoutButton(context);
if (isSupabaseEnabled) {
// If the user is logged in locally, render a third-party login button.
if (state.userProfile.authType == AuthTypePB.Local) {
return SettingThirdPartyLogin(
didLogin: didLogin,
);
}
}
if (state.userProfile.authType == AuthTypePB.Local) {
return SettingThirdPartyLogin(
didLogin: didLogin,
);
} else {
return _renderLogoutButton(context);
}
return _renderLogoutButton(context);
}
Widget _renderUserNameInput(BuildContext context) {
@ -111,6 +123,16 @@ class SettingsUserView extends StatelessWidget {
},
);
}
Widget _renderHistoricalUser(BuildContext context) {
return BlocBuilder<SettingsUserViewBloc, SettingsUserState>(
builder: (context, state) {
return HistoricalUserList(
didOpenUser: didOpenUser,
);
},
);
}
}
@visibleForTesting

View File

@ -0,0 +1,36 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/setting_supabase_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SyncSettingView extends StatelessWidget {
const SyncSettingView({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
SyncSettingBloc()..add(const SyncSettingEvent.initial()),
child: BlocBuilder<SyncSettingBloc, SyncSettingState>(
builder: (context, state) {
return Row(
children: [
FlowyText.medium(LocaleKeys.settings_menu_enableSync.tr()),
const Spacer(),
Switch(
onChanged: (bool value) {
context.read<SyncSettingBloc>().add(
SyncSettingEvent.enableSync(value),
);
},
value: state.config?.enableSync ?? false,
)
],
);
},
),
);
}
}

View File

@ -8,9 +8,10 @@ class PageRoutes {
static const Curve kDefaultEaseFwd = Curves.easeOut;
static const Curve kDefaultEaseReverse = Curves.easeOut;
static Route<T> fade<T>(PageBuilder pageBuilder,
static Route<T> fade<T>(PageBuilder pageBuilder, RouteSettings? settings,
[double duration = kDefaultDuration]) {
return PageRouteBuilder<T>(
settings: settings,
transitionDuration: Duration(milliseconds: (duration * 1000).round()),
pageBuilder: (context, animation, secondaryAnimation) => pageBuilder(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {

View File

@ -26,7 +26,8 @@
"alreadyHaveAnAccount": "Already have an account?",
"emailHint": "Email",
"passwordHint": "Password",
"repeatPasswordHint": "Repeat password"
"repeatPasswordHint": "Repeat password",
"signUpWith": "Sign up with:"
},
"signIn": {
"loginTitle": "Login to @:appName",
@ -38,7 +39,8 @@
"passwordHint": "Password",
"dontHaveAnAccount": "Don't have an account?",
"repeatPasswordEmptyError": "Repeat password can't be empty",
"unmatchedPasswordError": "Repeat password is not the same as password"
"unmatchedPasswordError": "Repeat password is not the same as password",
"signInWith": "Sign in with:"
},
"workspace": {
"create": "Create workspace",
@ -223,7 +225,11 @@
"open": "Open Settings",
"logout": "Logout",
"logoutPrompt": "Are you sure to logout?",
"supabaseSetting": "Supabase Setting"
"syncSetting": "Sync Setting",
"enableSync": "Enable sync",
"historicalUserList": "User history",
"historicalUserListTooltip": "This list shows your login history. You can click to login if it's a local user",
"openHistoricalUser": "Click to open user"
},
"appearance": {
"fontFamily": {

View File

@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::fmt::{Display, Formatter};
use std::sync::{Arc, Weak};
use appflowy_integrate::collab_builder::{CollabStorageProvider, CollabStorageType};
@ -37,14 +38,24 @@ pub enum ServerProviderType {
/// Offline mode, no user authentication and the data is stored locally.
Local = 0,
/// Self-hosted server provider.
/// The [AppFlowy-Server](https://github.com/AppFlowy-IO/AppFlowy-Server) is still a work in
/// The [AppFlowy-Server](https://github.com/AppFlowy-IO/AppFlowy-Cloud) is still a work in
/// progress.
SelfHosted = 1,
AppFlowyCloud = 1,
/// Supabase server provider.
/// It uses supabase's postgresql database to store data and user authentication.
Supabase = 2,
}
impl Display for ServerProviderType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ServerProviderType::Local => write!(f, "Local"),
ServerProviderType::AppFlowyCloud => write!(f, "AppFlowyCloud"),
ServerProviderType::Supabase => write!(f, "Supabase"),
}
}
}
/// The [AppFlowyServerProvider] provides list of [AppFlowyServer] base on the [AuthType]. Using
/// the auth type, the [AppFlowyServerProvider] will create a new [AppFlowyServer] if it doesn't
/// exist.
@ -95,7 +106,7 @@ impl AppFlowyServerProvider {
Ok::<Arc<dyn AppFlowyServer>, FlowyError>(server)
},
ServerProviderType::SelfHosted => {
ServerProviderType::AppFlowyCloud => {
let config = self_host_server_configuration().map_err(|e| {
FlowyError::new(
ErrorCode::InvalidAuthConfig,
@ -170,6 +181,10 @@ impl UserCloudServiceProvider for AppFlowyServerProvider {
.user_service(),
)
}
fn service_name(&self) -> String {
self.provider_type.read().to_string()
}
}
impl FolderCloudService for AppFlowyServerProvider {
@ -336,7 +351,7 @@ impl From<AuthType> for ServerProviderType {
fn from(auth_provider: AuthType) -> Self {
match auth_provider {
AuthType::Local => ServerProviderType::Local,
AuthType::SelfHosted => ServerProviderType::SelfHosted,
AuthType::SelfHosted => ServerProviderType::AppFlowyCloud,
AuthType::Supabase => ServerProviderType::Supabase,
}
}

View File

@ -415,7 +415,7 @@ impl From<ServerProviderType> for CollabStorageType {
fn from(server_provider: ServerProviderType) -> Self {
match server_provider {
ServerProviderType::Local => CollabStorageType::Local,
ServerProviderType::SelfHosted => CollabStorageType::Local,
ServerProviderType::AppFlowyCloud => CollabStorageType::Local,
ServerProviderType::Supabase => CollabStorageType::Supabase,
}
}

View File

@ -215,8 +215,8 @@ pub enum ErrorCode {
#[error("Postgres transaction error")]
PgTransactionError = 71,
#[error("Enable supabase sync")]
SupabaseSyncRequired = 72,
#[error("Enable data sync")]
DataSyncRequired = 72,
#[error("Conflict")]
Conflict = 73,

View File

@ -6,6 +6,7 @@ use parking_lot::Mutex;
use flowy_user_deps::cloud::UserService;
use flowy_user_deps::entities::*;
use flowy_user_deps::DEFAULT_USER_NAME;
use lib_infra::box_any::BoxAny;
use lib_infra::future::FutureResult;
@ -28,9 +29,14 @@ impl UserService for LocalServerUserAuthServiceImpl {
let uid = ID_GEN.lock().next_id();
let workspace_id = uuid::Uuid::new_v4().to_string();
let user_workspace = UserWorkspace::new(&workspace_id, uid);
let user_name = if params.name.is_empty() {
DEFAULT_USER_NAME()
} else {
params.name.clone()
};
Ok(SignUpResponse {
user_id: uid,
name: params.name,
name: user_name,
latest_workspace: user_workspace.clone(),
user_workspaces: vec![user_workspace],
is_new: true,

View File

@ -102,11 +102,14 @@ where
_id: MsgId,
update: Vec<u8>,
) -> Result<(), Error> {
let postgrest = self.0.try_get_postgrest()?;
let workspace_id = object
.get_workspace_id()
.ok_or(anyhow::anyhow!("Invalid workspace id"))?;
send_update(workspace_id, object, update, &postgrest).await
if let Some(postgrest) = self.0.get_postgrest() {
let workspace_id = object
.get_workspace_id()
.ok_or(anyhow::anyhow!("Invalid workspace id"))?;
send_update(workspace_id, object, update, &postgrest).await?;
}
Ok(())
}
async fn send_init_sync(

View File

@ -68,8 +68,8 @@ impl SupabaseServerService for SupabaseServerServiceImpl {
.map(|server| server.postgrest.clone())
.ok_or_else(|| {
FlowyError::new(
ErrorCode::SupabaseSyncRequired,
"Supabase sync is disabled, please enable it first",
ErrorCode::DataSyncRequired,
"Data Sync is disabled, please enable it first",
)
.into()
})

View File

@ -6,6 +6,7 @@ use uuid::Uuid;
use flowy_user_deps::cloud::*;
use flowy_user_deps::entities::*;
use flowy_user_deps::DEFAULT_USER_NAME;
use lib_infra::box_any::BoxAny;
use lib_infra::future::FutureResult;
@ -74,9 +75,15 @@ where
.find(|user_workspace| user_workspace.id == user_profile.latest_workspace_id)
.cloned();
let user_name = if user_profile.name.is_empty() {
DEFAULT_USER_NAME()
} else {
user_profile.name
};
Ok(SignUpResponse {
user_id: user_profile.uid,
name: user_profile.name,
name: user_name,
latest_workspace: latest_workspace.unwrap(),
user_workspaces,
is_new: is_new_user,
@ -100,9 +107,10 @@ where
.iter()
.find(|user_workspace| user_workspace.id == user_profile.latest_workspace_id)
.cloned();
Ok(SignInResponse {
user_id: user_profile.uid,
name: "".to_string(),
name: DEFAULT_USER_NAME(),
latest_workspace: latest_workspace.unwrap(),
user_workspaces,
email: None,

View File

@ -164,6 +164,7 @@ pub enum AuthType {
/// It uses Supabase as the backend.
Supabase = 2,
}
impl Default for AuthType {
fn default() -> Self {
Self::Local

View File

@ -1,2 +1,4 @@
pub mod cloud;
pub mod entities;
pub const DEFAULT_USER_NAME: fn() -> String = || "Me".to_string();

View File

@ -6,6 +6,7 @@ use flowy_user_deps::entities::*;
use crate::entities::parser::{UserEmail, UserIcon, UserName, UserOpenaiKey, UserPassword};
use crate::entities::AuthTypePB;
use crate::errors::ErrorCode;
use crate::services::HistoricalUser;
#[derive(Default, ProtoBuf)]
pub struct UserTokenPB {
@ -205,3 +206,46 @@ pub struct RemoveWorkspaceUserPB {
#[pb(index = 2)]
pub workspace_id: String,
}
#[derive(ProtoBuf, Default, Clone)]
pub struct RepeatedHistoricalUserPB {
#[pb(index = 1)]
pub items: Vec<HistoricalUserPB>,
}
#[derive(ProtoBuf, Default, Clone)]
pub struct HistoricalUserPB {
#[pb(index = 1)]
pub user_id: i64,
#[pb(index = 2)]
pub user_name: String,
#[pb(index = 3)]
pub last_time: i64,
#[pb(index = 4)]
pub auth_type: AuthTypePB,
}
impl From<Vec<HistoricalUser>> for RepeatedHistoricalUserPB {
fn from(historical_users: Vec<HistoricalUser>) -> Self {
Self {
items: historical_users
.into_iter()
.map(HistoricalUserPB::from)
.collect(),
}
}
}
impl From<HistoricalUser> for HistoricalUserPB {
fn from(historical_user: HistoricalUser) -> Self {
Self {
user_id: historical_user.user_id,
user_name: historical_user.user_name,
last_time: historical_user.sign_in_timestamp,
auth_type: historical_user.auth_type.into(),
}
}
}

View File

@ -260,3 +260,23 @@ pub async fn update_network_state_handler(
.did_update_network(reachable);
Ok(())
}
#[tracing::instrument(level = "debug", skip_all, err)]
pub async fn get_historical_users_handler(
session: AFPluginState<Weak<UserSession>>,
) -> DataResult<RepeatedHistoricalUserPB, FlowyError> {
let session = upgrade_session(session)?;
let users = RepeatedHistoricalUserPB::from(session.get_historical_users());
data_result_ok(users)
}
#[tracing::instrument(level = "debug", skip_all, err)]
pub async fn open_historical_users_handler(
user: AFPluginData<HistoricalUserPB>,
session: AFPluginState<Weak<UserSession>>,
) -> Result<(), FlowyError> {
let user = user.into_inner();
let session = upgrade_session(session)?;
session.open_historical_user(user.user_id)?;
Ok(())
}

View File

@ -47,6 +47,8 @@ pub fn init(user_session: Weak<UserSession>) -> AFPlugin {
remove_user_from_workspace_handler,
)
.event(UserEvent::UpdateNetworkState, update_network_state_handler)
.event(UserEvent::GetHistoricalUsers, get_historical_users_handler)
.event(UserEvent::OpenHistoricalUser, open_historical_users_handler)
}
pub struct SignUpContext {
@ -85,6 +87,7 @@ pub trait UserCloudServiceProvider: Send + Sync + 'static {
fn update_supabase_config(&self, supabase_config: &SupabaseConfiguration);
fn set_auth_type(&self, auth_type: AuthType);
fn get_user_service(&self) -> Result<Arc<dyn UserService>, FlowyError>;
fn service_name(&self) -> String;
}
impl<T> UserCloudServiceProvider for Arc<T>
@ -102,6 +105,10 @@ where
fn get_user_service(&self) -> Result<Arc<dyn UserService>, FlowyError> {
(**self).get_user_service()
}
fn service_name(&self) -> String {
(**self).service_name()
}
}
/// Acts as a placeholder [UserStatusCallback] for the user session, but does not perform any function
@ -208,4 +215,10 @@ pub enum UserEvent {
#[event(input = "NetworkStatePB")]
UpdateNetworkState = 24,
#[event(output = "RepeatedHistoricalUserPB")]
GetHistoricalUsers = 25,
#[event(input = "HistoricalUserPB")]
OpenHistoricalUser = 26,
}

View File

@ -8,7 +8,7 @@ use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use flowy_user_deps::entities::{SignInResponse, UserWorkspace};
use flowy_user_deps::entities::{SignInResponse, SignUpResponse, UserWorkspace};
#[derive(Debug, Clone, Serialize)]
pub struct Session {
@ -102,6 +102,15 @@ impl std::convert::From<Session> for String {
}
}
impl From<&SignUpResponse> for Session {
fn from(value: &SignUpResponse) -> Self {
Session {
user_id: value.user_id,
user_workspace: value.latest_workspace.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -1,3 +1,5 @@
use std::convert::TryFrom;
use std::string::ToString;
use std::sync::{Arc, Weak};
use appflowy_integrate::RocksCollabDB;
@ -152,27 +154,30 @@ impl UserSession {
params: BoxAny,
auth_type: AuthType,
) -> Result<UserProfile, FlowyError> {
let resp: SignInResponse = self
let response: SignInResponse = self
.cloud_services
.get_user_service()?
.sign_in(params)
.await?;
let session: Session = resp.clone().into();
let session: Session = response.clone().into();
let uid = session.user_id;
self.set_session(Some(session))?;
self.log_user(uid, self.user_dir(uid));
self.set_current_session(Some(session))?;
let user_workspace = resp.latest_workspace.clone();
self.log_user(uid, response.name.clone(), &auth_type, self.user_dir(uid));
let user_workspace = response.latest_workspace.clone();
save_user_workspaces(
self.db_pool(uid)?,
resp
response
.user_workspaces
.iter()
.map(|user_workspace| UserWorkspaceTable::from((uid, user_workspace)))
.flat_map(|user_workspace| UserWorkspaceTable::try_from((uid, user_workspace)).ok())
.collect(),
)?;
let user_profile: UserProfile = self.save_user(uid, (resp, auth_type).into()).await?.into();
let user_profile: UserProfile = self
.save_user(uid, (response, auth_type).into())
.await?
.into();
if let Err(e) = self
.user_status_callback
.read()
@ -226,19 +231,16 @@ impl UserSession {
is_new: response.is_new,
local_folder: None,
};
let new_session = Session {
user_id: response.user_id,
user_workspace: response.latest_workspace.clone(),
};
let uid = new_session.user_id;
self.set_session(Some(new_session.clone()))?;
self.log_user(uid, self.user_dir(uid));
let new_session = Session::from(&response);
self.set_current_session(Some(new_session.clone()))?;
let uid = response.user_id;
self.log_user(uid, response.name.clone(), &auth_type, self.user_dir(uid));
save_user_workspaces(
self.db_pool(uid)?,
response
.user_workspaces
.iter()
.map(|user_workspace| UserWorkspaceTable::from((uid, user_workspace)))
.flat_map(|user_workspace| UserWorkspaceTable::try_from((uid, user_workspace)).ok())
.collect(),
)?;
let user_table = self
@ -289,7 +291,7 @@ impl UserSession {
pub async fn sign_out(&self) -> Result<(), FlowyError> {
let session = self.get_session()?;
self.database.close(session.user_id)?;
self.set_session(None)?;
self.set_current_session(None)?;
let server = self.cloud_services.get_user_service()?;
tokio::spawn(async move {
@ -513,7 +515,7 @@ impl UserSession {
pool,
new_user_workspaces
.iter()
.map(|user_workspace| UserWorkspaceTable::from((uid, user_workspace)))
.flat_map(|user_workspace| UserWorkspaceTable::try_from((uid, user_workspace)).ok())
.collect(),
);
@ -561,8 +563,8 @@ impl UserSession {
})
}
fn set_session(&self, session: Option<Session>) -> Result<(), FlowyError> {
tracing::debug!("Set user session: {:?}", session);
fn set_current_session(&self, session: Option<Session>) -> Result<(), FlowyError> {
tracing::debug!("Set current user: {:?}", session);
match &session {
None => self
.store_preferences
@ -577,13 +579,15 @@ impl UserSession {
Ok(())
}
fn log_user(&self, uid: i64, storage_path: String) {
fn log_user(&self, uid: i64, user_name: String, auth_type: &AuthType, storage_path: String) {
let mut logger_users = self
.store_preferences
.get_object::<HistoricalUsers>(HISTORICAL_USER)
.unwrap_or_default();
logger_users.add_user(HistoricalUser {
user_id: uid,
user_name,
auth_type: auth_type.clone(),
sign_in_timestamp: timestamp(),
storage_path,
});
@ -593,11 +597,27 @@ impl UserSession {
}
pub fn get_historical_users(&self) -> Vec<HistoricalUser> {
self
let mut users = self
.store_preferences
.get_object::<HistoricalUsers>(HISTORICAL_USER)
.unwrap_or_default()
.users
.users;
users.sort_by(|a, b| b.sign_in_timestamp.cmp(&a.sign_in_timestamp));
users
}
pub fn open_historical_user(&self, uid: i64) -> FlowyResult<()> {
let conn = self.db_connection(uid)?;
let row = user_workspace_table::dsl::user_workspace_table
.filter(user_workspace_table::uid.eq(uid))
.first::<UserWorkspaceTable>(&*conn)?;
let user_workspace = UserWorkspace::from(row);
let session = Session {
user_id: uid,
user_workspace,
};
self.set_current_session(Some(session))?;
Ok(())
}
/// Returns the current user session.
@ -691,6 +711,12 @@ impl HistoricalUsers {
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HistoricalUser {
pub user_id: i64,
#[serde(default = "flowy_user_deps::DEFAULT_USER_NAME")]
pub user_name: String,
#[serde(default = "DEFAULT_AUTH_TYPE")]
pub auth_type: AuthType,
pub sign_in_timestamp: i64,
pub storage_path: String,
}
const DEFAULT_AUTH_TYPE: fn() -> AuthType = || AuthType::Local;

View File

@ -1,4 +1,6 @@
use chrono::{TimeZone, Utc};
use flowy_error::FlowyError;
use std::convert::TryFrom;
use flowy_sqlite::schema::user_workspace_table;
use flowy_user_deps::entities::UserWorkspace;
@ -13,15 +15,24 @@ pub struct UserWorkspaceTable {
pub database_storage_id: String,
}
impl From<(i64, &UserWorkspace)> for UserWorkspaceTable {
fn from(value: (i64, &UserWorkspace)) -> Self {
Self {
impl TryFrom<(i64, &UserWorkspace)> for UserWorkspaceTable {
type Error = FlowyError;
fn try_from(value: (i64, &UserWorkspace)) -> Result<Self, Self::Error> {
if value.1.id.is_empty() {
return Err(FlowyError::invalid_data().context("The id is empty"));
}
if value.1.database_storage_id.is_empty() {
return Err(FlowyError::invalid_data().context("The database storage id is empty"));
}
Ok(Self {
id: value.1.id.clone(),
name: value.1.name.clone(),
uid: value.0,
created_at: value.1.created_at.timestamp(),
database_storage_id: value.1.database_storage_id.clone(),
}
})
}
}
@ -34,7 +45,7 @@ impl From<UserWorkspaceTable> for UserWorkspace {
.timestamp_opt(value.created_at, 0)
.single()
.unwrap_or_default(),
database_storage_id: "".to_string(),
database_storage_id: value.database_storage_id,
}
}
}

View File

@ -65,7 +65,7 @@ script = [
"""
cd rust-lib/
rustup show
echo cargo build --package=dart-ffi --target ${RUST_COMPILE_TARGET} --features "${FLUTTER_DESKTOP_FEATURES}"
echo RUSTFLAGS="-C target-cpu=native -C link-arg=-mmacosx-version-min=11.0" cargo build --package=dart-ffi --target ${RUST_COMPILE_TARGET} --features "${FLUTTER_DESKTOP_FEATURES}"
RUSTFLAGS="-C target-cpu=native -C link-arg=-mmacosx-version-min=11.0" cargo build --package=dart-ffi --target ${RUST_COMPILE_TARGET} --features "${FLUTTER_DESKTOP_FEATURES}"
cd ../
""",