feat: support-OAuth-login (#4899)

* feat: support-OAuth-login

* fix: modified ubuntu version and devtool

* fix: ts lint error
This commit is contained in:
Kilu.He 2024-03-15 20:29:00 +08:00 committed by GitHub
parent 57e3a2ce68
commit 6d4cfe7316
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 861 additions and 198 deletions

View File

@ -22,7 +22,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
platform: [ubuntu-latest] platform: [ubuntu-20.04]
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
@ -32,7 +32,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Maximize build space (ubuntu only) - name: Maximize build space (ubuntu only)
if: matrix.platform == 'ubuntu-latest' if: matrix.platform == 'ubuntu-20.04'
run: | run: |
sudo rm -rf /usr/share/dotnet sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc sudo rm -rf /opt/ghc
@ -80,7 +80,7 @@ jobs:
vcpkg integrate install vcpkg integrate install
- name: install dependencies (ubuntu only) - name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-latest' if: matrix.platform == 'ubuntu-20.04'
working-directory: frontend working-directory: frontend
run: | run: |
sudo apt-get update sudo apt-get update
@ -111,3 +111,4 @@ jobs:
with: with:
tauriScript: pnpm tauri tauriScript: pnpm tauri
projectPath: frontend/appflowy_tauri projectPath: frontend/appflowy_tauri
args: "--debug"

View File

@ -31,7 +31,7 @@ jobs:
- platform: macos-latest - platform: macos-latest
args: "--target x86_64-apple-darwin" args: "--target x86_64-apple-darwin"
target: "macos-x86_64" target: "macos-x86_64"
- platform: ubuntu-latest - platform: ubuntu-20.04
args: "--target x86_64-unknown-linux-gnu" args: "--target x86_64-unknown-linux-gnu"
target: "linux-x86_64" target: "linux-x86_64"
@ -46,7 +46,7 @@ jobs:
ref: ${{ github.event.inputs.branch }} ref: ${{ github.event.inputs.branch }}
- name: Maximize build space (ubuntu only) - name: Maximize build space (ubuntu only)
if: matrix.settings.platform == 'ubuntu-latest' if: matrix.settings.platform == 'ubuntu-20.04'
run: | run: |
sudo rm -rf /usr/share/dotnet sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc sudo rm -rf /opt/ghc
@ -88,7 +88,7 @@ jobs:
vcpkg integrate install vcpkg integrate install
- name: install dependencies (ubuntu only) - name: install dependencies (ubuntu only)
if: matrix.settings.platform == 'ubuntu-latest' if: matrix.settings.platform == 'ubuntu-20.04'
working-directory: frontend working-directory: frontend
run: | run: |
sudo apt-get update sudo apt-get update
@ -140,14 +140,14 @@ jobs:
- name: Upload Deb package(ubuntu only) - name: Upload Deb package(ubuntu only)
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: matrix.settings.platform == 'ubuntu-latest' if: matrix.settings.platform == 'ubuntu-20.04'
with: with:
name: ${{ env.PACKAGE_PREFIX }}.deb 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 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) - name: Upload AppImage package(ubuntu only)
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: matrix.settings.platform == 'ubuntu-latest' if: matrix.settings.platform == 'ubuntu-20.04'
with: with:
name: ${{ env.PACKAGE_PREFIX }}.AppImage 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 path: frontend/appflowy_tauri/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/app-flowy_${{ github.event.inputs.version }}_amd64.AppImage

View File

@ -29,3 +29,5 @@ dist-ssr
coverage coverage
**/AppFlowy-Collab **/AppFlowy-Collab
.env

View File

@ -1,4 +1,4 @@
# Generated by Cargo # Generated by Cargo
# will have compiled files and executables # will have compiled files and executables
/target/ /target/
.env

View File

