From 6d4cfe7316d9effc873c9385b6d1090c32b986d9 Mon Sep 17 00:00:00 2001 From: "Kilu.He" <108015703+qinluhe@users.noreply.github.com> Date: Fri, 15 Mar 2024 20:29:00 +0800 Subject: [PATCH] feat: support-OAuth-login (#4899) * feat: support-OAuth-login * fix: modified ubuntu version and devtool * fix: ts lint error --- .github/workflows/tauri_ci.yaml | 9 +- .github/workflows/tauri_release.yml | 10 +- frontend/appflowy_tauri/.gitignore | 4 +- frontend/appflowy_tauri/src-tauri/.gitignore | 2 +- frontend/appflowy_tauri/src-tauri/Cargo.lock | 92 +++++++++ frontend/appflowy_tauri/src-tauri/Cargo.toml | 3 + frontend/appflowy_tauri/src-tauri/Info.plist | 19 ++ .../appflowy_tauri/src-tauri/env.development | 4 + .../appflowy_tauri/src-tauri/env.production | 4 + frontend/appflowy_tauri/src-tauri/src/init.rs | 28 ++- frontend/appflowy_tauri/src-tauri/src/main.rs | 23 ++- .../appflowy_tauri/src-tauri/tauri.conf.json | 2 +- .../src/appflowy_app/AppMain.hooks.ts | 7 +- .../src/appflowy_app/AppMain.tsx | 2 + .../application/folder/workspace.service.ts | 26 +-- .../application/user/auth.service.ts | 74 ++++++-- .../_shared/devtool/AppFlowyDevTool.tsx | 61 ++++++ .../_shared/devtool/ManualSignInDialog.tsx | 114 ++++++++++++ .../_shared/login/LoginButtonGroup.tsx | 26 --- .../components/_shared/login/index.ts | 1 - .../components/auth/LoginButtonGroup.tsx | 51 +++++ .../components/auth/ProtectedRoutes.tsx | 100 ++++++++-- .../appflowy_app/components/auth/Welcome.tsx | 2 +- .../components/auth/auth.hooks.ts | 174 ++++++++++++------ .../workspace_manager/Workspace.hooks.ts | 24 +-- .../layout/workspace_manager/Workspace.tsx | 16 +- .../workspace_manager/WorkspaceManager.tsx | 14 +- .../components/settings/Login.tsx | 2 +- .../components/settings/SettingsDialog.tsx | 29 ++- .../settings/my_account/AccountLogin.tsx | 15 +- .../settings/workplace/WorkplaceDisplay.tsx | 53 ++++-- .../stores/reducers/current-user/slice.ts | 24 ++- .../stores/reducers/workspace/slice.ts | 22 ++- .../src/appflowy_app/utils/color.ts | 21 ++- .../flowy-user/src/user_manager/manager.rs | 1 + 35 files changed, 861 insertions(+), 198 deletions(-) create mode 100644 frontend/appflowy_tauri/src-tauri/Info.plist create mode 100644 frontend/appflowy_tauri/src-tauri/env.development create mode 100644 frontend/appflowy_tauri/src-tauri/env.production create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/_shared/devtool/AppFlowyDevTool.tsx create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/_shared/devtool/ManualSignInDialog.tsx delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/_shared/login/LoginButtonGroup.tsx delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/_shared/login/index.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/auth/LoginButtonGroup.tsx diff --git a/.github/workflows/tauri_ci.yaml b/.github/workflows/tauri_ci.yaml index 8d99091aab..462bebb8dd 100644 --- a/.github/workflows/tauri_ci.yaml +++ b/.github/workflows/tauri_ci.yaml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - platform: [ubuntu-latest] + platform: [ubuntu-20.04] runs-on: ${{ matrix.platform }} @@ -32,7 +32,7 @@ jobs: - uses: actions/checkout@v4 - name: Maximize build space (ubuntu only) - if: matrix.platform == 'ubuntu-latest' + if: matrix.platform == 'ubuntu-20.04' run: | sudo rm -rf /usr/share/dotnet sudo rm -rf /opt/ghc @@ -80,7 +80,7 @@ jobs: vcpkg integrate install - name: install dependencies (ubuntu only) - if: matrix.platform == 'ubuntu-latest' + if: matrix.platform == 'ubuntu-20.04' working-directory: frontend run: | sudo apt-get update @@ -110,4 +110,5 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tauriScript: pnpm tauri - projectPath: frontend/appflowy_tauri \ No newline at end of file + projectPath: frontend/appflowy_tauri + args: "--debug" \ No newline at end of file diff --git a/.github/workflows/tauri_release.yml b/.github/workflows/tauri_release.yml index e031e65ccd..2e4be46dbe 100644 --- a/.github/workflows/tauri_release.yml +++ b/.github/workflows/tauri_release.yml @@ -31,7 +31,7 @@ jobs: - platform: macos-latest args: "--target x86_64-apple-darwin" target: "macos-x86_64" - - platform: ubuntu-latest + - platform: ubuntu-20.04 args: "--target x86_64-unknown-linux-gnu" target: "linux-x86_64" @@ -46,7 +46,7 @@ jobs: ref: ${{ github.event.inputs.branch }} - name: Maximize build space (ubuntu only) - if: matrix.settings.platform == 'ubuntu-latest' + if: matrix.settings.platform == 'ubuntu-20.04' run: | sudo rm -rf /usr/share/dotnet sudo rm -rf /opt/ghc @@ -88,7 +88,7 @@ jobs: vcpkg integrate install - name: install dependencies (ubuntu only) - if: matrix.settings.platform == 'ubuntu-latest' + if: matrix.settings.platform == 'ubuntu-20.04' working-directory: frontend run: | sudo apt-get update @@ -140,14 +140,14 @@ jobs: - name: Upload Deb package(ubuntu only) uses: actions/upload-artifact@v4 - if: matrix.settings.platform == 'ubuntu-latest' + if: matrix.settings.platform == 'ubuntu-20.04' with: name: ${{ env.PACKAGE_PREFIX }}.deb path: frontend/appflowy_tauri/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/app-flowy_${{ github.event.inputs.version }}_amd64.deb - name: Upload AppImage package(ubuntu only) uses: actions/upload-artifact@v4 - if: matrix.settings.platform == 'ubuntu-latest' + if: matrix.settings.platform == 'ubuntu-20.04' with: name: ${{ env.PACKAGE_PREFIX }}.AppImage path: frontend/appflowy_tauri/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/app-flowy_${{ github.event.inputs.version }}_amd64.AppImage diff --git a/frontend/appflowy_tauri/.gitignore b/frontend/appflowy_tauri/.gitignore index 6a6338d33e..32a3d59bc2 100644 --- a/frontend/appflowy_tauri/.gitignore +++ b/frontend/appflowy_tauri/.gitignore @@ -28,4 +28,6 @@ dist-ssr **/src/appflowy_app/i18n/translations/ coverage -**/AppFlowy-Collab \ No newline at end of file +**/AppFlowy-Collab + +.env \ No newline at end of file diff --git a/frontend/appflowy_tauri/src-tauri/.gitignore b/frontend/appflowy_tauri/src-tauri/.gitignore index f4dfb82b2c..61e1bdd46a 100644 --- a/frontend/appflowy_tauri/src-tauri/.gitignore +++ b/frontend/appflowy_tauri/src-tauri/.gitignore @@ -1,4 +1,4 @@ # Generated by Cargo # will have compiled files and executables /target/ - +.env diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.lock b/frontend/appflowy_tauri/src-tauri/Cargo.lock index 996812c1bb..e270df39a2 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.lock +++ b/frontend/appflowy_tauri/src-tauri/Cargo.lock @@ -182,6 +182,7 @@ name = "appflowy_tauri" version = "0.0.0" dependencies = [ "bytes", + "dotenv", "flowy-config", "flowy-core", "flowy-date", @@ -194,6 +195,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-utils", "tracing", "uuid", @@ -1439,6 +1441,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -1449,6 +1460,18 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -1466,6 +1489,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + [[package]] name = "dtoa" version = "1.0.6" @@ -3088,6 +3117,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "interprocess" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f2533f3be42fffe3b5e63b71aeca416c1c3bc33e4e27be018521e76b1f38fb" +dependencies = [ + "cfg-if", + "libc", + "rustc_version", + "to_method", + "winapi", +] + [[package]] name = "ipnet" version = "2.8.0" @@ -3831,6 +3873,28 @@ dependencies = [ "objc_id", ] +[[package]] +name = "objc-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c71324e4180d0899963fc83d9d241ac39e699609fc1025a850aadac8257459" + +[[package]] +name = "objc2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666" + [[package]] name = "objc_exception" version = "0.1.2" @@ -3934,6 +3998,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "os_pipe" version = "0.9.2" @@ -6016,6 +6086,22 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4536f5f6602e8fdfaa7b3b185076c2a0704f8eb7015f4e58461eb483ec3ed1f8" +dependencies = [ + "dirs", + "interprocess", + "log", + "objc2", + "once_cell", + "tauri-utils", + "windows-sys 0.48.0", + "winreg 0.50.0", +] + [[package]] name = "tauri-runtime" version = "0.14.1" @@ -6242,6 +6328,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "to_method" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c4ceeeca15c8384bbc3e011dbd8fccb7f068a440b752b7d9b32ceb0ca0e2e8" + [[package]] name = "tokio" version = "1.36.0" diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml index b3075f75ff..8682201a73 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -67,7 +67,10 @@ flowy-document = { path = "../../rust-lib/flowy-document", features = [ flowy-notification = { path = "../../rust-lib/flowy-notification", features = [ "tauri_ts", ] } + uuid = "1.5.0" +tauri-plugin-deep-link = "0.1.2" +dotenv = "0.15.0" [features] # by default Tauri runs in production mode diff --git a/frontend/appflowy_tauri/src-tauri/Info.plist b/frontend/appflowy_tauri/src-tauri/Info.plist new file mode 100644 index 0000000000..25b430c049 --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/Info.plist @@ -0,0 +1,19 @@ + + + + + + CFBundleURLTypes + + + CFBundleURLName + + appflowy-flutter + CFBundleURLSchemes + + appflowy-flutter + + + + + \ No newline at end of file diff --git a/frontend/appflowy_tauri/src-tauri/env.development b/frontend/appflowy_tauri/src-tauri/env.development new file mode 100644 index 0000000000..188835e3d0 --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/env.development @@ -0,0 +1,4 @@ +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_BASE_URL=https://test.appflowy.cloud +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_WS_BASE_URL=wss://test.appflowy.cloud/ws/v1 +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_GOTRUE_URL=https://test.appflowy.cloud/gotrue +APPFLOWY_CLOUD_ENV_CLOUD_TYPE=2 diff --git a/frontend/appflowy_tauri/src-tauri/env.production b/frontend/appflowy_tauri/src-tauri/env.production new file mode 100644 index 0000000000..b03c328b84 --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/env.production @@ -0,0 +1,4 @@ +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_BASE_URL=https://beta.appflowy.cloud +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_WS_BASE_URL=wss://beta.appflowy.cloud/ws/v1 +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_GOTRUE_URL=https://beta.appflowy.cloud/gotrue +APPFLOWY_CLOUD_ENV_CLOUD_TYPE=2 diff --git a/frontend/appflowy_tauri/src-tauri/src/init.rs b/frontend/appflowy_tauri/src-tauri/src/init.rs index 7f7c2726d3..40c0e5d47b 100644 --- a/frontend/appflowy_tauri/src-tauri/src/init.rs +++ b/frontend/appflowy_tauri/src-tauri/src/init.rs @@ -3,10 +3,33 @@ use flowy_core::{AppFlowyCore, DEFAULT_NAME}; use lib_dispatch::runtime::AFPluginRuntime; use std::sync::Arc; +use dotenv::dotenv; + +pub fn read_env() { + dotenv().ok(); + + let env = if cfg!(debug_assertions) { + include_str!("../env.development") + } else { + include_str!("../env.production") + }; + + for line in env.lines() { + if let Some((key, value)) = line.split_once('=') { + // Check if the environment variable is not already set in the system + let current_value = std::env::var(key).unwrap_or_default(); + if current_value.is_empty() { + std::env::set_var(key, value); + } + } + } +} + pub fn init_flowy_core() -> AppFlowyCore { let config_json = include_str!("../tauri.conf.json"); let config: tauri_utils::config::Config = serde_json::from_str(config_json).unwrap(); + let app_version = config.package.version.clone().map(|v| v.to_string()).unwrap_or_else(|| "0.0.0".to_string()); let mut data_path = tauri::api::path::app_local_data_dir(&config).unwrap(); if cfg!(debug_assertions) { data_path.push("data_dev"); @@ -18,10 +41,11 @@ pub fn init_flowy_core() -> AppFlowyCore { let application_path = data_path.to_str().unwrap().to_string(); let device_id = uuid::Uuid::new_v4().to_string(); + read_env(); std::env::set_var("RUST_LOG", "trace"); - // TODO(nathan): pass the real version here + let config = AppFlowyCoreConfig::new( - "1.0.0".to_string(), + app_version, custom_application_path, application_path, device_id, diff --git a/frontend/appflowy_tauri/src-tauri/src/main.rs b/frontend/appflowy_tauri/src-tauri/src/main.rs index 10a17b5a3a..6a69de07fd 100644 --- a/frontend/appflowy_tauri/src-tauri/src/main.rs +++ b/frontend/appflowy_tauri/src-tauri/src/main.rs @@ -3,6 +3,10 @@ windows_subsystem = "windows" )] +#[allow(dead_code)] +pub const DEEP_LINK_SCHEME: &str = "appflowy-flutter"; +pub const OPEN_DEEP_LINK: &str = "open_deep_link"; + mod init; mod notification; mod request; @@ -12,8 +16,11 @@ use init::*; use notification::*; use request::*; use tauri::Manager; +extern crate dotenv; fn main() { + tauri_plugin_deep_link::prepare(DEEP_LINK_SCHEME); + let flowy_core = init_flowy_core(); tauri::Builder::default() .invoke_handler(tauri::generate_handler![invoke_request]) @@ -26,6 +33,7 @@ fn main() { unregister_all_notification_sender(); register_notification_sender(TSNotificationSender::new(app_handler.clone())); // tauri::async_runtime::spawn(async move {}); + window.listen_global(AF_EVENT, move |event| { on_event(app_handler.clone(), event); }); @@ -33,18 +41,29 @@ fn main() { .setup(|_app| { let splashscreen_window = _app.get_window("splashscreen").unwrap(); let window = _app.get_window("main").unwrap(); + let handle = _app.handle(); // we perform the initialization code on a new task so the app doesn't freeze tauri::async_runtime::spawn(async move { // initialize your app here instead of sleeping :) - println!("Initializing..."); std::thread::sleep(std::time::Duration::from_secs(2)); - println!("Done initializing."); // After it's done, close the splashscreen and display the main window splashscreen_window.close().unwrap(); window.show().unwrap(); + // If you need macOS support this must be called in .setup() ! + // Otherwise this could be called right after prepare() but then you don't have access to tauri APIs + // On macOS You still have to install a .app bundle you got from tauri build --debug for this to work! + tauri_plugin_deep_link::register( + DEEP_LINK_SCHEME, + move |request| { + dbg!(&request); + handle.emit_all(OPEN_DEEP_LINK, request).unwrap(); + }, + ) + .unwrap(/* If listening to the scheme is optional for your app, you don't want to unwrap here. */); }); + Ok(()) }) .run(tauri::generate_context!()) diff --git a/frontend/appflowy_tauri/src-tauri/tauri.conf.json b/frontend/appflowy_tauri/src-tauri/tauri.conf.json index 5011422312..11dd7c206c 100644 --- a/frontend/appflowy_tauri/src-tauri/tauri.conf.json +++ b/frontend/appflowy_tauri/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "AppFlowy", - "version": "0.0.0" + "version": "0.0.1" }, "tauri": { "allowlist": { diff --git a/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts index 48c8194d27..9c46b8ab38 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts @@ -1,6 +1,6 @@ import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { useEffect, useMemo } from 'react'; -import { currentUserActions } from '$app_reducers/current-user/slice'; +import { currentUserActions, LoginState } from '$app_reducers/current-user/slice'; import { Theme as ThemeType, ThemeMode } from '$app/stores/reducers/current-user/slice'; import { createTheme } from '@mui/material/styles'; import { getDesignTokens } from '$app/utils/mui'; @@ -10,6 +10,8 @@ import { UserService } from '$app/application/user/user.service'; export function useUserSetting() { const dispatch = useAppDispatch(); const { i18n } = useTranslation(); + const loginState = useAppSelector((state) => state.currentUser.loginState); + const { themeMode = ThemeMode.System, theme: themeType = ThemeType.Default } = useAppSelector((state) => { return { themeMode: state.currentUser.userSetting.themeMode, @@ -22,6 +24,7 @@ export function useUserSetting() { (themeMode === ThemeMode.System && window.matchMedia('(prefers-color-scheme: dark)').matches); useEffect(() => { + if (loginState !== LoginState.Success && loginState !== undefined) return; void (async () => { const settings = await UserService.getAppearanceSetting(); @@ -29,7 +32,7 @@ export function useUserSetting() { dispatch(currentUserActions.setUserSetting(settings)); await i18n.changeLanguage(settings.language); })(); - }, [dispatch, i18n]); + }, [dispatch, i18n, loginState]); useEffect(() => { const html = document.documentElement; diff --git a/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx b/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx index b9fd53130a..76bdb167b0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx @@ -8,6 +8,7 @@ import { useUserSetting } from '$app/AppMain.hooks'; import TrashPage from '$app/views/TrashPage'; import DocumentPage from '$app/views/DocumentPage'; import { Toaster } from 'react-hot-toast'; +import AppFlowyDevTool from '$app/components/_shared/devtool/AppFlowyDevTool'; function AppMain() { const { muiTheme } = useUserSetting(); @@ -22,6 +23,7 @@ function AppMain() { + {process.env.NODE_ENV === 'development' && } ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/folder/workspace.service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/folder/workspace.service.ts index e58afb9f58..e6f28766f2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/folder/workspace.service.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/folder/workspace.service.ts @@ -9,6 +9,7 @@ import { UserEventOpenWorkspace, UserEventRenameWorkspace, UserEventChangeWorkspaceIcon, + UserEventGetAllWorkspace, } from '@/services/backend/events/flowy-user'; import { FolderEventCreateView, @@ -62,17 +63,13 @@ export async function getWorkspaceChildViews(id: string) { } export async function getWorkspaces() { - const result = await FolderEventReadCurrentWorkspace(); + const result = await UserEventGetAllWorkspace(); if (result.ok) { - const item = result.val; - - return [ - { - id: item.id, - name: item.name, - }, - ]; + return result.val.items.map((workspace) => ({ + id: workspace.workspace_id, + name: workspace.name, + })); } return []; @@ -92,12 +89,7 @@ export async function getCurrentWorkspace() { const result = await FolderEventReadCurrentWorkspace(); if (result.ok) { - const workspace = result.val; - - return { - id: workspace.id, - name: workspace.name, - }; + return result.val.id; } return null; @@ -111,9 +103,7 @@ export async function createCurrentWorkspaceChildView( const result = await FolderEventCreateView(payload); if (result.ok) { - const view = result.val; - - return view; + return result.val; } return Promise.reject(result.err); diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/user/auth.service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/user/auth.service.ts index 82c0a6779b..ec258abc87 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/user/auth.service.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/user/auth.service.ts @@ -1,33 +1,63 @@ -import { SignInPayloadPB, SignUpPayloadPB } from '@/services/backend'; import { - UserEventSignInWithEmailPassword, + SignUpPayloadPB, + OauthProviderPB, + ProviderTypePB, + OauthSignInPB, + AuthenticatorPB, + SignInPayloadPB, +} from '@/services/backend'; +import { UserEventSignOut, UserEventSignUp, + UserEventGetOauthURLWithProvider, + UserEventOauthSignIn, + UserEventSignInWithEmailPassword, } from '@/services/backend/events/flowy-user'; -import { nanoid } from '@reduxjs/toolkit'; import { Log } from '$app/utils/log'; export const AuthService = { - signIn: async (params: { email: string; password: string }) => { - const payload = SignInPayloadPB.fromObject({ email: params.email, password: params.password }); + getOAuthURL: async (provider: ProviderTypePB) => { + const providerDataRes = await UserEventGetOauthURLWithProvider( + OauthProviderPB.fromObject({ + provider, + }) + ); - const res = await UserEventSignInWithEmailPassword(payload); - - if (res.ok) { - return res.val; + if (!providerDataRes.ok) { + Log.error(providerDataRes.val.msg); + throw new Error(providerDataRes.val.msg); } - Log.error(res.val.msg); - throw new Error(res.val.msg); + const providerData = providerDataRes.val; + + return providerData.oauth_url; }, - signUp: async (params: { name: string; email: string; password: string }) => { - const deviceId = nanoid(8); + signInWithOAuth: async ({ uri, deviceId }: { uri: string; deviceId: string }) => { + const payload = OauthSignInPB.fromObject({ + authenticator: AuthenticatorPB.AppFlowyCloud, + map: { + sign_in_url: uri, + device_id: deviceId, + }, + }); + + const res = await UserEventOauthSignIn(payload); + + if (!res.ok) { + Log.error(res.val.msg); + throw new Error(res.val.msg); + } + + return res.val; + }, + + signUp: async (params: { deviceId: string; name: string; email: string; password: string }) => { const payload = SignUpPayloadPB.fromObject({ name: params.name, email: params.email, password: params.password, - device_id: deviceId, + device_id: params.deviceId, }); const res = await UserEventSignUp(payload); @@ -43,4 +73,20 @@ export const AuthService = { signOut: () => { return UserEventSignOut(); }, + + signIn: async (email: string, password: string) => { + const payload = SignInPayloadPB.fromObject({ + email, + password, + }); + + const res = await UserEventSignInWithEmailPassword(payload); + + if (!res.ok) { + Log.error(res.val.msg); + throw new Error(res.val.msg); + } + + return res.val; + }, }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/devtool/AppFlowyDevTool.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/devtool/AppFlowyDevTool.tsx new file mode 100644 index 0000000000..5d3ed1e3de --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/devtool/AppFlowyDevTool.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import SpeedDial from '@mui/material/SpeedDial'; +import SpeedDialIcon from '@mui/material/SpeedDialIcon'; +import SpeedDialAction from '@mui/material/SpeedDialAction'; +import { useMemo } from 'react'; +import { CloseOutlined, BuildOutlined, LoginOutlined, VisibilityOff } from '@mui/icons-material'; +import ManualSignInDialog from '$app/components/_shared/devtool/ManualSignInDialog'; +import { Portal } from '@mui/material'; + +function AppFlowyDevTool() { + const [openManualSignIn, setOpenManualSignIn] = React.useState(false); + const [hidden, setHidden] = React.useState(false); + const actions = useMemo( + () => [ + { + icon: , + name: 'Manual SignIn', + onClick: () => { + setOpenManualSignIn(true); + }, + }, + { + icon: , + name: 'Hide Dev Tool', + onClick: () => { + setHidden(true); + }, + }, + ], + [] + ); + + return ( + + + + ); +} + +export default AppFlowyDevTool; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/devtool/ManualSignInDialog.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/devtool/ManualSignInDialog.tsx new file mode 100644 index 0000000000..364b334a07 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/devtool/ManualSignInDialog.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { CircularProgress, DialogActions, DialogProps, Tab, Tabs, TextareaAutosize } from '@mui/material'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import Button from '@mui/material/Button'; +import { useAuth } from '$app/components/auth/auth.hooks'; +import TextField from '@mui/material/TextField'; + +function ManualSignInDialog(props: DialogProps) { + const [uri, setUri] = React.useState(''); + const [loading, setLoading] = React.useState(false); + const { signInWithOAuth, signInWithEmailPassword } = useAuth(); + const [tab, setTab] = React.useState(0); + const [email, setEmail] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [domain, setDomain] = React.useState(''); + const handleSignIn = async () => { + setLoading(true); + try { + if (tab === 1) { + if (!email || !password) return; + await signInWithEmailPassword(email, password, domain); + } else { + await signInWithOAuth(uri); + } + } finally { + setLoading(false); + } + + props?.onClose?.({}, 'backdropClick'); + }; + + return ( + { + if (e.key === 'Enter') { + e.preventDefault(); + void handleSignIn(); + } + }} + > + + { + setTab(value); + }} + > + + + + {tab === 1 ? ( +
+ setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + setDomain(e.target.value)} + /> +
+ ) : ( + { + setUri(e.target.value); + }} + /> + )} +
+ + + + +
+ ); +} + +export default ManualSignInDialog; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/login/LoginButtonGroup.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/login/LoginButtonGroup.tsx deleted file mode 100644 index 7334a94420..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/login/LoginButtonGroup.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import Button from '@mui/material/Button'; -import GoogleIcon from '$app/assets/settings/google.png'; -import GithubIcon from '$app/assets/settings/github.png'; -import DiscordIcon from '$app/assets/settings/discord.png'; -import { useTranslation } from 'react-i18next'; - -export const LoginButtonGroup = () => { - const { t } = useTranslation(); - - return ( -
- - - -
- ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/login/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/login/index.ts deleted file mode 100644 index 04605317ed..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/login/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './LoginButtonGroup'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/auth/LoginButtonGroup.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/auth/LoginButtonGroup.tsx new file mode 100644 index 0000000000..481b80a532 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/auth/LoginButtonGroup.tsx @@ -0,0 +1,51 @@ +import Button from '@mui/material/Button'; +import GoogleIcon from '$app/assets/settings/google.png'; +import GithubIcon from '$app/assets/settings/github.png'; +import DiscordIcon from '$app/assets/settings/discord.png'; +import { useTranslation } from 'react-i18next'; +import { useAuth } from '$app/components/auth/auth.hooks'; +import { ProviderTypePB } from '@/services/backend'; + +export const LoginButtonGroup = () => { + const { t } = useTranslation(); + + const { signIn } = useAuth(); + + return ( +
+ + + +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/auth/ProtectedRoutes.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/auth/ProtectedRoutes.tsx index 0d776bada5..523f0b5188 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/auth/ProtectedRoutes.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/auth/ProtectedRoutes.tsx @@ -3,15 +3,24 @@ import { useAuth } from './auth.hooks'; import Layout from '$app/components/layout/Layout'; import { useCallback, useEffect, useState } from 'react'; import { Welcome } from '$app/components/auth/Welcome'; -import { ReactComponent as AppflowyLogo } from '$app/assets/logo.svg'; +import { isTauri } from '$app/utils/env'; +import { notify } from '$app/components/_shared/notify'; +import { currentUserActions, LoginState } from '$app_reducers/current-user/slice'; +import { CircularProgress, Portal } from '@mui/material'; +import { ReactComponent as Logo } from '$app/assets/logo.svg'; +import { useAppDispatch } from '$app/stores/store'; export const ProtectedRoutes = () => { - const { currentUser, checkUser, subscribeToUser } = useAuth(); - const [isLoading, setIsLoading] = useState(true); + const { currentUser, checkUser, subscribeToUser, signInWithOAuth } = useAuth(); + const dispatch = useAppDispatch(); + + const isLoading = currentUser?.loginState === LoginState.Loading; + + const [checked, setChecked] = useState(false); const checkUserStatus = useCallback(async () => { await checkUser(); - setIsLoading(false); + setChecked(true); }, [checkUser]); useEffect(() => { @@ -24,21 +33,73 @@ export const ProtectedRoutes = () => { } }, [currentUser.isAuthenticated, subscribeToUser]); - if (isLoading) { - // It's better to make a fading effect to disappear the loading page - return ; - } else { - return ; - } + const onDeepLink = useCallback(async () => { + if (!isTauri()) return; + const { event } = await import('@tauri-apps/api'); + + // On macOS You still have to install a .app bundle you got from tauri build --debug for this to work! + return await event.listen('open_deep_link', async (e) => { + const payload = e.payload as string; + + const [, hash] = payload.split('//#'); + const obj = parseHash(hash); + + if (!obj.access_token) { + notify.error('Failed to sign in, the access token is missing'); + dispatch(currentUserActions.setLoginState(LoginState.Error)); + return; + } + + try { + await signInWithOAuth(payload); + } catch (e) { + notify.error('Failed to sign in, please try again'); + } + }); + }, [dispatch, signInWithOAuth]); + + useEffect(() => { + void onDeepLink(); + }, [onDeepLink]); + + return ( +
+ {checked ? ( + + ) : ( +
+ +
+ )} + + {isLoading && } +
+ ); }; const StartLoading = () => { + const dispatch = useAppDispatch(); + + useEffect(() => { + const preventDefault = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + dispatch(currentUserActions.resetLoginState()); + } + }; + + document.addEventListener('keydown', preventDefault, true); + + return () => { + document.removeEventListener('keydown', preventDefault, true); + }; + }, [dispatch]); return ( -
-
- + +
+
-
+ ); }; @@ -53,3 +114,14 @@ const SplashScreen = ({ isAuthenticated }: { isAuthenticated: boolean }) => { return ; } }; + +function parseHash(hash: string) { + const hashParams = new URLSearchParams(hash); + const hashObject: Record = {}; + + for (const [key, value] of hashParams) { + hashObject[key] = value; + } + + return hashObject; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/auth/Welcome.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/auth/Welcome.tsx index 2dd0401412..c3c699e462 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/auth/Welcome.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/auth/Welcome.tsx @@ -1,7 +1,7 @@ import { ReactComponent as AppflowyLogo } from '$app/assets/logo.svg'; import Button from '@mui/material/Button'; import { useTranslation } from 'react-i18next'; -import { LoginButtonGroup } from '$app/components/_shared/login'; +import { LoginButtonGroup } from '$app/components/auth/LoginButtonGroup'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '$app/components/auth/auth.hooks'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/auth/auth.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/auth/auth.hooks.ts index 56e44a4765..c49d65886f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/auth/auth.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/auth/auth.hooks.ts @@ -1,12 +1,13 @@ -import { currentUserActions } from '$app_reducers/current-user/slice'; -import { AuthenticatorPB, UserNotification, UserProfilePB } from '@/services/backend/events/flowy-user'; +import { currentUserActions, LoginState } from '$app_reducers/current-user/slice'; +import { AuthenticatorPB, ProviderTypePB, UserNotification, UserProfilePB } from '@/services/backend/events/flowy-user'; import { UserService } from '$app/application/user/user.service'; import { AuthService } from '$app/application/user/auth.service'; -import { useAppSelector, useAppDispatch } from '$app/stores/store'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { getCurrentWorkspaceSetting } from '$app/application/folder/workspace.service'; import { useCallback } from 'react'; import { subscribeNotifications } from '$app/application/notification'; import { nanoid } from 'nanoid'; +import { open } from '@tauri-apps/api/shell'; export const useAuth = () => { const dispatch = useAppDispatch(); @@ -31,72 +32,49 @@ export const useAuth = () => { }; }, [dispatch]); - // Check if the user is authenticated - const checkUser = useCallback(async () => { - const userProfile = await UserService.getUserProfile(); + const setUser = useCallback( + async (userProfile?: Partial) => { + if (!userProfile) return; - if (!userProfile) return; - const workspaceSetting = await getCurrentWorkspaceSetting(); - - const isLocal = userProfile.authenticator === AuthenticatorPB.Local; - - dispatch( - currentUserActions.checkUser({ - id: userProfile.id, - token: userProfile.token, - email: userProfile.email, - displayName: userProfile.name, - iconUrl: userProfile.icon_url, - isAuthenticated: true, - workspaceSetting: workspaceSetting, - isLocal, - }) - ); - - return userProfile; - }, [dispatch]); - - const register = useCallback( - async (email: string, password: string, name: string): Promise => { - const userProfile = await AuthService.signUp({ email, password, name }); - - // Get the workspace setting after user registered. The workspace setting - // contains the latest visiting page and the current workspace data. const workspaceSetting = await getCurrentWorkspaceSetting(); + const isLocal = userProfile.authenticator === AuthenticatorPB.Local; + dispatch( currentUserActions.updateUser({ id: userProfile.id, token: userProfile.token, email: userProfile.email, displayName: userProfile.name, + iconUrl: userProfile.icon_url, isAuthenticated: true, - workspaceSetting, + workspaceSetting: workspaceSetting, + isLocal, }) ); - - return userProfile; }, [dispatch] ); - const login = useCallback( - async (email: string, password: string): Promise => { - const user = await AuthService.signIn({ email, password }); - const { id, token, name } = user; + // Check if the user is authenticated + const checkUser = useCallback(async () => { + const userProfile = await UserService.getUserProfile(); - dispatch( - currentUserActions.updateUser({ - id: id, - token: token, - email, - displayName: name, - isAuthenticated: true, - }) - ); - return user; + await setUser(userProfile); + + return userProfile; + }, [setUser]); + + const register = useCallback( + async (email: string, password: string, name: string): Promise => { + const deviceId = currentUser?.deviceId ?? nanoid(8); + const userProfile = await AuthService.signUp({ deviceId, email, password, name }); + + await setUser(userProfile); + + return userProfile; }, - [dispatch] + [setUser, currentUser?.deviceId] ); const logout = useCallback(async () => { @@ -112,5 +90,97 @@ export const useAuth = () => { await register(fakeEmail, fakePassword, fakeName); }, [register]); - return { currentUser, checkUser, register, login, logout, subscribeToUser, signInAsAnonymous }; + const signIn = useCallback( + async (provider: ProviderTypePB) => { + dispatch(currentUserActions.setLoginState(LoginState.Loading)); + try { + const url = await AuthService.getOAuthURL(provider); + + await open(url); + } catch { + dispatch(currentUserActions.setLoginState(LoginState.Error)); + } + }, + [dispatch] + ); + + const signInWithOAuth = useCallback( + async (uri: string) => { + dispatch(currentUserActions.setLoginState(LoginState.Loading)); + try { + const deviceId = currentUser?.deviceId ?? nanoid(8); + + await AuthService.signInWithOAuth({ uri, deviceId }); + const userProfile = await UserService.getUserProfile(); + + await setUser(userProfile); + + return userProfile; + } catch (e) { + dispatch(currentUserActions.setLoginState(LoginState.Error)); + return Promise.reject(e); + } + }, + [dispatch, currentUser?.deviceId, setUser] + ); + + // Only for development purposes + const signInWithEmailPassword = useCallback( + async (email: string, password: string, domain?: string) => { + dispatch(currentUserActions.setLoginState(LoginState.Loading)); + + try { + const response = await fetch( + `https://${domain ? domain : 'test.appflowy.cloud'}/gotrue/token?grant_type=password`, + { + method: 'POST', + mode: 'cors', + cache: 'no-cache', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify({ + email, + password, + }), + } + ); + + const data = await response.json(); + + let uri = `appflowy-flutter://#`; + const params: string[] = []; + + Object.keys(data).forEach((key) => { + if (typeof data[key] === 'object') { + return; + } + + params.push(`${key}=${data[key]}`); + }); + uri += params.join('&'); + + return signInWithOAuth(uri); + } catch (e) { + dispatch(currentUserActions.setLoginState(LoginState.Error)); + return Promise.reject(e); + } + }, + [dispatch, signInWithOAuth] + ); + + return { + currentUser, + checkUser, + register, + logout, + subscribeToUser, + signInAsAnonymous, + signIn, + signInWithOAuth, + signInWithEmailPassword, + }; }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.hooks.ts index a754dcdf3b..c425835372 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.hooks.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { workspaceActions, WorkspaceItem } from '$app_reducers/workspace/slice'; import { Page, pagesActions, parserViewPBToPage } from '$app_reducers/pages/slice'; @@ -10,29 +10,29 @@ import { useNavigate } from 'react-router-dom'; export function useLoadWorkspaces() { const dispatch = useAppDispatch(); - const { workspaces, currentWorkspace } = useAppSelector((state) => state.workspace); + const { workspaces, currentWorkspaceId } = useAppSelector((state) => state.workspace); + + const currentWorkspace = useMemo(() => { + return workspaces.find((workspace) => workspace.id === currentWorkspaceId); + }, [workspaces, currentWorkspaceId]); const initializeWorkspaces = useCallback(async () => { const workspaces = await workspaceService.getWorkspaces(); - const currentWorkspace = await workspaceService.getCurrentWorkspace(); + + const currentWorkspaceId = await workspaceService.getCurrentWorkspace(); dispatch( workspaceActions.initWorkspaces({ workspaces, - currentWorkspace, + currentWorkspaceId, }) ); }, [dispatch]); - useEffect(() => { - void (async () => { - await initializeWorkspaces(); - })(); - }, [initializeWorkspaces]); - return { workspaces, currentWorkspace, + initializeWorkspaces, }; } @@ -82,8 +82,10 @@ export function useLoadWorkspace(workspace: WorkspaceItem) { { [FolderNotification.DidUpdateWorkspace]: async (changeset) => { dispatch( - workspaceActions.updateCurrentWorkspace({ + workspaceActions.updateWorkspace({ + id: String(changeset.id), name: changeset.name, + icon: changeset.icon_url, }) ); }, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.tsx index b2f0fbb0ca..24fc7be91e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.tsx @@ -43,8 +43,20 @@ function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: bo >
- - {workspace.name} + {!workspace.name ? ( + t('sideBar.personal') + ) : ( + <> + + {workspace.name} + + )}
{showAdd && ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/WorkspaceManager.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/WorkspaceManager.tsx index c6404d435c..4b4dbea10e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/WorkspaceManager.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/WorkspaceManager.tsx @@ -1,11 +1,21 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import NewPageButton from '$app/components/layout/workspace_manager/NewPageButton'; import { useLoadWorkspaces } from '$app/components/layout/workspace_manager/Workspace.hooks'; import Workspace from './Workspace'; import TrashButton from '$app/components/layout/workspace_manager/TrashButton'; +import { useAppSelector } from '@/appflowy_app/stores/store'; +import { LoginState } from '$app_reducers/current-user/slice'; function WorkspaceManager() { - const { workspaces, currentWorkspace } = useLoadWorkspaces(); + const { workspaces, currentWorkspace, initializeWorkspaces } = useLoadWorkspaces(); + + const loginState = useAppSelector((state) => state.currentUser.loginState); + + useEffect(() => { + if (loginState === LoginState.Success || loginState === undefined) { + void initializeWorkspaces(); + } + }, [initializeWorkspaces, loginState]); return (
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/Login.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/Login.tsx index 6daceadb61..d5ecc4bc0c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/Login.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/Login.tsx @@ -1,7 +1,7 @@ import Typography from '@mui/material/Typography'; import { useTranslation } from 'react-i18next'; import Button from '@mui/material/Button'; -import { LoginButtonGroup } from '$app/components/_shared/login'; +import { LoginButtonGroup } from '$app/components/auth/LoginButtonGroup'; export const Login = ({ onBack }: { onBack?: () => void }) => { const { t } = useTranslation(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/SettingsDialog.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/SettingsDialog.tsx index 72f2fddb49..9d2f02de1a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/SettingsDialog.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/SettingsDialog.tsx @@ -4,7 +4,7 @@ import Dialog, { DialogProps } from '@mui/material/Dialog'; import { Settings } from '$app/components/settings/Settings'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import DialogTitle from '@mui/material/DialogTitle'; import { IconButton } from '@mui/material'; import { ReactComponent as CloseIcon } from '$app/assets/close.svg'; @@ -14,10 +14,15 @@ import { SettingsRoutes } from '$app/components/settings/workplace/const'; import DialogContent from '@mui/material/DialogContent'; import { Login } from '$app/components/settings/Login'; import SwipeableViews from 'react-swipeable-views'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { currentUserActions, LoginState } from '$app_reducers/current-user/slice'; +import { useNavigate } from 'react-router-dom'; export const SettingsDialog = (props: DialogProps) => { + const dispatch = useAppDispatch(); const [routes, setRoutes] = useState([]); - + const loginState = useAppSelector((state) => state.currentUser.loginState); + const lastLoginStateRef = useRef(loginState); const { t } = useTranslation(); const handleForward = useCallback((route: SettingsRoutes) => { setRoutes((prev) => { @@ -29,14 +34,28 @@ export const SettingsDialog = (props: DialogProps) => { setRoutes((prevState) => { return prevState.slice(0, -1); }); - }, []); + dispatch(currentUserActions.resetLoginState()); + }, [dispatch]); - const handleClose = () => { + const handleClose = useCallback(() => { + dispatch(currentUserActions.resetLoginState()); props?.onClose?.({}, 'backdropClick'); - }; + }, [dispatch, props]); const currentRoute = routes[routes.length - 1]; + const navigate = useNavigate(); + + useEffect(() => { + if (lastLoginStateRef.current === LoginState.Loading && loginState === LoginState.Success) { + navigate('/'); + handleClose(); + return; + } + + lastLoginStateRef.current = loginState; + }, [loginState, handleClose, navigate]); + return ( void }) => { const { t } = useTranslation(); + const { currentUser, logout } = useAuth(); + + const isLocal = currentUser.isLocal; return ( <> @@ -15,12 +19,17 @@ export const AccountLogin = ({ onForward }: { onForward?: (route: SettingsRoutes {t('newSettings.myAccount.accountLogin')} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/WorkplaceDisplay.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/WorkplaceDisplay.tsx index cbb68e5d56..3a71c5f070 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/WorkplaceDisplay.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/WorkplaceDisplay.tsx @@ -1,39 +1,65 @@ import { useTranslation } from 'react-i18next'; import Typography from '@mui/material/Typography'; import { Divider, OutlinedInput } from '@mui/material'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import Button from '@mui/material/Button'; -import { useAppSelector } from '$app/stores/store'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { changeWorkspaceIcon, renameWorkspace } from '$app/application/folder/workspace.service'; import { notify } from '$app/components/_shared/notify'; import { WorkplaceAvatar } from '$app/components/_shared/avatar'; import Popover from '@mui/material/Popover'; import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; import EmojiPicker from '$app/components/_shared/emoji_picker/EmojiPicker'; +import { workspaceActions } from '$app_reducers/workspace/slice'; +import debounce from 'lodash-es/debounce'; export const WorkplaceDisplay = () => { const { t } = useTranslation(); const isLocal = useAppSelector((state) => state.currentUser.isLocal); - const workspace = useAppSelector((state) => state.workspace.currentWorkspace); + const { workspaces, currentWorkspaceId } = useAppSelector((state) => state.workspace); + const workspace = useMemo( + () => workspaces.find((workspace) => workspace.id === currentWorkspaceId), + [workspaces, currentWorkspaceId] + ); const [name, setName] = useState(workspace?.name ?? ''); const [emojiPickerAnchor, setEmojiPickerAnchor] = useState(null); const openEmojiPicker = Boolean(emojiPickerAnchor); + const dispatch = useAppDispatch(); + + const debounceUpdateWorkspace = useMemo(() => { + return debounce(async ({ id, name, icon }: { id: string; name?: string; icon?: string }) => { + if (!id || !name) return; + + if (icon) { + try { + await changeWorkspaceIcon(id, icon); + } catch { + notify.error(t('newSettings.workplace.updateIconError')); + } + } + + if (name) { + try { + await renameWorkspace(id, name); + } catch { + notify.error(t('newSettings.workplace.renameError')); + } + } + }, 500); + }, [t]); + const handleSave = async () => { if (!workspace || !name) return; - try { - await renameWorkspace(workspace.id, name); - } catch { - notify.error(t('newSettings.workplace.renameError')); - } + dispatch(workspaceActions.updateWorkspace({ id: workspace.id, name })); + + await debounceUpdateWorkspace({ id: workspace.id, name }); }; const handleEmojiSelect = async (icon: string) => { if (!workspace) return; - try { - await changeWorkspaceIcon(workspace.id, icon); - } catch { - notify.error(t('newSettings.workplace.updateIconError')); - } + dispatch(workspaceActions.updateWorkspace({ id: workspace.id, icon })); + + await debounceUpdateWorkspace({ id: workspace.id, icon }); }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -93,6 +119,7 @@ export const WorkplaceDisplay = () => { workplaceName={name} width={62} height={62} + icon={workspace?.icon} className={'rounded-lg border border-bg-body p-[2px] hover:opacity-90'} /> diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/current-user/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/current-user/slice.ts index 193d77282f..322f899560 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/current-user/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/current-user/slice.ts @@ -17,8 +17,15 @@ export enum Theme { Lavender = 'lavender', } +export enum LoginState { + Loading = 'loading', + Success = 'success', + Error = 'error', +} + export interface ICurrentUser { id?: number; + deviceId?: string; displayName?: string; email?: string; token?: string; @@ -27,6 +34,7 @@ export interface ICurrentUser { workspaceSetting?: WorkspaceSettingPB; userSetting: UserSetting; isLocal: boolean; + loginState?: LoginState; } const initialState: ICurrentUser | null = { @@ -39,17 +47,11 @@ export const currentUserSlice = createSlice({ name: 'currentUser', initialState: initialState, reducers: { - checkUser: (state, action: PayloadAction>) => { - return { - ...state, - ...action.payload, - }; - }, - updateUser: (state, action: PayloadAction>) => { return { ...state, ...action.payload, + loginState: LoginState.Success, }; }, logout: () => { @@ -61,6 +63,14 @@ export const currentUserSlice = createSlice({ ...action.payload, }; }, + + setLoginState: (state, action: PayloadAction) => { + state.loginState = action.payload; + }, + + resetLoginState: (state) => { + state.loginState = undefined; + }, }, }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/workspace/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/workspace/slice.ts index cbda3bb9ae..d071de846e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/workspace/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/workspace/slice.ts @@ -3,16 +3,17 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; export interface WorkspaceItem { id: string; name: string; + icon?: string; } interface WorkspaceState { workspaces: WorkspaceItem[]; - currentWorkspace: WorkspaceItem | null; + currentWorkspaceId: string | null; } const initialState: WorkspaceState = { workspaces: [], - currentWorkspace: null, + currentWorkspaceId: null, }; export const workspaceSlice = createSlice({ @@ -23,18 +24,21 @@ export const workspaceSlice = createSlice({ state, action: PayloadAction<{ workspaces: WorkspaceItem[]; - currentWorkspace: WorkspaceItem | null; + currentWorkspaceId: string | null; }> ) => { return action.payload; }, - updateCurrentWorkspace: (state, action: PayloadAction>) => { - if (!state.currentWorkspace) return; - state.currentWorkspace = { - ...state.currentWorkspace, - ...action.payload, - }; + updateWorkspace: (state, action: PayloadAction>) => { + const index = state.workspaces.findIndex((workspace) => workspace.id === action.payload.id); + + if (index !== -1) { + state.workspaces[index] = { + ...state.workspaces[index], + ...action.payload, + }; + } }, }, }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/color.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/color.ts index 4861e4de2d..025c8c45ed 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/color.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/color.ts @@ -22,10 +22,29 @@ export const colorMap = { [ColorEnum.Blue]: 'var(--tint-blue)', }; +// Convert ARGB to RGBA +// Flutter uses ARGB, but CSS uses RGBA +function argbToRgba(color: string): string { + const hex = color.replace(/^#|0x/, ''); + + const hasAlpha = hex.length === 8; + + if (!hasAlpha) { + return color.replace('0x', '#'); + } + + const r = parseInt(hex.slice(2, 4), 16); + const g = parseInt(hex.slice(4, 6), 16); + const b = parseInt(hex.slice(6, 8), 16); + const a = hasAlpha ? parseInt(hex.slice(0, 2), 16) / 255 : 1; + + return `rgba(${r}, ${g}, ${b}, ${a})`; +} + export function renderColor(color: string) { if (colorMap[color as ColorEnum]) { return colorMap[color as ColorEnum]; } - return color.replace('0x', '#'); + return argbToRgba(color); } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index af75c0d395..73b57161ef 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -695,6 +695,7 @@ impl UserManager { save_user_workspaces(uid, self.db_connection(uid)?, response.user_workspaces())?; event!(tracing::Level::INFO, "Save new user profile to disk"); + self.authenticate_user.set_session(Some(session.clone()))?; self .save_user(uid, (user_profile, authenticator.clone()).into())