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:
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
projectPath: frontend/appflowy_tauri
args: "--debug"

View File

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

View File

@ -28,4 +28,6 @@ dist-ssr
**/src/appflowy_app/i18n/translations/
coverage
**/AppFlowy-Collab
**/AppFlowy-Collab
.env

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -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() {
</Route>
</Routes>
<Toaster />
{process.env.NODE_ENV === 'development' && <AppFlowyDevTool />}
</ThemeProvider>
);
}

View File

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

View File

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

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 { 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 <StartLoading />;
} else {
return <SplashScreen isAuthenticated={currentUser.isAuthenticated} />;
}
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 (
<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 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 (
<div className='flex h-screen w-full flex-col items-center justify-center'>
<div className='h-40 w-40 justify-center'>
<AppflowyLogo className={'h-24 w-24'} />
<Portal>
<div className={'fixed inset-0 z-[1400] flex h-full w-full items-center justify-center bg-bg-mask bg-opacity-50'}>
<CircularProgress />
</div>
</div>
</Portal>
);
};
@ -53,3 +114,14 @@ const SplashScreen = ({ isAuthenticated }: { isAuthenticated: boolean }) => {
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 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';

View File

@ -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<UserProfilePB>) => {
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 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<UserProfilePB> => {
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<UserProfilePB> => {
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,
};
};

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

View File

@ -43,8 +43,20 @@ function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: bo
>
<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'}>
<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>
</Tooltip>
{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 { 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 (
<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 { 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();

View File

@ -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<SettingsRoutes[]>([]);
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 (
<Dialog
{...props}

View File

@ -4,9 +4,13 @@ import Button from '@mui/material/Button';
import { Divider } from '@mui/material';
import { DeleteAccount } from '$app/components/settings/my_account/DeleteAccount';
import { SettingsRoutes } from '$app/components/settings/workplace/const';
import { useAuth } from '$app/components/auth/auth.hooks';
export const AccountLogin = ({ onForward }: { onForward?: (route: SettingsRoutes) => 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')}
</Typography>
<Button
onClick={() => {
onForward?.(SettingsRoutes.LOGIN);
onClick={async () => {
if (isLocal) {
onForward?.(SettingsRoutes.LOGIN);
return;
}
await logout();
}}
variant={'contained'}
>
{t('button.login')}
{!isLocal ? t('button.logout') : t('button.login')}
</Button>
<Divider className={'my-4'} />
<DeleteAccount />

View File

@ -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<HTMLElement | null>(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<HTMLInputElement>) => {
@ -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'}
/>
</Button>

View File

@ -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<Partial<ICurrentUser>>) => {
return {
...state,
...action.payload,
};
},
updateUser: (state, action: PayloadAction<Partial<ICurrentUser>>) => {
return {
...state,
...action.payload,
loginState: LoginState.Success,
};
},
logout: () => {
@ -61,6 +63,14 @@ export const currentUserSlice = createSlice({
...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 {
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<Partial<WorkspaceItem>>) => {
if (!state.currentWorkspace) return;
state.currentWorkspace = {
...state.currentWorkspace,
...action.payload,
};
updateWorkspace: (state, action: PayloadAction<Partial<WorkspaceItem>>) => {
const index = state.workspaces.findIndex((workspace) => workspace.id === action.payload.id);
if (index !== -1) {
state.workspaces[index] = {
...state.workspaces[index],
...action.payload,
};
}
},
},
});

View File

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

View File

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