1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-11 18:24:43 +02:00

Desktop: Change Joplin Cloud login process to allow MFA via browser (#9445)

Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
This commit is contained in:
pedr 2024-03-09 07:35:54 -03:00 committed by GitHub
parent 75cb639ed2
commit 4d8fcff6d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 347 additions and 124 deletions

View File

@ -191,6 +191,7 @@ packages/app-desktop/gui/IconButton.js
packages/app-desktop/gui/ImportScreen.js
packages/app-desktop/gui/ItemList.js
packages/app-desktop/gui/JoplinCloudConfigScreen.js
packages/app-desktop/gui/JoplinCloudLoginScreen.js
packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.js
packages/app-desktop/gui/KeymapConfig/ShortcutRecorder.js
packages/app-desktop/gui/KeymapConfig/styles/index.js
@ -884,6 +885,7 @@ packages/lib/services/interop/InteropService_Importer_Raw.js
packages/lib/services/interop/Module.test.js
packages/lib/services/interop/Module.js
packages/lib/services/interop/types.js
packages/lib/services/joplinCloudUtils.js
packages/lib/services/joplinServer/personalizedUserContentBaseUrl.js
packages/lib/services/keychain/KeychainService.js
packages/lib/services/keychain/KeychainServiceDriver.dummy.js

2
.gitignore vendored
View File

@ -171,6 +171,7 @@ packages/app-desktop/gui/IconButton.js
packages/app-desktop/gui/ImportScreen.js
packages/app-desktop/gui/ItemList.js
packages/app-desktop/gui/JoplinCloudConfigScreen.js
packages/app-desktop/gui/JoplinCloudLoginScreen.js
packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.js
packages/app-desktop/gui/KeymapConfig/ShortcutRecorder.js
packages/app-desktop/gui/KeymapConfig/styles/index.js
@ -864,6 +865,7 @@ packages/lib/services/interop/InteropService_Importer_Raw.js
packages/lib/services/interop/Module.test.js
packages/lib/services/interop/Module.js
packages/lib/services/interop/types.js
packages/lib/services/joplinCloudUtils.js
packages/lib/services/joplinServer/personalizedUserContentBaseUrl.js
packages/lib/services/keychain/KeychainService.js
packages/lib/services/keychain/KeychainServiceDriver.dummy.js

View File

@ -58,6 +58,15 @@ class ConfigScreenComponent extends React.Component<any, any> {
}
private async checkSyncConfig_() {
if (this.state.settings['sync.target'] === SyncTargetRegistry.nameToId('joplinCloud')) {
const isAuthenticated = await reg.syncTarget().isAuthenticated();
if (!isAuthenticated) {
return this.props.dispatch({
type: 'NAV_GO',
routeName: 'JoplinCloudLogin',
});
}
}
await shared.checkSyncConfig(this, this.state.settings);
}

View File

@ -0,0 +1,32 @@
.login-page {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--joplin-background-color);
color: var(--joplin-color);
> .page-container {
height: calc(100% - (var(--joplin-margin) * 2));
flex: 1;
padding: var(--joplin-config-screen-padding);
> .text {
font-size: var(--joplin-font-size);
}
> .buttons-container {
margin-bottom: calc(var(--joplin-font-size) * 2);
display: flex;
> button:first-child {
margin-right: calc(var(--joplin-font-size) * 2);
}
}
> .bold {
font-weight: bold;
font-size: calc(var(--joplin-font-size) * 1.3);
}
}
}

View File

