This commit is contained in:
Laurent Cozic committed 2026-02-08 16:06:40 +00:00
1 parent f439835281
commit fa07eb3db0
8 files changed
+189 -3

No files matched your search

+1
View File
@@ -878,6 +878,7 @@ packages/app-mobile/components/screens/encryption-config.js
packages/app-mobile/components/screens/folder.js
packages/app-mobile/components/screens/status.js
packages/app-mobile/components/screens/tags.js
packages/app-mobile/components/screens/webdav-oidc-login.js
packages/app-mobile/components/side-menu-content.js
packages/app-mobile/components/testing/TestProviderStack.js
packages/app-mobile/components/voiceTyping/AudioRecordingBanner.js
+1
View File
@@ -851,6 +851,7 @@ packages/app-mobile/components/screens/encryption-config.js
packages/app-mobile/components/screens/folder.js
packages/app-mobile/components/screens/status.js
packages/app-mobile/components/screens/tags.js
packages/app-mobile/components/screens/webdav-oidc-login.js
packages/app-mobile/components/side-menu-content.js
packages/app-mobile/components/testing/TestProviderStack.js
packages/app-mobile/components/voiceTyping/AudioRecordingBanner.js
@@ -739,6 +739,14 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
return false;
};
private handleSettingButtonPress = async (key: string) => {
if (key === 'sync.6.oidcLogin') {
// Save current settings before navigating to login
await shared.saveSettings(this);
await NavService.go('WebDavOidcLogin');
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public settingToComponent(key: string, value: any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -755,6 +763,7 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
themeId={this.props.themeId}
updateSettingValue={updateSettingValue}
styles={this.styles()}
onSettingButtonPress={this.handleSettingButtonPress}
/>
);
}
@@ -1,7 +1,7 @@
import * as React from 'react';
import { UpdateSettingValueCallback } from './types';
import { View, Text } from 'react-native';
import { View, Text, Button } from 'react-native';
import Setting, { AppType } from '@joplin/lib/models/Setting';
import Dropdown from '../../Dropdown';
import { ConfigScreenStyles } from './configScreenStyles';
@@ -23,6 +23,7 @@ interface Props {
themeId: number;
updateSettingValue: UpdateSettingValueCallback;
onSettingButtonPress?: (key: string)=> void;
}
@@ -127,7 +128,21 @@ const SettingComponent: React.FunctionComponent<Props> = props => {
/>
);
} else if (md.type === Setting.TYPE_BUTTON) {
// TODO: Not yet supported
return (
<View key={props.settingId} style={containerStyles.outerContainer}>
<View style={containerStyles.innerContainer}>
<Button
title={md.label()}
onPress={() => {
if (props.onSettingButtonPress) {
props.onSettingButtonPress(props.settingId);
}
}}
/>
</View>
{descriptionComp}
</View>
);
} else if (Setting.value('env') === 'dev') {
throw new Error(`Unsupported setting type: ${md.type}`);
}
@@ -0,0 +1,157 @@
import * as React from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { View, Button } from 'react-native';
import { WebView, WebViewNavigation } from 'react-native-webview';
import { useDispatch, useSelector } from 'react-redux';
import { ScreenHeader } from '../ScreenHeader';
import { reg } from '@joplin/lib/registry';
import { _ } from '@joplin/lib/locale';
import { themeStyle } from '../global-style';
import shim from '@joplin/lib/shim';
import Setting from '@joplin/lib/models/Setting';
import OidcApi from '@joplin/lib/OidcApi';
const parseUri = require('@joplin/lib/parseUri');
const WebDavOidcLoginScreen: React.FC = () => {
const dispatch = useDispatch();
const themeId = useSelector((state: { settings: { theme: number } }) => state.settings.theme);
const [webviewUrl, setWebviewUrl] = useState('');
const [oidcApi, setOidcApi] = useState<OidcApi | null>(null);
const [redirectUri, setRedirectUri] = useState('');
const [oauthState, setOauthState] = useState('');
const authCodeRef = useRef<string | null>(null);
const styles = useMemo(() => {
const theme = themeStyle(themeId);
return {
screen: {
flex: 1,
backgroundColor: theme.backgroundColor,
},
};
}, [themeId]);
useEffect(() => {
const initOidc = async () => {
const api = new OidcApi({
issuerUrl: Setting.value('sync.6.oidcIssuerUrl'),
clientId: Setting.value('sync.6.oidcClientId'),
clientSecret: Setting.value('sync.6.oidcClientSecret'),
ignoreTlsErrors: Setting.value('net.ignoreTlsErrors'),
});
// Use a custom redirect URI that the WebView can intercept
// This is a common pattern for mobile OAuth - using a non-http URI
const redirect = 'joplin://oidc-callback';
const state = Math.random().toString(36).substring(7);
const authCodeUrl = await api.authCodeUrl(redirect, state);
setOidcApi(api);
setRedirectUri(redirect);
setOauthState(state);
setWebviewUrl(authCodeUrl);
};
void initOidc();
}, []);
const handleWebviewLoad = useCallback(async (event: WebViewNavigation) => {
const url = event.url;
// Check if this is our callback URL
if (url.startsWith('joplin://oidc-callback')) {
const parsedUrl = parseUri(url);
const query = parsedUrl.queryKey;
if (query.error) {
const errorDesc = query.error_description || query.error;
alert(`${_('Authentication failed')}: ${errorDesc}`);
dispatch({ type: 'NAV_BACK' });
return;
}
if (!authCodeRef.current && query.code) {
// Verify state to prevent CSRF
if (query.state !== oauthState) {
alert(_('Authentication failed: Invalid state parameter'));
dispatch({ type: 'NAV_BACK' });
return;
}
authCodeRef.current = query.code;
try {
await oidcApi.execTokenRequest(authCodeRef.current, redirectUri);
const auth = oidcApi.auth();
const syncTargetId = Setting.value('sync.target');
Setting.setValue(`sync.${syncTargetId}.oidcAuth`, auth ? JSON.stringify(auth) : '');
// Update the sync target's API with the new auth
const syncTarget = reg.syncTarget(syncTargetId);
if (syncTarget.api && syncTarget.api()) {
syncTarget.api().setAuth(auth);
}
dispatch({ type: 'NAV_BACK' });
void reg.scheduleSync(0);
} catch (error) {
alert(`${_('Could not authenticate with OIDC provider. Please try again')}\n\n${(error as Error).message}`);
}
authCodeRef.current = null;
}
}
}, [dispatch, oidcApi, oauthState, redirectUri]);
const handleWebviewError = useCallback(() => {
alert(_('Could not load page. Please check your connection and try again.'));
}, []);
const handleRetryPress = useCallback(() => {
// Reload the page by setting a temporary URL then back to the auth URL
const authUrl = webviewUrl;
setWebviewUrl('about:blank');
shim.setTimeout(() => {
setWebviewUrl(authUrl);
}, 500);
}, [webviewUrl]);
const handleShouldStartLoadWithRequest = useCallback((request: { url: string }) => {
// Intercept the callback URL
if (request.url.startsWith('joplin://oidc-callback')) {
void handleWebviewLoad({ url: request.url } as WebViewNavigation);
return false;
}
return true;
}, [handleWebviewLoad]);
const source = useMemo(() => ({ uri: webviewUrl }), [webviewUrl]);
return (
<View style={styles.screen}>
<ScreenHeader title={_('WebDAV OIDC Login')} />
<WebView
source={source}
onNavigationStateChange={(event: WebViewNavigation) => {
void handleWebviewLoad(event);
}}
onError={handleWebviewError}
// Allow the custom joplin:// scheme to be intercepted
originWhitelist={['*']}
onShouldStartLoadWithRequest={handleShouldStartLoadWithRequest}
/>
<Button
title={_('Refresh')}
onPress={handleRetryPress}
/>
</View>
);
};
export default WebDavOidcLoginScreen;
+2
View File
@@ -42,6 +42,7 @@ import SearchScreen from './components/screens/SearchScreen';
const { OneDriveLoginScreen } = require('./components/screens/onedrive-login.js');
import EncryptionConfigScreen from './components/screens/encryption-config';
import DropboxLoginScreen from './components/screens/dropbox-login.js';
import WebDavOidcLoginScreen from './components/screens/webdav-oidc-login';
import { MenuProvider } from 'react-native-popup-menu';
import SideMenu, { SideMenuPosition } from './components/SideMenu';
import SideMenuContent from './components/side-menu-content';
@@ -712,6 +713,7 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
OneDriveLogin: { screen: OneDriveLoginScreen },
DropboxLogin: { screen: DropboxLoginScreen },
JoplinCloudLogin: { screen: JoplinCloudLoginScreen },
WebDavOidcLogin: { screen: WebDavOidcLoginScreen },
JoplinServerSamlLogin: { screen: SsoLoginScreen(new SamlShared()) },
EncryptionConfig: { screen: EncryptionConfigScreen },
UpgradeSyncTarget: { screen: UpgradeSyncTargetScreen },
+1
View File
@@ -21,6 +21,7 @@ function historyCanGoBackTo(route: any) {
// it can be buggy to do so, due to incorrectly relying on global state (reg.syncTarget...)
if (route.routeName === 'OneDriveLogin') return false;
if (route.routeName === 'DropboxLogin') return false;
if (route.routeName === 'WebDavOidcLogin') return false;
return true;
}
@@ -319,7 +319,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
value: null as null,
type: SettingItemType.Button,
public: true,
appTypes: [AppType.Desktop],
appTypes: [AppType.Desktop, AppType.Mobile],
section: 'sync',
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- No type is available for settings in this context
show: (settings: any) => {