diff --git a/packages/api/methods.ts b/packages/api/methods.ts index a6dec0e743a..5ed53e9c7ec 100644 --- a/packages/api/methods.ts +++ b/packages/api/methods.ts @@ -86,7 +86,10 @@ export function addTransactions( } export function importTransactions(accountId, transactions) { - return send('api/transactions-import', { accountId, transactions }); + return send('api/transactions-import', { + accountId, + transactions, + }); } export function getTransactions(accountId, startDate, endDate) { diff --git a/packages/desktop-client/e2e/settings.test.js-snapshots/Settings-checks-the-page-visuals-1-chromium-linux.png b/packages/desktop-client/e2e/settings.test.js-snapshots/Settings-checks-the-page-visuals-1-chromium-linux.png index f4ebfe318ad..18d6f7f5e57 100644 Binary files a/packages/desktop-client/e2e/settings.test.js-snapshots/Settings-checks-the-page-visuals-1-chromium-linux.png and b/packages/desktop-client/e2e/settings.test.js-snapshots/Settings-checks-the-page-visuals-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/settings.test.js-snapshots/Settings-checks-the-page-visuals-3-chromium-linux.png b/packages/desktop-client/e2e/settings.test.js-snapshots/Settings-checks-the-page-visuals-3-chromium-linux.png index 8d85b9cf4c3..38ed8bfcb43 100644 Binary files a/packages/desktop-client/e2e/settings.test.js-snapshots/Settings-checks-the-page-visuals-3-chromium-linux.png and b/packages/desktop-client/e2e/settings.test.js-snapshots/Settings-checks-the-page-visuals-3-chromium-linux.png differ diff --git a/packages/desktop-client/src/auth/AuthProvider.tsx b/packages/desktop-client/src/auth/AuthProvider.tsx new file mode 100644 index 00000000000..e0d5903783d --- /dev/null +++ b/packages/desktop-client/src/auth/AuthProvider.tsx @@ -0,0 +1,48 @@ +import React, { createContext, useContext, type ReactNode } from 'react'; +import { useSelector } from 'react-redux'; + +import { type State } from 'loot-core/client/state-types'; + +import { useServerURL } from '../components/ServerContext'; + +import { type Permissions } from './types'; + +type AuthContextType = { + hasPermission: (permission?: Permissions) => boolean; +}; + +const AuthContext = createContext(undefined); + +type AuthProviderProps = { + children?: ReactNode; +}; + +export const AuthProvider = ({ children }: AuthProviderProps) => { + const userData = useSelector((state: State) => state.user.data); + const serverUrl = useServerURL(); + + const hasPermission = (permission?: Permissions) => { + if (!permission) { + return true; + } + + return ( + !serverUrl || + userData?.permission?.toUpperCase() === permission?.toUpperCase() + ); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/packages/desktop-client/src/auth/ProtectedRoute.tsx b/packages/desktop-client/src/auth/ProtectedRoute.tsx new file mode 100644 index 00000000000..5dacd055782 --- /dev/null +++ b/packages/desktop-client/src/auth/ProtectedRoute.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState, type ReactElement } from 'react'; +import { useSelector } from 'react-redux'; + +import { type RemoteFile, type SyncedLocalFile } from 'loot-core/types/file'; + +import { View } from '../components/common/View'; +import { useMetadataPref } from '../hooks/useMetadataPref'; + +import { useAuth } from './AuthProvider'; +import { type Permissions } from './types'; + +type ProtectedRouteProps = { + permission: Permissions; + element: ReactElement; + validateOwner?: boolean; +}; + +export const ProtectedRoute = ({ + element, + permission, + validateOwner, +}: ProtectedRouteProps) => { + const { hasPermission } = useAuth(); + const [permissionGranted, setPermissionGranted] = useState(false); + const [cloudFileId] = useMetadataPref('cloudFileId'); + const allFiles = useSelector(state => state.budgets.allFiles || []); + const remoteFiles = allFiles.filter( + (f): f is SyncedLocalFile | RemoteFile => + f.state === 'remote' || f.state === 'synced' || f.state === 'detached', + ); + const currentFile = remoteFiles.find(f => f.cloudFileId === cloudFileId); + const userData = useSelector(state => state.user.data); + + useEffect(() => { + const hasRequiredPermission = hasPermission(permission); + setPermissionGranted(hasRequiredPermission); + + if (!hasRequiredPermission && validateOwner) { + if (currentFile) { + setPermissionGranted( + currentFile.usersWithAccess.some(u => u.userId === userData?.userId), + ); + } + } + }, [ + cloudFileId, + permission, + validateOwner, + hasPermission, + currentFile, + userData, + ]); + + return permissionGranted ? ( + element + ) : ( + +

You don't have permission to view this page

+
+ ); +}; diff --git a/packages/desktop-client/src/auth/types.ts b/packages/desktop-client/src/auth/types.ts new file mode 100644 index 00000000000..7c88e304d7e --- /dev/null +++ b/packages/desktop-client/src/auth/types.ts @@ -0,0 +1,3 @@ +export enum Permissions { + ADMINISTRATOR = 'ADMIN', +} diff --git a/packages/desktop-client/src/browser-preload.browser.js b/packages/desktop-client/src/browser-preload.browser.js index 95c048247c8..3f12e472942 100644 --- a/packages/desktop-client/src/browser-preload.browser.js +++ b/packages/desktop-client/src/browser-preload.browser.js @@ -81,6 +81,8 @@ global.Actual = { }); }, + startOAuthServer: () => {}, + restartElectronServer: () => {}, openFileDialog: async ({ filters = [] }) => { diff --git a/packages/desktop-client/src/components/App.tsx b/packages/desktop-client/src/components/App.tsx index 4c5895c52eb..431bed29fb4 100644 --- a/packages/desktop-client/src/components/App.tsx +++ b/packages/desktop-client/src/components/App.tsx @@ -9,7 +9,7 @@ import { } from 'react-error-boundary'; import { HotkeysProvider } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; import { @@ -20,12 +20,14 @@ import { sync, } from 'loot-core/client/actions'; import { SpreadsheetProvider } from 'loot-core/client/SpreadsheetProvider'; +import { type State } from 'loot-core/client/state-types'; import * as Platform from 'loot-core/src/client/platform'; import { init as initConnection, send, } from 'loot-core/src/platform/client/fetch'; +import { useActions } from '../hooks/useActions'; import { useMetadataPref } from '../hooks/useMetadataPref'; import { installPolyfills } from '../polyfills'; import { styles, hasHiddenScrollbars, ThemeStyle, useTheme } from '../style'; @@ -49,6 +51,8 @@ function AppInner() { const { t } = useTranslation(); const { showBoundary: showErrorBoundary } = useErrorBoundary(); const dispatch = useDispatch(); + const userData = useSelector((state: State) => state.user.data); + const { signOut, addNotification } = useActions(); const maybeUpdate = async (cb?: () => T): Promise => { if (global.Actual.isUpdateReadyForDownload()) { @@ -123,6 +127,22 @@ function AppInner() { global.Actual.updateAppMenu(budgetId); }, [budgetId]); + useEffect(() => { + if (userData?.tokenExpired) { + addNotification({ + type: 'error', + id: 'login-expired', + title: t('Login expired'), + sticky: true, + message: t('Login expired, please login again.'), + button: { + title: t('Go to login'), + action: signOut, + }, + }); + } + }, [userData, userData?.tokenExpired]); + return budgetId ? : ; } diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx index 6983b2708be..8b987636ee3 100644 --- a/packages/desktop-client/src/components/FinancesApp.tsx +++ b/packages/desktop-client/src/components/FinancesApp.tsx @@ -14,6 +14,8 @@ import { addNotification, sync } from 'loot-core/client/actions'; import { type State } from 'loot-core/src/client/state-types'; import * as undo from 'loot-core/src/platform/client/undo'; +import { ProtectedRoute } from '../auth/ProtectedRoute'; +import { Permissions } from '../auth/types'; import { useAccounts } from '../hooks/useAccounts'; import { useLocalPref } from '../hooks/useLocalPref'; import { useMetaThemeColor } from '../hooks/useMetaThemeColor'; @@ -21,6 +23,7 @@ import { useNavigate } from '../hooks/useNavigate'; import { theme } from '../style'; import { getIsOutdated, getLatestVersion } from '../util/versions'; +import { UserAccessPage } from './admin/UserAccess/UserAccessPage'; import { BankSyncStatus } from './BankSyncStatus'; import { View } from './common/View'; import { GlobalKeys } from './GlobalKeys'; @@ -34,7 +37,9 @@ import { Reports } from './reports'; import { LoadingIndicator } from './reports/LoadingIndicator'; import { NarrowAlternate, WideComponent } from './responsive'; import { useResponsive } from './responsive/ResponsiveProvider'; +import { UserDirectoryPage } from './responsive/wide'; import { ScrollProvider } from './ScrollProvider'; +import { useMultiuserEnabled } from './ServerContext'; import { Settings } from './settings'; import { FloatableSidebar } from './sidebar'; import { Titlebar } from './Titlebar'; @@ -93,6 +98,8 @@ export function FinancesApp() { 'flags.updateNotificationShownForVersion', ); + const multiuserEnabled = useMultiuserEnabled(); + useEffect(() => { // Wait a little bit to make sure the sync button will get the // sync start event. This can be improved later. @@ -281,7 +288,29 @@ export function FinancesApp() { } /> - + {multiuserEnabled && ( + } + /> + } + /> + )} + {multiuserEnabled && ( + } + /> + } + /> + )} {/* redirect all other traffic to the budget page */} } /> diff --git a/packages/desktop-client/src/components/LoggedInUser.tsx b/packages/desktop-client/src/components/LoggedInUser.tsx index bba3b2e885a..982b1ae9b42 100644 --- a/packages/desktop-client/src/components/LoggedInUser.tsx +++ b/packages/desktop-client/src/components/LoggedInUser.tsx @@ -1,11 +1,15 @@ -// @ts-strict-ignore import React, { useState, useEffect, useRef, type CSSProperties } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; +import { useLocation } from 'react-router-dom'; import { closeBudget, getUserData, signOut } from 'loot-core/client/actions'; import { type State } from 'loot-core/src/client/state-types'; +import { type RemoteFile, type SyncedLocalFile } from 'loot-core/types/file'; +import { useAuth } from '../auth/AuthProvider'; +import { Permissions } from '../auth/types'; +import { useMetadataPref } from '../hooks/useMetadataPref'; import { useNavigate } from '../hooks/useNavigate'; import { theme, styles } from '../style'; @@ -14,13 +18,15 @@ import { Menu } from './common/Menu'; import { Popover } from './common/Popover'; import { Text } from './common/Text'; import { View } from './common/View'; -import { useServerURL } from './ServerContext'; +import { PrivacyFilter } from './PrivacyFilter'; +import { useMultiuserEnabled, useServerURL } from './ServerContext'; type LoggedInUserProps = { hideIfNoServer?: boolean; style?: CSSProperties; color?: string; }; + export function LoggedInUser({ hideIfNoServer, style, @@ -33,7 +39,18 @@ export function LoggedInUser({ const [loading, setLoading] = useState(true); const [menuOpen, setMenuOpen] = useState(false); const serverUrl = useServerURL(); - const triggerRef = useRef(null); + const triggerRef = useRef(null); + const [budgetId] = useMetadataPref('id'); + const [cloudFileId] = useMetadataPref('cloudFileId'); + const location = useLocation(); + const { hasPermission } = useAuth(); + const multiuserEnabled = useMultiuserEnabled(); + const allFiles = useSelector(state => state.budgets.allFiles || []); + const remoteFiles = allFiles.filter( + f => f.state === 'remote' || f.state === 'synced' || f.state === 'detached', + ) as (SyncedLocalFile | RemoteFile)[]; + const currentFile = remoteFiles.find(f => f.cloudFileId === cloudFileId); + const hasSyncedPrefs = useSelector((state: State) => state.prefs.synced); useEffect(() => { async function init() { @@ -52,7 +69,7 @@ export function LoggedInUser({ navigate('/change-password'); } - async function onMenuSelect(type) { + const handleMenuSelect = async (type: string) => { setMenuOpen(false); switch (type) { @@ -63,6 +80,15 @@ export function LoggedInUser({ await onCloseBudget(); navigate('/login'); break; + case 'user-access': + navigate('/user-access'); + break; + case 'user-directory': + navigate('/user-directory'); + break; + case 'index': + navigate('/'); + break; case 'sign-out': dispatch(signOut()); break; @@ -71,8 +97,9 @@ export function LoggedInUser({ navigate('/config-server'); break; default: + break; } - } + }; function serverMessage() { if (!serverUrl) { @@ -86,9 +113,7 @@ export function LoggedInUser({ return t('Server online'); } - if (hideIfNoServer && !serverUrl) { - return null; - } + if (hideIfNoServer && !serverUrl) return null; if (loading && serverUrl) { return ( @@ -105,16 +130,101 @@ export function LoggedInUser({ ); } + type MenuItem = { + name: string; + text: string; + }; + + const getMenuItems = (): (MenuItem | typeof Menu.line)[] => { + const isAdmin = hasPermission(Permissions.ADMINISTRATOR); + + const baseMenu: (MenuItem | typeof Menu.line)[] = []; + if ( + serverUrl && + !userData?.offline && + userData?.loginMethod === 'password' + ) { + baseMenu.push({ name: 'change-password', text: t('Change password') }); + } + if (serverUrl) { + baseMenu.push({ name: 'sign-out', text: t('Sign out') }); + } + baseMenu.push({ + name: 'config-server', + text: serverUrl ? t('Change server URL') : t('Start using a server'), + }); + + const adminMenu: (MenuItem | typeof Menu.line)[] = []; + if (multiuserEnabled && isAdmin) { + if (!budgetId && location.pathname !== '/') { + adminMenu.push({ name: 'index', text: t('View file list') }); + } else if ( + serverUrl && + !userData?.offline && + location.pathname !== '/user-directory' + ) { + adminMenu.push({ name: 'user-directory', text: t('User Directory') }); + } + } + + if ( + multiuserEnabled && + ((currentFile && userData && currentFile.owner === userData.userId) || + isAdmin) && + serverUrl && + !userData?.offline && + cloudFileId && + location.pathname !== '/user-access' + ) { + adminMenu.push({ + name: 'user-access', + text: t('User Access Management'), + }); + } + + if (adminMenu.length > 0) { + adminMenu.push(Menu.line); + } + + return [...adminMenu, ...baseMenu]; + }; + return ( + {!loading && + multiuserEnabled && + userData && + userData?.displayName && + !hasSyncedPrefs && ( + + + (logged in as: {userData?.displayName}) + + + )} + {!loading && + multiuserEnabled && + userData && + userData?.displayName && + hasSyncedPrefs && ( + + + (logged in as:{' '} + + {userData?.displayName} + + ) + + + )} setMenuOpen(false)} > diff --git a/packages/desktop-client/src/components/ManageRules.tsx b/packages/desktop-client/src/components/ManageRules.tsx index f27660eb438..36643423ba5 100644 --- a/packages/desktop-client/src/components/ManageRules.tsx +++ b/packages/desktop-client/src/components/ManageRules.tsx @@ -30,12 +30,12 @@ import { theme } from '../style'; import { Button } from './common/Button2'; import { Link } from './common/Link'; import { Search } from './common/Search'; +import { SimpleTable } from './common/SimpleTable'; import { Stack } from './common/Stack'; import { Text } from './common/Text'; import { View } from './common/View'; import { RulesHeader } from './rules/RulesHeader'; import { RulesList } from './rules/RulesList'; -import { SimpleTable } from './rules/SimpleTable'; function mapValue( field, diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx index 58ba6f051f4..f0c008c141e 100644 --- a/packages/desktop-client/src/components/Modals.tsx +++ b/packages/desktop-client/src/components/Modals.tsx @@ -28,8 +28,10 @@ import { CoverModal } from './modals/CoverModal'; import { CreateAccountModal } from './modals/CreateAccountModal'; import { CreateEncryptionKeyModal } from './modals/CreateEncryptionKeyModal'; import { CreateLocalAccountModal } from './modals/CreateLocalAccountModal'; +import { EditUserAccess } from './modals/EditAccess'; import { EditFieldModal } from './modals/EditFieldModal'; import { EditRuleModal } from './modals/EditRuleModal'; +import { EditUserFinanceApp } from './modals/EditUser'; import { EnvelopeBalanceMenuModal } from './modals/EnvelopeBalanceMenuModal'; import { EnvelopeBudgetMenuModal } from './modals/EnvelopeBudgetMenuModal'; import { EnvelopeBudgetMonthMenuModal } from './modals/EnvelopeBudgetMonthMenuModal'; @@ -54,7 +56,9 @@ import { ImportYNAB5Modal } from './modals/manager/ImportYNAB5Modal'; import { ManageRulesModal } from './modals/ManageRulesModal'; import { MergeUnusedPayeesModal } from './modals/MergeUnusedPayeesModal'; import { NotesModal } from './modals/NotesModal'; +import { OpenIDEnableModal } from './modals/OpenIDEnableModal'; import { OutOfSyncMigrationsModal } from './modals/OutOfSyncMigrationsModal'; +import { PasswordEnableModal } from './modals/PasswordEnableModal'; import { PayeeAutocompleteModal } from './modals/PayeeAutocompleteModal'; import { ScheduledTransactionMenuModal } from './modals/ScheduledTransactionMenuModal'; import { SelectLinkedAccountsModal } from './modals/SelectLinkedAccountsModal'; @@ -65,6 +69,7 @@ import { TrackingBudgetMenuModal } from './modals/TrackingBudgetMenuModal'; import { TrackingBudgetMonthMenuModal } from './modals/TrackingBudgetMonthMenuModal'; import { TrackingBudgetSummaryModal } from './modals/TrackingBudgetSummaryModal'; import { TransferModal } from './modals/TransferModal'; +import { TransferOwnership } from './modals/TransferOwnership'; import { DiscoverSchedules } from './schedules/DiscoverSchedules'; import { PostsOfflineNotification } from './schedules/PostsOfflineNotification'; import { ScheduleDetails } from './schedules/ScheduleDetails'; @@ -615,9 +620,45 @@ export function Modals() { return ; case 'import-actual': return ; + case 'manager-load-backup': + return ( + + ); case 'out-of-sync-migrations': return ; + case 'edit-access': + return ( + + ); + + case 'edit-user': + return ( + + ); + + case 'transfer-ownership': + return ; + + case 'enable-openid': + return ; + + case 'enable-password-auth': + return ; + default: throw new Error('Unknown modal'); } diff --git a/packages/desktop-client/src/components/ServerContext.tsx b/packages/desktop-client/src/components/ServerContext.tsx index a3db98801ea..c32ed2ac518 100644 --- a/packages/desktop-client/src/components/ServerContext.tsx +++ b/packages/desktop-client/src/components/ServerContext.tsx @@ -1,4 +1,3 @@ -// @ts-strict-ignore import React, { createContext, useState, @@ -8,26 +7,64 @@ import React, { type ReactNode, } from 'react'; +import { t } from 'i18next'; + +import { addNotification } from 'loot-core/client/actions'; import { send } from 'loot-core/src/platform/client/fetch'; +import { type Handlers } from 'loot-core/types/handlers'; + +type LoginMethods = { + method: string; + displayName: string; + active: boolean; +}; type ServerContextValue = { url: string | null; version: string; + multiuserEnabled: boolean; + availableLoginMethods: LoginMethods[]; setURL: ( url: string, opts?: { validate?: boolean }, ) => Promise<{ error?: string }>; + refreshLoginMethods: () => Promise; + setMultiuserEnabled: (enabled: boolean) => void; + setLoginMethods: (methods: LoginMethods[]) => void; }; const ServerContext = createContext({ url: null, version: '', + multiuserEnabled: false, + availableLoginMethods: [], setURL: () => Promise.reject(new Error('ServerContext not initialized')), + refreshLoginMethods: () => + Promise.reject(new Error('ServerContext not initialized')), + setMultiuserEnabled: () => {}, + setLoginMethods: () => {}, }); export const useServerURL = () => useContext(ServerContext).url; export const useServerVersion = () => useContext(ServerContext).version; export const useSetServerURL = () => useContext(ServerContext).setURL; +export const useMultiuserEnabled = () => { + const { multiuserEnabled } = useContext(ServerContext); + const loginMethod = useLoginMethod(); + return multiuserEnabled && loginMethod === 'openid'; +}; + +export const useLoginMethod = () => { + const availableLoginMethods = useContext(ServerContext).availableLoginMethods; + + if (!availableLoginMethods || availableLoginMethods.length === 0) { + return 'password'; + } + + return availableLoginMethods.filter(m => m.active)[0]?.method ?? 'password'; +}; +export const useAvailableLoginMethods = () => + useContext(ServerContext).availableLoginMethods; async function getServerVersion() { const result = await send('get-server-version'); @@ -37,9 +74,22 @@ async function getServerVersion() { return ''; } +export const useRefreshLoginMethods = () => + useContext(ServerContext).refreshLoginMethods; + +export const useSetMultiuserEnabled = () => + useContext(ServerContext).setMultiuserEnabled; + +export const useSetLoginMethods = () => + useContext(ServerContext).setLoginMethods; + export function ServerProvider({ children }: { children: ReactNode }) { const [serverURL, setServerURL] = useState(''); const [version, setVersion] = useState(''); + const [multiuserEnabled, setMultiuserEnabled] = useState(false); + const [availableLoginMethods, setAvailableLoginMethods] = useState< + LoginMethods[] + >([]); useEffect(() => { async function run() { @@ -49,6 +99,38 @@ export function ServerProvider({ children }: { children: ReactNode }) { run(); }, []); + const refreshLoginMethods = useCallback(async () => { + if (serverURL) { + const data: Awaited> = + await send('subscribe-get-login-methods'); + if ('error' in data) { + addNotification({ + type: 'error', + title: t('Failed to refresh login methods'), + message: data.error ?? t('Unknown'), + }); + setAvailableLoginMethods([]); + } else if (data.methods) { + setAvailableLoginMethods(data.methods); + } else { + setAvailableLoginMethods([]); + } + } + }, [serverURL]); + + useEffect(() => { + if (serverURL) { + send('subscribe-needs-bootstrap').then( + (data: Awaited>) => { + if ('hasServer' in data && data.hasServer) { + setAvailableLoginMethods(data.availableLoginMethods); + setMultiuserEnabled(data.multiuser); + } + }, + ); + } + }, [serverURL]); + const setURL = useCallback( async (url: string, opts: { validate?: boolean } = {}) => { const { error } = await send('set-server-url', { ...opts, url }); @@ -65,8 +147,13 @@ export function ServerProvider({ children }: { children: ReactNode }) { {children} diff --git a/packages/desktop-client/src/components/admin/UserAccess/UserAccess.tsx b/packages/desktop-client/src/components/admin/UserAccess/UserAccess.tsx new file mode 100644 index 00000000000..5183f86bded --- /dev/null +++ b/packages/desktop-client/src/components/admin/UserAccess/UserAccess.tsx @@ -0,0 +1,292 @@ +// @ts-strict-ignore +import React, { + useState, + useEffect, + useCallback, + useMemo, + type SetStateAction, + type Dispatch, + type CSSProperties, +} from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; + +import { addNotification, pushModal } from 'loot-core/client/actions'; +import { send } from 'loot-core/src/platform/client/fetch'; +import * as undo from 'loot-core/src/platform/client/undo'; +import { type Handlers } from 'loot-core/types/handlers'; +import { type UserAvailable } from 'loot-core/types/models'; +import { type UserAccessEntity } from 'loot-core/types/models/userAccess'; + +import { useMetadataPref } from '../../../hooks/useMetadataPref'; +import { SvgLockOpen } from '../../../icons/v1'; +import { SvgLockClosed } from '../../../icons/v2'; +import { theme } from '../../../style'; +import { Button } from '../../common/Button2'; +import { Link } from '../../common/Link'; +import { Search } from '../../common/Search'; +import { SimpleTable } from '../../common/SimpleTable'; +import { Text } from '../../common/Text'; +import { View } from '../../common/View'; + +import { UserAccessHeader } from './UserAccessHeader'; +import { UserAccessRow } from './UserAccessRow'; + +type ManageUserAccessContentProps = { + isModal: boolean; + setLoading?: Dispatch>; +}; + +function UserAccessContent({ + isModal, + setLoading, +}: ManageUserAccessContentProps) { + const { t } = useTranslation(); + + const [allAccess, setAllAccess] = useState([]); + const [page, setPage] = useState(0); + const [filter, setFilter] = useState(''); + const [cloudFileId] = useMetadataPref('cloudFileId'); + + const filteredAccesses = useMemo( + () => + (filter === '' + ? allAccess + : allAccess.filter( + access => + access?.displayName + .toLowerCase() + .includes(filter.toLowerCase()) ?? false, + ) + ).slice(0, 100 + page * 50), + [allAccess, filter, page], + ); + const [hoveredUserAccess, setHoveredUserAccess] = useState(null); + + const onSearchChange = useCallback( + (value: string) => { + setFilter(value); + setPage(0); + }, + [setFilter], + ); + + const loadAccess = useCallback(async () => { + setLoading(true); + const data: Awaited> = + await send('access-get-available-users', cloudFileId as string); + + const sortUsers = (a: UserAvailable, b: UserAvailable) => { + if ((a.owner ?? 0) !== (b.owner ?? 0)) { + return (b.owner ?? 0) - (a.owner ?? 0); + } + return (a.displayName ?? '').localeCompare(b.displayName ?? ''); + }; + + if ('error' in data) { + addNotification({ + type: 'error', + id: 'error', + title: t('Error getting available users'), + sticky: true, + message: data.error, + }); + return []; + } + + const loadedAccess = data + .map(user => ({ + ...user, + displayName: user.displayName || user.userName, + })) + .sort(sortUsers); + + setAllAccess(loadedAccess); + return loadedAccess; + }, [cloudFileId, setLoading, t]); + + const loadOwner = useCallback(async () => { + const file: Awaited> = + (await send('get-user-file-info', cloudFileId as string)) ?? {}; + const owner = file?.usersWithAccess.filter(user => user.owner); + + if (owner.length > 0) { + return owner[0]; + } + + return null; + }, [cloudFileId]); + + useEffect(() => { + async function loadData() { + try { + await loadAccess(); + } catch (error) { + console.error('Error loading user access data:', error); + } finally { + setLoading(false); + } + } + + loadData(); + + return () => { + undo.setUndoState('openModal', null); + }; + }, [setLoading, loadAccess, loadOwner]); + + function loadMore() { + setPage(page => page + 1); + } + + const onHover = useCallback(id => { + setHoveredUserAccess(id); + }, []); + + return ( + + + + + + Determine which users can view and manage your budgets..{' '} + + Learn more + + + + + + + + + + + + + + + + { + await loadAccess(); + setLoading(false); + }} + /> + + + ); +} + +type ManageUsersProps = { + isModal: boolean; + setLoading?: Dispatch>; +}; + +export function UserAccess({ + isModal, + setLoading = () => {}, +}: ManageUsersProps) { + return ; +} + +type UsersAccessListProps = { + accesses: UserAccessEntity[]; + hoveredAccess?: string; + onHover?: (id: string | null) => void; +}; + +function UserAccessList({ + accesses, + hoveredAccess, + onHover, +}: UsersAccessListProps) { + if (accesses.length === 0) { + return null; + } + + return ( + + {accesses.map(access => { + const hovered = hoveredAccess === access.userId; + + return ( + + ); + })} + + ); +} + +type LockToggleProps = { + style: CSSProperties; + onToggleSave: () => void; +}; + +function LockToggle({ style, onToggleSave }: LockToggleProps) { + const [hover, setHover] = useState(false); + const dispatch = useDispatch(); + + return ( + + ); +} diff --git a/packages/desktop-client/src/components/admin/UserAccess/UserAccessHeader.tsx b/packages/desktop-client/src/components/admin/UserAccess/UserAccessHeader.tsx new file mode 100644 index 00000000000..66f0a330643 --- /dev/null +++ b/packages/desktop-client/src/components/admin/UserAccess/UserAccessHeader.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Cell, TableHeader } from '../../table'; + +export function UserAccessHeader() { + const { t } = useTranslation(); + + return ( + + + + + + ); +} diff --git a/packages/desktop-client/src/components/admin/UserAccess/UserAccessPage.tsx b/packages/desktop-client/src/components/admin/UserAccess/UserAccessPage.tsx new file mode 100644 index 00000000000..3760768e48c --- /dev/null +++ b/packages/desktop-client/src/components/admin/UserAccess/UserAccessPage.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Page } from '../../Page'; + +import { UserAccess } from './UserAccess'; + +export function UserAccessPage() { + const { t } = useTranslation(); + + return ( + + + + ); +} diff --git a/packages/desktop-client/src/components/admin/UserAccess/UserAccessRow.tsx b/packages/desktop-client/src/components/admin/UserAccess/UserAccessRow.tsx new file mode 100644 index 00000000000..7473156df60 --- /dev/null +++ b/packages/desktop-client/src/components/admin/UserAccess/UserAccessRow.tsx @@ -0,0 +1,148 @@ +// @ts-strict-ignore +import React, { memo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { send } from 'loot-core/platform/client/fetch'; +import { getUserAccessErrors } from 'loot-core/shared/errors'; +import { type UserAvailable } from 'loot-core/types/models'; + +import { useActions } from '../../../hooks/useActions'; +import { useMetadataPref } from '../../../hooks/useMetadataPref'; +import { theme } from '../../../style'; +import { View } from '../../common/View'; +import { Checkbox } from '../../forms'; +import { Row, Cell } from '../../table'; + +type UserAccessProps = { + access: UserAvailable; + hovered?: boolean; + onHover?: (id: string | null) => void; +}; + +export const UserAccessRow = memo( + ({ access, hovered, onHover }: UserAccessProps) => { + const { t } = useTranslation(); + + const backgroundFocus = hovered; + const [marked, setMarked] = useState( + access.owner === 1 || access.haveAccess === 1, + ); + const [cloudFileId] = useMetadataPref('cloudFileId'); + const actions = useActions(); + + const handleAccessToggle = async () => { + const newValue = !marked; + if (newValue) { + const { error } = await send('access-add', { + fileId: cloudFileId as string, + userId: access.userId, + }); + + if (error) { + handleError(error); + } + } else { + const { someDeletionsFailed } = await send('access-delete-all', { + fileId: cloudFileId as string, + ids: [access.userId], + }); + + if (someDeletionsFailed) { + actions.addNotification({ + type: 'error', + title: t('Access Revocation Incomplete'), + message: t( + 'Some access permissions were not revoked successfully.', + ), + sticky: true, + }); + } + } + setMarked(newValue); + }; + + const handleError = (error: string) => { + if (error === 'token-expired') { + actions.addNotification({ + type: 'error', + id: 'login-expired', + title: t('Login expired'), + sticky: true, + message: getUserAccessErrors(error), + button: { + title: t('Go to login'), + action: () => { + actions.signOut(); + }, + }, + }); + } else { + actions.addNotification({ + type: 'error', + title: t('Something happened while editing access'), + sticky: true, + message: getUserAccessErrors(error), + }); + } + }; + + return ( + onHover && onHover(access.userId)} + onMouseLeave={() => onHover && onHover(null)} + > + + + + + + {access.displayName ?? access.userName} + + + + + + + + + ); + }, +); + +UserAccessRow.displayName = 'UserRow'; diff --git a/packages/desktop-client/src/components/admin/UserDirectory/UserDirectory.tsx b/packages/desktop-client/src/components/admin/UserDirectory/UserDirectory.tsx new file mode 100644 index 00000000000..f5b59d57a41 --- /dev/null +++ b/packages/desktop-client/src/components/admin/UserDirectory/UserDirectory.tsx @@ -0,0 +1,370 @@ +// @ts-strict-ignore +import { + useState, + useEffect, + useCallback, + useMemo, + type SetStateAction, + type Dispatch, + type CSSProperties, +} from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; + +import { pushModal } from 'loot-core/src/client/actions/modals'; +import { send } from 'loot-core/src/platform/client/fetch'; +import * as undo from 'loot-core/src/platform/client/undo'; +import { + type NewUserEntity, + type UserEntity, +} from 'loot-core/types/models/user'; + +import { useActions } from '../../../hooks/useActions'; +import { SelectedProvider, useSelected } from '../../../hooks/useSelected'; +import { theme } from '../../../style'; +import { Button } from '../../common/Button2'; +import { Link } from '../../common/Link'; +import { Search } from '../../common/Search'; +import { SimpleTable } from '../../common/SimpleTable'; +import { Stack } from '../../common/Stack'; +import { Text } from '../../common/Text'; +import { View } from '../../common/View'; + +import { UserDirectoryHeader } from './UserDirectoryHeader'; +import { UserDirectoryRow } from './UserDirectoryRow'; + +type ManageUserDirectoryContentProps = { + isModal: boolean; + setLoading?: Dispatch>; +}; + +function useGetUserDirectoryErrors() { + const { t } = useTranslation(); + + function getUserDirectoryErrors(reason) { + switch (reason) { + case 'unauthorized': + return t('You are not logged in.'); + case 'token-expired': + return t('Login expired, please login again.'); + case 'user-cant-be-empty': + return t( + 'Please enter a value for the username; the field cannot be empty.', + ); + case 'role-cant-be-empty': + return t('Select a role; the field cannot be empty.'); + case 'user-already-exists': + return t( + 'The username you entered already exists. Please choose a different username.', + ); + case 'not-all-deleted': + return t( + 'Not all users were deleted. Check if one of the selected users is the server owner.', + ); + case 'role-does-not-exists': + return t( + 'Selected role does not exists, possibly a bug? Visit https://actualbudget.org/contact/ for support.', + ); + default: + return t( + 'An internal error occurred, sorry! Visit https://actualbudget.org/contact/ for support. (ref: {{reason}})', + { reason }, + ); + } + } + + return { getUserDirectoryErrors }; +} + +function UserDirectoryContent({ + isModal, + setLoading, +}: ManageUserDirectoryContentProps) { + const { t } = useTranslation(); + + const [allUsers, setAllUsers] = useState([]); + const [page, setPage] = useState(0); + const [filter, setFilter] = useState(''); + const dispatch = useDispatch(); + const actions = useActions(); + + const { getUserDirectoryErrors } = useGetUserDirectoryErrors(); + + const filteredUsers = useMemo(() => { + return ( + filter === '' + ? allUsers + : allUsers.filter( + user => + user.displayName.toLowerCase().includes(filter.toLowerCase()) || + user.userName.toLowerCase().includes(filter.toLowerCase()) || + user.role.toLowerCase().includes(filter.toLowerCase()), + ) + ).slice(0, 100 + page * 50); + }, [allUsers, filter, page]); + const selectedInst = useSelected('manage-users', allUsers, []); + const [hoveredUser, setHoveredUser] = useState(null); + + const onSearchChange = useCallback( + (value: string) => { + setFilter(value); + setPage(0); + }, + [setFilter], + ); + + const loadUsers = useCallback(async () => { + setLoading(true); + + const loadedUsers = (await send('users-get')) ?? []; + + setAllUsers(loadedUsers); + setLoading(false); + return loadedUsers; + }, [setLoading]); + + useEffect(() => { + async function loadData() { + await loadUsers(); + setLoading(false); + } + + loadData(); + + return () => { + undo.setUndoState('openModal', null); + }; + }, [setLoading, loadUsers]); + + function loadMore() { + setPage(page => page + 1); + } + + const onDeleteSelected = useCallback(async () => { + setLoading(true); + const { error } = await send('user-delete-all', [...selectedInst.items]); + + if (error) { + if (error === 'token-expired') { + actions.addNotification({ + type: 'error', + id: 'login-expired', + title: t('Login expired'), + sticky: true, + message: getUserDirectoryErrors(error), + button: { + title: t('Go to login'), + action: () => actions.signOut(), + }, + }); + } else { + actions.addNotification({ + type: 'error', + title: t('Something happened while deleting users'), + sticky: true, + message: getUserDirectoryErrors(error), + }); + } + } + + await loadUsers(); + selectedInst.dispatch({ type: 'select-none' }); + setLoading(false); + }, [actions, loadUsers, selectedInst, setLoading, getUserDirectoryErrors, t]); + + const onEditUser = useCallback( + user => { + dispatch( + pushModal('edit-user', { + user, + onSave: async () => { + await loadUsers(); + setLoading(false); + }, + }), + ); + }, + [dispatch, loadUsers, setLoading], + ); + + function onAddUser() { + const user: NewUserEntity = { + userName: '', + role: null, + enabled: true, + displayName: '', + }; + + dispatch( + pushModal('edit-user', { + user, + onSave: async () => { + await loadUsers(); + setLoading(false); + }, + }), + ); + } + + const onHover = useCallback(id => { + setHoveredUser(id); + }, []); + + return ( + + + + + + + Manage and view users who can create new budgets or be invited + to access existing ones.{' '} + + Learn more + + + + + + + + + + + + {filteredUsers.length === 0 ? ( + + ) : ( + + )} + + + + + {selectedInst.items.size > 0 && ( + + )} + + + + + + ); +} + +type EmptyMessageProps = { + text: string; + style?: CSSProperties; +}; + +function EmptyMessage({ text, style }: EmptyMessageProps) { + return ( + + {text} + + ); +} + +type ManageUsersProps = { + isModal: boolean; + setLoading?: Dispatch>; +}; + +export function UserDirectory({ + isModal, + setLoading = () => {}, +}: ManageUsersProps) { + return ; +} + +type UsersListProps = { + users: UserEntity[]; + selectedItems: Set; + hoveredUser?: string; + onHover?: (id: string | null) => void; + onEditUser?: (rule: UserEntity) => void; +}; + +function UsersList({ + users, + selectedItems, + hoveredUser, + onHover, + onEditUser, +}: UsersListProps) { + if (users.length === 0) { + return null; + } + + return ( + + {users.map(user => { + const hovered = hoveredUser === user.id; + const selected = selectedItems.has(user.id); + + return ( + + ); + })} + + ); +} diff --git a/packages/desktop-client/src/components/admin/UserDirectory/UserDirectoryHeader.tsx b/packages/desktop-client/src/components/admin/UserDirectory/UserDirectoryHeader.tsx new file mode 100644 index 00000000000..6cc105f6f74 --- /dev/null +++ b/packages/desktop-client/src/components/admin/UserDirectory/UserDirectoryHeader.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + useSelectedItems, + useSelectedDispatch, +} from '../../../hooks/useSelected'; +import { SelectCell, Cell, TableHeader } from '../../table'; + +export function UserDirectoryHeader() { + const { t } = useTranslation(); + + const selectedItems = useSelectedItems(); + const dispatchSelected = useSelectedDispatch(); + + return ( + + 0} + onSelect={e => + dispatchSelected({ type: 'select-all', isRangeSelect: e.shiftKey }) + } + /> + + + + + + + + ); +} diff --git a/packages/desktop-client/src/components/admin/UserDirectory/UserDirectoryPage.tsx b/packages/desktop-client/src/components/admin/UserDirectory/UserDirectoryPage.tsx new file mode 100644 index 00000000000..ec8fd223f6e --- /dev/null +++ b/packages/desktop-client/src/components/admin/UserDirectory/UserDirectoryPage.tsx @@ -0,0 +1,49 @@ +import React, { type ReactNode } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { useNavigate } from '../../../hooks/useNavigate'; +import { Button } from '../../common/Button2'; +import { View } from '../../common/View'; +import { Page } from '../../Page'; + +import { UserDirectory } from './UserDirectory'; + +export function UserDirectoryPage({ + bottomContent, +}: { + bottomContent?: ReactNode; +}) { + const { t } = useTranslation(); + + return ( + + + + {bottomContent} + + + ); +} + +export function BackToFileListButton() { + const navigate = useNavigate(); + + return ( + + ); +} diff --git a/packages/desktop-client/src/components/admin/UserDirectory/UserDirectoryRow.tsx b/packages/desktop-client/src/components/admin/UserDirectory/UserDirectoryRow.tsx new file mode 100644 index 00000000000..391b9f33912 --- /dev/null +++ b/packages/desktop-client/src/components/admin/UserDirectory/UserDirectoryRow.tsx @@ -0,0 +1,144 @@ +// @ts-strict-ignore +import React, { memo } from 'react'; +import { Trans } from 'react-i18next'; + +import { PossibleRoles, type UserEntity } from 'loot-core/types/models/user'; + +import { useSelectedDispatch } from '../../../hooks/useSelected'; +import { theme } from '../../../style'; +import { Button } from '../../common/Button2'; +import { View } from '../../common/View'; +import { Checkbox } from '../../forms'; +import { SelectCell, Row, Cell } from '../../table'; + +type UserDirectoryProps = { + user: UserEntity; + hovered?: boolean; + selected?: boolean; + onHover?: (id: string | null) => void; + onEditUser?: (user: UserEntity) => void; +}; + +export const UserDirectoryRow = memo( + ({ user, hovered, selected, onHover, onEditUser }: UserDirectoryProps) => { + const dispatchSelected = useSelectedDispatch(); + const borderColor = selected ? theme.tableBorderSelected : 'none'; + const backgroundFocus = hovered; + + return ( + onHover && onHover(user.id)} + onMouseLeave={() => onHover && onHover(null)} + > + {!user.owner && ( + { + dispatchSelected({ + type: 'select', + id: user.id, + isRangeSelect: e.shiftKey, + }); + }} + selected={selected} + /> + )} + {user.owner && ( + + )} + + + + {user.userName} + + + + + + {user.displayName} + + + + + {PossibleRoles[user.role]} + + + + + + + + + + + + + + + ); + }, +); + +UserDirectoryRow.displayName = 'UserRow'; diff --git a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.test.tsx b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.test.tsx index 226f1b1c232..cd9f20a122c 100644 --- a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.test.tsx +++ b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.test.tsx @@ -6,6 +6,7 @@ import { generateAccount } from 'loot-core/src/mocks'; import { TestProvider } from 'loot-core/src/mocks/redux'; import type { AccountEntity, PayeeEntity } from 'loot-core/types/models'; +import { AuthProvider } from '../../auth/AuthProvider'; import { useCommonPayees } from '../../hooks/usePayees'; import { ResponsiveProvider } from '../responsive/ResponsiveProvider'; @@ -63,17 +64,19 @@ function renderPayeeAutocomplete( render( - -
- -
-
+ + +
+ +
+
+
, ); return screen.getByTestId('autocomplete-test'); diff --git a/packages/desktop-client/src/components/common/Button.tsx b/packages/desktop-client/src/components/common/Button.tsx index d0662efe6b4..fdad12cd3bc 100644 --- a/packages/desktop-client/src/components/common/Button.tsx +++ b/packages/desktop-client/src/components/common/Button.tsx @@ -7,6 +7,8 @@ import React, { import { css } from '@emotion/css'; +import { useAuth } from '../../auth/AuthProvider'; +import { type Permissions } from '../../auth/types'; import { AnimatedLoading } from '../../icons/AnimatedLoading'; import { styles, theme } from '../../style'; @@ -25,6 +27,7 @@ type ButtonProps = HTMLProps & { textStyle?: CSSProperties; bounce?: boolean; as?: ElementType; + permission?: Permissions; }; type ButtonType = 'normal' | 'primary' | 'bare' | 'menu' | 'menuSelected'; @@ -138,10 +141,13 @@ export const Button = forwardRef( activeStyle, bounce = true, as = 'button', + permission, ...nativeProps - }, + }: ButtonProps, ref, ) => { + const { hasPermission } = useAuth(); + const typeWithDisabled: ButtonType | `${ButtonType}Disabled` = disabled ? `${type}Disabled` : type; @@ -186,7 +192,7 @@ export const Button = forwardRef( {...(typeof as === 'string' ? { className: css(buttonStyle) } : { style: buttonStyle })} - disabled={disabled} + disabled={disabled ? disabled : !hasPermission(permission)} type={isSubmit ? 'submit' : 'button'} {...nativeProps} > diff --git a/packages/desktop-client/src/components/common/Button2.tsx b/packages/desktop-client/src/components/common/Button2.tsx index 7fda685aec2..d32e6f64b61 100644 --- a/packages/desktop-client/src/components/common/Button2.tsx +++ b/packages/desktop-client/src/components/common/Button2.tsx @@ -9,6 +9,8 @@ import { Button as ReactAriaButton } from 'react-aria-components'; import { css } from '@emotion/css'; +import { useAuth } from '../../auth/AuthProvider'; +import { type Permissions } from '../../auth/types'; import { AnimatedLoading } from '../../icons/AnimatedLoading'; import { styles, theme } from '../../style'; @@ -132,13 +134,22 @@ type ButtonProps = ComponentPropsWithoutRef & { variant?: ButtonVariant; bounce?: boolean; children?: ReactNode; + permission?: Permissions; }; type ButtonVariant = 'normal' | 'primary' | 'bare' | 'menu' | 'menuSelected'; export const Button = forwardRef( (props, ref) => { - const { children, variant = 'normal', bounce = true, ...restProps } = props; + const { + permission, + children, + variant = 'normal', + bounce = true, + ...restProps + } = props; + + const { hasPermission } = useAuth(); const variantWithDisabled: ButtonVariant | `${ButtonVariant}Disabled` = props.isDisabled ? `${variant}Disabled` : variant; @@ -173,6 +184,7 @@ export const Button = forwardRef( return ( void; diff --git a/packages/desktop-client/src/components/manager/BudgetList.tsx b/packages/desktop-client/src/components/manager/BudgetList.tsx index 12defd2e4dd..03b8ab1300e 100644 --- a/packages/desktop-client/src/components/manager/BudgetList.tsx +++ b/packages/desktop-client/src/components/manager/BudgetList.tsx @@ -1,4 +1,10 @@ -import React, { useState, useRef, type CSSProperties } from 'react'; +import React, { + useState, + useRef, + useEffect, + type CSSProperties, + useCallback, +} from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; @@ -17,6 +23,7 @@ import { isNonProductionEnvironment, } from 'loot-core/src/shared/environment'; import { + type RemoteFile, type File, type LocalFile, type SyncableLocalFile, @@ -32,6 +39,8 @@ import { SvgCog, SvgDotsHorizontalTriple, SvgFileDouble, + SvgUser, + SvgUserGroup, } from '../../icons/v1'; import { SvgCloudUnknown, SvgKey, SvgRefreshArrow } from '../../icons/v2'; import { styles, theme } from '../../style'; @@ -40,8 +49,10 @@ import { Button } from '../common/Button2'; import { Menu } from '../common/Menu'; import { Popover } from '../common/Popover'; import { Text } from '../common/Text'; +import { Tooltip } from '../common/Tooltip'; import { View } from '../common/View'; import { useResponsive } from '../responsive/ResponsiveProvider'; +import { useMultiuserEnabled } from '../ServerContext'; function getFileDescription(file: File, t: (key: string) => string) { if (file.state === 'unknown') { @@ -61,6 +72,10 @@ function getFileDescription(file: File, t: (key: string) => string) { return null; } +function isLocalFile(file: File): file is LocalFile { + return file.state === 'local'; +} + function FileMenu({ onDelete, onClose, @@ -132,52 +147,112 @@ function FileMenuButton({ ); } -function FileState({ file }: { file: File }) { +function FileState({ + file, + currentUserId, +}: { + file: File; + currentUserId: string; +}) { const { t } = useTranslation(); + const multiuserEnabled = useMultiuserEnabled(); let Icon; let status; let color; + let ownerName = null; + + const getOwnerDisplayName = useCallback(() => { + if ('usersWithAccess' in file) { + const userFound = file.usersWithAccess?.find(f => f.owner); + + if (userFound?.userName === '') { + return 'Server'; + } + + return userFound?.displayName ?? userFound?.userName ?? 'Unassigned'; + } + + return 'Unknown'; + }, [file]); switch (file.state) { case 'unknown': Icon = SvgCloudUnknown; status = t('Network unavailable'); color = theme.buttonNormalDisabledText; + ownerName = 'Unknown'; break; case 'remote': Icon = SvgCloudDownload; status = t('Available for download'); + ownerName = getOwnerDisplayName(); break; case 'local': + Icon = SvgFileDouble; + status = 'Local'; + ownerName = 'You'; + break; case 'broken': + ownerName = 'unknown'; Icon = SvgFileDouble; status = t('Local'); + ownerName = 'You'; break; default: Icon = SvgCloudCheck; status = t('Syncing'); + ownerName = getOwnerDisplayName(); break; } + const showOwnerContent = multiuserEnabled && file.owner !== currentUserId; + return ( - - + + > + - {status} + {status} + + + + {showOwnerContent && ( + + + Owner: + + + {ownerName} + + + )} + ); } @@ -188,14 +263,17 @@ function FileItem({ onSelect, onDelete, onDuplicate, + currentUserId, }: { file: File; quickSwitchMode: boolean; onSelect: (file: File) => void; onDelete: (file: File) => void; onDuplicate: (file: File) => void; + currentUserId: string; }) { const { t } = useTranslation(); + const multiuserEnabled = useMultiuserEnabled(); const selecting = useRef(false); @@ -231,11 +309,19 @@ function FileItem({ > - {file.name} - - + + {file.name} + {multiuserEnabled && 'cloudFileId' in file && ( + + )} + + + void; onDelete: (file: File) => void; onDuplicate: (file: File) => void; + currentUserId: string; }) { - function isLocalFile(file: File): file is LocalFile { - return file.state === 'local'; - } - return ( state.budgets.allFiles || []); + const multiuserEnabled = useMultiuserEnabled(); const [id] = useMetadataPref('id'); + const [currentUserId, setCurrentUserId] = useState(''); + const userData = useSelector(state => state.user.data); + + const fetchUsers = useCallback(async () => { + try { + setCurrentUserId(userData?.userId ?? ''); + } catch (error) { + console.error('Failed to fetch users:', error); + } + }, [userData?.userId]); + + useEffect(() => { + if (multiuserEnabled && !userData?.offline) { + fetchUsers(); + } + }, [multiuserEnabled, userData?.offline, fetchUsers]); // Remote files do not have the 'id' field function isNonRemoteFile( @@ -423,6 +525,7 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) { ): file is LocalFile | SyncableLocalFile | SyncedLocalFile { return file.state !== 'remote'; } + const nonRemoteFiles = allFiles.filter(isNonRemoteFile); const files = id ? nonRemoteFiles.filter(f => f.id !== id) : allFiles; @@ -470,6 +573,7 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) { return ( @@ -558,3 +663,104 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) { ); } + +type UserAccessForFileProps = { + fileId: string; + currentUserId: string; +}; + +function UserAccessForFile({ fileId, currentUserId }: UserAccessForFileProps) { + const allFiles = useSelector(state => state.budgets.allFiles || []); + const remoteFiles = allFiles.filter( + f => f.state === 'remote' || f.state === 'synced' || f.state === 'detached', + ) as (SyncedLocalFile | RemoteFile)[]; + const currentFile = remoteFiles.find(f => f.cloudFileId === fileId); + const multiuserEnabled = useMultiuserEnabled(); + + let usersAccess = currentFile?.usersWithAccess ?? []; + usersAccess = usersAccess?.filter(user => user.userName !== '') ?? []; + + const sortedUsersAccess = [...usersAccess].sort((a, b) => { + const textA = + a.userId === currentUserId ? 'You' : (a.displayName ?? a.userName); + const textB = + b.userId === currentUserId ? 'You' : (b.displayName ?? b.userName); + return textA.localeCompare(textB); + }); + + return ( + + {multiuserEnabled && + usersAccess.length > 0 && + !(sortedUsersAccess.length === 1 && sortedUsersAccess[0].owner) && ( + + + + File shared with: + + + {sortedUsersAccess.map(user => ( + + + + {user.userId === currentUserId + ? 'You' + : (user.displayName ?? user.userName)} + + + ))} + + + } + placement="bottom end" + > + + + + )} + + ); +} diff --git a/packages/desktop-client/src/components/manager/ManagementApp.tsx b/packages/desktop-client/src/components/manager/ManagementApp.tsx index 194ecadd1ba..05f22b26dd9 100644 --- a/packages/desktop-client/src/components/manager/ManagementApp.tsx +++ b/packages/desktop-client/src/components/manager/ManagementApp.tsx @@ -4,16 +4,22 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { loggedIn, setAppState } from 'loot-core/client/actions'; +import { ProtectedRoute } from '../../auth/ProtectedRoute'; +import { Permissions } from '../../auth/types'; import { useMetaThemeColor } from '../../hooks/useMetaThemeColor'; import { theme } from '../../style'; import { tokens } from '../../tokens'; +import { + BackToFileListButton, + UserDirectoryPage, +} from '../admin/UserDirectory/UserDirectoryPage'; import { AppBackground } from '../AppBackground'; import { Text } from '../common/Text'; import { View } from '../common/View'; import { LoggedInUser } from '../LoggedInUser'; import { Notifications } from '../Notifications'; import { useResponsive } from '../responsive/ResponsiveProvider'; -import { useServerVersion } from '../ServerContext'; +import { useMultiuserEnabled, useServerVersion } from '../ServerContext'; import { BudgetList } from './BudgetList'; import { ConfigServer } from './ConfigServer'; @@ -22,6 +28,7 @@ import { Bootstrap } from './subscribe/Bootstrap'; import { ChangePassword } from './subscribe/ChangePassword'; import { Error } from './subscribe/Error'; import { Login } from './subscribe/Login'; +import { OpenIdCallback } from './subscribe/OpenIdCallback'; import { WelcomeScreen } from './WelcomeScreen'; function Version() { @@ -58,6 +65,8 @@ export function ManagementApp() { const files = useSelector(state => state.budgets.allFiles); const isLoading = useSelector(state => state.app.loadingText !== null); const userData = useSelector(state => state.user.data); + const multiuserEnabled = useMultiuserEnabled(); + const managerHasInitialized = useSelector( state => state.app.managerHasInitialized, ); @@ -127,6 +136,22 @@ export function ManagementApp() { ) : ( } /> )} + + {multiuserEnabled && ( + } + /> + } + /> + } + /> + )} {/* Redirect all other pages to this route */} } /> @@ -156,10 +181,23 @@ export function ManagementApp() { ) : ( - } /> + } /> + } /> } /> } /> } /> + {multiuserEnabled && ( + } + /> + } + /> + )} + {/* Redirect all other pages to this route */} } /> diff --git a/packages/desktop-client/src/components/manager/subscribe/Bootstrap.tsx b/packages/desktop-client/src/components/manager/subscribe/Bootstrap.tsx index 863c469c1aa..3e2d48b199d 100644 --- a/packages/desktop-client/src/components/manager/subscribe/Bootstrap.tsx +++ b/packages/desktop-client/src/components/manager/subscribe/Bootstrap.tsx @@ -4,15 +4,16 @@ import { Trans, useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { createBudget } from 'loot-core/src/client/actions/budgets'; -import { loggedIn } from 'loot-core/src/client/actions/user'; import { send } from 'loot-core/src/platform/client/fetch'; +import { useNavigate } from '../../../hooks/useNavigate'; import { theme } from '../../../style'; import { Button } from '../../common/Button2'; import { Link } from '../../common/Link'; import { Paragraph } from '../../common/Paragraph'; import { Text } from '../../common/Text'; import { View } from '../../common/View'; +import { useRefreshLoginMethods } from '../../ServerContext'; import { useBootstrapped, Title } from './common'; import { ConfirmPasswordForm } from './ConfirmPasswordForm'; @@ -21,8 +22,10 @@ export function Bootstrap() { const { t } = useTranslation(); const dispatch = useDispatch(); const [error, setError] = useState(null); + const refreshLoginMethods = useRefreshLoginMethods(); const { checked } = useBootstrapped(); + const navigate = useNavigate(); function getErrorMessage(error) { switch (error) { @@ -32,6 +35,12 @@ export function Bootstrap() { return t('Passwords do not match'); case 'network-failure': return t('Unable to contact the server'); + case 'missing-issuer': + return t('OpenID server cannot be empty'); + case 'missing-client-id': + return t('Client ID cannot be empty'); + case 'missing-client-secret': + return t('Client secret cannot be empty'); default: return t(`An unknown error occurred: {{error}}`, { error }); } @@ -44,7 +53,8 @@ export function Bootstrap() { if (error) { setError(error); } else { - dispatch(loggedIn()); + await refreshLoginMethods(); + navigate('/login'); } } @@ -57,7 +67,7 @@ export function Bootstrap() { } return ( - + <Paragraph style={{ fontSize: 16, color: theme.pageTextDark }}> <Trans> @@ -94,7 +104,11 @@ export function Bootstrap() { buttons={ <Button variant="bare" - style={{ fontSize: 15, color: theme.pageTextLink, marginRight: 15 }} + style={{ + fontSize: 15, + color: theme.pageTextLink, + marginRight: 15, + }} onPress={onDemo} > {t('Try Demo')} diff --git a/packages/desktop-client/src/components/manager/subscribe/ConfirmPasswordForm.tsx b/packages/desktop-client/src/components/manager/subscribe/ConfirmPasswordForm.tsx index ae6c4db017e..cfb64077e44 100644 --- a/packages/desktop-client/src/components/manager/subscribe/ConfirmPasswordForm.tsx +++ b/packages/desktop-client/src/components/manager/subscribe/ConfirmPasswordForm.tsx @@ -1,12 +1,22 @@ // @ts-strict-ignore -import React, { useState } from 'react'; +import React, { type ChangeEvent, type ReactNode, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; +import { theme } from '../../../style'; import { ButtonWithLoading } from '../../common/Button2'; import { BigInput } from '../../common/Input'; import { View } from '../../common/View'; -export function ConfirmPasswordForm({ buttons, onSetPassword, onError }) { +type ConfirmPasswordFormProps = { + buttons: ReactNode; + onSetPassword: (password: string) => Promise<void>; + onError: (error: string) => void; +}; +export function ConfirmPasswordForm({ + buttons, + onSetPassword, + onError, +}: ConfirmPasswordFormProps) { const { t } = useTranslation(); const [password1, setPassword1] = useState(''); @@ -83,3 +93,75 @@ export function ConfirmPasswordForm({ buttons, onSetPassword, onError }) { </View> ); } + +export function ConfirmOldPasswordForm({ buttons, onSetPassword }) { + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + const { t } = useTranslation(); + + async function onSubmit() { + if (loading) { + return; + } + + setLoading(true); + await onSetPassword(password); + setLoading(false); + } + + function onShowPassword(e) { + setShowPassword(e.target.checked); + } + + return ( + <View + style={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + marginTop: 30, + }} + > + <BigInput + autoFocus={true} + placeholder={t('Password')} + type={showPassword ? 'text' : 'password'} + value={password} + onChange={(e: ChangeEvent<HTMLInputElement>) => + setPassword(e.target.value) + } + onEnter={onSubmit} + style={{ + borderColor: theme.buttonMenuBorder, + borderWidth: 1, + borderStyle: 'solid', + ':focus': {}, + }} + /> + + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + fontSize: 15, + marginTop: 20, + }} + > + <label style={{ userSelect: 'none' }}> + <input type="checkbox" onChange={onShowPassword} />{' '} + <Trans>Show password</Trans> + </label> + <View style={{ flex: 1 }} /> + {buttons} + <ButtonWithLoading + variant="primary" + isLoading={loading} + onPress={onSubmit} + > + <Trans>OK</Trans> + </ButtonWithLoading> + </View> + </View> + ); +} diff --git a/packages/desktop-client/src/components/manager/subscribe/Login.tsx b/packages/desktop-client/src/components/manager/subscribe/Login.tsx index bbc2c4f99b1..df8aefaf423 100644 --- a/packages/desktop-client/src/components/manager/subscribe/Login.tsx +++ b/packages/desktop-client/src/components/manager/subscribe/Login.tsx @@ -2,44 +2,223 @@ import React, { useState, useEffect } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; -import { useParams, useSearchParams } from 'react-router-dom'; +import { useSearchParams } from 'react-router-dom'; -import { createBudget } from 'loot-core/src/client/actions/budgets'; +import { isElectron } from 'loot-core/shared/environment'; import { loggedIn } from 'loot-core/src/client/actions/user'; import { send } from 'loot-core/src/platform/client/fetch'; +import { type OpenIdConfig } from 'loot-core/types/models/openid'; +import { useNavigate } from '../../../hooks/useNavigate'; import { AnimatedLoading } from '../../../icons/AnimatedLoading'; -import { theme } from '../../../style'; +import { styles, theme } from '../../../style'; import { Button, ButtonWithLoading } from '../../common/Button2'; import { BigInput } from '../../common/Input'; +import { Label } from '../../common/Label'; import { Link } from '../../common/Link'; +import { Select } from '../../common/Select'; import { Text } from '../../common/Text'; import { View } from '../../common/View'; +import { useAvailableLoginMethods, useLoginMethod } from '../../ServerContext'; import { useBootstrapped, Title } from './common'; +import { OpenIdForm } from './OpenIdForm'; + +function PasswordLogin({ setError, dispatch }) { + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const { t } = useTranslation(); + + async function onSubmitPassword() { + if (password === '' || loading) { + return; + } + + setError(null); + setLoading(true); + const { error } = await send('subscribe-sign-in', { + password, + loginMethod: 'password', + }); + setLoading(false); + + if (error) { + setError(error); + } else { + dispatch(loggedIn()); + } + } + + return ( + <View style={{ flexDirection: 'row', marginTop: 5 }}> + <BigInput + autoFocus={true} + placeholder={t('Password')} + type="password" + onChangeValue={newValue => setPassword(newValue)} + style={{ flex: 1, marginRight: 10 }} + onEnter={onSubmitPassword} + /> + <ButtonWithLoading + variant="primary" + isLoading={loading} + style={{ fontSize: 15, width: 170 }} + onPress={onSubmitPassword} + > + <Trans>Sign in</Trans> + </ButtonWithLoading> + </View> + ); +} + +function OpenIdLogin({ setError }) { + const [warnMasterCreation, setWarnMasterCreation] = useState(false); + const [reviewOpenIdConfiguration, setReviewOpenIdConfiguration] = + useState(false); + const navigate = useNavigate(); + + async function onSetOpenId(config: OpenIdConfig) { + setError(null); + const { error } = await send('subscribe-bootstrap', { openId: config }); + + if (error) { + setError(error); + } else { + navigate('/'); + } + } + + useEffect(() => { + send('owner-created').then(created => setWarnMasterCreation(!created)); + }, []); + + async function onSubmitOpenId() { + const { error, redirect_url } = await send('subscribe-sign-in', { + return_url: isElectron() + ? await window.Actual.startOAuthServer() + : window.location.origin, + loginMethod: 'openid', + }); + + if (error) { + setError(error); + } else { + if (isElectron()) { + window.Actual?.openURLInBrowser(redirect_url); + } else { + window.location.href = redirect_url; + } + } + } + + return ( + <View> + {!reviewOpenIdConfiguration && ( + <> + <View style={{ flexDirection: 'row', justifyContent: 'flex-end' }}> + <Button + variant="primary" + style={{ + padding: 10, + fontSize: 14, + width: 170, + marginTop: 5, + }} + onPress={onSubmitOpenId} + > + <Trans>Sign in with OpenID</Trans> + </Button> + </View> + {warnMasterCreation && ( + <> + <label style={{ color: theme.warningText, marginTop: 10 }}> + <Trans> + The first user to login with OpenID will be the{' '} + <Text style={{ fontWeight: 'bold' }}>server owner</Text>. This + can't be changed using UI. + </Trans> + </label> + <Button + variant="bare" + onPress={() => setReviewOpenIdConfiguration(true)} + style={{ marginTop: 5 }} + > + <Trans>Review OpenID configuration</Trans> + </Button> + </> + )} + </> + )} + {reviewOpenIdConfiguration && ( + <OpenIdForm + loadData={true} + otherButtons={[ + <Button + key="cancel" + variant="bare" + style={{ marginRight: 10 }} + onPress={() => setReviewOpenIdConfiguration(false)} + > + <Trans>Cancel</Trans> + </Button>, + ]} + onSetOpenId={async config => { + onSetOpenId(config); + }} + /> + )} + </View> + ); +} + +function HeaderLogin({ error }) { + return ( + <View + style={{ + flexDirection: 'row', + justifyContent: 'center', + marginTop: 15, + }} + > + {error ? ( + <Link + variant="button" + type="button" + style={{ fontSize: 15 }} + to={'/login/password?error=' + error} + > + <Trans>Login with Password</Trans> + </Link> + ) : ( + <span> + <Trans>Checking Header Token Login ...</Trans>{' '} + <AnimatedLoading style={{ width: 20, height: 20 }} /> + </span> + )} + </View> + ); +} export function Login() { const { t } = useTranslation(); const dispatch = useDispatch(); - const { method = 'password' } = useParams(); + const defaultLoginMethod = useLoginMethod(); + const [method, setMethod] = useState(defaultLoginMethod); const [searchParams, _setSearchParams] = useSearchParams(); - const [password, setPassword] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(searchParams.get('error')); - const { checked } = useBootstrapped(!searchParams.has('error')); + const [error, setError] = useState(null); + const { checked } = useBootstrapped(); + const loginMethods = useAvailableLoginMethods(); useEffect(() => { if (checked && !searchParams.has('error')) { (async () => { if (method === 'header') { setError(null); - setLoading(true); const { error } = await send('subscribe-sign-in', { password: '', loginMethod: method, }); - setLoading(false); if (error) { setError(error); @@ -49,47 +228,25 @@ export function Login() { } })(); } - }, [checked, searchParams, method, dispatch]); + }, [loginMethods, checked, searchParams, method, dispatch]); function getErrorMessage(error) { switch (error) { case 'invalid-header': - return 'Auto login failed - No header sent'; + return t('Auto login failed - No header sent'); case 'proxy-not-trusted': - return 'Auto login failed - Proxy not trusted'; + return t('Auto login failed - Proxy not trusted'); case 'invalid-password': - return 'Invalid password'; + return t('Invalid password'); case 'network-failure': - return 'Unable to contact the server'; + return t('Unable to contact the server'); + case 'internal-error': + return t('Internal error'); default: - return `An unknown error occurred: ${error}`; + return t(`An unknown error occurred: {{error}}`, { error }); } } - async function onSubmit() { - if (password === '' || loading) { - return; - } - - setError(null); - setLoading(true); - const { error } = await send('subscribe-sign-in', { - password, - loginMethod: method, - }); - setLoading(false); - - if (error) { - setError(error); - } else { - dispatch(loggedIn()); - } - } - - async function onDemo() { - await dispatch(createBudget({ demoMode: true })); - } - if (!checked) { return null; } @@ -97,18 +254,43 @@ export function Login() { return ( <View style={{ maxWidth: 450, marginTop: -30, color: theme.pageText }}> <Title text={t('Sign in to this Actual instance')} /> - <Text - style={{ - fontSize: 16, - color: theme.pageTextDark, - lineHeight: 1.4, - }} - > - <Trans> - If you lost your password, you likely still have access to your server - to manually reset it. - </Trans> - </Text> + + {loginMethods?.length > 1 && ( + <Text + style={{ + fontSize: 16, + color: theme.pageTextDark, + lineHeight: 1.4, + marginBottom: 10, + }} + > + <Trans> + If you lost your password, you likely still have access to your + server to manually reset it. + </Trans> + </Text> + )} + + {loginMethods?.length > 1 && ( + <View style={{ marginTop: 10 }}> + <Label + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + paddingTop: 5, + }} + title={t('Select the login method')} + /> + <Select + value={method} + onChange={newValue => { + setError(null); + setMethod(newValue); + }} + options={loginMethods?.map(m => [m.method, m.displayName])} + /> + </View> + )} {error && ( <Text @@ -124,66 +306,12 @@ export function Login() { )} {method === 'password' && ( - <View style={{ display: 'flex', flexDirection: 'row', marginTop: 30 }}> - <BigInput - autoFocus={true} - placeholder={t('Password')} - type="password" - onChangeValue={setPassword} - style={{ flex: 1, marginRight: 10 }} - onEnter={onSubmit} - /> - <ButtonWithLoading - variant="primary" - isLoading={loading} - style={{ fontSize: 15 }} - onPress={onSubmit} - > - <Trans>Sign in</Trans> - </ButtonWithLoading> - </View> - )} - {method === 'header' && ( - <View - style={{ - flexDirection: 'row', - justifyContent: 'center', - marginTop: 15, - }} - > - {error && ( - <Link - variant="button" - buttonVariant="primary" - style={{ fontSize: 15 }} - to={'/login/password?error=' + error} - > - <Trans>Login with Password</Trans> - </Link> - )} - {!error && ( - <span> - <Trans>Checking Header Token Login ...</Trans>{' '} - <AnimatedLoading style={{ width: 20, height: 20 }} /> - </span> - )} - </View> + <PasswordLogin setError={setError} dispatch={dispatch} /> )} - <View - style={{ - flexDirection: 'row', - justifyContent: 'center', - marginTop: 15, - }} - > - <Button - variant="bare" - style={{ fontSize: 15, color: theme.pageTextLink, marginLeft: 10 }} - onPress={onDemo} - > - <Trans>Try Demo →</Trans> - </Button> - </View> + + {method === 'openid' && <OpenIdLogin setError={setError} />} + + {method === 'header' && <HeaderLogin error={error} />} </View> ); } diff --git a/packages/desktop-client/src/components/manager/subscribe/OpenIdCallback.ts b/packages/desktop-client/src/components/manager/subscribe/OpenIdCallback.ts new file mode 100644 index 00000000000..02928047eab --- /dev/null +++ b/packages/desktop-client/src/components/manager/subscribe/OpenIdCallback.ts @@ -0,0 +1,16 @@ +import { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; + +import { loggedIn } from 'loot-core/src/client/actions/user'; +import { send } from 'loot-core/src/platform/client/fetch'; + +export function OpenIdCallback() { + const dispatch = useDispatch(); + useEffect(() => { + const token = new URLSearchParams(window.location.search).get('token'); + send('subscribe-set-token', { token: token as string }).then(() => { + dispatch(loggedIn()); + }); + }); + return null; +} diff --git a/packages/desktop-client/src/components/manager/subscribe/OpenIdForm.tsx b/packages/desktop-client/src/components/manager/subscribe/OpenIdForm.tsx new file mode 100644 index 00000000000..0355a897329 --- /dev/null +++ b/packages/desktop-client/src/components/manager/subscribe/OpenIdForm.tsx @@ -0,0 +1,448 @@ +import { type ReactNode, useEffect, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useLocation, type Location } from 'react-router-dom'; + +import { addNotification } from 'loot-core/client/actions'; +import { send } from 'loot-core/platform/client/fetch'; +import { type Handlers } from 'loot-core/types/handlers'; +import { type OpenIdConfig } from 'loot-core/types/models/openid'; + +import { theme, styles } from '../../../style'; +import { ButtonWithLoading } from '../../common/Button2'; +import { Input } from '../../common/Input'; +import { Link } from '../../common/Link'; +import { Menu } from '../../common/Menu'; +import { Select } from '../../common/Select'; +import { Stack } from '../../common/Stack'; +import { Text } from '../../common/Text'; +import { View } from '../../common/View'; +import { FormField, FormLabel } from '../../forms'; +import { useServerURL } from '../../ServerContext'; + +type OpenIdCallback = (config: OpenIdConfig) => Promise<void>; + +type OnProviderChangeCallback = (provider: OpenIdProviderOption) => void; + +type OpenIdFormProps = { + onSetOpenId: OpenIdCallback; + otherButtons?: ReactNode[]; + loadData?: boolean; +}; + +type OpenIdProviderOption = { + label: string; + value: string; + issuer?: string | ((location: Location, serverUrl: string) => string); + clientId?: string | ((location: Location, serverUrl: string) => string); + clientSecret?: string | ((location: Location, serverUrl: string) => string); + clientIdRequired: boolean; + clientIdDisabled?: boolean; + clientSecretRequired: boolean; + clientSecretDisabled: boolean; + submitButtonDisabled?: boolean; + tip: ReactNode; +}; + +export function OpenIdForm({ + onSetOpenId, + otherButtons, + loadData, +}: OpenIdFormProps) { + const { t } = useTranslation(); + + const [issuer, setIssuer] = useState(''); + const [clientId, setClientId] = useState(''); + const [clientSecret, setClientSecret] = useState(''); + const [clientIdRequired, setClientIdRequired] = useState(true); + const [clientIdDisabled, setClientIdDisabled] = useState(false); + const [clientSecretRequired, setClientSecretRequired] = useState(true); + const [clientSecretDisabled, setClientSecretDisabled] = useState(false); + const [providerName, setProviderName] = useState('other'); + const serverUrl = useServerURL(); + const location = useLocation(); + const [tip, setTip] = useState((<Text />) as ReactNode); + const [submitButtonDisabled, setSubmitButtonDisabled] = useState(false); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (loadData) { + send('get-openid-config').then( + (config: Awaited<ReturnType<Handlers['get-openid-config']>>) => { + if (!config) return; + + if ('error' in config) { + addNotification({ + type: 'error', + id: 'error', + title: t('Error getting openid config'), + sticky: true, + message: config.error, + }); + } else if ('openId' in config) { + setProviderName(config?.openId?.selectedProvider ?? 'other'); + setIssuer(config?.openId?.issuer ?? ''); + setClientId(config?.openId?.client_id ?? ''); + setClientSecret(config?.openId?.client_secret ?? ''); + } + }, + ); + } + }, [loadData, t]); + + const handleProviderChange = (provider: OpenIdProviderOption) => { + if (provider) { + setProviderName(provider.value); + const newIssuer = + typeof provider.issuer === 'function' + ? provider.issuer(location, serverUrl ?? '') + : provider.issuer; + + setIssuer(newIssuer ?? ''); + + const newClientId = + typeof provider.clientId === 'function' + ? provider.clientId(location, serverUrl ?? '') + : provider.clientId; + + setClientId(newClientId ?? ''); + + const newclientSecret = + typeof provider.clientSecret === 'function' + ? provider.clientSecret(location, serverUrl ?? '') + : provider.clientSecret; + + setClientSecret(newclientSecret ?? ''); + + setClientIdRequired(provider.clientIdRequired ?? true); + setClientIdDisabled(provider.clientIdDisabled ?? false); + setClientSecretRequired(provider.clientSecretRequired ?? true); + setClientSecretDisabled(provider.clientSecretDisabled ?? false); + + setTip(provider.tip ?? <Text />); + + setSubmitButtonDisabled(provider.submitButtonDisabled ?? false); + } + }; + + async function onSubmit() { + if (loading) { + return; + } + + setLoading(true); + await onSetOpenId({ + selectedProvider: providerName, + issuer: issuer ?? '', + client_id: clientId ?? '', + client_secret: clientSecret ?? '', + server_hostname: serverUrl ?? '', + }); + setLoading(false); + } + + return ( + <> + <OpenIdProviderSelector + onProviderChange={handleProviderChange} + defaultValue={providerName} + /> + <Stack direction="column" style={{ marginTop: 5 }}> + <FormField style={{ flex: 1 }}> + {!submitButtonDisabled && ( + <View> + <Input + id="issuer-field" + type="text" + value={issuer} + placeholder="https://accounts.domain.tld/" + onChangeValue={newValue => setIssuer(newValue)} + /> + </View> + )} + </FormField> + </Stack> + <label + htmlFor="issuer-field" + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + minWidth: '150px', + marginTop: 5, + marginBottom: 10, + maxWidth: '500px', + }} + > + {!submitButtonDisabled && t('The OpenID provider URL.')}{' '} + <Text + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + }} + > + {tip} + </Text> + </label>{' '} + <Stack> + <FormField style={{ flex: 1 }}> + <FormLabel title={t('Client ID')} htmlFor="clientid-field" /> + <Input + type="text" + id="clientid-field" + value={clientId} + disabled={clientIdDisabled} + onChangeValue={newValue => setClientId(newValue)} + required={clientIdRequired} + /> + <label + htmlFor="clientid-field" + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + }} + > + <Trans>The Client ID generated by the OpenID provider.</Trans> + </label> + </FormField> + <FormField style={{ flex: 1 }}> + <FormLabel title={t('Client secret')} htmlFor="clientsecret-field" /> + <Input + type="text" + id="clientsecret-field" + value={clientSecret} + onChangeValue={newValue => setClientSecret(newValue)} + disabled={clientSecretDisabled} + required={clientSecretRequired} + /> + <label + htmlFor="clientsecret-field" + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + }} + > + <Trans> + The client secret associated with the ID generated by the OpenID + provider. + </Trans> + </label> + </FormField> + + <Stack direction="row" justify="flex-end" align="center"> + {otherButtons} + <ButtonWithLoading + variant="primary" + isLoading={loading} + onPress={onSubmit} + isDisabled={submitButtonDisabled} + > + OK + </ButtonWithLoading> + </Stack> + </Stack> + </> + ); +} + +const openIdProviders: (OpenIdProviderOption | typeof Menu.line)[] = [ + ...[ + { + label: 'Google Accounts', + value: 'google', + issuer: 'https://accounts.google.com', + clientIdRequired: true, + clientSecretRequired: true, + clientSecretDisabled: false, + tip: ( + <Link + variant="external" + to="https://developers.google.com/identity/sign-in/web/sign-in" + > + <Trans>Integrating Google Sign-In into your web app</Trans> + </Link> + ), + }, + { + label: 'Passwordless.id', + value: 'passwordless', + issuer: 'https://api.passwordless.id', + clientId: (location: Location, serverUrl: string) => + serverUrl + ? serverUrl + : window.location.href.replace(location.pathname, ''), + clientIdRequired: true, + clientSecretRequired: true, + clientSecretDisabled: true, + tip: ( + <Link variant="external" to="https://passwordless.id/"> + <Trans>Get started with passwordless.id</Trans> + </Link> + ), + }, + { + label: 'Microsoft Entra', + value: 'microsoft', + issuer: 'https://login.microsoftonline.com/{tenant-id}', + clientIdRequired: true, + clientSecretRequired: true, + clientSecretDisabled: false, + tip: ( + <Link + variant="external" + to="https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc" + > + <Trans>OpenID Connect on the Microsoft identity platform</Trans> + </Link> + ), + }, + { + label: 'Auth0', + value: 'auth0', + issuer: 'https://{domain.region}.auth0.com/', + clientIdRequired: true, + clientSecretRequired: true, + clientSecretDisabled: false, + tip: ( + <Text style={{ color: theme.warningText }}> + <Trans> + Note that the URL depends on your application domain and region. + </Trans>{' '} + <Link + variant="external" + to="https://auth0.com/docs/get-started/applications/application-settings" + > + <Trans>Auth0 application settings</Trans> + </Link> + </Text> + ), + }, + { + label: 'Keycloak', + value: 'keycloak', + issuer: 'https://{domain}/realms/{realm}/', + clientIdRequired: true, + clientSecretRequired: true, + clientSecretDisabled: false, + tip: ( + <Text style={{ color: theme.warningText }}> + <Trans> + Note that the URL depends on your Keycloak domain and realm. + </Trans>{' '} + <Link + variant="external" + to="https://www.keycloak.org/docs/22.0.0/securing_apps/" + > + <Trans>Securing Applications with Keycloak</Trans> + </Link> + </Text> + ), + }, + { + label: 'Github', + value: 'github', + clientIdRequired: true, + clientSecretRequired: true, + clientSecretDisabled: true, + clientIdDisabled: true, + submitButtonDisabled: true, + tip: ( + <> + <Text style={{ color: theme.errorText }}> + <Trans> + Github does not support discovery. You need to configure it in the + server. + </Trans> + </Text>{' '} + <Link + variant="external" + to="https://actualbudget.org/docs/" + linkColor="muted" + > + <Trans>Learn more</Trans> + </Link> + </> + ), + }, + { + label: 'Authentik', + value: 'authentik', + issuer: 'https://{domain}/application/o/{provider-slug-name}/', + clientIdRequired: true, + clientSecretRequired: true, + clientSecretDisabled: false, + tip: ( + <Text style={{ color: theme.warningText }}> + <Trans> + Note that the URL depends on your Authentik domain and provider slug + name. + </Trans>{' '} + <Link + variant="external" + to="https://docs.goauthentik.io/docs/providers/oauth2/" + > + <Trans>Configure OAuth2 Provider</Trans> + </Link> + </Text> + ), + }, + ].sort((a, b) => a.label.localeCompare(b.label)), + Menu.line, + { + label: 'Other', + value: 'other', + issuer: '', + clientIdRequired: true, + clientSecretRequired: true, + clientSecretDisabled: false, + tip: ( + <Text> + <Trans> + Use any OpenId provider of your preference.{' '} + <Text style={{ color: theme.warningText }}> + If your provider does not support discovery, configure it manually + from server + </Text> + </Trans>{' '} + <Link + variant="external" + to="https://actualbudget.org/docs/" + linkColor="muted" + > + <Trans>Learn more</Trans> + </Link> + </Text> + ), + }, +]; + +function OpenIdProviderSelector({ + onProviderChange, + defaultValue, +}: { + onProviderChange: OnProviderChangeCallback; + defaultValue: string; +}) { + const { t } = useTranslation(); + + const handleProviderChange = (newValue: string) => { + const selectedProvider = openIdProviders.find(provider => + provider !== Menu.line ? provider.value === newValue : false, + ); + if (selectedProvider && selectedProvider !== Menu.line) { + onProviderChange(selectedProvider); + } + }; + + return ( + <FormField style={{ flex: 1, marginTop: 20 }}> + <FormLabel title={t('OpenID Provider')} htmlFor="provider-selector" /> + <Select + options={openIdProviders.map(provider => + provider === Menu.line ? Menu.line : [provider.value, provider.label], + )} + defaultLabel={t('Select Provider')} + value={defaultValue} + onChange={handleProviderChange} + /> + </FormField> + ); +} diff --git a/packages/desktop-client/src/components/manager/subscribe/common.tsx b/packages/desktop-client/src/components/manager/subscribe/common.tsx index 929b4bbbe20..be7729f7eb1 100644 --- a/packages/desktop-client/src/components/manager/subscribe/common.tsx +++ b/packages/desktop-client/src/components/manager/subscribe/common.tsx @@ -3,10 +3,15 @@ import React, { useEffect, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { send } from 'loot-core/src/platform/client/fetch'; +import { type Handlers } from 'loot-core/types/handlers'; import { useNavigate } from '../../../hooks/useNavigate'; import { theme } from '../../../style'; -import { useSetServerURL } from '../../ServerContext'; +import { + useSetLoginMethods, + useSetMultiuserEnabled, + useSetServerURL, +} from '../../ServerContext'; // There are two URLs that dance with each other: `/login` and // `/bootstrap`. Both of these URLs check the state of the the server @@ -22,6 +27,8 @@ export function useBootstrapped(redirect = true) { const navigate = useNavigate(); const location = useLocation(); const setServerURL = useSetServerURL(); + const setMultiuserEnabled = useSetMultiuserEnabled(); + const setLoginMethods = useSetLoginMethods(); useEffect(() => { async function run() { @@ -40,7 +47,9 @@ export function useBootstrapped(redirect = true) { if (url == null && !bootstrapped) { // A server hasn't been specified yet const serverURL = window.location.origin; - const result = await send('subscribe-needs-bootstrap', { + const result: Awaited< + ReturnType<Handlers['subscribe-needs-bootstrap']> + > = await send('subscribe-needs-bootstrap', { url: serverURL, }); @@ -52,17 +61,28 @@ export function useBootstrapped(redirect = true) { await setServerURL(serverURL, { validate: false }); + setMultiuserEnabled(result.multiuser); + setLoginMethods(result.availableLoginMethods); + if (result.bootstrapped) { - ensure(`/login/${result.loginMethod}`); + ensure(`/login`); } else { ensure('/bootstrap'); } } else { - const result = await send('subscribe-needs-bootstrap'); + const result: Awaited< + ReturnType<Handlers['subscribe-needs-bootstrap']> + > = await send('subscribe-needs-bootstrap'); + if ('error' in result) { navigate('/error', { state: { error: result.error } }); } else if (result.bootstrapped) { - ensure(`/login/${result.loginMethod}`); + ensure(`/login`); + + if ('hasServer' in result && result.hasServer) { + setMultiuserEnabled(result.multiuser); + setLoginMethods(result.availableLoginMethods); + } } else { ensure('/bootstrap'); } diff --git a/packages/desktop-client/src/components/modals/CreateAccountModal.tsx b/packages/desktop-client/src/components/modals/CreateAccountModal.tsx index bb73dc2a3d5..7245a399d33 100644 --- a/packages/desktop-client/src/components/modals/CreateAccountModal.tsx +++ b/packages/desktop-client/src/components/modals/CreateAccountModal.tsx @@ -1,17 +1,20 @@ import React, { useEffect, useState } from 'react'; import { DialogTrigger } from 'react-aria-components'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { pushModal } from 'loot-core/client/actions'; import { send } from 'loot-core/src/platform/client/fetch'; +import { useAuth } from '../../auth/AuthProvider'; +import { Permissions } from '../../auth/types'; import { authorizeBank } from '../../gocardless'; import { useGoCardlessStatus } from '../../hooks/useGoCardlessStatus'; import { useSimpleFinStatus } from '../../hooks/useSimpleFinStatus'; import { useSyncServerStatus } from '../../hooks/useSyncServerStatus'; import { SvgDotsHorizontalTriple } from '../../icons/v1'; import { theme } from '../../style'; +import { Warning } from '../alerts'; import { Button, ButtonWithLoading } from '../common/Button2'; import { InitialFocus } from '../common/InitialFocus'; import { Link } from '../common/Link'; @@ -21,6 +24,7 @@ import { Paragraph } from '../common/Paragraph'; import { Popover } from '../common/Popover'; import { Text } from '../common/Text'; import { View } from '../common/View'; +import { useMultiuserEnabled } from '../ServerContext'; type CreateAccountProps = { upgradingAccountId?: string; @@ -28,6 +32,7 @@ type CreateAccountProps = { export function CreateAccountModal({ upgradingAccountId }: CreateAccountProps) { const { t } = useTranslation(); + const syncServerStatus = useSyncServerStatus(); const dispatch = useDispatch(); const [isGoCardlessSetupComplete, setIsGoCardlessSetupComplete] = useState< @@ -36,6 +41,8 @@ export function CreateAccountModal({ upgradingAccountId }: CreateAccountProps) { const [isSimpleFinSetupComplete, setIsSimpleFinSetupComplete] = useState< boolean | null >(null); + const { hasPermission } = useAuth(); + const multiuserEnabled = useMultiuserEnabled(); const onConnectGoCardless = () => { if (!isGoCardlessSetupComplete) { @@ -178,6 +185,9 @@ export function CreateAccountModal({ upgradingAccountId }: CreateAccountProps) { title = t('Link Account'); } + const canSetSecrets = + !multiuserEnabled || hasPermission(Permissions.ADMINISTRATOR); + return ( <Modal name="add-account"> {({ state: { close } }) => ( @@ -223,126 +233,149 @@ export function CreateAccountModal({ upgradingAccountId }: CreateAccountProps) { <View style={{ gap: 10 }}> {syncServerStatus === 'online' ? ( <> - <View - style={{ - flexDirection: 'row', - gap: 10, - alignItems: 'center', - }} - > - <ButtonWithLoading - isDisabled={syncServerStatus !== 'online'} - style={{ - padding: '10px 0', - fontSize: 15, - fontWeight: 600, - flex: 1, - }} - onPress={onConnectGoCardless} - > - {isGoCardlessSetupComplete - ? t('Link bank account with GoCardless') - : t('Set up GoCardless for bank sync')} - </ButtonWithLoading> - {isGoCardlessSetupComplete && ( - <DialogTrigger> - <Button - variant="bare" - aria-label={t('GoCardless menu')} + {canSetSecrets && ( + <> + <View + style={{ + flexDirection: 'row', + gap: 10, + alignItems: 'center', + }} + > + <ButtonWithLoading + isDisabled={syncServerStatus !== 'online'} + style={{ + padding: '10px 0', + fontSize: 15, + fontWeight: 600, + flex: 1, + }} + onPress={onConnectGoCardless} > - <SvgDotsHorizontalTriple - width={15} - height={15} - style={{ transform: 'rotateZ(90deg)' }} - /> - </Button> + {isGoCardlessSetupComplete + ? t('Link bank account with GoCardless') + : t('Set up GoCardless for bank sync')} + </ButtonWithLoading> + {isGoCardlessSetupComplete && ( + <DialogTrigger> + <Button + variant="bare" + aria-label={t('GoCardless menu')} + > + <SvgDotsHorizontalTriple + width={15} + height={15} + style={{ transform: 'rotateZ(90deg)' }} + /> + </Button> - <Popover> - <Menu - onMenuSelect={item => { - if (item === 'reconfigure') { - onGoCardlessReset(); - } - }} - items={[ - { - name: 'reconfigure', - text: t('Reset GoCardless credentials'), - }, - ]} - /> - </Popover> - </DialogTrigger> - )} - </View> - <Text style={{ lineHeight: '1.4em', fontSize: 15 }}> - <strong> - {t('Link a')} <em>{t('European')}</em> {t('bank account')} - </strong>{' '} - {t( - 'to automatically download transactions. GoCardless provides reliable, up-to-date information from hundreds of banks.', - )} - </Text> - - <View - style={{ - flexDirection: 'row', - gap: 10, - marginTop: '18px', - alignItems: 'center', - }} - > - <ButtonWithLoading - isDisabled={syncServerStatus !== 'online'} - isLoading={loadingSimpleFinAccounts} - style={{ - padding: '10px 0', - fontSize: 15, - fontWeight: 600, - flex: 1, - }} - onPress={onConnectSimpleFin} - > - {isSimpleFinSetupComplete - ? t('Link bank account with SimpleFIN') - : t('Set up SimpleFIN for bank sync')} - </ButtonWithLoading> - {isSimpleFinSetupComplete && ( - <DialogTrigger> - <Button variant="bare" aria-label={t('SimpleFIN menu')}> - <SvgDotsHorizontalTriple - width={15} - height={15} - style={{ transform: 'rotateZ(90deg)' }} - /> - </Button> - <Popover> - <Menu - onMenuSelect={item => { - if (item === 'reconfigure') { - onSimpleFinReset(); - } - }} - items={[ - { - name: 'reconfigure', - text: t('Reset SimpleFIN credentials'), - }, - ]} - /> - </Popover> - </DialogTrigger> + <Popover> + <Menu + onMenuSelect={item => { + if (item === 'reconfigure') { + onGoCardlessReset(); + } + }} + items={[ + { + name: 'reconfigure', + text: t('Reset GoCardless credentials'), + }, + ]} + /> + </Popover> + </DialogTrigger> + )} + </View> + <Text style={{ lineHeight: '1.4em', fontSize: 15 }}> + <strong> + {t('Link a')} <em>{t('European')}</em>{' '} + {t('bank account')} + </strong>{' '} + {t( + 'to automatically download transactions. GoCardless provides reliable, up-to-date information from hundreds of banks.', + )} + </Text> + <View + style={{ + flexDirection: 'row', + gap: 10, + marginTop: '18px', + alignItems: 'center', + }} + > + <ButtonWithLoading + isDisabled={syncServerStatus !== 'online'} + isLoading={loadingSimpleFinAccounts} + style={{ + padding: '10px 0', + fontSize: 15, + fontWeight: 600, + flex: 1, + }} + onPress={onConnectSimpleFin} + > + {isSimpleFinSetupComplete + ? t('Link bank account with SimpleFIN') + : t('Set up SimpleFIN for bank sync')} + </ButtonWithLoading> + {isSimpleFinSetupComplete && ( + <DialogTrigger> + <Button + variant="bare" + aria-label={t('SimpleFIN menu')} + > + <SvgDotsHorizontalTriple + width={15} + height={15} + style={{ transform: 'rotateZ(90deg)' }} + /> + </Button> + <Popover> + <Menu + onMenuSelect={item => { + if (item === 'reconfigure') { + onSimpleFinReset(); + } + }} + items={[ + { + name: 'reconfigure', + text: t('Reset SimpleFIN credentials'), + }, + ]} + /> + </Popover> + </DialogTrigger> + )} + </View> + <Text style={{ lineHeight: '1.4em', fontSize: 15 }}> + <strong> + {t('Link a')} <em>{t('North American')}</em> + {t(' bank account')} + </strong>{' '} + {t( + 'to automatically download transactions. SimpleFIN provides reliable, up-to-date information from hundreds of banks.', + )}{' '} + </Text> + </> + )} + {(!isGoCardlessSetupComplete || !isSimpleFinSetupComplete) && + !canSetSecrets && ( + <Warning> + <Trans> + You don't have the required permissions to set up + secrets. Please contact an Admin to configure + </Trans>{' '} + {[ + isGoCardlessSetupComplete ? '' : 'GoCardless', + isSimpleFinSetupComplete ? '' : 'SimpleFin', + ] + .filter(Boolean) + .join(' or ')} + . + </Warning> )} - </View> - <Text style={{ lineHeight: '1.4em', fontSize: 15 }}> - <strong> - {t('Link a')} <em>{t('North American')}</em> - {t(' bank account')} - </strong>{' '} - {t( - 'to automatically download transactions. SimpleFIN provides reliable, up-to-date information from hundreds of banks.', - )}{' '} - </Text> </> ) : ( <> diff --git a/packages/desktop-client/src/components/modals/EditAccess.tsx b/packages/desktop-client/src/components/modals/EditAccess.tsx new file mode 100644 index 00000000000..93dacc800c4 --- /dev/null +++ b/packages/desktop-client/src/components/modals/EditAccess.tsx @@ -0,0 +1,154 @@ +import { useEffect, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { send } from 'loot-core/platform/client/fetch'; +import { getUserAccessErrors } from 'loot-core/shared/errors'; +import { type Handlers } from 'loot-core/types/handlers'; +import { type UserAccessEntity } from 'loot-core/types/models/userAccess'; + +import { useActions } from '../../hooks/useActions'; +import { styles, theme } from '../../style'; +import { Button } from '../common/Button2'; +import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal'; +import { Select } from '../common/Select'; +import { Stack } from '../common/Stack'; +import { Text } from '../common/Text'; +import { View } from '../common/View'; +import { FormField, FormLabel } from '../forms'; + +type EditUserAccessProps = { + defaultUserAccess: UserAccessEntity; + onSave?: (userAccess: UserAccessEntity) => void; +}; + +export function EditUserAccess({ + defaultUserAccess, + onSave: originalOnSave, +}: EditUserAccessProps) { + const { t } = useTranslation(); + + const actions = useActions(); + const [userId, setUserId] = useState(defaultUserAccess.userId ?? ''); + const [error, setSetError] = useState(''); + const [availableUsers, setAvailableUsers] = useState<[string, string][]>([]); + + useEffect(() => { + send('access-get-available-users', defaultUserAccess.fileId).then( + (data: Awaited<ReturnType<Handlers['access-get-available-users']>>) => { + if ('error' in data) { + setSetError(data.error); + } else { + setAvailableUsers( + data.map(user => [ + user.userId, + user.displayName + ? `${user.displayName} (${user.userName})` + : user.userName, + ]), + ); + } + }, + ); + }, [defaultUserAccess.fileId, actions]); + + async function onSave(close: () => void) { + const userAccess = { + ...defaultUserAccess, + userId, + }; + + const { error } = await send('access-add', userAccess); + if (!error) { + originalOnSave?.(userAccess); + close(); + } else { + if (error === 'token-expired') { + actions.addNotification({ + type: 'error', + id: 'login-expired', + title: t('Login expired'), + sticky: true, + message: getUserAccessErrors(error), + button: { + title: t('Go to login'), + action: () => { + actions.signOut(); + }, + }, + }); + } else { + setSetError(getUserAccessErrors(error)); + } + } + } + + return ( + <Modal name="edit-access"> + {({ state: { close } }: { state: { close: () => void } }) => ( + <> + <ModalHeader + title={t('User Access')} + rightContent={<ModalCloseButton onPress={close} />} + /> + <Stack direction="row" style={{ marginTop: 10 }}> + <FormField style={{ flex: 1 }}> + <FormLabel title={t('User')} htmlFor="user-field" /> + {availableUsers.length > 0 && ( + <View> + <Select + options={availableUsers} + onChange={(newValue: string) => setUserId(newValue)} + value={userId} + /> + <label + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + marginTop: 5, + }} + > + <Trans>Select a user from the directory</Trans> + </label> + </View> + )} + {availableUsers.length === 0 && ( + <Text + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + marginTop: 5, + }} + > + <Trans>No users available to give access</Trans> + </Text> + )} + </FormField> + </Stack> + + <Stack + direction="row" + justify="flex-end" + align="center" + style={{ marginTop: 20 }} + > + {error && <Text style={{ color: theme.errorText }}>{error}</Text>} + <Button + variant="bare" + style={{ marginRight: 10 }} + onPress={actions.popModal} + > + Cancel + </Button> + <Button + variant="primary" + isDisabled={availableUsers.length === 0} + onPress={() => onSave(close)} + > + {defaultUserAccess.userId ? t('Save') : t('Add')} + </Button> + </Stack> + </> + )} + </Modal> + ); +} diff --git a/packages/desktop-client/src/components/modals/EditUser.tsx b/packages/desktop-client/src/components/modals/EditUser.tsx new file mode 100644 index 00000000000..e8ddccca135 --- /dev/null +++ b/packages/desktop-client/src/components/modals/EditUser.tsx @@ -0,0 +1,415 @@ +import { useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { send } from 'loot-core/platform/client/fetch'; +import { + PossibleRoles, + type UserEntity, +} from 'loot-core/src/types/models/user'; + +import { type BoundActions, useActions } from '../../hooks/useActions'; +import { styles, theme } from '../../style'; +import { Button } from '../common/Button2'; +import { Input } from '../common/Input'; +import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal'; +import { Select } from '../common/Select'; +import { Stack } from '../common/Stack'; +import { Text } from '../common/Text'; +import { View } from '../common/View'; +import { Checkbox, FormField, FormLabel } from '../forms'; + +type User = UserEntity; + +type EditUserProps = { + defaultUser: User; + onSave: ( + method: 'user-add' | 'user-update', + user: User, + setError: (error: string) => void, + actions: BoundActions, + ) => Promise<void>; +}; + +type EditUserFinanceAppProps = { + defaultUser: User; + onSave: (user: User) => void; +}; + +function useGetUserDirectoryErrors() { + const { t } = useTranslation(); + + function getUserDirectoryErrors(reason: string) { + switch (reason) { + case 'unauthorized': + return t('You are not logged in.'); + case 'token-expired': + return t('Login expired, please login again.'); + case 'user-cant-be-empty': + return t( + 'Please enter a value for the username; the field cannot be empty.', + ); + case 'role-cant-be-empty': + return t('Select a role; the field cannot be empty.'); + case 'user-already-exists': + return t( + 'The username you entered already exists. Please choose a different username.', + ); + case 'not-all-deleted': + return t( + 'Not all users were deleted. Check if one of the selected users is the server owner.', + ); + case 'role-does-not-exists': + return t( + 'Selected role does not exists, possibly a bug? Visit https://actualbudget.org/contact/ for support.', + ); + default: + return t( + 'An internal error occurred, sorry! Visit https://actualbudget.org/contact/ for support. (ref: {{reason}})', + { reason }, + ); + } + } + + return { getUserDirectoryErrors }; +} + +function useSaveUser() { + const { t } = useTranslation(); + const { getUserDirectoryErrors } = useGetUserDirectoryErrors(); + + async function saveUser( + method: 'user-add' | 'user-update', + user: User, + setError: (error: string) => void, + actions: BoundActions, + ): Promise<boolean> { + const { error, id: newId } = (await send(method, user)) || {}; + if (!error) { + if (newId) { + user.id = newId; + } + } else { + setError(getUserDirectoryErrors(error)); + if (error === 'token-expired') { + actions.addNotification({ + type: 'error', + id: 'login-expired', + title: t('Login expired'), + sticky: true, + message: getUserDirectoryErrors(error), + button: { + title: t('Go to login'), + action: () => { + actions.signOut(); + }, + }, + }); + } + + return false; + } + + return true; + } + + return { saveUser }; +} + +export function EditUserFinanceApp({ + defaultUser, + onSave: originalOnSave, +}: EditUserFinanceAppProps) { + const { t } = useTranslation(); + const { saveUser } = useSaveUser(); + + return ( + <Modal name="edit-user"> + {({ state: { close } }) => ( + <> + <ModalHeader + title={ + defaultUser.id + ? t('Edit user {{userName}}', { + userName: defaultUser.displayName ?? defaultUser.userName, + }) + : 'Add user' + } + rightContent={<ModalCloseButton onPress={close} />} + /> + <EditUser + defaultUser={defaultUser} + onSave={async (method, user, setError, actions) => { + if (await saveUser(method, user, setError, actions)) { + originalOnSave(user); + close(); + } + }} + /> + </> + )} + </Modal> + ); +} + +function EditUser({ defaultUser, onSave: originalOnSave }: EditUserProps) { + const { t } = useTranslation(); + + const actions = useActions(); + const [userName, setUserName] = useState<string>(defaultUser.userName ?? ''); + const [displayName, setDisplayName] = useState<string>( + defaultUser.displayName ?? '', + ); + const [enabled, setEnabled] = useState<boolean>(defaultUser.enabled); + const [role, setRole] = useState<string>(defaultUser.role ?? 'BASIC'); + const [error, setError] = useState<string>(''); + + async function onSave() { + if (!userName.trim()) { + setError(t('Username is required.')); + return; + } + if (!role) { + setError(t('Role is required.')); + return; + } + const user: User = { + ...defaultUser, + userName, + displayName, + enabled, + role, + }; + + const method = user.id ? 'user-update' : 'user-add'; + await originalOnSave(method, user, setError, actions); + } + + return ( + <> + <Stack direction="row" style={{ marginTop: 10 }}> + <FormField style={{ flex: 1 }}> + <FormLabel title={t('Username')} htmlFor="name-field" /> + <Input + id="name-field" + value={userName} + onChangeValue={text => setUserName(text)} + style={{ + borderColor: theme.buttonMenuBorder, + }} + /> + <label + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + marginTop: 5, + }} + > + <Trans>The username registered within the OpenID provider.</Trans> + </label> + </FormField> + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + userSelect: 'none', + }} + > + {' '} + <Checkbox + id="enabled-field" + checked={enabled} + disabled={defaultUser.owner} + style={{ + color: defaultUser.owner ? theme.pageTextSubdued : 'inherit', + }} + onChange={() => setEnabled(!enabled)} + /> + <label htmlFor="enabled-field" style={{ userSelect: 'none' }}> + Enabled + </label> + </View> + </Stack> + {defaultUser.owner && ( + <label + style={{ + ...styles.verySmallText, + color: theme.errorText, + marginTop: 5, + }} + > + <Trans> + Change this username with caution; it is the server owner. + </Trans> + </label> + )} + <Stack direction="row" style={{ marginTop: 10 }}> + <FormField style={{ flex: 1 }}> + <FormLabel title={t('Display Name')} htmlFor="displayname-field" /> + <Input + id="displayname-field" + value={displayName} + onChangeValue={text => setDisplayName(text)} + placeholder={t('(Optional)')} + style={{ + borderColor: theme.buttonMenuBorder, + }} + /> + <View + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + marginTop: 5, + }} + > + <Trans> + If left empty, it will be updated from your OpenID provider on the + user's login, if available there. + </Trans> + </View> + <View + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + }} + > + <Trans> + When displaying user information, this will be shown instead of + the username. + </Trans> + </View> + </FormField> + </Stack> + <Stack direction="row" style={{ marginTop: 10, width: '100px' }}> + <FormField style={{ flex: 1 }}> + <FormLabel title="Role" htmlFor="role-field" /> + <Select + id="role-field" + disabled={defaultUser.owner} + options={Object.entries(PossibleRoles)} + value={role} + onChange={newValue => setRole(newValue)} + style={{ + borderColor: theme.buttonMenuBorder, + }} + /> + </FormField> + </Stack> + <RoleDescription /> + + <Stack + direction="row" + justify="flex-end" + align="center" + style={{ marginTop: 20 }} + > + {error && <Text style={{ color: theme.errorText }}>{error}</Text>} + <Button + variant="bare" + style={{ marginRight: 10 }} + onPress={actions.popModal} + > + <Trans>Cancel</Trans> + </Button> + <Button variant="primary" onPress={onSave}> + {defaultUser.id ? 'Save' : 'Add'} + </Button> + </Stack> + </> + ); +} + +const RoleDescription = () => { + return ( + <View style={{ paddingTop: 10 }}> + <Text + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + }} + > + <Trans> + In our user directory, each user is assigned a specific role that + determines their permissions and capabilities within the system. + </Trans> + </Text> + <Text + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + }} + > + <Trans> + Understanding these roles is essential for managing users and + responsibilities effectively. + </Trans> + </Text> + <View style={{ paddingTop: 5 }}> + <label + style={{ + ...styles.altMenuHeaderText, + ...styles.verySmallText, + color: theme.pageTextLight, + }} + > + <Trans>Basic</Trans> + </label> + <Text + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + }} + > + <Trans> + Users with the Basic role can create new budgets and be invited to + collaborate on budgets created by others. + </Trans> + </Text> + <Text + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + }} + > + <Trans> + This role is ideal for users who primarily need to manage their own + budgets and participate in shared budget activities. + </Trans> + </Text> + </View> + <View style={{ paddingTop: 10 }}> + <label + style={{ + ...styles.altMenuHeaderText, + ...styles.verySmallText, + color: theme.pageTextLight, + }} + > + <Trans>Admin</Trans> + </label> + <Text + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + }} + > + <Trans> + Can do everything that Basic users can. In addition, they have the + ability to add new users to the directory and access budget files + from all users. + </Trans> + </Text> + <Text + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + }} + > + <Trans> + Also can assign ownership of a budget to another person, ensuring + efficient budget management. + </Trans> + </Text> + </View> + </View> + ); +}; diff --git a/packages/desktop-client/src/components/modals/GoCardlessInitialiseModal.tsx b/packages/desktop-client/src/components/modals/GoCardlessInitialiseModal.tsx index a2c01a79448..d305caf15d5 100644 --- a/packages/desktop-client/src/components/modals/GoCardlessInitialiseModal.tsx +++ b/packages/desktop-client/src/components/modals/GoCardlessInitialiseModal.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { getSecretsError } from 'loot-core/shared/errors'; import { send } from 'loot-core/src/platform/client/fetch'; import { Error } from '../alerts'; @@ -31,26 +32,47 @@ export const GoCardlessInitialiseModal = ({ const [secretKey, setSecretKey] = useState(''); const [isValid, setIsValid] = useState(true); const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState( + t('It is required to provide both the secret id and secret key.'), + ); const onSubmit = async (close: () => void) => { if (!secretId || !secretKey) { setIsValid(false); + setError( + t('It is required to provide both the secret id and secret key.'), + ); return; } setIsLoading(true); - await Promise.all([ - send('secret-set', { + let { error, reason } = + (await send('secret-set', { name: 'gocardless_secretId', value: secretId, - }), - send('secret-set', { - name: 'gocardless_secretKey', - value: secretKey, - }), - ]); + })) || {}; + + if (error) { + setIsLoading(false); + setIsValid(false); + setError(getSecretsError(error, reason)); + return; + } else { + ({ error, reason } = + (await send('secret-set', { + name: 'gocardless_secretKey', + value: secretKey, + })) || {}); + if (error) { + setIsLoading(false); + setIsValid(false); + setError(getSecretsError(error, reason)); + return; + } + } + setIsValid(true); onSuccess(); setIsLoading(false); close(); @@ -107,13 +129,7 @@ export const GoCardlessInitialiseModal = ({ /> </FormField> - {!isValid && ( - <Error> - {t( - 'It is required to provide both the secret id and secret key.', - )} - </Error> - )} + {!isValid && <Error>{error}</Error>} </View> <ModalButtons> diff --git a/packages/desktop-client/src/components/modals/OpenIDEnableModal.tsx b/packages/desktop-client/src/components/modals/OpenIDEnableModal.tsx new file mode 100644 index 00000000000..d77bd0781fe --- /dev/null +++ b/packages/desktop-client/src/components/modals/OpenIDEnableModal.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { send } from 'loot-core/platform/client/fetch'; +import * as asyncStorage from 'loot-core/platform/server/asyncStorage'; +import { getOpenIdErrors } from 'loot-core/shared/errors'; +import { type OpenIdConfig } from 'loot-core/types/models/openid'; + +import { useActions } from '../../hooks/useActions'; +import { theme, styles } from '../../style'; +import { Error } from '../alerts'; +import { Button } from '../common/Button2'; +import { Label } from '../common/Label'; +import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal'; +import { View } from '../common/View'; +import { OpenIdForm } from '../manager/subscribe/OpenIdForm'; +import { useRefreshLoginMethods } from '../ServerContext'; + +type OpenIDEnableModalProps = { + onSave?: () => void; +}; + +export function OpenIDEnableModal({ + onSave: originalOnSave, +}: OpenIDEnableModalProps) { + const { t } = useTranslation(); + + const [error, setError] = useState(''); + const actions = useActions(); + const { closeBudget } = useActions(); + const refreshLoginMethods = useRefreshLoginMethods(); + + async function onSave(config: OpenIdConfig) { + try { + const { error } = (await send('enable-openid', { openId: config })) || {}; + if (!error) { + originalOnSave?.(); + try { + await refreshLoginMethods(); + await asyncStorage.removeItem('user-token'); + await closeBudget(); + } catch (e) { + console.error('Failed to cleanup after OpenID enable:', e); + setError( + t( + 'OpenID was enabled but cleanup failed. Please refresh the application.', + ), + ); + } + } else { + setError(getOpenIdErrors(error)); + } + } catch (e) { + console.error('Failed to enable OpenID:', e); + setError(t('Failed to enable OpenID. Please try again.')); + } + } + + return ( + <Modal name="enable-openid"> + {({ state: { close } }) => ( + <> + <ModalHeader + title={t('Enable OpenID')} + rightContent={<ModalCloseButton onPress={close} />} + /> + + <View style={{ flexDirection: 'column' }}> + <OpenIdForm + onSetOpenId={onSave} + otherButtons={[ + <Button + key="cancel" + variant="bare" + style={{ marginRight: 10 }} + onPress={actions.popModal} + > + <Trans>Cancel</Trans> + </Button>, + ]} + /> + <Label + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + paddingTop: 5, + }} + title={t('After enabling openid all sessions will be closed')} + /> + <Label + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + }} + title={t('The first user to login will become the server owner')} + /> + <Label + style={{ + ...styles.verySmallText, + color: theme.warningText, + }} + title={t('The current password will be disabled')} + /> + + {error && <Error>{error}</Error>} + </View> + </> + )} + </Modal> + ); +} diff --git a/packages/desktop-client/src/components/modals/PasswordEnableModal.tsx b/packages/desktop-client/src/components/modals/PasswordEnableModal.tsx new file mode 100644 index 00000000000..3e9d83c27fc --- /dev/null +++ b/packages/desktop-client/src/components/modals/PasswordEnableModal.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { send } from 'loot-core/platform/client/fetch'; +import * as asyncStorage from 'loot-core/src/platform/server/asyncStorage'; + +import { useActions } from '../../hooks/useActions'; +import { theme, styles } from '../../style'; +import { Error as ErrorAlert } from '../alerts'; +import { Button } from '../common/Button2'; +import { Label } from '../common/Label'; +import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal'; +import { View } from '../common/View'; +import { FormField } from '../forms'; +import { + ConfirmOldPasswordForm, + ConfirmPasswordForm, +} from '../manager/subscribe/ConfirmPasswordForm'; +import { + useAvailableLoginMethods, + useMultiuserEnabled, + useRefreshLoginMethods, +} from '../ServerContext'; + +type PasswordEnableModalProps = { + onSave?: () => void; +}; + +export function PasswordEnableModal({ + onSave: originalOnSave, +}: PasswordEnableModalProps) { + const { t } = useTranslation(); + + const [error, setError] = useState<string | null>(null); + const { closeBudget, popModal } = useActions(); + const multiuserEnabled = useMultiuserEnabled(); + const availableLoginMethods = useAvailableLoginMethods(); + const refreshLoginMethods = useRefreshLoginMethods(); + + const errorMessages = { + 'invalid-password': t('Invalid Password'), + 'password-match': t('Passwords do not match'), + 'network-failure': t('Unable to contact the server'), + 'unable-to-change-file-config-enabled': t( + 'Unable to disable OpenID. Please update the config.json file in this case.', + ), + }; + + function getErrorMessage(error: string): string { + return ( + errorMessages[error as keyof typeof errorMessages] || + t('Internal server error') + ); + } + + async function onSetPassword(password: string) { + setError(null); + const { error } = (await send('enable-password', { password })) || {}; + if (!error) { + originalOnSave?.(); + await refreshLoginMethods(); + await asyncStorage.removeItem('user-token'); + await closeBudget(); + } else { + setError(getErrorMessage(error)); + } + } + + return ( + <Modal name="enable-password-auth"> + {({ state: { close } }) => ( + <> + <ModalHeader + title={t('Revert to server password')} + rightContent={<ModalCloseButton onPress={close} />} + /> + + <View style={{ flexDirection: 'column' }}> + <FormField style={{ flex: 1 }}> + {!availableLoginMethods.some( + login => login.method === 'password', + ) && ( + <ConfirmPasswordForm + buttons={ + <Button + variant="bare" + style={{ fontSize: 15, marginRight: 10 }} + onPress={() => popModal()} + > + <Trans>Cancel</Trans> + </Button> + } + onSetPassword={onSetPassword} + onError={(error: string) => setError(getErrorMessage(error))} + /> + )} + {availableLoginMethods.some( + login => login.method === 'password', + ) && ( + <ConfirmOldPasswordForm + buttons={ + <Button + variant="bare" + style={{ fontSize: 15, marginRight: 10 }} + onPress={() => popModal()} + > + <Trans>Cancel</Trans> + </Button> + } + onSetPassword={onSetPassword} + /> + )} + </FormField> + <Label + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + paddingTop: 5, + }} + title={t('Type the server password to disable OpenID')} + /> + <Label + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + paddingTop: 5, + }} + title={t('After disabling OpenID all sessions will be closed')} + /> + {multiuserEnabled && ( + <Label + style={{ + ...styles.verySmallText, + color: theme.errorText, + }} + title={t('Multi-user will not work after disabling')} + /> + )} + {error && <ErrorAlert>{error}</ErrorAlert>} + </View> + </> + )} + </Modal> + ); +} diff --git a/packages/desktop-client/src/components/modals/SimpleFinInitialiseModal.tsx b/packages/desktop-client/src/components/modals/SimpleFinInitialiseModal.tsx index 54fac7a98a2..08058f5b525 100644 --- a/packages/desktop-client/src/components/modals/SimpleFinInitialiseModal.tsx +++ b/packages/desktop-client/src/components/modals/SimpleFinInitialiseModal.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { getSecretsError } from 'loot-core/shared/errors'; import { send } from 'loot-core/src/platform/client/fetch'; import { Error } from '../alerts'; @@ -29,6 +30,7 @@ export const SimpleFinInitialiseModal = ({ const [token, setToken] = useState(''); const [isValid, setIsValid] = useState(true); const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(t('It is required to provide a token.')); const onSubmit = async (close: () => void) => { if (!token) { @@ -38,12 +40,18 @@ export const SimpleFinInitialiseModal = ({ setIsLoading(true); - await send('secret-set', { - name: 'simplefin_token', - value: token, - }); + const { error, reason } = + (await send('secret-set', { + name: 'simplefin_token', + value: token, + })) || {}; - onSuccess(); + if (error) { + setIsValid(false); + setError(getSecretsError(error, reason)); + } else { + onSuccess(); + } setIsLoading(false); close(); }; @@ -84,7 +92,7 @@ export const SimpleFinInitialiseModal = ({ /> </FormField> - {!isValid && <Error>It is required to provide a token.</Error>} + {!isValid && <Error>{error}</Error>} </View> <ModalButtons> diff --git a/packages/desktop-client/src/components/modals/TransferOwnership.tsx b/packages/desktop-client/src/components/modals/TransferOwnership.tsx new file mode 100644 index 00000000000..bb2a198c48c --- /dev/null +++ b/packages/desktop-client/src/components/modals/TransferOwnership.tsx @@ -0,0 +1,206 @@ +import { useEffect, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; + +import { addNotification, closeAndLoadBudget } from 'loot-core/client/actions'; +import { type State } from 'loot-core/client/state-types'; +import { send } from 'loot-core/platform/client/fetch'; +import { getUserAccessErrors } from 'loot-core/shared/errors'; +import { type Budget } from 'loot-core/types/budget'; +import { type RemoteFile, type SyncedLocalFile } from 'loot-core/types/file'; +import { type Handlers } from 'loot-core/types/handlers'; + +import { useActions } from '../../hooks/useActions'; +import { useMetadataPref } from '../../hooks/useMetadataPref'; +import { styles, theme } from '../../style'; +import { Button } from '../common/Button2'; +import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal'; +import { Select } from '../common/Select'; +import { Stack } from '../common/Stack'; +import { Text } from '../common/Text'; +import { View } from '../common/View'; +import { FormField, FormLabel } from '../forms'; + +type TransferOwnershipProps = { + onSave?: () => void; +}; + +export function TransferOwnership({ + onSave: originalOnSave, +}: TransferOwnershipProps) { + const { t } = useTranslation(); + + const userData = useSelector((state: State) => state.user.data); + const actions = useActions(); + const [userId, setUserId] = useState(''); + const [error, setError] = useState<string | null>(null); + const [availableUsers, setAvailableUsers] = useState<[string, string][]>([]); + const [cloudFileId] = useMetadataPref('cloudFileId'); + const allFiles = useSelector(state => state.budgets.allFiles || []); + const remoteFiles = allFiles.filter( + f => f.state === 'remote' || f.state === 'synced' || f.state === 'detached', + ) as (SyncedLocalFile | RemoteFile)[]; + const currentFile = remoteFiles.find(f => f.cloudFileId === cloudFileId); + const dispatch = useDispatch(); + const [isTransferring, setIsTransferring] = useState(false); + + useEffect(() => { + send('users-get').then( + (data: Awaited<ReturnType<Handlers['users-get']>>) => { + if (!data) { + setAvailableUsers([]); + } else if ('error' in data) { + addNotification({ + type: 'error', + title: t('Error getting users'), + message: t( + 'Failed to complete ownership transfer. Please try again.', + ), + sticky: true, + }); + } else { + setAvailableUsers( + data + .filter(f => currentFile?.owner !== f.id) + .map(user => [ + user.id, + user.displayName + ? `${user.displayName} (${user.userName})` + : user.userName, + ]), + ); + } + }, + ); + }, [userData?.userId, currentFile?.owner, t]); + + async function onSave() { + if (cloudFileId) { + const response = await send('transfer-ownership', { + fileId: cloudFileId as string, + newUserId: userId, + }); + const { error } = response || {}; + if (!error) { + originalOnSave?.(); + } else { + setError(getUserAccessErrors(error)); + } + } else { + setError(t('Cloud file ID is missing.')); + } + } + + return ( + <Modal name="transfer-ownership"> + {({ state: { close } }: { state: { close: () => void } }) => ( + <> + <ModalHeader + title={t('Transfer ownership')} + rightContent={<ModalCloseButton onPress={close} />} + /> + <Stack direction="row" style={{ marginTop: 10 }}> + <FormField style={{ flex: 1 }}> + <FormLabel title={t('User')} htmlFor="user-field" /> + {availableUsers.length > 0 && ( + <View> + <Select + options={availableUsers} + onChange={(newValue: string) => { + setUserId(newValue); + }} + value={userId} + defaultLabel={t('Select a user')} + /> + <label + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + marginTop: 5, + }} + > + {t( + 'Select a user from the directory to designate as the new budget owner.', + )} + </label> + <label + style={{ + ...styles.verySmallText, + color: theme.errorText, + marginTop: 5, + }} + > + {t( + 'This action is irreversible, ownership of this budget file will only be able to be transferred by the server administrator or new owner.', + )} + </label> + <label + style={{ + ...styles.verySmallText, + color: theme.errorText, + marginTop: 5, + }} + > + {t('Proceed with caution.')} + </label> + </View> + )} + {availableUsers.length === 0 && ( + <Text + style={{ + ...styles.verySmallText, + color: theme.pageTextLight, + marginTop: 5, + }} + > + {t('No users available')} + </Text> + )} + </FormField> + </Stack> + + <Stack + direction="row" + justify="flex-end" + align="center" + style={{ marginTop: 20 }} + > + {error && <Text style={{ color: theme.errorText }}>{error}</Text>} + <Button style={{ marginRight: 10 }} onPress={actions.popModal}> + <Trans>Cancel</Trans> + </Button> + + <Button + variant="primary" + isDisabled={ + availableUsers.length === 0 || !userId || isTransferring + } + onPress={async () => { + setIsTransferring(true); + try { + await onSave(); + await dispatch( + closeAndLoadBudget((currentFile as Budget).id), + ); + close(); + } catch (error) { + addNotification({ + type: 'error', + title: t('Failed to transfer ownership'), + message: t( + 'Failed to complete ownership transfer. Please try again.', + ), + sticky: true, + }); + setIsTransferring(false); + } + }} + > + {isTransferring ? t('Transferring...') : t('Transfer ownership')} + </Button> + </Stack> + </> + )} + </Modal> + ); +} diff --git a/packages/desktop-client/src/components/responsive/wide.ts b/packages/desktop-client/src/components/responsive/wide.ts index 5b54b8eac01..94eb01e43c5 100644 --- a/packages/desktop-client/src/components/responsive/wide.ts +++ b/packages/desktop-client/src/components/responsive/wide.ts @@ -6,3 +6,5 @@ export { GoCardlessLink } from '../gocardless/GoCardlessLink'; export { Account as Accounts } from '../accounts/Account'; export { Account } from '../accounts/Account'; + +export { UserDirectoryPage } from '../admin/UserDirectory/UserDirectoryPage'; diff --git a/packages/desktop-client/src/components/settings/AuthSettings.tsx b/packages/desktop-client/src/components/settings/AuthSettings.tsx new file mode 100644 index 00000000000..229a5ed92b4 --- /dev/null +++ b/packages/desktop-client/src/components/settings/AuthSettings.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; + +import { pushModal } from 'loot-core/client/actions'; + +import { useFeatureFlag } from '../../hooks/useFeatureFlag'; +import { theme } from '../../style'; +import { Button } from '../common/Button2'; +import { Label } from '../common/Label'; +import { Text } from '../common/Text'; +import { useMultiuserEnabled, useLoginMethod } from '../ServerContext'; + +import { Setting } from './UI'; + +export function AuthSettings() { + const { t } = useTranslation(); + + const multiuserEnabled = useMultiuserEnabled(); + const loginMethod = useLoginMethod(); + const dispatch = useDispatch(); + const openidAuthFeatureFlag = useFeatureFlag('openidAuth'); + + return openidAuthFeatureFlag === true ? ( + <Setting + primaryAction={ + <> + <label> + <Trans>OpenID is</Trans>{' '} + <label style={{ fontWeight: 'bold' }}> + {loginMethod === 'openid' ? t('enabled') : t('disabled')} + </label> + </label> + {loginMethod === 'password' && ( + <> + <Button + id="start-using" + style={{ + marginTop: '10px', + }} + variant="normal" + onPress={() => + dispatch( + pushModal('enable-openid', { + onSave: async () => {}, + }), + ) + } + > + Start using OpenID + </Button> + <Label + style={{ paddingTop: 5 }} + title={t('OpenID is required to enable multi-user mode.')} + /> + </> + )} + {loginMethod !== 'password' && ( + <> + <Button + style={{ + marginTop: '10px', + }} + variant="normal" + onPress={() => + dispatch( + pushModal('enable-password-auth', { + onSave: async () => {}, + }), + ) + } + > + <Trans>Disable OpenID</Trans> + </Button> + {multiuserEnabled && ( + <label style={{ paddingTop: 5, color: theme.errorText }}> + <Trans> + Disabling OpenID will deactivate multi-user mode. + </Trans> + </label> + )} + </> + )} + </> + } + > + <Text> + <Trans> + <strong>Authentication method</strong> modifies how users log in to + the system. + </Trans> + </Text> + </Setting> + ) : null; +} diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx index 53ebba44eaa..94a5f1182fc 100644 --- a/packages/desktop-client/src/components/settings/Experimental.tsx +++ b/packages/desktop-client/src/components/settings/Experimental.tsx @@ -96,6 +96,12 @@ export function ExperimentalFeatures() { > <Trans>Context menus</Trans> </FeatureToggle> + <FeatureToggle + flag="openidAuth" + feedbackLink="https://github.com/actualbudget/actual/issues/524" + > + <Trans>OpenID authentication method</Trans> + </FeatureToggle> </View> ) : ( <Link diff --git a/packages/desktop-client/src/components/settings/index.tsx b/packages/desktop-client/src/components/settings/index.tsx index 962ce69f999..a0a04890a98 100644 --- a/packages/desktop-client/src/components/settings/index.tsx +++ b/packages/desktop-client/src/components/settings/index.tsx @@ -24,6 +24,7 @@ import { Page } from '../Page'; import { useResponsive } from '../responsive/ResponsiveProvider'; import { useServerVersion } from '../ServerContext'; +import { AuthSettings } from './AuthSettings'; import { Backups } from './Backups'; import { BudgetTypeSettings } from './BudgetTypeSettings'; import { EncryptionSettings } from './Encryption'; @@ -182,6 +183,7 @@ export function Settings() { <About /> <ThemeSettings /> <FormatSettings /> + <AuthSettings /> <EncryptionSettings /> <BudgetTypeSettings /> {isElectron() && <Backups />} diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx index a654e329d26..5f5ed4c4170 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx @@ -22,6 +22,7 @@ import { } from 'loot-core/src/shared/transactions'; import { integerToCurrency } from 'loot-core/src/shared/util'; +import { AuthProvider } from '../../auth/AuthProvider'; import { SelectedProviderWithItems } from '../../hooks/useSelected'; import { SplitsExpandedProvider } from '../../hooks/useSplitsExpanded'; import { ResponsiveProvider } from '../responsive/ResponsiveProvider'; @@ -148,33 +149,35 @@ function LiveTransactionTable(props) { return ( <TestProvider> <ResponsiveProvider> - <SpreadsheetProvider> - <SchedulesProvider> - <SelectedProviderWithItems - name="transactions" - items={transactions} - fetchAllIds={() => transactions.map(t => t.id)} - > - <SplitsExpandedProvider> - <TransactionTable - {...props} - transactions={transactions} - loadMoreTransactions={() => {}} - commonPayees={[]} - payees={payees} - addNotification={n => console.log(n)} - onSave={onSave} - onSplit={onSplit} - onAdd={onAdd} - onAddSplit={onAddSplit} - onCreatePayee={onCreatePayee} - showSelection={true} - allowSplitTransaction={true} - /> - </SplitsExpandedProvider> - </SelectedProviderWithItems> - </SchedulesProvider> - </SpreadsheetProvider> + <AuthProvider> + <SpreadsheetProvider> + <SchedulesProvider> + <SelectedProviderWithItems + name="transactions" + items={transactions} + fetchAllIds={() => transactions.map(t => t.id)} + > + <SplitsExpandedProvider> + <TransactionTable + {...props} + transactions={transactions} + loadMoreTransactions={() => {}} + commonPayees={[]} + payees={payees} + addNotification={n => console.log(n)} + onSave={onSave} + onSplit={onSplit} + onAdd={onAdd} + onAddSplit={onAddSplit} + onCreatePayee={onCreatePayee} + showSelection={true} + allowSplitTransaction={true} + /> + </SplitsExpandedProvider> + </SelectedProviderWithItems> + </SchedulesProvider> + </SpreadsheetProvider> + </AuthProvider> </ResponsiveProvider> </TestProvider> ); diff --git a/packages/desktop-client/src/hooks/useFeatureFlag.ts b/packages/desktop-client/src/hooks/useFeatureFlag.ts index f1883a8649b..7b8b2e27f33 100644 --- a/packages/desktop-client/src/hooks/useFeatureFlag.ts +++ b/packages/desktop-client/src/hooks/useFeatureFlag.ts @@ -7,6 +7,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = { actionTemplating: false, upcomingLengthAdjustment: false, contextMenus: false, + openidAuth: false, }; export function useFeatureFlag(name: FeatureFlag): boolean { diff --git a/packages/desktop-client/src/hooks/useSyncServerStatus.ts b/packages/desktop-client/src/hooks/useSyncServerStatus.ts index ae5108a82ce..f9a2351d3fd 100644 --- a/packages/desktop-client/src/hooks/useSyncServerStatus.ts +++ b/packages/desktop-client/src/hooks/useSyncServerStatus.ts @@ -14,5 +14,5 @@ export function useSyncServerStatus(): SyncServerStatus { return 'no-server'; } - return !userData || userData.offline ? 'offline' : 'online'; + return !userData || userData?.offline ? 'offline' : 'online'; } diff --git a/packages/desktop-client/src/index.tsx b/packages/desktop-client/src/index.tsx index 8d3e585a1c4..296e6f07ee1 100644 --- a/packages/desktop-client/src/index.tsx +++ b/packages/desktop-client/src/index.tsx @@ -27,6 +27,7 @@ import { initialState as initialAppState } from 'loot-core/src/client/reducers/a import { send } from 'loot-core/src/platform/client/fetch'; import { q } from 'loot-core/src/shared/query'; +import { AuthProvider } from './auth/AuthProvider'; import { App } from './components/App'; import { ServerProvider } from './components/ServerContext'; import { handleGlobalEvents } from './global-events'; @@ -104,7 +105,9 @@ const root = createRoot(container); root.render( <Provider store={store}> <ServerProvider> - <App /> + <AuthProvider> + <App /> + </AuthProvider> </ServerProvider> </Provider>, ); diff --git a/packages/desktop-client/vite.config.mts b/packages/desktop-client/vite.config.mts index b61316d3b36..917e93ee37b 100644 --- a/packages/desktop-client/vite.config.mts +++ b/packages/desktop-client/vite.config.mts @@ -158,6 +158,13 @@ export default defineConfig(async ({ mode }) => { '**/*.{js,css,html,txt,wasm,sql,sqlite,ico,png,woff2,webmanifest}', ], ignoreURLParametersMatching: [/^v$/], + navigateFallback: '/index.html', + navigateFallbackDenylist: [ + /^\/account\/.*$/, + /^\/admin\/.*$/, + /^\/secret\/.*$/, + /^\/openid\/.*$/, + ], }, }), injectShims(), diff --git a/packages/desktop-electron/index.ts b/packages/desktop-electron/index.ts index 3a9d4e86089..3a910e05573 100644 --- a/packages/desktop-electron/index.ts +++ b/packages/desktop-electron/index.ts @@ -1,4 +1,5 @@ import fs from 'fs'; +import { createServer, Server } from 'http'; import path from 'path'; import { @@ -54,6 +55,47 @@ if (!isDev || !process.env.ACTUAL_DATA_DIR) { let clientWin: BrowserWindow | null; let serverProcess: UtilityProcess | null; +let oAuthServer: ReturnType<typeof createServer> | null; + +const createOAuthServer = async () => { + const port = 3010; + console.log(`OAuth server running on port: ${port}`); + + if (oAuthServer) { + return { url: `http://localhost:${port}`, server: oAuthServer }; + } + + return new Promise<{ url: string; server: Server }>(resolve => { + const server = createServer((req, res) => { + const query = new URL(req.url || '', `http://localhost:${port}`) + .searchParams; + + const code = query.get('token'); + if (code && clientWin) { + if (isDev) { + clientWin.loadURL(`http://localhost:3001/openid-cb?token=${code}`); + } else { + clientWin.loadURL(`app://actual/openid-cb?token=${code}`); + } + + // Respond to the browser + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('OpenID login successful! You can close this tab.'); + + // Clean up the server after receiving the code + server.close(); + } else { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('No token received.'); + } + }); + + server.listen(port, '127.0.0.1', () => { + resolve({ url: `http://localhost:${port}`, server }); + }); + }); +}; + if (isDev) { process.traceProcessWarnings = true; } @@ -355,6 +397,12 @@ ipcMain.on('get-bootstrap-data', event => { event.returnValue = payload; }); +ipcMain.handle('start-oauth-server', async () => { + const { url, server: newServer } = await createOAuthServer(); + oAuthServer = newServer; + return url; +}); + ipcMain.handle('restart-server', () => { if (serverProcess) { serverProcess.kill(); diff --git a/packages/desktop-electron/preload.ts b/packages/desktop-electron/preload.ts index 1f98a39096a..85b282dcb12 100644 --- a/packages/desktop-electron/preload.ts +++ b/packages/desktop-electron/preload.ts @@ -13,7 +13,6 @@ contextBridge.exposeInMainWorld('Actual', { IS_DEV, ACTUAL_VERSION: VERSION, logToTerminal: console.log, - ipcConnect: ( func: (payload: { on: IpcRenderer['on']; @@ -30,6 +29,8 @@ contextBridge.exposeInMainWorld('Actual', { }); }, + startOAuthServer: () => ipcRenderer.invoke('start-oauth-server'), + relaunch: () => { ipcRenderer.invoke('relaunch'); }, diff --git a/packages/loot-core/src/client/reducers/budgets.ts b/packages/loot-core/src/client/reducers/budgets.ts index 9465fa0bda6..70d9f535f11 100644 --- a/packages/loot-core/src/client/reducers/budgets.ts +++ b/packages/loot-core/src/client/reducers/budgets.ts @@ -48,6 +48,7 @@ function reconcileFiles( deleted: false, state: 'unknown', hasKey: true, + owner: '', }; } @@ -66,6 +67,8 @@ function reconcileFiles( encryptKeyId: remote.encryptKeyId, hasKey: remote.hasKey, state: 'synced', + owner: remote.owner, + usersWithAccess: remote.usersWithAccess, }; } else { return { @@ -77,6 +80,8 @@ function reconcileFiles( encryptKeyId: remote.encryptKeyId, hasKey: remote.hasKey, state: 'detached', + owner: remote.owner, + usersWithAccess: remote.usersWithAccess, }; } } else { @@ -87,6 +92,7 @@ function reconcileFiles( deleted: false, state: 'broken', hasKey: true, + owner: '', }; } } else { @@ -108,6 +114,8 @@ function reconcileFiles( encryptKeyId: f.encryptKeyId, hasKey: f.hasKey, state: 'remote', + owner: f.owner, + usersWithAccess: f.usersWithAccess, }; }), ) diff --git a/packages/loot-core/src/client/shared-listeners.ts b/packages/loot-core/src/client/shared-listeners.ts index 93f56d15cc1..eb970fe6026 100644 --- a/packages/loot-core/src/client/shared-listeners.ts +++ b/packages/loot-core/src/client/shared-listeners.ts @@ -276,6 +276,18 @@ export function listenForSyncEvent(actions, store) { case 'network': // Show nothing break; + case 'token-expired': + notif = { + title: 'Login expired', + message: 'Please login again.', + sticky: true, + id: 'login-expired', + button: { + title: 'Go to login', + action: () => actions.signOut(), + }, + }; + break; default: console.trace('unknown error', info); notif = { diff --git a/packages/loot-core/src/client/state-types/modals.d.ts b/packages/loot-core/src/client/state-types/modals.d.ts index 9a415988fcf..8e09e5ba3e6 100644 --- a/packages/loot-core/src/client/state-types/modals.d.ts +++ b/packages/loot-core/src/client/state-types/modals.d.ts @@ -8,6 +8,8 @@ import type { TransactionEntity, } from '../../types/models'; import type { NewRuleEntity, RuleEntity } from '../../types/models/rule'; +import { type NewUserEntity, type UserEntity } from '../../types/models/user'; +import { type UserAccessEntity } from '../../types/models/userAccess'; import type { EmptyObject, StripNever } from '../../types/util'; import type * as constants from '../constants'; export type ModalType = keyof FinanceModals; @@ -312,6 +314,23 @@ type FinanceModals = { message?: string; onConfirm: () => void; }; + 'edit-user': { + user: UserEntity | NewUserEntity; + onSave: (rule: UserEntity) => void; + }; + 'edit-access': { + access: UserAccessEntity | NewUserAccessEntity; + onSave: (rule: UserEntity) => void; + }; + 'transfer-ownership': { + onSave: () => void; + }; + 'enable-openid': { + onSave: () => void; + }; + 'enable-password-auth': { + onSave: () => void; + }; 'confirm-unlink-account': { accountName: string; onUnlink: () => void; diff --git a/packages/loot-core/src/server/admin/app.ts b/packages/loot-core/src/server/admin/app.ts new file mode 100644 index 00000000000..121236c6a49 --- /dev/null +++ b/packages/loot-core/src/server/admin/app.ts @@ -0,0 +1,191 @@ +// @ts-strict-ignore +import * as asyncStorage from '../../platform/server/asyncStorage'; +import { UserAvailable, UserEntity } from '../../types/models/user'; +import { createApp } from '../app'; +import { del, get, patch, post } from '../post'; +import { getServer } from '../server-config'; + +import { AdminHandlers } from './types/handlers'; + +// Expose functions to the client +export const app = createApp<AdminHandlers>(); + +app.method('user-delete-all', async function (ids) { + const userToken = await asyncStorage.getItem('user-token'); + if (userToken) { + try { + const res = await del( + getServer().BASE_SERVER + '/admin/users', + { + ids, + }, + { + 'X-ACTUAL-TOKEN': userToken, + }, + ); + + if (res) { + return res; + } + } catch (err) { + return { error: err.reason }; + } + } + + return { someDeletionsFailed: true }; +}); + +app.method('users-get', async function () { + const userToken = await asyncStorage.getItem('user-token'); + + if (userToken) { + const res = await get(getServer().BASE_SERVER + '/admin/users/', { + headers: { + 'X-ACTUAL-TOKEN': userToken, + }, + }); + + if (res) { + try { + const list = JSON.parse(res) as UserEntity[]; + return list; + } catch (err) { + return { error: 'Failed to parse response: ' + err.message }; + } + } + } + + return null; +}); + +app.method('user-add', async function (user) { + const userToken = await asyncStorage.getItem('user-token'); + + if (userToken) { + try { + const res = await post(getServer().BASE_SERVER + '/admin/users/', user, { + 'X-ACTUAL-TOKEN': userToken, + }); + + return res as UserEntity; + } catch (err) { + return { error: err.reason }; + } + } + + return null; +}); + +app.method('user-update', async function (user) { + const userToken = await asyncStorage.getItem('user-token'); + + if (userToken) { + try { + const res = await patch(getServer().BASE_SERVER + '/admin/users/', user, { + 'X-ACTUAL-TOKEN': userToken, + }); + + return res as UserEntity; + } catch (err) { + return { error: err.reason }; + } + } + + return null; +}); + +app.method('access-add', async function (access) { + const userToken = await asyncStorage.getItem('user-token'); + + if (userToken) { + try { + await post(getServer().BASE_SERVER + '/admin/access/', access, { + 'X-ACTUAL-TOKEN': userToken, + }); + + return {}; + } catch (err) { + return { error: err.reason }; + } + } + + return null; +}); + +app.method('access-delete-all', async function ({ fileId, ids }) { + const userToken = await asyncStorage.getItem('user-token'); + if (userToken) { + try { + const res = await del( + getServer().BASE_SERVER + `/admin/access?fileId=${fileId}`, + { + token: userToken, + ids, + }, + ); + + if (res) { + return res; + } + } catch (err) { + return { error: err.reason }; + } + } + + return { someDeletionsFailed: true }; +}); + +app.method('access-get-available-users', async function (fileId) { + const userToken = await asyncStorage.getItem('user-token'); + + if (userToken) { + const res = await get( + `${getServer().BASE_SERVER + '/admin/access/users'}?fileId=${fileId}`, + { + headers: { + 'X-ACTUAL-TOKEN': userToken, + }, + }, + ); + + if (res) { + try { + return JSON.parse(res) as UserAvailable[]; + } catch (err) { + return { error: 'Failed to parse response: ' + err.message }; + } + } + } + + return []; +}); + +app.method('transfer-ownership', async function ({ fileId, newUserId }) { + const userToken = await asyncStorage.getItem('user-token'); + + if (userToken) { + try { + await post( + getServer().BASE_SERVER + '/admin/access/transfer-ownership/', + { fileId, newUserId }, + { + 'X-ACTUAL-TOKEN': userToken, + }, + ); + } catch (err) { + return { error: err.reason }; + } + } + + return {}; +}); + +app.method('owner-created', async function () { + const res = await get(getServer().BASE_SERVER + '/admin/owner-created/'); + + if (res) { + return JSON.parse(res) as boolean; + } + + return null; +}); diff --git a/packages/loot-core/src/server/admin/types/handlers.ts b/packages/loot-core/src/server/admin/types/handlers.ts new file mode 100644 index 00000000000..ec99a415925 --- /dev/null +++ b/packages/loot-core/src/server/admin/types/handlers.ts @@ -0,0 +1,44 @@ +import { UserAvailable, UserEntity } from '../../../types/models/user'; +import { NewUserAccessEntity } from '../../../types/models/userAccess'; + +export interface AdminHandlers { + 'users-get': () => Promise<UserEntity[] | null | { error: string }>; + + 'user-delete-all': ( + ids: string[], + ) => Promise<{ someDeletionsFailed: boolean; ids?: number[] }>; + + 'user-add': ( + user: Omit<UserEntity, 'id'>, + ) => Promise<{ error?: string } | { id: string }>; + + 'user-update': ( + user: Omit<UserEntity, 'id'>, + ) => Promise<{ error?: string } | { id: string }>; + + 'access-add': ( + user: NewUserAccessEntity, + ) => Promise<{ error?: string } | Record<string, never>>; + + 'access-delete-all': ({ + fileId, + ids, + }: { + fileId: string; + ids: string[]; + }) => Promise<{ someDeletionsFailed: boolean; ids?: number[] }>; + + 'access-get-available-users': ( + fileId: string, + ) => Promise<UserAvailable[] | { error: string }>; + + 'transfer-ownership': ({ + fileId, + newUserId, + }: { + fileId: string; + newUserId: string; + }) => Promise<{ error?: string } | Record<string, never>>; + + 'owner-created': () => Promise<boolean>; +} diff --git a/packages/loot-core/src/server/api-models.ts b/packages/loot-core/src/server/api-models.ts index ebd68e8d47f..b6923bcc832 100644 --- a/packages/loot-core/src/server/api-models.ts +++ b/packages/loot-core/src/server/api-models.ts @@ -135,6 +135,8 @@ export const remoteFileModel = { name: file.name, encryptKeyId: file.encryptKeyId, hasKey: file.hasKey, + owner: file.owner, + usersWithAccess: file.usersWithAccess, }; }, diff --git a/packages/loot-core/src/server/cloud-storage.ts b/packages/loot-core/src/server/cloud-storage.ts index 010dbd019d0..c382a60d54a 100644 --- a/packages/loot-core/src/server/cloud-storage.ts +++ b/packages/loot-core/src/server/cloud-storage.ts @@ -22,6 +22,12 @@ import { getServer } from './server-config'; const UPLOAD_FREQUENCY_IN_DAYS = 7; +export interface UsersWithAccess { + userId: string; + userName: string; + displayName: string; + owner: boolean; +} export interface RemoteFile { deleted: boolean; fileId: string; @@ -29,10 +35,24 @@ export interface RemoteFile { name: string; encryptKeyId: string; hasKey: boolean; + owner: string; + usersWithAccess: UsersWithAccess[]; } async function checkHTTPStatus(res) { if (res.status !== 200) { + if (res.status === 403) { + try { + const text = await res.text(); + const data = JSON.parse(text)?.data; + if (data?.reason === 'token-expired') { + await asyncStorage.removeItem('user-token'); + throw new HTTPError(403, 'token-expired'); + } + } catch (e) { + if (e instanceof HTTPError) throw e; + } + } return res.text().then(str => { throw new HTTPError(res.status, str); }); @@ -375,6 +395,38 @@ export async function listRemoteFiles(): Promise<RemoteFile[] | null> { })); } +export async function getRemoteFile( + fileId: string, +): Promise<RemoteFile | null> { + const userToken = await asyncStorage.getItem('user-token'); + if (!userToken) { + return null; + } + + let res; + try { + res = await fetchJSON(getServer().SYNC_SERVER + '/get-user-file-info', { + headers: { + 'X-ACTUAL-TOKEN': userToken, + 'X-ACTUAL-FILE-ID': fileId, + }, + }); + } catch (e) { + console.log('Unexpected error fetching file from server', e); + return null; + } + + if (res.status === 'error') { + console.log('Error fetching file from server', res); + return null; + } + + return { + ...res.data, + hasKey: encryption.hasKey(res.data.encryptKeyId), + }; +} + export async function download(fileId) { const userToken = await asyncStorage.getItem('user-token'); const syncServer = getServer().SYNC_SERVER; diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index 89a29484f7a..b9ec5307ff8 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -19,6 +19,7 @@ import { q, Query } from '../shared/query'; import { amountToInteger, stringToInteger } from '../shared/util'; import { type Budget } from '../types/budget'; import { Handlers } from '../types/handlers'; +import { OpenIdConfig } from '../types/models/openid'; import { exportToCSV, exportQueryToCSV } from './accounts/export-to-csv'; import * as link from './accounts/link'; @@ -27,6 +28,7 @@ import { getStartingBalancePayee } from './accounts/payees'; import * as bankSync from './accounts/sync'; import * as rules from './accounts/transaction-rules'; import { batchUpdateTransactions } from './accounts/transactions'; +import { app as adminApp } from './admin/app'; import { installAPI } from './api'; import { runQuery as aqlQuery } from './aql'; import { @@ -866,8 +868,7 @@ handlers['secret-set'] = async function ({ name, value }) { }, ); } catch (error) { - console.error(error); - return { error: 'failed' }; + return { error: 'failed', reason: error.reason }; } }; @@ -1543,22 +1544,35 @@ handlers['subscribe-needs-bootstrap'] = async function ({ return { bootstrapped: res.data.bootstrapped, - loginMethod: res.data.loginMethod || 'password', + availableLoginMethods: res.data.availableLoginMethods || [ + { method: 'password', active: true, displayName: 'Password' }, + ], + multiuser: res.data.multiuser || false, hasServer: true, }; }; -handlers['subscribe-bootstrap'] = async function ({ password }) { +handlers['subscribe-bootstrap'] = async function (loginConfig) { + try { + await post(getServer().SIGNUP_SERVER + '/bootstrap', loginConfig); + } catch (err) { + return { error: err.reason || 'network-failure' }; + } + return {}; +}; + +handlers['subscribe-get-login-methods'] = async function () { let res; try { - res = await post(getServer().SIGNUP_SERVER + '/bootstrap', { password }); + res = await fetch(getServer().SIGNUP_SERVER + '/login-methods').then(res => + res.json(), + ); } catch (err) { return { error: err.reason || 'network-failure' }; } - if (res.token) { - await asyncStorage.setItem('user-token', res.token); - return {}; + if (res.methods) { + return { methods: res.methods }; } return { error: 'internal' }; }; @@ -1583,16 +1597,38 @@ handlers['subscribe-get-user'] = async function () { 'X-ACTUAL-TOKEN': userToken, }, }); - const { status, reason } = JSON.parse(res); + let tokenExpired = false; + const { + status, + reason, + data: { + userName = null, + permission = '', + userId = null, + displayName = null, + loginMethod = null, + } = {}, + } = JSON.parse(res) || {}; if (status === 'error') { if (reason === 'unauthorized') { return null; + } else if (reason === 'token-expired') { + tokenExpired = true; + } else { + return { offline: true }; } - return { offline: true }; } - return { offline: false }; + return { + offline: false, + userName, + permission, + userId, + displayName, + loginMethod, + tokenExpired, + }; } catch (e) { console.log(e); return { offline: true }; @@ -1617,21 +1653,25 @@ handlers['subscribe-change-password'] = async function ({ password }) { return {}; }; -handlers['subscribe-sign-in'] = async function ({ password, loginMethod }) { - if (typeof loginMethod !== 'string' || loginMethod == null) { - loginMethod = 'password'; +handlers['subscribe-sign-in'] = async function (loginInfo) { + if ( + typeof loginInfo.loginMethod !== 'string' || + loginInfo.loginMethod == null + ) { + loginInfo.loginMethod = 'password'; } let res; try { - res = await post(getServer().SIGNUP_SERVER + '/login', { - loginMethod, - password, - }); + res = await post(getServer().SIGNUP_SERVER + '/login', loginInfo); } catch (err) { return { error: err.reason || 'network-failure' }; } + if (res.redirect_url) { + return { redirect_url: res.redirect_url }; + } + if (!res.token) { throw new Error('login: User token not set'); } @@ -1651,6 +1691,10 @@ handlers['subscribe-sign-out'] = async function () { return 'ok'; }; +handlers['subscribe-set-token'] = async function ({ token }) { + await asyncStorage.setItem('user-token', token); +}; + handlers['get-server-version'] = async function () { if (!getServer()) { return { error: 'no-server' }; @@ -1735,6 +1779,7 @@ handlers['get-budgets'] = async function () { ? { encryptKeyId: prefs.encryptKeyId } : {}), ...(prefs.groupId ? { groupId: prefs.groupId } : {}), + ...(prefs.owner ? { owner: prefs.owner } : {}), name: prefs.budgetName || '(no name)', } satisfies Budget; } @@ -1752,6 +1797,10 @@ handlers['get-remote-files'] = async function () { return cloudStorage.listRemoteFiles(); }; +handlers['get-user-file-info'] = async function (fileId: string) { + return cloudStorage.getRemoteFile(fileId); +}; + handlers['reset-budget-cache'] = mutator(async function () { // Recomputing everything will update the cache await sheet.loadUserBudgets(db); @@ -2077,6 +2126,104 @@ handlers['export-budget'] = async function () { } }; +handlers['enable-openid'] = async function (loginConfig) { + try { + const userToken = await asyncStorage.getItem('user-token'); + + if (!userToken) { + return { error: 'unauthorized' }; + } + + await post(getServer().BASE_SERVER + '/openid/enable', loginConfig, { + 'X-ACTUAL-TOKEN': userToken, + }); + } catch (err) { + return { error: err.reason || 'network-failure' }; + } + return {}; +}; + +handlers['enable-password'] = async function (loginConfig) { + try { + const userToken = await asyncStorage.getItem('user-token'); + + if (!userToken) { + return { error: 'unauthorized' }; + } + + await post(getServer().BASE_SERVER + '/openid/disable', loginConfig, { + 'X-ACTUAL-TOKEN': userToken, + }); + } catch (err) { + return { error: err.reason || 'network-failure' }; + } + return {}; +}; + +handlers['get-openid-config'] = async function () { + try { + const res = await get(getServer().BASE_SERVER + '/openid/config'); + + if (res) { + const config = JSON.parse(res) as OpenIdConfig; + return { openId: config }; + } + + return null; + } catch (err) { + return { error: 'config-fetch-failed' }; + } +}; + +handlers['enable-openid'] = async function (loginConfig) { + try { + const userToken = await asyncStorage.getItem('user-token'); + + if (!userToken) { + return { error: 'unauthorized' }; + } + + await post(getServer().BASE_SERVER + '/openid/enable', loginConfig, { + 'X-ACTUAL-TOKEN': userToken, + }); + } catch (err) { + return { error: err.reason || 'network-failure' }; + } + return {}; +}; + +handlers['enable-password'] = async function (loginConfig) { + try { + const userToken = await asyncStorage.getItem('user-token'); + + if (!userToken) { + return { error: 'unauthorized' }; + } + + await post(getServer().BASE_SERVER + '/openid/disable', loginConfig, { + 'X-ACTUAL-TOKEN': userToken, + }); + } catch (err) { + return { error: err.reason || 'network-failure' }; + } + return {}; +}; + +handlers['get-openid-config'] = async function () { + try { + const res = await get(getServer().BASE_SERVER + '/openid/config'); + + if (res) { + const config = JSON.parse(res) as OpenIdConfig; + return { openId: config }; + } + + return null; + } catch (err) { + return { error: 'config-fetch-failed' }; + } +}; + async function loadBudget(id: string) { let dir: string; try { @@ -2265,6 +2412,7 @@ app.combine( filtersApp, reportsApp, rulesApp, + adminApp, ); function getDefaultDocumentDir() { diff --git a/packages/loot-core/src/server/post.ts b/packages/loot-core/src/server/post.ts index 1fa28cd22a0..b521d87a147 100644 --- a/packages/loot-core/src/server/post.ts +++ b/packages/loot-core/src/server/post.ts @@ -80,6 +80,102 @@ export async function post(url, data, headers = {}, timeout = null) { return res.data; } +export async function del(url, data, headers = {}, timeout = null) { + let text; + let res; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + const signal = timeout ? controller.signal : null; + res = await fetch(url, { + method: 'DELETE', + body: JSON.stringify(data), + signal, + headers: { + ...headers, + 'Content-Type': 'application/json', + }, + }); + clearTimeout(timeoutId); + text = await res.text(); + } catch (err) { + throw new PostError('network-failure'); + } + + throwIfNot200(res, text); + + try { + res = JSON.parse(text); + } catch (err) { + // Something seriously went wrong. TODO handle errors + throw new PostError('parse-json', { meta: text }); + } + + if (res.status !== 'ok') { + console.log( + 'API call failed: ' + + url + + '\nData: ' + + JSON.stringify(data, null, 2) + + '\nResponse: ' + + JSON.stringify(res, null, 2), + ); + + throw new PostError(res.description || res.reason || 'unknown'); + } + + return res.data; +} + +export async function patch(url, data, headers = {}, timeout = null) { + let text; + let res; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + const signal = timeout ? controller.signal : null; + res = await fetch(url, { + method: 'PATCH', + body: JSON.stringify(data), + signal, + headers: { + ...headers, + 'Content-Type': 'application/json', + }, + }); + clearTimeout(timeoutId); + text = await res.text(); + } catch (err) { + throw new PostError('network-failure'); + } + + throwIfNot200(res, text); + + try { + res = JSON.parse(text); + } catch (err) { + // Something seriously went wrong. TODO handle errors + throw new PostError('parse-json', { meta: text }); + } + + if (res.status !== 'ok') { + console.log( + 'API call failed: ' + + url + + '\nData: ' + + JSON.stringify(data, null, 2) + + '\nResponse: ' + + JSON.stringify(res, null, 2), + ); + + throw new PostError(res.description || res.reason || 'unknown'); + } + + return res.data; +} + export async function postBinary(url, data, headers) { let res; try { diff --git a/packages/loot-core/src/shared/errors.ts b/packages/loot-core/src/shared/errors.ts index 0dffe54f229..c9a69c937d6 100644 --- a/packages/loot-core/src/shared/errors.ts +++ b/packages/loot-core/src/shared/errors.ts @@ -123,3 +123,54 @@ export class LazyLoadFailedError extends Error { this.cause = cause; } } + +export function getUserAccessErrors(reason: string) { + switch (reason) { + case 'unauthorized': + return t('You are not logged in.'); + case 'token-expired': + return t('Login expired, please login again.'); + case 'user-cant-be-empty': + return t('Please select a user.'); + case 'invalid-file-id': + return t('This file is invalid.'); + case 'file-denied': + return t('You don`t have permissions over this file.'); + case 'user-already-have-access': + return t('User already has access.'); + default: + return t( + 'An internal error occurred, sorry! Visit https://actualbudget.org/contact/ for support. (ref: {{reason}})', + { reason }, + ); + } +} + +export function getSecretsError(error: string, reason: string) { + switch (reason) { + case 'unauthorized': + return t('You are not logged in.'); + case 'not-admin': + return t('You have to be admin to set secrets'); + default: + return error; + } +} + +export function getOpenIdErrors(reason: string) { + switch (reason) { + case 'unauthorized': + return t('You are not logged in.'); + case 'configuration-error': + return t('This configuration is not valid. Please check it again.'); + case 'unable-to-change-file-config-enabled': + return t( + 'Unable to enable OpenID. Please update the config.json file in this case.', + ); + default: + return t( + 'An internal error occurred, sorry! Visit https://actualbudget.org/contact/ for support. (ref: {{reason}})', + { reason }, + ); + } +} diff --git a/packages/loot-core/src/types/budget.d.ts b/packages/loot-core/src/types/budget.d.ts index 5d8e394fdb4..321ed41a69c 100644 --- a/packages/loot-core/src/types/budget.d.ts +++ b/packages/loot-core/src/types/budget.d.ts @@ -4,4 +4,5 @@ export type Budget = { encryptKeyId?: string; groupId?: string; name: string; + owner?: string; }; diff --git a/packages/loot-core/src/types/file.d.ts b/packages/loot-core/src/types/file.d.ts index e9db42f96fe..27bf6c051bb 100644 --- a/packages/loot-core/src/types/file.d.ts +++ b/packages/loot-core/src/types/file.d.ts @@ -1,3 +1,5 @@ +import { UsersWithAccess } from '../server/cloud-storage'; + import { Budget } from './budget'; export type FileState = @@ -18,6 +20,7 @@ export type SyncableLocalFile = Budget & { groupId: string; state: 'broken' | 'unknown'; hasKey: boolean; + owner: string; }; export type SyncedLocalFile = Budget & { @@ -26,6 +29,8 @@ export type SyncedLocalFile = Budget & { encryptKeyId?: string; hasKey: boolean; state: 'synced' | 'detached'; + owner: string; + usersWithAccess: UsersWithAccess[]; }; export type RemoteFile = { @@ -35,6 +40,8 @@ export type RemoteFile = { encryptKeyId?: string; hasKey: boolean; state: 'remote'; + owner: string; + usersWithAccess: UsersWithAccess[]; }; export type File = LocalFile | SyncableLocalFile | SyncedLocalFile | RemoteFile; diff --git a/packages/loot-core/src/types/handlers.d.ts b/packages/loot-core/src/types/handlers.d.ts index ef880e2515b..cf5b2c1bec8 100644 --- a/packages/loot-core/src/types/handlers.d.ts +++ b/packages/loot-core/src/types/handlers.d.ts @@ -1,3 +1,4 @@ +import type { AdminHandlers } from '../server/admin/types/handlers'; import type { BudgetHandlers } from '../server/budget/types/handlers'; import type { DashboardHandlers } from '../server/dashboard/types/handlers'; import type { FiltersHandlers } from '../server/filters/types/handlers'; @@ -22,6 +23,7 @@ export interface Handlers ReportsHandlers, RulesHandlers, SchedulesHandlers, + AdminHandlers, ToolsHandlers {} export type HandlerFunctions = Handlers[keyof Handlers]; diff --git a/packages/loot-core/src/types/models/index.d.ts b/packages/loot-core/src/types/models/index.d.ts index b4ba56346c7..543ca5eca11 100644 --- a/packages/loot-core/src/types/models/index.d.ts +++ b/packages/loot-core/src/types/models/index.d.ts @@ -12,3 +12,4 @@ export type * from './rule'; export type * from './schedule'; export type * from './transaction'; export type * from './transaction-filter'; +export type * from './user'; diff --git a/packages/loot-core/src/types/models/openid.d.ts b/packages/loot-core/src/types/models/openid.d.ts new file mode 100644 index 00000000000..3d357bedf10 --- /dev/null +++ b/packages/loot-core/src/types/models/openid.d.ts @@ -0,0 +1,7 @@ +export type OpenIdConfig = { + selectedProvider: string; + issuer: string; + client_id: string; + client_secret: string; + server_hostname: string; +}; diff --git a/packages/loot-core/src/types/models/user.ts b/packages/loot-core/src/types/models/user.ts new file mode 100644 index 00000000000..43b85030d0e --- /dev/null +++ b/packages/loot-core/src/types/models/user.ts @@ -0,0 +1,30 @@ +export interface NewUserEntity { + userName: string; + displayName: string; + role: string; + enabled: boolean; +} + +export interface UserEntity extends NewUserEntity { + id: string; + owner: boolean; +} + +export interface UserEntityDropdown { + userId: string; + userName: string; + displayName?: string; +} + +export interface UserAvailable { + userId: string; + displayName?: string; + userName: string; + haveAccess?: number; + owner?: number; +} + +export const PossibleRoles = { + ADMIN: 'Admin', + BASIC: 'Basic', +}; diff --git a/packages/loot-core/src/types/models/userAccess.ts b/packages/loot-core/src/types/models/userAccess.ts new file mode 100644 index 00000000000..a1fdb2508e5 --- /dev/null +++ b/packages/loot-core/src/types/models/userAccess.ts @@ -0,0 +1,10 @@ +export interface NewUserAccessEntity { + fileId: string; + userId: string; +} + +export interface UserAccessEntity extends NewUserAccessEntity { + displayName: string; + userName: string; + fileName: string; +} diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts index 6c54362a90d..97610ce05d8 100644 --- a/packages/loot-core/src/types/prefs.d.ts +++ b/packages/loot-core/src/types/prefs.d.ts @@ -2,7 +2,8 @@ export type FeatureFlag = | 'goalTemplatesEnabled' | 'actionTemplating' | 'upcomingLengthAdjustment' - | 'contextMenus'; + | 'contextMenus' + | 'openidAuth'; /** * Cross-device preferences. These sync across devices when they are changed. @@ -81,3 +82,5 @@ export type GlobalPrefs = Partial<{ documentDir: string; // Electron only serverSelfSignedCert: string; // Electron only }>; + +export type AuthMethods = 'password' | 'openid'; diff --git a/packages/loot-core/src/types/server-handlers.d.ts b/packages/loot-core/src/types/server-handlers.d.ts index 92b872e54f5..4c623262c00 100644 --- a/packages/loot-core/src/types/server-handlers.d.ts +++ b/packages/loot-core/src/types/server-handlers.d.ts @@ -17,6 +17,7 @@ import { RuleEntity, PayeeEntity, } from './models'; +import { OpenIdConfig } from './models/openid'; import { GlobalPrefs, MetadataPrefs } from './prefs'; import { Query } from './query'; import { EmptyObject } from './util'; @@ -269,27 +270,64 @@ export interface ServerHandlers { 'get-did-bootstrap': () => Promise<boolean>; - 'subscribe-needs-bootstrap': (args: { - url; - }) => Promise< - { error: string } | { bootstrapped: unknown; hasServer: boolean } + 'subscribe-needs-bootstrap': (args: { url }) => Promise< + | { error: string } + | { + bootstrapped: boolean; + hasServer: false; + } + | { + bootstrapped: boolean; + hasServer: true; + availableLoginMethods: { + method: string; + displayName: string; + active: boolean; + }[]; + multiuser: boolean; + } >; - 'subscribe-bootstrap': (arg: { password }) => Promise<{ error?: string }>; + 'subscribe-get-login-methods': () => Promise<{ + methods?: { method: string; displayName: string; active: boolean }[]; + error?: string; + }>; - 'subscribe-get-user': () => Promise<{ offline: boolean } | null>; + 'subscribe-bootstrap': (arg: { + password?: string; + openId?: OpenIdConfig; + }) => Promise<{ error?: string }>; + + 'subscribe-get-user': () => Promise<{ + offline: boolean; + userName?: string; + userId?: string; + displayName?: string; + permission?: string; + loginMethod?: string; + tokenExpired?: boolean; + } | null>; 'subscribe-change-password': (arg: { password; }) => Promise<{ error?: string }>; - 'subscribe-sign-in': (arg: { - password; - loginMethod?: string; - }) => Promise<{ error?: string }>; + 'subscribe-sign-in': ( + arg: + | { + password; + loginMethod?: string; + } + | { + return_url; + loginMethod?: 'openid'; + }, + ) => Promise<{ error?: string }>; 'subscribe-sign-out': () => Promise<'ok'>; + 'subscribe-set-token': (arg: { token: string }) => Promise<void>; + 'get-server-version': () => Promise<{ error?: string } | { version: string }>; 'get-server-url': () => Promise<string | null>; @@ -314,6 +352,8 @@ export interface ServerHandlers { 'get-remote-files': () => Promise<RemoteFile[]>; + 'get-user-file-info': (fileId: string) => Promise<RemoteFile | null>; + 'reset-budget-cache': () => Promise<unknown>; 'upload-budget': (arg: { id }) => Promise<{ error?: string }>; @@ -380,4 +420,18 @@ export interface ServerHandlers { 'get-last-opened-backup': () => Promise<string | null>; 'app-focused': () => Promise<void>; + + 'enable-openid': (arg: { + openId?: OpenIdConfig; + }) => Promise<{ error?: string }>; + + 'enable-password': (arg: { password: string }) => Promise<{ error?: string }>; + + 'get-openid-config': () => Promise< + | { + openId: OpenIdConfig; + } + | { error: string } + | null + >; } diff --git a/packages/loot-core/typings/window.d.ts b/packages/loot-core/typings/window.d.ts index 1a56a14fb20..3a63353e776 100644 --- a/packages/loot-core/typings/window.d.ts +++ b/packages/loot-core/typings/window.d.ts @@ -17,6 +17,7 @@ declare global { relaunch: () => void; reload: (() => Promise<void>) | undefined; restartElectronServer: () => void; + startOAuthServer: () => Promise<string>; moveBudgetDirectory: ( currentBudgetDirectory: string, newDirectory: string, diff --git a/upcoming-release-notes/3878.md b/upcoming-release-notes/3878.md new file mode 100644 index 00000000000..e1b8c807de7 --- /dev/null +++ b/upcoming-release-notes/3878.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [apilat, lelemm] +--- + +Add support for authentication using OpenID Connect.