@ -0,0 +1,115 @@
import { useEffect, useMemo, useReducer, useState } from 'react';
import ButtonBar from './ConfigScreen/ButtonBar';
import { _ } from '@joplin/lib/locale';
import { clipboard } from 'electron';
import Button, { ButtonLevel } from './Button/Button';
const bridge = require('@electron/remote').require('./bridge').default;
import { uuidgen } from '@joplin/lib/uuid';
import { Dispatch } from 'redux';
import { reducer, defaultState, generateApplicationConfirmUrl, checkIfLoginWasSuccessful } from '@joplin/lib/services/joplinCloudUtils';
import { AppState } from '../app.reducer';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('JoplinCloudLoginScreen');
const { connect } = require('react-redux');
interface Props {
dispatch: Dispatch;
joplinCloudWebsite: string;
joplinCloudApi: string;
}
const JoplinCloudScreenComponent = (props: Props) => {
const confirmUrl = (applicationAuthId: string) => `${props.joplinCloudWebsite}/applications/${applicationAuthId}/confirm`;
const applicationAuthUrl = (applicationAuthId: string) => `${props.joplinCloudApi}/api/application_auth/${applicationAuthId}`;
const [intervalIdentifier, setIntervalIdentifier] = useState(undefined);
const [state, dispatch] = useReducer(reducer, defaultState);
const applicatioAuthId = useMemo(() => uuidgen(), []);
const periodicallyCheckForCredentials = () => {
if (intervalIdentifier) return;
const interval = setInterval(async () => {
try {
const response = await checkIfLoginWasSuccessful(applicationAuthUrl(applicatioAuthId));
if (response && response.success) {
dispatch({ type: 'COMPLETED' });
clearInterval(interval);
}
} catch (error) {
logger.error(error);
dispatch({ type: 'ERROR', payload: error.message });
clearInterval(interval);
}
}, 2 * 1000);
setIntervalIdentifier(interval);
};
const onButtonUsed = () => {
if (state.next === 'LINK_USED') {
dispatch({ type: 'LINK_USED' });
}
periodicallyCheckForCredentials();
};
const onAuthorizeClicked = async () => {
const url = await generateApplicationConfirmUrl(confirmUrl(applicatioAuthId));
bridge().openExternal(url);
onButtonUsed();
};
const onCopyToClipboardClicked = async () => {
const url = await generateApplicationConfirmUrl(confirmUrl(applicatioAuthId));
clipboard.writeText(url);
onButtonUsed();
};
useEffect(() => {
return () => {
clearInterval(intervalIdentifier);
};
}, [intervalIdentifier]);
return (
<div className="login-page">
<div className="page-container">
<p className="text">{_('To allow Joplin to synchronise with Joplin Cloud, open this URL in your browser to authorise the application:')}</p>
<div className="buttons-container">
<Button
onClick={onAuthorizeClicked}
title={_('Authorise')}
iconName='fa fa-external-link-alt'
level={ButtonLevel.Primary}
/>
<Button
onClick={onCopyToClipboardClicked}
title={_('Copy link to website')}
iconName='fa fa-clone'
level={ButtonLevel.Secondary}
/>
</div>
<p className={state.className}>{state.message()}
{state.active === 'ERROR' ? (
<span className={state.className}>{state.errorMessage}</span>
) : null}
</p>
{state.active === 'LINK_USED' ? <div id="loading-animation" /> : null}
</div>
<ButtonBar onCancelClick={() => props.dispatch({ type: 'NAV_BACK' })} />
</div>
);
};
const mapStateToProps = (state: AppState) => {
return {
joplinCloudWebsite: state.settings['sync.10.website'],
joplinCloudApi: state.settings['sync.10.path'],
};
};
export default connect(mapStateToProps)(JoplinCloudScreenComponent);

View File