@ -182,6 +182,7 @@ name = "appflowy_tauri"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"bytes", "bytes",
"dotenv",
"flowy-config", "flowy-config",
"flowy-core", "flowy-core",
"flowy-date", "flowy-date",
@ -194,6 +195,7 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-deep-link",
"tauri-utils", "tauri-utils",
"tracing", "tracing",
"uuid", "uuid",
@ -1439,6 +1441,15 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys",
]
[[package]] [[package]]
name = "dirs-next" name = "dirs-next"
version = "2.0.0" version = "2.0.0"
@ -1449,6 +1460,18 @@ dependencies = [
"dirs-sys-next", "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]] [[package]]
name = "dirs-sys-next" name = "dirs-sys-next"
version = "0.1.2" version = "0.1.2"
@ -1466,6 +1489,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
[[package]]
name = "dotenv"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
[[package]] [[package]]
name = "dtoa" name = "dtoa"
version = "1.0.6" version = "1.0.6"
@ -3088,6 +3117,19 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.8.0" version = "2.8.0"
@ -3831,6 +3873,28 @@ dependencies = [
"objc_id", "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]] [[package]]
name = "objc_exception" name = "objc_exception"
version = "0.1.2" version = "0.1.2"
@ -3934,6 +3998,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]] [[package]]
name = "os_pipe" name = "os_pipe"
version = "0.9.2" version = "0.9.2"
@ -6016,6 +6086,22 @@ dependencies = [
"tauri-utils", "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]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "0.14.1" version = "0.14.1"
@ -6242,6 +6328,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "to_method"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c4ceeeca15c8384bbc3e011dbd8fccb7f068a440b752b7d9b32ceb0ca0e2e8"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.36.0" version = "1.36.0"

View File

@ -67,7 +67,10 @@ flowy-document = { path = "../../rust-lib/flowy-document", features = [
flowy-notification = { path = "../../rust-lib/flowy-notification", features = [ flowy-notification = { path = "../../rust-lib/flowy-notification", features = [
"tauri_ts", "tauri_ts",
] } ] }
uuid = "1.5.0" uuid = "1.5.0"
tauri-plugin-deep-link = "0.1.2"
dotenv = "0.15.0"
[features] [features]
# by default Tauri runs in production mode # by default Tauri runs in production mode

View File

@ -0,0 +1,19 @@
<!-- Add this file next to your tauri.conf.json file -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<!-- Obviously needs to be replaced with your app's bundle identifier -->
<string>appflowy-flutter</string>
<key>CFBundleURLSchemes</key>
<array>
<string>appflowy-flutter</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

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

View File

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

View File

@ -3,10 +3,33 @@ use flowy_core::{AppFlowyCore, DEFAULT_NAME};
use lib_dispatch::runtime::AFPluginRuntime; use lib_dispatch::runtime::AFPluginRuntime;
use std::sync::Arc; 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 { pub fn init_flowy_core() -> AppFlowyCore {
let config_json = include_str!("../tauri.conf.json"); let config_json = include_str!("../tauri.conf.json");
let config: tauri_utils::config::Config = serde_json::from_str(config_json).unwrap(); 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(); let mut data_path = tauri::api::path::app_local_data_dir(&config).unwrap();
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
data_path.push("data_dev"); 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 application_path = data_path.to_str().unwrap().to_string();
let device_id = uuid::Uuid::new_v4().to_string(); let device_id = uuid::Uuid::new_v4().to_string();
read_env();
std::env::set_var("RUST_LOG", "trace"); std::env::set_var("RUST_LOG", "trace");
// TODO(nathan): pass the real version here
let config = AppFlowyCoreConfig::new( let config = AppFlowyCoreConfig::new(
"1.0.0".to_string(), app_version,
custom_application_path, custom_application_path,
application_path, application_path,
device_id, device_id,

View File

@ -3,6 +3,10 @@
windows_subsystem = "windows" 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 init;
mod notification; mod notification;
mod request; mod request;
@ -12,8 +16,11 @@ use init::*;
use notification::*; use notification::*;
use request::*; use request::*;
use tauri::Manager; use tauri::Manager;
extern crate dotenv;
fn main() { fn main() {
tauri_plugin_deep_link::prepare(DEEP_LINK_SCHEME);
let flowy_core = init_flowy_core(); let flowy_core = init_flowy_core();
tauri::Builder::default() tauri::Builder::default()
.invoke_handler(tauri::generate_handler![invoke_request]) .invoke_handler(tauri::generate_handler![invoke_request])
@ -26,6 +33,7 @@ fn main() {
unregister_all_notification_sender(); unregister_all_notification_sender();
register_notification_sender(TSNotificationSender::new(app_handler.clone())); register_notification_sender(TSNotificationSender::new(app_handler.clone()));
// tauri::async_runtime::spawn(async move {}); // tauri::async_runtime::spawn(async move {});
window.listen_global(AF_EVENT, move |event| { window.listen_global(AF_EVENT, move |event| {
on_event(app_handler.clone(), event); on_event(app_handler.clone(), event);
}); });
@ -33,18 +41,29 @@ fn main() {
.setup(|_app| { .setup(|_app| {
let splashscreen_window = _app.get_window("splashscreen").unwrap(); let splashscreen_window = _app.get_window("splashscreen").unwrap();
let window = _app.get_window("main").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 // we perform the initialization code on a new task so the app doesn't freeze
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
// initialize your app here instead of sleeping :) // initialize your app here instead of sleeping :)
println!("Initializing...");
std::thread::sleep(std::time::Duration::from_secs(2)); std::thread::sleep(std::time::Duration::from_secs(2));
println!("Done initializing.");
// After it's done, close the splashscreen and display the main window // After it's done, close the splashscreen and display the main window
splashscreen_window.close().unwrap(); splashscreen_window.close().unwrap();
window.show().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(()) Ok(())
}) })
.run(tauri::generate_context!()) .run(tauri::generate_context!())

View File

@ -8,7 +8,7 @@
}, },
"package": { "package": {
"productName": "AppFlowy", "productName": "AppFlowy",
"version": "0.0.0" "version": "0.0.1"
}, },
"tauri": { "tauri": {
"allowlist": { "allowlist": {

View File

@ -1,6 +1,6 @@
import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { useEffect, useMemo } from 'react'; 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 { Theme as ThemeType, ThemeMode } from '$app/stores/reducers/current-user/slice';
import { createTheme } from '@mui/material/styles'; import { createTheme } from '@mui/material/styles';
import { getDesignTokens } from '$app/utils/mui'; import { getDesignTokens } from '$app/utils/mui';
@ -10,6 +10,8 @@ import { UserService } from '$app/application/user/user.service';
export function useUserSetting() { export function useUserSetting() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const loginState = useAppSelector((state) => state.currentUser.loginState);
const { themeMode = ThemeMode.System, theme: themeType = ThemeType.Default } = useAppSelector((state) => { const { themeMode = ThemeMode.System, theme: themeType = ThemeType.Default } = useAppSelector((state) => {
return { return {
themeMode: state.currentUser.userSetting.themeMode, themeMode: state.currentUser.userSetting.themeMode,
@ -22,6 +24,7 @@ export function useUserSetting() {
(themeMode === ThemeMode.System && window.matchMedia('(prefers-color-scheme: dark)').matches); (themeMode === ThemeMode.System && window.matchMedia('(prefers-color-scheme: dark)').matches);
useEffect(() => { useEffect(() => {
if (loginState !== LoginState.Success && loginState !== undefined) return;
void (async () => { void (async () => {
const settings = await UserService.getAppearanceSetting(); const settings = await UserService.getAppearanceSetting();
@ -29,7 +32,7 @@ export function useUserSetting() {
dispatch(currentUserActions.setUserSetting(settings)); dispatch(currentUserActions.setUserSetting(settings));
await i18n.changeLanguage(settings.language); await i18n.changeLanguage(settings.language);
})(); })();
}, [dispatch, i18n]); }, [dispatch, i18n, loginState]);
useEffect(() => { useEffect(() => {
const html = document.documentElement; const html = document.documentElement;

View File

@ -8,6 +8,7 @@ import { useUserSetting } from '$app/AppMain.hooks';
import TrashPage from '$app/views/TrashPage'; import TrashPage from '$app/views/TrashPage';
import DocumentPage from '$app/views/DocumentPage'; import DocumentPage from '$app/views/DocumentPage';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import AppFlowyDevTool from '$app/components/_shared/devtool/AppFlowyDevTool';
function AppMain() { function AppMain() {
const { muiTheme } = useUserSetting(); const { muiTheme } = useUserSetting();
@ -22,6 +23,7 @@ function AppMain() {
</Route> </Route>
</Routes> </Routes>
<Toaster /> <Toaster />
{process.env.NODE_ENV === 'development' && <AppFlowyDevTool />}
</ThemeProvider> </ThemeProvider>
); );
} }

View File

@ -9,6 +9,7 @@ import {
UserEventOpenWorkspace, UserEventOpenWorkspace,
UserEventRenameWorkspace, UserEventRenameWorkspace,
UserEventChangeWorkspaceIcon, UserEventChangeWorkspaceIcon,
UserEventGetAllWorkspace,
} from '@/services/backend/events/flowy-user'; } from '@/services/backend/events/flowy-user';
import { import {
FolderEventCreateView, FolderEventCreateView,
@ -62,17 +63,13 @@ export async function getWorkspaceChildViews(id: string) {
} }
export async function getWorkspaces() { export async function getWorkspaces() {
const result = await FolderEventReadCurrentWorkspace(); const result = await UserEventGetAllWorkspace();
if (result.ok) { if (result.ok) {
const item = result.val; return result.val.items.map((workspace) => ({
id: workspace.workspace_id,
return [ name: workspace.name,
{ }));
id: item.id,
name: item.name,
},
];
} }
return []; return [];
@ -92,12 +89,7 @@ export async function getCurrentWorkspace() {
const result = await FolderEventReadCurrentWorkspace(); const result = await FolderEventReadCurrentWorkspace();
if (result.ok) { if (result.ok) {
const workspace = result.val; return result.val.id;
return {
id: workspace.id,
name: workspace.name,
};
} }
return null; return null;
@ -111,9 +103,7 @@ export async function createCurrentWorkspaceChildView(
const result = await FolderEventCreateView(payload); const result = await FolderEventCreateView(payload);
if (result.ok) { if (result.ok) {
const view = result.val; return result.val;
return view;
} }
return Promise.reject(result.err); return Promise.reject(result.err);

View File

@ -1,33 +1,63 @@
import { SignInPayloadPB, SignUpPayloadPB } from '@/services/backend';
import { import {
UserEventSignInWithEmailPassword, SignUpPayloadPB,
OauthProviderPB,
ProviderTypePB,
OauthSignInPB,
AuthenticatorPB,
SignInPayloadPB,
} from '@/services/backend';
import {
UserEventSignOut, UserEventSignOut,
UserEventSignUp, UserEventSignUp,
UserEventGetOauthURLWithProvider,
UserEventOauthSignIn,
UserEventSignInWithEmailPassword,
} from '@/services/backend/events/flowy-user'; } from '@/services/backend/events/flowy-user';
import { nanoid } from '@reduxjs/toolkit';
import { Log } from '$app/utils/log'; import { Log } from '$app/utils/log';
export const AuthService = { export const AuthService = {
signIn: async (params: { email: string; password: string }) => { getOAuthURL: async (provider: ProviderTypePB) => {
const payload = SignInPayloadPB.fromObject({ email: params.email, password: params.password }); const providerDataRes = await UserEventGetOauthURLWithProvider(
OauthProviderPB.fromObject({
provider,
})
);
const res = await UserEventSignInWithEmailPassword(payload); if (!providerDataRes.ok) {
Log.error(providerDataRes.val.msg);
if (res.ok) { throw new Error(providerDataRes.val.msg);
return res.val;
} }
Log.error(res.val.msg); const providerData = providerDataRes.val;
throw new Error(res.val.msg);
return providerData.oauth_url;
}, },
signUp: async (params: { name: string; email: string; password: string }) => { signInWithOAuth: async ({ uri, deviceId }: { uri: string; deviceId: string }) => {
const deviceId = nanoid(8); 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({ const payload = SignUpPayloadPB.fromObject({
name: params.name, name: params.name,
email: params.email, email: params.email,
password: params.password, password: params.password,
device_id: deviceId, device_id: params.deviceId,
}); });
const res = await UserEventSignUp(payload); const res = await UserEventSignUp(payload);
@ -43,4 +73,20 @@ export const AuthService = {
signOut: () => { signOut: () => {
return UserEventSignOut(); 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;
},
}; };

View File

@ -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: <LoginOutlined />,
name: 'Manual SignIn',
onClick: () => {
setOpenManualSignIn(true);
},
},
{
icon: <VisibilityOff />,
name: 'Hide Dev Tool',
onClick: () => {
setHidden(true);
},
},
],
[]
);
return (
<Portal>
<SpeedDial
hidden={hidden}
direction={'left'}
draggable={true}
title={'AppFlowy Dev Tool'}
ariaLabel='SpeedDial basic example'
sx={{ position: 'absolute', zIndex: 1500, top: 64, right: 16 }}
icon={<SpeedDialIcon className={'text-content-on-fill'} openIcon={<CloseOutlined />} icon={<BuildOutlined />} />}
>
{actions.map((action) => (
<SpeedDialAction onClick={action.onClick} key={action.name} icon={action.icon} tooltipTitle={action.name} />
))}
{openManualSignIn && (
<ManualSignInDialog
open={openManualSignIn}
onClose={() => {
setOpenManualSignIn(false);
}}
/>
)}
</SpeedDial>
</Portal>
);
}
export default AppFlowyDevTool;

View File

@ -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 (
<Dialog
{...props}
sx={{
zIndex: 1500,
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void handleSignIn();
}
}}
>
<DialogContent className={'pt-3'}>
<Tabs
className={'mb-4'}
defaultValue={0}
value={tab}
onChange={(_, value) => {
setTab(value);
}}
>
<Tab value={0} label={'OAuth URI'} />
<Tab value={1} label={'Email & Password'} />
</Tabs>
{tab === 1 ? (
<div className={'flex flex-col gap-3'}>
<TextField
label={'Email'}
size={'small'}
required={true}
placeholder={'name@gmail.com'}
type={'email'}
onChange={(e) => setEmail(e.target.value)}
/>
<TextField
size={'small'}
required={true}
label={'Password'}
placeholder={'Password'}
type={'password'}
onChange={(e) => setPassword(e.target.value)}
/>
<TextField
size={'small'}
label={'Domain(Optional)'}
placeholder={'test.appflowy.cloud'}
onChange={(e) => setDomain(e.target.value)}
/>
</div>
) : (
<TextareaAutosize
value={uri}
autoFocus
className={'max-h-[300px] w-[400px] overflow-hidden rounded-md border border-line-border p-2 text-xs'}
placeholder={'Paste the OAuth URI here'}
minRows={3}
spellCheck={false}
onChange={(e) => {
setUri(e.target.value);
}}
/>
)}
</DialogContent>
<DialogActions className={'mb-4 w-full px-6'}>
<Button
size={'small'}
variant={'outlined'}
color={'inherit'}
onClick={() => props?.onClose?.({}, 'backdropClick')}
>
Cancel
</Button>
<Button disabled={loading} size={'small'} className={'w-auto'} variant={'outlined'} onClick={handleSignIn}>
{loading ? <CircularProgress size={14} /> : 'Sign In'}
</Button>
</DialogActions>
</Dialog>
);
}
export default ManualSignInDialog;

View File

@ -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 (
<div className={'flex w-full flex-col items-center gap-4'}>
<Button className={'w-full rounded-lg border-text-title py-3 text-sm'} color={'inherit'} variant={'outlined'}>
<img src={GoogleIcon} alt={'Google'} className={'mr-2 h-6 w-6'} />
{t('button.signInGoogle')}
</Button>
<Button className={'w-full rounded-lg border-text-title py-3 text-sm'} color={'inherit'} variant={'outlined'}>
<img src={GithubIcon} alt={'Github'} className={'mr-2 h-6 w-6'} />
{t('button.signInGithub')}
</Button>
<Button className={'w-full rounded-lg border-text-title py-3 text-sm'} color={'inherit'} variant={'outlined'}>
<img src={DiscordIcon} alt={'Discord'} className={'mr-2 h-6 w-6'} />
{t('button.signInDiscord')}
</Button>
</div>
);
};

View File

@ -1 +0,0 @@
export * from './LoginButtonGroup';

View File

@ -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 (
<div className={'flex w-full flex-col items-center gap-4'}>
<Button
onClick={() => {
void signIn(ProviderTypePB.Google);
}}
className={'w-full rounded-lg border-text-title py-3 text-sm'}
color={'inherit'}
variant={'outlined'}
>
<img src={GoogleIcon} alt={'Google'} className={'mr-2 h-6 w-6'} />
{t('button.signInGoogle')}
</Button>
<Button
onClick={() => {
void signIn(ProviderTypePB.Github);
}}
className={'w-full rounded-lg border-text-title py-3 text-sm'}
color={'inherit'}
variant={'outlined'}
>
<img src={GithubIcon} alt={'Github'} className={'mr-2 h-6 w-6'} />
{t('button.signInGithub')}
</Button>
<Button
onClick={() => {
void signIn(ProviderTypePB.Discord);
}}
className={'w-full rounded-lg border-text-title py-3 text-sm'}
color={'inherit'}
variant={'outlined'}
>
<img src={DiscordIcon} alt={'Discord'} className={'mr-2 h-6 w-6'} />
{t('button.signInDiscord')}
</Button>
</div>
);
};

View File

@ -3,15 +3,24 @@ import { useAuth } from './auth.hooks';
import Layout from '$app/components/layout/Layout'; import Layout from '$app/components/layout/Layout';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Welcome } from '$app/components/auth/Welcome'; 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 = () => { export const ProtectedRoutes = () => {
const { currentUser, checkUser, subscribeToUser } = useAuth(); const { currentUser, checkUser, subscribeToUser, signInWithOAuth } = useAuth();
const [isLoading, setIsLoading] = useState(true); const dispatch = useAppDispatch();
const isLoading = currentUser?.loginState === LoginState.Loading;
const [checked, setChecked] = useState(false);
const checkUserStatus = useCallback(async () => { const checkUserStatus = useCallback(async () => {
await checkUser(); await checkUser();
setIsLoading(false); setChecked(true);
}, [checkUser]); }, [checkUser]);
useEffect(() => { useEffect(() => {
@ -24,21 +33,73 @@ export const ProtectedRoutes = () => {
} }
}, [currentUser.isAuthenticated, subscribeToUser]); }, [currentUser.isAuthenticated, subscribeToUser]);
if (isLoading) { const onDeepLink = useCallback(async () => {
// It's better to make a fading effect to disappear the loading page if (!isTauri()) return;
return <StartLoading />; const { event } = await import('@tauri-apps/api');
} else {
return <SplashScreen isAuthenticated={currentUser.isAuthenticated} />; // 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 (
<div className={'relative h-screen w-screen'}>
{checked ? (
<SplashScreen isAuthenticated={currentUser.isAuthenticated} />
) : (
<div className={'flex h-screen w-screen items-center justify-center'}>
<Logo className={'h-20 w-20'} />
</div>
)}
{isLoading && <StartLoading />}
</div>
);
}; };
const StartLoading = () => { 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 ( return (
<div className='flex h-screen w-full flex-col items-center justify-center'> <Portal>
<div className='h-40 w-40 justify-center'> <div className={'fixed inset-0 z-[1400] flex h-full w-full items-center justify-center bg-bg-mask bg-opacity-50'}>
<AppflowyLogo className={'h-24 w-24'} /> <CircularProgress />
</div> </div>
</div> </Portal>
); );
}; };
@ -53,3 +114,14 @@ const SplashScreen = ({ isAuthenticated }: { isAuthenticated: boolean }) => {
return <Welcome />; return <Welcome />;
} }
}; };
function parseHash(hash: string) {
const hashParams = new URLSearchParams(hash);
const hashObject: Record<string, string> = {};
for (const [key, value] of hashParams) {
hashObject[key] = value;
}
return hashObject;
}

View File

@ -1,7 +1,7 @@
import { ReactComponent as AppflowyLogo } from '$app/assets/logo.svg'; import { ReactComponent as AppflowyLogo } from '$app/assets/logo.svg';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import { useTranslation } from 'react-i18next'; 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 { useNavigate } from 'react-router-dom';
import { useAuth } from '$app/components/auth/auth.hooks'; import { useAuth } from '$app/components/auth/auth.hooks';

View File

@ -1,12 +1,13 @@
import { currentUserActions } from '$app_reducers/current-user/slice'; import { currentUserActions, LoginState } from '$app_reducers/current-user/slice';
import { AuthenticatorPB, UserNotification, UserProfilePB } from '@/services/backend/events/flowy-user'; import { AuthenticatorPB, ProviderTypePB, UserNotification, UserProfilePB } from '@/services/backend/events/flowy-user';
import { UserService } from '$app/application/user/user.service'; import { UserService } from '$app/application/user/user.service';
import { AuthService } from '$app/application/user/auth.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 { getCurrentWorkspaceSetting } from '$app/application/folder/workspace.service';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { subscribeNotifications } from '$app/application/notification'; import { subscribeNotifications } from '$app/application/notification';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { open } from '@tauri-apps/api/shell';
export const useAuth = () => { export const useAuth = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -31,72 +32,49 @@ export const useAuth = () => {
}; };
}, [dispatch]); }, [dispatch]);
// Check if the user is authenticated const setUser = useCallback(
const checkUser = useCallback(async () => { async (userProfile?: Partial<UserProfilePB>) => {
const userProfile = await UserService.getUserProfile(); 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<UserProfilePB> => {
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 workspaceSetting = await getCurrentWorkspaceSetting();
const isLocal = userProfile.authenticator === AuthenticatorPB.Local;
dispatch( dispatch(
currentUserActions.updateUser({ currentUserActions.updateUser({
id: userProfile.id, id: userProfile.id,
token: userProfile.token, token: userProfile.token,
email: userProfile.email, email: userProfile.email,
displayName: userProfile.name, displayName: userProfile.name,
iconUrl: userProfile.icon_url,
isAuthenticated: true, isAuthenticated: true,
workspaceSetting, workspaceSetting: workspaceSetting,
isLocal,
}) })
); );
return userProfile;
}, },
[dispatch] [dispatch]
); );
const login = useCallback( // Check if the user is authenticated
async (email: string, password: string): Promise<UserProfilePB> => { const checkUser = useCallback(async () => {
const user = await AuthService.signIn({ email, password }); const userProfile = await UserService.getUserProfile();
const { id, token, name } = user;
dispatch( await setUser(userProfile);
currentUserActions.updateUser({
id: id, return userProfile;
token: token, }, [setUser]);
email,
displayName: name, const register = useCallback(
isAuthenticated: true, async (email: string, password: string, name: string): Promise<UserProfilePB> => {
}) const deviceId = currentUser?.deviceId ?? nanoid(8);
); const userProfile = await AuthService.signUp({ deviceId, email, password, name });
return user;
await setUser(userProfile);
return userProfile;
}, },
[dispatch] [setUser, currentUser?.deviceId]
); );
const logout = useCallback(async () => { const logout = useCallback(async () => {
@ -112,5 +90,97 @@ export const useAuth = () => {
await register(fakeEmail, fakePassword, fakeName); await register(fakeEmail, fakePassword, fakeName);
}, [register]); }, [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,
};
}; };

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { workspaceActions, WorkspaceItem } from '$app_reducers/workspace/slice'; import { workspaceActions, WorkspaceItem } from '$app_reducers/workspace/slice';
import { Page, pagesActions, parserViewPBToPage } from '$app_reducers/pages/slice'; import { Page, pagesActions, parserViewPBToPage } from '$app_reducers/pages/slice';
@ -10,29 +10,29 @@ import { useNavigate } from 'react-router-dom';
export function useLoadWorkspaces() { export function useLoadWorkspaces() {
const dispatch = useAppDispatch(); 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 initializeWorkspaces = useCallback(async () => {
const workspaces = await workspaceService.getWorkspaces(); const workspaces = await workspaceService.getWorkspaces();
const currentWorkspace = await workspaceService.getCurrentWorkspace();
const currentWorkspaceId = await workspaceService.getCurrentWorkspace();
dispatch( dispatch(
workspaceActions.initWorkspaces({ workspaceActions.initWorkspaces({
workspaces, workspaces,
currentWorkspace, currentWorkspaceId,
}) })
); );
}, [dispatch]); }, [dispatch]);
useEffect(() => {
void (async () => {
await initializeWorkspaces();
})();
}, [initializeWorkspaces]);
return { return {
workspaces, workspaces,
currentWorkspace, currentWorkspace,
initializeWorkspaces,
}; };
} }
@ -82,8 +82,10 @@ export function useLoadWorkspace(workspace: WorkspaceItem) {
{ {
[FolderNotification.DidUpdateWorkspace]: async (changeset) => { [FolderNotification.DidUpdateWorkspace]: async (changeset) => {
dispatch( dispatch(
workspaceActions.updateCurrentWorkspace({ workspaceActions.updateWorkspace({
id: String(changeset.id),
name: changeset.name, name: changeset.name,
icon: changeset.icon_url,
}) })
); );
}, },

View File

@ -43,8 +43,20 @@ function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: bo
> >
<Tooltip disableInteractive={true} placement={'top-start'} title={t('sideBar.clickToHidePersonal')}> <Tooltip disableInteractive={true} placement={'top-start'} title={t('sideBar.clickToHidePersonal')}>
<div className={'flex items-center gap-2 rounded px-2 py-1 text-xs font-medium hover:bg-fill-list-active'}> <div className={'flex items-center gap-2 rounded px-2 py-1 text-xs font-medium hover:bg-fill-list-active'}>
<WorkplaceAvatar workplaceName={workspace.name} width={18} height={18} className={'text-[70%]'} /> {!workspace.name ? (
{workspace.name} t('sideBar.personal')
) : (
<>
<WorkplaceAvatar
icon={workspace.icon}
workplaceName={workspace.name}
width={18}
height={18}
className={'text-[70%]'}
/>
{workspace.name}
</>
)}
</div> </div>
</Tooltip> </Tooltip>
{showAdd && ( {showAdd && (

View File

@ -1,11 +1,21 @@
import React from 'react'; import React, { useEffect } from 'react';
import NewPageButton from '$app/components/layout/workspace_manager/NewPageButton'; import NewPageButton from '$app/components/layout/workspace_manager/NewPageButton';
import { useLoadWorkspaces } from '$app/components/layout/workspace_manager/Workspace.hooks'; import { useLoadWorkspaces } from '$app/components/layout/workspace_manager/Workspace.hooks';
import Workspace from './Workspace'; import Workspace from './Workspace';
import TrashButton from '$app/components/layout/workspace_manager/TrashButton'; 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() { 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 ( return (
<div className={'workspaces flex h-full select-none flex-col justify-between'}> <div className={'workspaces flex h-full select-none flex-col justify-between'}>

View File

@ -1,7 +1,7 @@
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button'; 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 }) => { export const Login = ({ onBack }: { onBack?: () => void }) => {
const { t } = useTranslation(); const { t } = useTranslation();

View File

@ -4,7 +4,7 @@
import Dialog, { DialogProps } from '@mui/material/Dialog'; import Dialog, { DialogProps } from '@mui/material/Dialog';
import { Settings } from '$app/components/settings/Settings'; 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 DialogTitle from '@mui/material/DialogTitle';
import { IconButton } from '@mui/material'; import { IconButton } from '@mui/material';
import { ReactComponent as CloseIcon } from '$app/assets/close.svg'; 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 DialogContent from '@mui/material/DialogContent';
import { Login } from '$app/components/settings/Login'; import { Login } from '$app/components/settings/Login';
import SwipeableViews from 'react-swipeable-views'; 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) => { export const SettingsDialog = (props: DialogProps) => {
const dispatch = useAppDispatch();
const [routes, setRoutes] = useState<SettingsRoutes[]>([]); const [routes, setRoutes] = useState<SettingsRoutes[]>([]);
const loginState = useAppSelector((state) => state.currentUser.loginState);
const lastLoginStateRef = useRef(loginState);
const { t } = useTranslation(); const { t } = useTranslation();
const handleForward = useCallback((route: SettingsRoutes) => { const handleForward = useCallback((route: SettingsRoutes) => {
setRoutes((prev) => { setRoutes((prev) => {
@ -29,14 +34,28 @@ export const SettingsDialog = (props: DialogProps) => {
setRoutes((prevState) => { setRoutes((prevState) => {
return prevState.slice(0, -1); return prevState.slice(0, -1);
}); });
}, []); dispatch(currentUserActions.resetLoginState());
}, [dispatch]);
const handleClose = () => { const handleClose = useCallback(() => {
dispatch(currentUserActions.resetLoginState());
props?.onClose?.({}, 'backdropClick'); props?.onClose?.({}, 'backdropClick');
}; }, [dispatch, props]);
const currentRoute = routes[routes.length - 1]; 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 ( return (
<Dialog <Dialog
{...props} {...props}

View File

@ -4,9 +4,13 @@ import Button from '@mui/material/Button';
import { Divider } from '@mui/material'; import { Divider } from '@mui/material';
import { DeleteAccount } from '$app/components/settings/my_account/DeleteAccount'; import { DeleteAccount } from '$app/components/settings/my_account/DeleteAccount';
import { SettingsRoutes } from '$app/components/settings/workplace/const'; import { SettingsRoutes } from '$app/components/settings/workplace/const';
import { useAuth } from '$app/components/auth/auth.hooks';
export const AccountLogin = ({ onForward }: { onForward?: (route: SettingsRoutes) => void }) => { export const AccountLogin = ({ onForward }: { onForward?: (route: SettingsRoutes) => void }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { currentUser, logout } = useAuth();
const isLocal = currentUser.isLocal;
return ( return (
<> <>
@ -15,12 +19,17 @@ export const AccountLogin = ({ onForward }: { onForward?: (route: SettingsRoutes
{t('newSettings.myAccount.accountLogin')} {t('newSettings.myAccount.accountLogin')}
</Typography> </Typography>
<Button <Button
onClick={() => { onClick={async () => {
onForward?.(SettingsRoutes.LOGIN); if (isLocal) {
onForward?.(SettingsRoutes.LOGIN);
return;
}
await logout();
}} }}
variant={'contained'} variant={'contained'}
> >
{t('button.login')} {!isLocal ? t('button.logout') : t('button.login')}
</Button> </Button>
<Divider className={'my-4'} /> <Divider className={'my-4'} />
<DeleteAccount /> <DeleteAccount />

View File

@ -1,39 +1,65 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { Divider, OutlinedInput } from '@mui/material'; import { Divider, OutlinedInput } from '@mui/material';
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import Button from '@mui/material/Button'; 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 { changeWorkspaceIcon, renameWorkspace } from '$app/application/folder/workspace.service';
import { notify } from '$app/components/_shared/notify'; import { notify } from '$app/components/_shared/notify';
import { WorkplaceAvatar } from '$app/components/_shared/avatar'; import { WorkplaceAvatar } from '$app/components/_shared/avatar';
import Popover from '@mui/material/Popover'; import Popover from '@mui/material/Popover';
import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; import { PopoverCommonProps } from '$app/components/editor/components/tools/popover';
import EmojiPicker from '$app/components/_shared/emoji_picker/EmojiPicker'; 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 = () => { export const WorkplaceDisplay = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const isLocal = useAppSelector((state) => state.currentUser.isLocal); 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 [name, setName] = useState(workspace?.name ?? '');
const [emojiPickerAnchor, setEmojiPickerAnchor] = useState<HTMLElement | null>(null); const [emojiPickerAnchor, setEmojiPickerAnchor] = useState<HTMLElement | null>(null);
const openEmojiPicker = Boolean(emojiPickerAnchor); 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 () => { const handleSave = async () => {
if (!workspace || !name) return; if (!workspace || !name) return;
try { dispatch(workspaceActions.updateWorkspace({ id: workspace.id, name }));
await renameWorkspace(workspace.id, name);
} catch { await debounceUpdateWorkspace({ id: workspace.id, name });
notify.error(t('newSettings.workplace.renameError'));
}
}; };
const handleEmojiSelect = async (icon: string) => { const handleEmojiSelect = async (icon: string) => {
if (!workspace) return; if (!workspace) return;
try { dispatch(workspaceActions.updateWorkspace({ id: workspace.id, icon }));
await changeWorkspaceIcon(workspace.id, icon);
} catch { await debounceUpdateWorkspace({ id: workspace.id, icon });
notify.error(t('newSettings.workplace.updateIconError'));
}
}; };
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@ -93,6 +119,7 @@ export const WorkplaceDisplay = () => {
workplaceName={name} workplaceName={name}
width={62} width={62}
height={62} height={62}
icon={workspace?.icon}
className={'rounded-lg border border-bg-body p-[2px] hover:opacity-90'} className={'rounded-lg border border-bg-body p-[2px] hover:opacity-90'}
/> />
</Button> </Button>

View File

@ -17,8 +17,15 @@ export enum Theme {
Lavender = 'lavender', Lavender = 'lavender',
} }
export enum LoginState {
Loading = 'loading',
Success = 'success',
Error = 'error',
}
export interface ICurrentUser { export interface ICurrentUser {
id?: number; id?: number;
deviceId?: string;
displayName?: string; displayName?: string;
email?: string; email?: string;
token?: string; token?: string;
@ -27,6 +34,7 @@ export interface ICurrentUser {
workspaceSetting?: WorkspaceSettingPB; workspaceSetting?: WorkspaceSettingPB;
userSetting: UserSetting; userSetting: UserSetting;
isLocal: boolean; isLocal: boolean;
loginState?: LoginState;
} }
const initialState: ICurrentUser | null = { const initialState: ICurrentUser | null = {
@ -39,17 +47,11 @@ export const currentUserSlice = createSlice({
name: 'currentUser', name: 'currentUser',
initialState: initialState, initialState: initialState,
reducers: { reducers: {
checkUser: (state, action: PayloadAction<Partial<ICurrentUser>>) => {
return {
...state,
...action.payload,
};
},
updateUser: (state, action: PayloadAction<Partial<ICurrentUser>>) => { updateUser: (state, action: PayloadAction<Partial<ICurrentUser>>) => {
return { return {
...state, ...state,
...action.payload, ...action.payload,
loginState: LoginState.Success,
}; };
}, },
logout: () => { logout: () => {
@ -61,6 +63,14 @@ export const currentUserSlice = createSlice({
...action.payload, ...action.payload,
}; };
}, },
setLoginState: (state, action: PayloadAction<LoginState>) => {
state.loginState = action.payload;
},
resetLoginState: (state) => {
state.loginState = undefined;
},
}, },
}); });

View File

@ -3,16 +3,17 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface WorkspaceItem { export interface WorkspaceItem {
id: string; id: string;
name: string; name: string;
icon?: string;
} }
interface WorkspaceState { interface WorkspaceState {
workspaces: WorkspaceItem[]; workspaces: WorkspaceItem[];
currentWorkspace: WorkspaceItem | null; currentWorkspaceId: string | null;
} }
const initialState: WorkspaceState = { const initialState: WorkspaceState = {
workspaces: [], workspaces: [],
currentWorkspace: null, currentWorkspaceId: null,
}; };
export const workspaceSlice = createSlice({ export const workspaceSlice = createSlice({
@ -23,18 +24,21 @@ export const workspaceSlice = createSlice({
state, state,
action: PayloadAction<{ action: PayloadAction<{
workspaces: WorkspaceItem[]; workspaces: WorkspaceItem[];
currentWorkspace: WorkspaceItem | null; currentWorkspaceId: string | null;
}> }>
) => { ) => {
return action.payload; return action.payload;
}, },
updateCurrentWorkspace: (state, action: PayloadAction<Partial<WorkspaceItem>>) => { updateWorkspace: (state, action: PayloadAction<Partial<WorkspaceItem>>) => {
if (!state.currentWorkspace) return; const index = state.workspaces.findIndex((workspace) => workspace.id === action.payload.id);
state.currentWorkspace = {
...state.currentWorkspace, if (index !== -1) {
...action.payload, state.workspaces[index] = {
}; ...state.workspaces[index],
...action.payload,
};
}
}, },
}, },
}); });

View File

@ -22,10 +22,29 @@ export const colorMap = {
[ColorEnum.Blue]: 'var(--tint-blue)', [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) { export function renderColor(color: string) {
if (colorMap[color as ColorEnum]) { if (colorMap[color as ColorEnum]) {
return colorMap[color as ColorEnum]; return colorMap[color as ColorEnum];
} }
return color.replace('0x', '#'); return argbToRgba(color);
} }

View File

@ -695,6 +695,7 @@ impl UserManager {
save_user_workspaces(uid, self.db_connection(uid)?, response.user_workspaces())?; save_user_workspaces(uid, self.db_connection(uid)?, response.user_workspaces())?;
event!(tracing::Level::INFO, "Save new user profile to disk"); event!(tracing::Level::INFO, "Save new user profile to disk");
self.authenticate_user.set_session(Some(session.clone()))?; self.authenticate_user.set_session(Some(session.clone()))?;
self self
.save_user(uid, (user_profile, authenticator.clone()).into()) .save_user(uid, (user_profile, authenticator.clone()).into())