@ -28,6 +28,7 @@ import ImportScreen from './ImportScreen';
const { ResourceScreen } = require('./ResourceScreen.js');
import Navigator from './Navigator';
import WelcomeUtils from '@joplin/lib/WelcomeUtils';
import JoplinCloudLoginScreen from './JoplinCloudLoginScreen';
const { ThemeProvider, StyleSheetManager, createGlobalStyle } = require('styled-components');
const bridge = require('@electron/remote').require('./bridge').default;
@ -224,6 +225,7 @@ class RootComponent extends React.Component<Props, any> {
Main: { screen: MainScreen },
OneDriveLogin: { screen: OneDriveLoginScreen, title: () => _('OneDrive Login') },
DropboxLogin: { screen: DropboxLoginScreen, title: () => _('Dropbox Login') },
JoplinCloudLogin: { screen: JoplinCloudLoginScreen, title: () => _('Joplin Cloud Login') },
Import: { screen: ImportScreen, title: () => _('Import') },
Config: { screen: ConfigScreen, title: () => _('Options') },
Resources: { screen: ResourceScreen, title: () => _('Note attachments') },

View File

@ -1,5 +1,5 @@
import * as React from 'react';
import { useState, useRef, useCallback } from 'react';
import { useRef, useCallback } from 'react';
import { _ } from '@joplin/lib/locale';
import DialogButtonRow from '../DialogButtonRow';
import Dialog from '../Dialog';
@ -9,10 +9,7 @@ import SyncTargetRegistry, { SyncTargetInfo } from '@joplin/lib/SyncTargetRegist
import useElementSize from '@joplin/lib/hooks/useElementSize';
import Button, { ButtonLevel } from '../Button/Button';
import bridge from '../../services/bridge';
import StyledInput from '../style/StyledInput';
import Setting from '@joplin/lib/models/Setting';
import SyncTargetJoplinCloud from '@joplin/lib/SyncTargetJoplinCloud';
import StyledLink from '../style/StyledLink';
interface Props {
themeId: number;
@ -32,10 +29,6 @@ const SyncTargetDescription = styled.div<{ height: number }>`
font-size: 16px;
`;
const CreateAccountLink = styled(StyledLink)`
font-size: 16px;
`;
const ContentRoot = styled.div`
background-color: ${props => props.theme.backgroundColor3};
padding: 1em;
@ -70,7 +63,7 @@ const SyncTargetLogo = styled.img`
margin-right: 0.4em;
`;
const SyncTargetBox = styled.div<{ faded: boolean }>`
const SyncTargetBox = styled.div`
display: flex;
flex: 1;
flex-direction: column;
@ -82,7 +75,7 @@ const SyncTargetBox = styled.div<{ faded: boolean }>`
padding: 2em 2.2em 2em 2.2em;
margin-right: 1em;
max-width: 400px;
opacity: ${props => props.faded ? 0.5 : 1};
opacity: 1;
`;
const FeatureList = styled.div`
@ -117,16 +110,6 @@ const SelectButton = styled(Button)`
font-size: 1em;
`;
const JoplinCloudLoginForm = styled.div`
display: flex;
flex-direction: column;
`;
const FormLabel = styled.label`
font-weight: bold;
margin: 1em 0 0.6em 0;
`;
const SlowSyncWarning = styled.div`
margin-top: 1em;
opacity: 0.8;
@ -152,12 +135,10 @@ const logosImageNames: Record<string, string> = {
'onedrive': 'SyncTarget_OneDrive.svg',
};
type SyncTargetInfoName = 'dropbox' | 'onedrive' | 'joplinCloud';
export default function(props: Props) {
const [showJoplinCloudForm, setShowJoplinCloudForm] = useState(false);
const joplinCloudDescriptionRef = useRef(null);
const [joplinCloudEmail, setJoplinCloudEmail] = useState('');
const [joplinCloudPassword, setJoplinCloudPassword] = useState('');
const [joplinCloudLoginInProgress, setJoplinCloudLoginInProgress] = useState(false);
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
function closeDialog(dispatch: Function) {
@ -192,93 +173,33 @@ export default function(props: Props) {
);
}
const onJoplinCloudEmailChange = useCallback((event: any) => {
setJoplinCloudEmail(event.target.value);
}, []);
const onSelectButtonClick = useCallback(async (name: SyncTargetInfoName) => {
const routes = {
'dropbox': { name: 'DropboxLogin', target: 7 },
'onedrive': { name: 'OneDriveLogin', target: 3 },
'joplinCloud': { name: 'JoplinCloudLogin', target: 10 },
};
const route = routes[name];
if (!route) return; // throw error??
const onJoplinCloudPasswordChange = useCallback((event: any) => {
setJoplinCloudPassword(event.target.value);
}, []);
const onJoplinCloudLoginClick = useCallback(async () => {
setJoplinCloudLoginInProgress(true);
let result = null;
try {
result = await SyncTargetJoplinCloud.checkConfig({
password: () => joplinCloudPassword,
path: () => Setting.value('sync.10.path'),
userContentPath: () => Setting.value('sync.10.userContentPath'),
username: () => joplinCloudEmail,
});
} finally {
setJoplinCloudLoginInProgress(false);
}
if (result.ok) {
Setting.setValue('sync.target', 10);
Setting.setValue('sync.10.username', joplinCloudEmail);
Setting.setValue('sync.10.password', joplinCloudPassword);
await Setting.saveAll();
alert(_('Thank you! Your Joplin Cloud account is now setup and ready to use.'));
closeDialog(props.dispatch);
props.dispatch({
type: 'NAV_GO',
routeName: 'Main',
});
} else {
alert(_('There was an error setting up your Joplin Cloud account. Please verify your email and password and try again. Error was:\n\n%s', result.errorMessage));
}
}, [joplinCloudEmail, joplinCloudPassword, props.dispatch]);
const onJoplinCloudCreateAccountClick = useCallback(() => {
void bridge().openExternal('https://joplinapp.org/plans/');
}, []);
function renderJoplinCloudLoginForm() {
return (
<JoplinCloudLoginForm>
<div style={{ fontSize: '16px' }}>{_('Login below.')} <CreateAccountLink href="#" onClick={onJoplinCloudCreateAccountClick}>{_('Or create an account.')}</CreateAccountLink></div>
<FormLabel>{_('Email')}</FormLabel>
<StyledInput type="email" onChange={onJoplinCloudEmailChange}/>
<FormLabel>{_('Password')}</FormLabel>
<StyledInput type="password" onChange={onJoplinCloudPasswordChange}/>
<SelectButton mt="1.3em" disabled={joplinCloudLoginInProgress} level={ButtonLevel.Primary} title={_('Login')} onClick={onJoplinCloudLoginClick}/>
</JoplinCloudLoginForm>
);
}
const onSelectButtonClick = useCallback(async (name: string) => {
if (name === 'joplinCloud') {
setShowJoplinCloudForm(true);
} else {
Setting.setValue('sync.target', name === 'dropbox' ? 7 : 3);
await Setting.saveAll();
closeDialog(props.dispatch);
props.dispatch({
type: 'NAV_GO',
routeName: name === 'dropbox' ? 'DropboxLogin' : 'OneDriveLogin',
});
}
Setting.setValue('sync.target', route.target);
await Setting.saveAll();
closeDialog(props.dispatch);
props.dispatch({
type: 'NAV_GO',
routeName: route.name,
});
}, [props.dispatch]);
function renderSelectArea(info: SyncTargetInfo) {
if (info.name === 'joplinCloud' && showJoplinCloudForm) {
return renderJoplinCloudLoginForm();
} else {
return (
<SelectButton
level={ButtonLevel.Primary}
title={_('Select')}
onClick={() => onSelectButtonClick(info.name)}
disabled={joplinCloudLoginInProgress}
/>
);
}
return (
<SelectButton
level={ButtonLevel.Primary}
title={_('Select')}
onClick={() => onSelectButtonClick(info.name as SyncTargetInfoName)}
disabled={false}
/>
);
}
function renderSyncTarget(info: SyncTargetInfo) {
@ -289,7 +210,7 @@ export default function(props: Props) {
const logoImageSrc = logoImageName ? `${bridge().buildDir()}/images/${logoImageName}` : '';
const logo = logoImageSrc ? <SyncTargetLogo src={logoImageSrc}/> : null;
const descriptionComp = <SyncTargetDescription height={height} ref={info.name === 'joplinCloud' ? joplinCloudDescriptionRef : null}>{info.description}</SyncTargetDescription>;
const featuresComp = showJoplinCloudForm && info.name === 'joplinCloud' ? null : renderFeatures(info.name);
const featuresComp = renderFeatures(info.name);
const renderSlowSyncWarning = () => {
if (info.name === 'joplinCloud') return null;
@ -297,7 +218,7 @@ export default function(props: Props) {
};
return (
<SyncTargetBox id={key} key={key} faded={showJoplinCloudForm && info.name !== 'joplinCloud'}>
<SyncTargetBox id={key} key={key}>
<SyncTargetTitle>{logo}{info.label}</SyncTargetTitle>
{descriptionComp}
{featuresComp}
@ -328,7 +249,7 @@ export default function(props: Props) {
boxes.push(renderSyncTarget(info));
}
const selfHostingMessage = showJoplinCloudForm ? null : <SelfHostingMessage>Self-hosting? Joplin also supports various self-hosting options such as Nextcloud, WebDAV, AWS S3 and Joplin Server. <a href="#" onClick={onSelfHostingClick}>Click here to select one</a>.</SelfHostingMessage>;
const selfHostingMessage = <SelfHostingMessage>Self-hosting? Joplin also supports various self-hosting options such as Nextcloud, WebDAV, AWS S3 and Joplin Server. <a href="#" onClick={onSelfHostingClick}>Click here to select one</a>.</SelfHostingMessage>;
return (
<ContentRoot>

View File

@ -122,7 +122,7 @@ a {
margin: 40px 20px;
}
.modal-message #loading-animation {
#loading-animation {
margin-right: 20px;
width: 20px;
height: 20px;

View File

@ -6,6 +6,7 @@
@use 'gui/Dropdown/style.scss' as dropdown-control;
@use 'gui/ShareFolderDialog/style.scss' as share-folder-dialog;
@use 'gui/NoteList/style.scss' as note-list;
@use 'gui/JoplinCloudLoginScreen.scss' as joplin-cloud-login-screen;
@use 'gui/NoteListHeader/style.scss' as note-list-header;
@use 'gui/TrashNotification/style.scss' as trash-notification;
@use 'main.scss' as main;

View File

@ -777,6 +777,7 @@ export default class BaseApplication {
// Setting.setValue('sync.10.userContentPath', 'https://joplinusercontent.com');
Setting.setValue('sync.10.path', 'http://api.joplincloud.local:22300');
Setting.setValue('sync.10.userContentPath', 'http://joplinusercontent.local:22300');
Setting.setValue('sync.10.website', 'http://joplincloud.local:22300');
}
// For now always disable fuzzy search due to performance issues:

View File

@ -6,6 +6,7 @@ import { Env } from './models/Setting';
import Logger from '@joplin/utils/Logger';
import personalizedUserContentBaseUrl from './services/joplinServer/personalizedUserContentBaseUrl';
import { getHttpStatusMessage } from './net-utils';
import { getApplicationInformation } from './services/joplinCloudUtils';
const { stringify } = require('query-string');
const logger = Logger.create('JoplinServerApi');
@ -63,13 +64,27 @@ export default class JoplinServerApi {
return personalizedUserContentBaseUrl(userId, this.baseUrl(), this.options_.userContentBaseUrl());
}
private async getClientInfo() {
const { platform, type } = await getApplicationInformation();
const clientInfo = {
platform,
type,
version: shim.appVersion(),
};
return clientInfo;
}
private async session() {
if (this.session_) return this.session_;
const clientInfo = await this.getClientInfo();
try {
this.session_ = await this.exec_('POST', 'api/sessions', null, {
email: this.options_.username(),
password: this.options_.password(),
...clientInfo,
});
return this.session_;

View File

@ -46,7 +46,19 @@ export default class SyncTargetJoplinCloud extends BaseSyncTarget {
}
public async isAuthenticated() {
return true;
try {
const fileApi = await this.fileApi();
const api = fileApi.driver().api();
const sessionId = await api.sessionId();
return !!sessionId;
} catch (error) {
if (error.code === 403) return false;
throw error;
}
}
public authRouteName() {
return 'JoplinCloudLogin';
}
public static requiresPassword() {

View File

@ -705,26 +705,22 @@ class Setting extends BaseModel {
public: false,
storage: SettingStorage.Database,
},
'sync.10.website': {
value: 'https://joplincloud.com',
type: SettingItemType.String,
public: false,
storage: SettingStorage.Database,
},
'sync.10.username': {
value: '',
type: SettingItemType.String,
section: 'sync',
show: (settings: any) => {
return settings['sync.target'] === SyncTargetRegistry.nameToId('joplinCloud');
},
public: true,
label: () => _('Joplin Cloud email'),
public: false,
storage: SettingStorage.File,
},
'sync.10.password': {
value: '',
type: SettingItemType.String,
section: 'sync',
show: (settings: any) => {
return settings['sync.target'] === SyncTargetRegistry.nameToId('joplinCloud');
},
public: true,
label: () => _('Joplin Cloud password'),
public: false,
secure: true,
},

View File

@ -0,0 +1,114 @@
import { Reducer } from 'react';
import Setting from '../models/Setting';
import { ApplicationPlatform, ApplicationType } from '../types';
import shim from '../shim';
import { _ } from '../locale';
type ActionType = 'LINK_USED' | 'COMPLETED' | 'ERROR';
type Action = {
type: ActionType;
payload?: any;
};
type DefaultState = {
className: 'text' | 'bold';
message: ()=> string;
next: ActionType;
active: ActionType | 'INITIAL';
errorMessage?: string;
};
export const defaultState: DefaultState = {
className: 'text',
message: ()=> _('Waiting for authorisation...'),
next: 'LINK_USED',
active: 'INITIAL',
};
export const reducer: Reducer<DefaultState, Action> = (state: DefaultState, action: Action) => {
switch (action.type) {
case 'LINK_USED': {
return {
className: 'text',
message: () => _('If you have already authorised, please wait for the application to sync to Joplin Cloud.'),
next: 'COMPLETED',
active: 'LINK_USED',
};
}
case 'COMPLETED': {
return {
className: 'bold',
message: () => _('You are logged in into Joplin Cloud, you can leave this screen now.'),
active: 'COMPLETED',
next: 'COMPLETED',
};
}
case 'ERROR': {
return {
className: 'text',
message: () => _('You were unable to connect to Joplin Cloud, verify your connection. Error: '),
active: 'ERROR',
next: 'COMPLETED',
errorMessage: action.payload,
};
}
default: {
return state;
}
}
};
export const getApplicationInformation = async () => {
const platformName = await shim.platformName();
switch (platformName) {
case 'ios':
return { type: ApplicationType.Mobile, platform: ApplicationPlatform.Ios };
case 'android':
return { type: ApplicationType.Mobile, platform: ApplicationPlatform.Android };
case 'darwin':
return { type: ApplicationType.Desktop, platform: ApplicationPlatform.MacOs };
case 'win32':
return { type: ApplicationType.Desktop, platform: ApplicationPlatform.Windows };
case 'linux':
return { type: ApplicationType.Desktop, platform: ApplicationPlatform.Linux };
default:
return { type: ApplicationType.Unknown, platform: ApplicationPlatform.Unknown };
}
};
export const generateApplicationConfirmUrl = async (confirmUrl: string) => {
const applicationInfo = await getApplicationInformation();
const searchParams = new URLSearchParams();
searchParams.append('platform', applicationInfo.platform.toString());
searchParams.append('type', applicationInfo.type.toString());
searchParams.append('version', shim.appVersion());
return `${confirmUrl}?${searchParams.toString()}`;
};
// We have isWaitingResponse inside the function to avoid any state from lingering
// after an error occurs. E.g.: if the function would throw an error while isWaitingResponse
// was set to true the next time we call the function the value would still be true.
// The closure function prevents that.
export const checkIfLoginWasSuccessful = async (applicationsUrl: string) => {
let isWaitingResponse = false;
const performLoginRequest = async () => {
if (isWaitingResponse) return undefined;
isWaitingResponse = true;
const response = await fetch(applicationsUrl);
const jsonBody = await response.json();
if (!response.ok || jsonBody.status !== 'finished') {
isWaitingResponse = false;
return undefined;
}
Setting.setValue('sync.10.username', jsonBody.id);
Setting.setValue('sync.10.password', jsonBody.password);
return { success: true };
};
return performLoginRequest();
};

View File

@ -4,6 +4,7 @@ export enum ApplicationPlatform {
Linux = 2,
MacOs = 3,
Android = 4,
Ios = 5,
}
export enum ApplicationType {